Files
agent-pet-companion/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayGeometry.swift
T
xjxandgd-ops 1807246b7e Add portable skill management and harden protected delivery (#10)
## What changed

- add App and PetCore portable Agent Pet Maker skill management, with
fd-relative ownership and race-safe install/uninstall
- improve Pi no-model failure projection, session bubble message
selection, child-session filtering, onboarding rendering, and related
App behavior
- delete verified PR source branches with exact leases after merge while
dispatching exact-main CI first
- document direct/train multi-agent delivery, explicit
add/commit/clean/push checks, post-merge worktree audits, and exact-SHA
release proof

## Validation

- full `cargo test -p petcore`
- Swift package suite: 734 tests
- Pi connector privacy tests
- promotion/release Python tests and actionlint
- `./script/validate_pre_push.sh`
- independent specification and safety reviews: no remaining blockers

This PR changes the protected CI control plane, so it is intended for
full CI followed by trusted exact-head squash merge.

---------

Co-authored-by: gd-ops <gd-ops@xmfunny.com>
2026-08-12 01:49:51 +08:00

3285 lines
115 KiB
Swift

import AppKit
import AgentPetCompanionCore
import Combine
import CoreGraphics
import Foundation
import QuartzCore
struct OverlayDisplayGeometry: Equatable, Sendable {
var frame: CGRect
var visibleFrame: CGRect
var backingScaleFactor: CGFloat
}
/// The target display's presentation cadence. `CGDisplayMode.refreshRate`
/// reflects an explicitly selected 50/60/etc. Hz mode; some built-in and
/// adaptive displays report zero there, so AppKit's advertised maximum is the
/// bounded fallback before the conservative 60 Hz default.
struct OverlayDisplayRefreshCadence: Equatable, Sendable {
let displayID: String
let framesPerSecond: Double
init(displayID: String, framesPerSecond: Double) {
self.displayID = displayID
self.framesPerSecond = framesPerSecond.isFinite && framesPerSecond > 0
? framesPerSecond
: 60
}
var intervalSeconds: TimeInterval {
1 / framesPerSecond
}
static func resolved(for screen: NSScreen?) -> Self {
let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber")
let screenNumber = screen?.deviceDescription[screenNumberKey] as? NSNumber
let displayID = screenNumber?.stringValue ?? "fallback"
let directDisplayID = screenNumber.map { CGDirectDisplayID($0.uint32Value) }
let selectedModeRefreshRate = directDisplayID
.flatMap(CGDisplayCopyDisplayMode)?
.refreshRate ?? 0
let advertisedMaximum = Double(screen?.maximumFramesPerSecond ?? 0)
let refreshRate = selectedModeRefreshRate > 0
? selectedModeRefreshRate
: (advertisedMaximum > 0 ? advertisedMaximum : 60)
return Self(displayID: displayID, framesPerSecond: refreshRate)
}
}
/// Latest-value coalescing state shared by pointer drag and display-width
/// preview. Callers inject monotonic timestamps, which keeps the cadence logic
/// deterministic in tests and lets production own exactly one sleeping task.
struct OverlayDisplayRefreshCoalescer<Value> {
private var pending: Value?
private var cadence: OverlayDisplayRefreshCadence?
private var lastDeliveryTime: TimeInterval?
private(set) var scheduledDeadline: TimeInterval?
@discardableResult
mutating func submit(
_ value: Value,
at time: TimeInterval,
cadence proposedCadence: OverlayDisplayRefreshCadence
) -> TimeInterval {
let now = max(0, time)
pending = value
if scheduledDeadline == nil || cadence != proposedCadence {
let earliest: TimeInterval
if let lastDeliveryTime {
earliest = lastDeliveryTime + proposedCadence.intervalSeconds
} else {
earliest = now + proposedCadence.intervalSeconds
}
scheduledDeadline = max(now, earliest)
}
cadence = proposedCadence
return scheduledDeadline ?? now
}
mutating func takePending(deliveredAt time: TimeInterval) -> Value? {
guard let pending else { return nil }
self.pending = nil
scheduledDeadline = nil
lastDeliveryTime = max(lastDeliveryTime ?? 0, max(0, time))
return pending
}
mutating func cancelPending() {
pending = nil
scheduledDeadline = nil
cadence = nil
}
}
@MainActor
protocol OverlayDisplayRefreshTickSource: AnyObject {
var isPaused: Bool { get set }
func invalidate()
}
@MainActor
private final class OverlayDisplayLinkWeakTarget: NSObject {
private let onTick: @MainActor () -> Void
init(onTick: @escaping @MainActor () -> Void) {
self.onTick = onTick
}
@objc func displayLinkDidFire(_ displayLink: CADisplayLink) {
onTick()
}
}
@MainActor
private final class OverlayScreenDisplayLinkTickSource:
OverlayDisplayRefreshTickSource
{
private let target: OverlayDisplayLinkWeakTarget
// Source creation, mutation, and teardown are MainActor-owned. The unsafe
// annotation only lets deinit invalidate AppKit's non-Sendable token.
nonisolated(unsafe) private let displayLink: CADisplayLink
init?(screen: NSScreen?, onTick: @escaping @MainActor () -> Void) {
guard let screen else { return nil }
let target = OverlayDisplayLinkWeakTarget(onTick: onTick)
let displayLink = screen.displayLink(
target: target,
selector: #selector(OverlayDisplayLinkWeakTarget.displayLinkDidFire(_:))
)
self.target = target
self.displayLink = displayLink
displayLink.isPaused = true
displayLink.add(to: .main, forMode: .common)
}
var isPaused: Bool {
get { displayLink.isPaused }
set { displayLink.isPaused = newValue }
}
func invalidate() {
displayLink.invalidate()
}
deinit {
displayLink.invalidate()
}
}
/// Tick-driven latest-value delivery for direct manipulation. A real target
/// screen binds an `NSScreen` display link, so adaptive-refresh panels follow
/// actual presentation ticks rather than an assumed maximum FPS. The time
/// coalescer remains only as the no-screen/display-link fallback.
@MainActor
final class OverlayDisplayLinkCoalescer<Value> {
typealias TickSourceFactory = @MainActor (
_ screen: NSScreen?,
_ onTick: @escaping @MainActor () -> Void
) -> (any OverlayDisplayRefreshTickSource)?
private let tickSourceFactory: TickSourceFactory
private var tickSource: (any OverlayDisplayRefreshTickSource)?
private var targetDisplayID: String?
private var pending: Value?
private var delivery: ((Value) -> Void)?
private var fallbackCoalescer = OverlayDisplayRefreshCoalescer<Void>()
private var fallbackTask: Task<Void, Never>?
private var fallbackTaskDeadline: TimeInterval?
/// Keeps the tick source armed for a whole gesture. Pausing after every
/// delivery and re-arming on the next pointer sample can miss the upcoming
/// vsync, so a steady drag presents on an irregular cadence even though
/// every sample arrives on time.
private var sustainsTicks = false
init(
tickSourceFactory: @escaping TickSourceFactory = { screen, onTick in
return OverlayScreenDisplayLinkTickSource(
screen: screen,
onTick: onTick
)
}
) {
self.tickSourceFactory = tickSourceFactory
}
var hasPending: Bool { pending != nil }
/// Holds the tick source unpaused until `endSustainedDelivery`, so an
/// active gesture presents on every display tick instead of re-arming a
/// paused link per pointer sample.
func beginSustainedDelivery() {
sustainsTicks = true
tickSource?.isPaused = false
}
func endSustainedDelivery() {
sustainsTicks = false
guard pending == nil else { return }
tickSource?.isPaused = true
}
func submit(
_ value: Value,
targetDisplayID: String,
screen: NSScreen?,
fallbackCadence: OverlayDisplayRefreshCadence,
deliver: @escaping (Value) -> Void
) {
pending = value
delivery = deliver
if self.targetDisplayID != targetDisplayID {
rebind(targetDisplayID: targetDisplayID, screen: screen)
}
if let tickSource {
tickSource.isPaused = false
return
}
let deadline = fallbackCoalescer.submit(
(),
at: ProcessInfo.processInfo.systemUptime,
cadence: fallbackCadence
)
scheduleFallback(at: deadline)
}
func flushNow() {
deliverPending()
}
func cancelPending() {
pending = nil
delivery = nil
sustainsTicks = false
tickSource?.isPaused = true
fallbackTask?.cancel()
fallbackTask = nil
fallbackTaskDeadline = nil
fallbackCoalescer.cancelPending()
}
func invalidate() {
cancelPending()
tickSource?.invalidate()
tickSource = nil
targetDisplayID = nil
}
func receiveDisplayRefreshTick() {
deliverPending()
}
private func rebind(targetDisplayID: String, screen: NSScreen?) {
tickSource?.invalidate()
tickSource = nil
fallbackTask?.cancel()
fallbackTask = nil
fallbackTaskDeadline = nil
fallbackCoalescer.cancelPending()
self.targetDisplayID = targetDisplayID
tickSource = tickSourceFactory(screen) { [weak self] in
self?.receiveDisplayRefreshTick()
}
}
private func deliverPending() {
guard let pending else {
tickSource?.isPaused = !sustainsTicks
return
}
let delivery = delivery
self.pending = nil
self.delivery = nil
tickSource?.isPaused = !sustainsTicks
fallbackTask?.cancel()
fallbackTask = nil
fallbackTaskDeadline = nil
_ = fallbackCoalescer.takePending(
deliveredAt: ProcessInfo.processInfo.systemUptime
)
delivery?(pending)
}
private func scheduleFallback(at deadline: TimeInterval) {
if let fallbackTaskDeadline,
abs(fallbackTaskDeadline - deadline) < 0.000_001,
fallbackTask != nil {
return
}
fallbackTask?.cancel()
fallbackTaskDeadline = deadline
let delay = max(0, deadline - ProcessInfo.processInfo.systemUptime)
fallbackTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self, !Task.isCancelled else { return }
self.fallbackTask = nil
self.fallbackTaskDeadline = nil
self.deliverPending()
}
}
}
struct OverlayPetVisualEnvelope: Equatable, Sendable {
var canvasSize: CGSize
var visibleBounds: CGRect
}
/// A one-bit-per-pixel interaction mask extracted while a pet frame is
/// decoded. Rows are stored in the same top-to-bottom order as the source
/// `CGImage`; callers query it with bottom-left image coordinates so the
/// conversion to the Metal renderer's coordinate system stays explicit.
struct OverlayPetAlphaMask: Equatable, Sendable {
static let interactionAlphaThreshold: UInt8 = 2
let pixelWidth: Int
let pixelHeight: Int
private let opaqueBits: [UInt8]
var storageByteCount: Int { opaqueBits.count }
init?(
pixelWidth: Int,
pixelHeight: Int,
opaqueBits: [UInt8]
) {
guard let requiredByteCount = Self.requiredByteCount(
pixelWidth: pixelWidth,
pixelHeight: pixelHeight
), opaqueBits.count == requiredByteCount else {
return nil
}
self.pixelWidth = pixelWidth
self.pixelHeight = pixelHeight
self.opaqueBits = opaqueBits
}
/// Test/support initializer for top-to-bottom, one-byte alpha samples.
init?(
pixelWidth: Int,
pixelHeight: Int,
alphaValuesTopToBottom: [UInt8],
alphaThreshold: UInt8 = interactionAlphaThreshold
) {
guard
let pixelCount = Self.pixelCount(pixelWidth: pixelWidth, pixelHeight: pixelHeight),
alphaValuesTopToBottom.count == pixelCount,
let byteCount = Self.requiredByteCount(
pixelWidth: pixelWidth,
pixelHeight: pixelHeight
)
else {
return nil
}
var bits = [UInt8](repeating: 0, count: byteCount)
for index in 0..<pixelCount where alphaValuesTopToBottom[index] > alphaThreshold {
bits[index >> 3] |= UInt8(1 << (index & 7))
}
self.init(pixelWidth: pixelWidth, pixelHeight: pixelHeight, opaqueBits: bits)
}
func containsOpaquePixel(atBottomLeftPoint point: CGPoint) -> Bool {
guard point.x.isFinite, point.y.isFinite,
point.x >= 0, point.y >= 0,
point.x < CGFloat(pixelWidth), point.y < CGFloat(pixelHeight) else {
return false
}
let x = Int(point.x.rounded(.down))
let bottomRow = Int(point.y.rounded(.down))
let topRow = pixelHeight - 1 - bottomRow
let index = topRow * pixelWidth + x
return opaqueBits[index >> 3] & UInt8(1 << (index & 7)) != 0
}
static func requiredByteCount(pixelWidth: Int, pixelHeight: Int) -> Int? {
guard let pixels = pixelCount(pixelWidth: pixelWidth, pixelHeight: pixelHeight) else {
return nil
}
let (adjusted, overflow) = pixels.addingReportingOverflow(7)
return overflow ? nil : adjusted / 8
}
private static func pixelCount(pixelWidth: Int, pixelHeight: Int) -> Int? {
guard pixelWidth > 0, pixelHeight > 0 else { return nil }
let (count, overflow) = pixelWidth.multipliedReportingOverflow(by: pixelHeight)
return overflow ? nil : count
}
}
/// Describes the exact decoded frame currently presented by the Metal view.
/// `frameID` lets the renderer and AppStore coalesce repeated display-link
/// draws without comparing the mask payload for every authored frame.
struct OverlayPetFrameHitTest: Equatable, Sendable {
let frameID: UUID
let canvasSize: CGSize
let alphaMask: OverlayPetAlphaMask
init(
frameID: UUID = UUID(),
canvasSize: CGSize,
alphaMask: OverlayPetAlphaMask
) {
self.frameID = frameID
self.canvasSize = canvasSize
self.alphaMask = alphaMask
}
}
enum OverlayPetAnimationIdentity {
static func stateEntryID(for state: ActiveAgentState?) -> String {
guard let state else { return "idle" }
if let projectedID = nonEmpty(state.overlayDisplay?.stateEntryID) {
return projectedID
}
let event = state.event
switch event.eventType {
case .start:
return "idle"
case .thinking, .plan:
let activation = nonEmpty(state.sessionActivatedAt)
?? event.id
return scopedEntryID(
event: event,
sessionID: state.sessionID ?? event.sessionID,
marker: activation,
reaction: "thinking"
)
case .done:
let completion = nonEmpty(state.sessionActivatedAt)
?? event.id
return scopedEntryID(
event: event,
sessionID: state.sessionID ?? event.sessionID,
marker: completion,
reaction: "done"
)
case .tool, .waiting, .failed:
// PetCore owns the projected identity, including the tool activity
// run that lets genuinely new tool work replay its entry burst. A
// single event carries no run history, so this compatibility
// fallback keeps one stable identity per reaction.
return event.eventType.rawValue
}
}
private static func scopedEntryID(
event: AgentEvent,
sessionID: String?,
marker: String,
reaction: String
) -> String {
[
reaction,
event.source.rawValue,
nonEmpty(sessionID),
marker
]
.compactMap { $0 }
.joined(separator: ":")
}
private static func nonEmpty(_ value: String?) -> String? {
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return value?.isEmpty == false ? value : nil
}
}
enum OverlayControlVisibility {
static let hoverShowDelay = Duration.zero
static let hoverHideDelay = Duration.milliseconds(300)
static func isVisible(
pointerNearPet: Bool,
petDragInProgress: Bool,
keyboardFocusActive: Bool = false
) -> Bool {
pointerNearPet || petDragInProgress || keyboardFocusActive
}
static func transitionDelay(showing: Bool, forced: Bool) -> Duration {
forced ? .zero : (showing ? hoverShowDelay : hoverHideDelay)
}
}
/// Motion values shared by SwiftUI content and the AppKit overlay panels.
enum OverlayMotion {
static let controlFadeDuration: TimeInterval = 0.14
static let controlFadeDelay = Duration.milliseconds(140)
static let bubbleLayoutDuration: TimeInterval = 0.16
static let bubbleLayoutDelay = Duration.milliseconds(160)
static let reducedMotionCrossfadeDuration: TimeInterval = 0.16
static let reducedMotionCrossfadeDelay = Duration.milliseconds(160)
static let reducedMotionCrossfadeHalfDelay = Duration.milliseconds(80)
}
/// One presentation state coordinates every transient overlay control. Pointer
/// hover begins the visual fade immediately, while pointer exit is delayed to
/// avoid flicker between the pet and its controls. Keyboard focus and an active
/// pet drag remain immediate.
@MainActor
final class OverlayControlPresentationState: ObservableObject {
typealias TransitionSleeper = @MainActor @Sendable (Duration) async throws -> Void
enum Region: Hashable {
case pet
case bubble
case menu
}
@Published private(set) var isVisible = false
@Published private(set) var keyboardNavigationActive = false
var visibilityDidChange: (() -> Void)?
private var hoveredRegions: Set<Region> = []
private var focusedRegions: Set<Region> = []
private var activeRegions: Set<Region> = []
private var transitionTask: Task<Void, Never>?
private let transitionSleeper: TransitionSleeper
init(
transitionSleeper: @escaping TransitionSleeper = { delay in
try await Task.sleep(for: delay)
}
) {
self.transitionSleeper = transitionSleeper
}
func setHovered(_ region: Region, _ hovered: Bool) {
update(region, enabled: hovered, in: &hoveredRegions)
scheduleVisibilityUpdate()
}
func setFocused(_ region: Region, _ focused: Bool) {
update(region, enabled: focused, in: &focusedRegions)
let nextKeyboardNavigationActive = !focusedRegions.isEmpty
if keyboardNavigationActive != nextKeyboardNavigationActive {
keyboardNavigationActive = nextKeyboardNavigationActive
}
scheduleVisibilityUpdate()
}
func setActive(_ region: Region, _ active: Bool) {
update(region, enabled: active, in: &activeRegions)
scheduleVisibilityUpdate()
}
func reset() {
transitionTask?.cancel()
transitionTask = nil
hoveredRegions.removeAll()
focusedRegions.removeAll()
activeRegions.removeAll()
keyboardNavigationActive = false
setVisible(false)
}
private func update(
_ region: Region,
enabled: Bool,
in regions: inout Set<Region>
) {
if enabled {
regions.insert(region)
} else {
regions.remove(region)
}
}
private func scheduleVisibilityUpdate() {
transitionTask?.cancel()
let shouldShow = !hoveredRegions.isEmpty
|| !focusedRegions.isEmpty
|| !activeRegions.isEmpty
let forced = !focusedRegions.isEmpty || !activeRegions.isEmpty
guard shouldShow != isVisible else { return }
let delay = OverlayControlVisibility.transitionDelay(
showing: shouldShow,
forced: forced
)
if delay == .zero {
setVisible(shouldShow)
return
}
let transitionSleeper = transitionSleeper
transitionTask = Task { @MainActor [weak self] in
do {
try await transitionSleeper(delay)
} catch {
return
}
guard let self, !Task.isCancelled else { return }
let latestShouldShow = !self.hoveredRegions.isEmpty
|| !self.focusedRegions.isEmpty
|| !self.activeRegions.isEmpty
guard latestShouldShow == shouldShow else { return }
self.setVisible(shouldShow)
}
}
private func setVisible(_ visible: Bool) {
guard isVisible != visible else { return }
isVisible = visible
visibilityDidChange?()
}
}
/// Keeps high-frequency display-size preview out of `AppStore`. Only the pet
/// canvas observes this state, so slider movement does not invalidate the
/// control center, bubbles, or unrelated overlay content.
@MainActor
final class OverlayInteractionPresentationState: ObservableObject {
private struct DisplayWidthPresentation: Equatable {
var displayWidthPt: CGFloat
var petLocalCenter: CGPoint
}
@Published private var displayWidthPresentation: DisplayWidthPresentation?
@Published private(set) var petInteraction: OverlayPetInteractionPresentation?
@Published private(set) var pressFeedbackActive = false
private var dragInteractionID: UUID?
private var lastDragCenter: CGPoint?
func resolvedDisplayWidthPt(fallback: CGFloat) -> CGFloat {
displayWidthPresentation?.displayWidthPt ?? fallback
}
func resolvedPetLocalCenter(fallback: CGPoint) -> CGPoint {
displayWidthPresentation?.petLocalCenter ?? fallback
}
func present(displayWidthPt: CGFloat, petLocalCenter: CGPoint) {
let next = DisplayWidthPresentation(
displayWidthPt: displayWidthPt,
petLocalCenter: petLocalCenter
)
guard displayWidthPresentation != next else { return }
displayWidthPresentation = next
}
func clearDisplayWidthPreview() {
guard displayWidthPresentation != nil else { return }
displayWidthPresentation = nil
}
func acknowledge(reduceMotion: Bool) {
guard !reduceMotion, dragInteractionID == nil else { return }
let interactionID = UUID()
petInteraction = OverlayPetInteractionPresentation(
stateName: "acknowledge",
entryID: "interaction:\(interactionID.uuidString):acknowledge"
)
}
func beginPressFeedback(enabled: Bool, reduceMotion: Bool) {
guard enabled, !reduceMotion, dragInteractionID == nil else { return }
pressFeedbackActive = true
}
func beginDrag(interactionID: UUID, center: CGPoint) {
dragInteractionID = interactionID
lastDragCenter = center
petInteraction = nil
}
func updateDrag(interactionID: UUID, center: CGPoint) {
guard dragInteractionID == interactionID else { return }
pressFeedbackActive = false
let priorCenter = lastDragCenter ?? center
lastDragCenter = center
let horizontalDelta = center.x - priorCenter.x
guard abs(horizontalDelta) >= OverlayPetInteractionPolicy.directionThresholdPt else {
return
}
let stateName = horizontalDelta < 0 ? "drag_left" : "drag_right"
guard petInteraction?.stateName != stateName else { return }
petInteraction = OverlayPetInteractionPresentation(
stateName: stateName,
entryID: "interaction:\(interactionID.uuidString):\(stateName)"
)
}
func endDrag(interactionID: UUID?) {
if let interactionID, dragInteractionID != interactionID { return }
dragInteractionID = nil
lastDragCenter = nil
pressFeedbackActive = false
if petInteraction?.stateName == "drag_left"
|| petInteraction?.stateName == "drag_right"
{
petInteraction = nil
}
}
func complete(entryID: String) {
guard petInteraction?.entryID == entryID,
petInteraction?.stateName == "acknowledge"
else { return }
petInteraction = nil
}
}
struct OverlayPetInteractionPresentation: Equatable, Sendable {
var stateName: String
var entryID: String
}
enum OverlayPetInteractionPolicy {
static let directionThresholdPt: CGFloat = 0.5
static let pressFeedbackDurationSeconds = 0.16
}
enum OverlayPointerMaskState: Equatable, Sendable {
case missing
case valid
case stale
}
struct OverlayPointerOwnershipInput: Equatable, Sendable {
var overlayVisible: Bool
var activeInteractionID: UUID?
var maskState: OverlayPointerMaskState
var validMaskPixelIsOpaque: Bool
var pointerInBubble: Bool
var pointerInMenu: Bool
var pointerInGeometricPetRegion: Bool
}
enum OverlayPointerOwnership: Equatable, Sendable {
case passthrough
case pet
case auxiliarySurface
case activeLease(UUID)
var isOwnedByOverlay: Bool {
self != .passthrough
}
}
/// The only policy that decides whether the overlay owns a pointer location.
/// AppKit event routing remains a defensive delivery mechanism; it does not
/// redefine mask fallback or active-gesture ownership.
enum OverlayPointerOwnershipPolicy {
static func resolve(
_ input: OverlayPointerOwnershipInput
) -> OverlayPointerOwnership {
guard input.overlayVisible else { return .passthrough }
if let interactionID = input.activeInteractionID {
return .activeLease(interactionID)
}
if input.pointerInBubble || input.pointerInMenu {
return .auxiliarySurface
}
guard input.pointerInGeometricPetRegion else {
return .passthrough
}
switch input.maskState {
case .valid:
return input.validMaskPixelIsOpaque ? .pet : .passthrough
case .missing, .stale:
return .pet
}
}
}
enum OverlayPetPointerGesture {
static let dragThreshold: CGFloat = 4
static func exceedsDragThreshold(from start: CGPoint, to current: CGPoint) -> Bool {
hypot(current.x - start.x, current.y - start.y) >= dragThreshold
}
/// AppKit reports a double-click as click counts one and two. Performing
/// the primary action only for the first release preserves responsive
/// single-click behavior without toggling the bubble back on the second.
static func shouldPerformPrimaryClick(clickCount: Int, didDrag: Bool) -> Bool {
!didDrag && clickCount == 1
}
}
/// Converts a pointer sample out of CoreGraphics' global event space into the
/// AppKit screen space the drag anchor uses. A drag translates the host window
/// on every display tick, so a sample derived from the window's own coordinate
/// system silently subtracts a move it never observed; taking the event's
/// absolute location instead keeps the grab point fixed under the pointer no
/// matter how far or how fast the composition has moved.
enum OverlayPointerCoordinateSpace {
static func screenPoint(
forGlobalEventLocation location: CGPoint,
zeroOriginScreenFrame: CGRect
) -> CGPoint {
CGPoint(
x: location.x,
y: zeroOriginScreenFrame.maxY - location.y
)
}
}
enum OverlayPointerGesturePhase: Equatable, Sendable {
case idle
case pressed
case dragging
case finalized
}
struct OverlayDragSession: Equatable, Sendable {
let interactionID: UUID
let startPointerScreen: CGPoint
let startAnchorScreen: CGPoint
var latestPointerScreen: CGPoint
let startDisplayID: String
var hasCrossedThreshold: Bool
private(set) var phase: OverlayPointerGesturePhase
init(
interactionID: UUID = UUID(),
startPointerScreen: CGPoint,
startAnchorScreen: CGPoint,
startDisplayID: String
) {
self.interactionID = interactionID
self.startPointerScreen = startPointerScreen
self.startAnchorScreen = startAnchorScreen
latestPointerScreen = startPointerScreen
self.startDisplayID = startDisplayID
hasCrossedThreshold = false
phase = .pressed
}
var proposedAnchorScreen: CGPoint {
CGPoint(
x: startAnchorScreen.x
+ latestPointerScreen.x
- startPointerScreen.x,
y: startAnchorScreen.y
+ latestPointerScreen.y
- startPointerScreen.y
)
}
mutating func updatePointer(_ point: CGPoint) {
guard phase != .finalized else { return }
latestPointerScreen = point
if !hasCrossedThreshold {
hasCrossedThreshold = OverlayPetPointerGesture.exceedsDragThreshold(
from: startPointerScreen,
to: point
)
if hasCrossedThreshold {
phase = .dragging
}
}
}
mutating func compareAndFinalize(interactionID: UUID) -> Bool {
guard self.interactionID == interactionID,
phase != .finalized else { return false }
phase = .finalized
return true
}
}
struct OverlayDragDisplay: Equatable, Sendable {
let id: String
let frame: CGRect
let visibleFrame: CGRect
}
/// Display geometry and presentation cadence captured once per gesture rather
/// than per pointer sample. Rebuilding the screen list and asking CoreGraphics
/// for the selected display mode on every event costs a window-server round
/// trip inside the pointer handler, which is exactly the per-event main-thread
/// work that makes a fast drag stutter. The desktop arrangement only changes
/// through a screen-parameter notification, so a gesture-scoped snapshot stays
/// authoritative for the whole drag.
struct OverlayDragDisplayCatalog: Equatable, Sendable {
let displays: [OverlayDragDisplay]
let fallbackCadence: OverlayDisplayRefreshCadence
private let cadences: [String: OverlayDisplayRefreshCadence]
init(
displays: [OverlayDragDisplay],
cadences: [String: OverlayDisplayRefreshCadence],
fallbackCadence: OverlayDisplayRefreshCadence
) {
self.displays = displays
self.cadences = cadences
self.fallbackCadence = fallbackCadence
}
func cadence(for displayID: String?) -> OverlayDisplayRefreshCadence {
guard let displayID, let cadence = cadences[displayID] else {
return fallbackCadence
}
return cadence
}
}
/// Selects the drag display from injected geometry so crossing a display edge
/// does not replace the absolute pointer anchor with window-relative deltas.
enum OverlayDragScreenResolver {
static func resolve(
pointer: CGPoint,
proposedPetCenter: CGPoint,
displays: [OverlayDragDisplay],
fallbackDisplayID: String?
) -> OverlayDragDisplay? {
displays.first { $0.frame.contains(pointer) }
?? displays.first { $0.frame.contains(proposedPetCenter) }
?? fallbackDisplayID.flatMap { fallbackID in
displays.first { $0.id == fallbackID }
}
?? displays.first
}
}
enum OverlayInteractionWindowRole: Equatable, Sendable {
case parentPetPanel
case bubbleChildPanel
case menuChildPanel
}
struct OverlayInteractionWindowMove: Equatable, Sendable {
let role: OverlayInteractionWindowRole
let frame: CGRect
}
enum OverlayAuxiliaryPanelAttachmentPlan: Equatable, Sendable {
case none
case attach
case reparent
case rejectCycle
}
enum OverlayAuxiliaryPanelAttachmentPolicy {
static func plan(
currentParentMatchesTarget: Bool,
hasCurrentParent: Bool,
wouldCreateCycle: Bool
) -> OverlayAuxiliaryPanelAttachmentPlan {
if wouldCreateCycle { return .rejectCycle }
if currentParentMatchesTarget { return .none }
return hasCurrentParent ? .reparent : .attach
}
}
enum OverlayCompositionLayoutMode: Equatable, Sendable {
case resting
case directManipulation
}
/// The bubble attaches to the pet's top or bottom edge only. Placing it beside
/// the pet would break the single vertical gap the composition is built on, so
/// there is deliberately no left/right case to fall back to.
enum OverlayBubbleAnchorDirection: String, CaseIterable, Equatable, Sendable {
case above
case below
var opposite: Self {
self == .above ? .below : .above
}
}
/// The complete bubble attachment decision. Both the side the bubble hangs
/// from and the edge it aligns to are sticky: each is re-decided only when the
/// current choice no longer fits the safe area. Deciding the horizontal edge
/// from the raw screen midline instead would flip the bubble by its own width
/// the moment a drag crosses the middle of the display.
struct OverlayBubbleAnchor: Equatable, Sendable {
let direction: OverlayBubbleAnchorDirection
let alignsLeft: Bool
init(direction: OverlayBubbleAnchorDirection, alignsLeft: Bool) {
self.direction = direction
self.alignsLeft = alignsLeft
}
}
struct OverlayBubblePanelLayout: Equatable, Sendable {
let frame: CGRect
let anchor: OverlayBubbleAnchor
var direction: OverlayBubbleAnchorDirection { anchor.direction }
}
struct OverlayAuxiliaryRelativeFrameSnapshot: Equatable, Sendable {
let bubbleFrame: CGRect?
let menuFrame: CGRect?
static func capture(
parentFrame: CGRect,
bubbleFrame: CGRect?,
menuFrame: CGRect?
) -> Self {
Self(
bubbleFrame: bubbleFrame.map {
$0.offsetBy(dx: -parentFrame.minX, dy: -parentFrame.minY)
},
menuFrame: menuFrame.map {
$0.offsetBy(dx: -parentFrame.minX, dy: -parentFrame.minY)
}
)
}
}
/// The pet's position inside its own panel, captured once when a gesture
/// begins. Every presented frame then derives the panel origin from the
/// presented pet center alone, so the composition can never accumulate a
/// per-frame error and no other window move can shift the grab point.
struct OverlayDirectManipulationAnchor: Equatable, Sendable {
let panelOriginOffset: CGVector
let panelSize: CGSize
init(panelFrame: CGRect, petScreenCenter: CGPoint) {
panelOriginOffset = CGVector(
dx: panelFrame.minX - petScreenCenter.x,
dy: panelFrame.minY - petScreenCenter.y
)
panelSize = panelFrame.size
}
func panelFrame(forPetScreenCenter center: CGPoint) -> CGRect {
CGRect(
origin: CGPoint(
x: center.x + panelOriginOffset.dx,
y: center.y + panelOriginOffset.dy
),
size: panelSize
)
}
}
/// Auxiliary geometry that stays fixed for a whole gesture. Bubble content is
/// not re-measured while the pointer moves, so the drag path re-anchors the
/// size captured when the gesture began and never re-runs text layout per
/// display tick.
struct OverlayDirectManipulationAuxiliaryInput: Equatable, Sendable {
let displayWidthPt: CGFloat
let visibleFrame: CGRect
let petVisualEnvelope: OverlayPetVisualEnvelope?
let bubbleSize: CGSize?
let previousBubbleAnchor: OverlayBubbleAnchor?
let menuVisible: Bool
init(
displayWidthPt: CGFloat,
visibleFrame: CGRect,
petVisualEnvelope: OverlayPetVisualEnvelope?,
bubbleSize: CGSize?,
previousBubbleAnchor: OverlayBubbleAnchor?,
menuVisible: Bool
) {
self.displayWidthPt = displayWidthPt
self.visibleFrame = visibleFrame
self.petVisualEnvelope = petVisualEnvelope
self.bubbleSize = bubbleSize
self.previousBubbleAnchor = previousBubbleAnchor
self.menuVisible = menuVisible
}
}
struct OverlayDirectManipulationPlan: Equatable, Sendable {
let moves: [OverlayInteractionWindowMove]
let bubbleAnchor: OverlayBubbleAnchor?
}
/// The display-link delivery path moves the whole composition, not just the
/// pet. Every frame in the plan is an absolute position derived from the
/// presented pet center and the anchor captured when the gesture began, so a
/// dropped, late, or externally disturbed frame cannot leave a residue that the
/// next frame builds on. Auxiliary panels are AppKit child windows, so a parent
/// translation already carries them; these moves additionally re-anchor them,
/// which is what keeps a bubble attached to the pet while the pet crosses a
/// screen edge instead of deferring one hard correction to the release. Frames
/// stay unrounded during a gesture so a 1 px integral snap cannot shimmer
/// against the smoothly translated parent.
enum OverlayDirectManipulationMovePlan {
static func moves(
anchor: OverlayDirectManipulationAnchor,
parentFrame: CGRect,
presentedPetCenter: CGPoint
) -> [OverlayInteractionWindowMove] {
plan(
anchor: anchor,
parentFrame: parentFrame,
presentedPetCenter: presentedPetCenter,
auxiliary: nil
).moves
}
static func plan(
anchor: OverlayDirectManipulationAnchor,
parentFrame: CGRect,
presentedPetCenter: CGPoint,
auxiliary: OverlayDirectManipulationAuxiliaryInput?
) -> OverlayDirectManipulationPlan {
var moves: [OverlayInteractionWindowMove] = []
let targetFrame = anchor.panelFrame(forPetScreenCenter: presentedPetCenter)
if targetFrame != parentFrame {
moves.append(OverlayInteractionWindowMove(
role: .parentPetPanel,
frame: targetFrame
))
}
guard let auxiliary else {
return OverlayDirectManipulationPlan(moves: moves, bubbleAnchor: nil)
}
var bubbleAnchor: OverlayBubbleAnchor?
if let bubbleSize = auxiliary.bubbleSize,
bubbleSize.width > 0,
bubbleSize.height > 0
{
let placement = OverlayGeometry.bubblePlacement(
bubbleSize: bubbleSize,
displayWidthPt: auxiliary.displayWidthPt,
petScreenCenter: presentedPetCenter,
screenFrame: auxiliary.visibleFrame,
petVisualEnvelope: auxiliary.petVisualEnvelope,
previousAnchor: auxiliary.previousBubbleAnchor
)
bubbleAnchor = placement.anchor
moves.append(OverlayInteractionWindowMove(
role: .bubbleChildPanel,
frame: OverlayGeometry.rect(
center: placement.center,
size: bubbleSize
)
))
}
if auxiliary.menuVisible {
moves.append(OverlayInteractionWindowMove(
role: .menuChildPanel,
frame: OverlayGeometry.rect(
center: OverlayGeometry.menuScreenCenter(
petScreenCenter: presentedPetCenter,
displayWidthPt: auxiliary.displayWidthPt,
petVisualEnvelope: auxiliary.petVisualEnvelope
),
size: OverlayGeometry.menuHitSize
)
))
}
return OverlayDirectManipulationPlan(
moves: moves,
bubbleAnchor: bubbleAnchor
)
}
}
/// Pure geometry for direct manipulation. Dragging has no rubber band,
/// projection, velocity handoff, settling, momentum, or bounce.
enum OverlayPetDragGeometry {
static func clampedCenter(
_ proposedCenter: CGPoint,
displayWidthPt: CGFloat,
visibleFrame: CGRect,
clickMenuEnabled: Bool = true,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGPoint {
OverlayGeometry.clampedPetScreenCenter(
proposedCenter,
displayWidthPt: displayWidthPt,
visibleFrame: visibleFrame,
clickMenuEnabled: clickMenuEnabled,
petVisualEnvelope: petVisualEnvelope
)
}
}
enum OverlayPresentedAgentState {
/// Current PetCore snapshots use `activeSessions` as the bounded liveness
/// projection for both bubbles and pet reactions. `AppStore` already
/// synthesizes that array from `canonicalState` when decoding an older
/// compatible snapshot, so a current empty array must not fall back to a
/// canonical session that PetCore deliberately omitted as expired.
static func resolve(
canonicalState _: ActiveAgentState?,
activeSessions: [ActiveAgentState],
dismissedSessionIDs: Set<String>
) -> ActiveAgentState? {
activeSessions.first(where: {
!isDismissed($0, dismissedSessionIDs: dismissedSessionIDs)
})
}
static func newlyActivatedDismissalIDs(
activeSessions: [ActiveAgentState],
lastReopenIDBySession: [String: String]
) -> Set<String> {
Set(activeSessions.compactMap { state -> String? in
let stableID = OverlaySessionContent.stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
)
guard lastReopenIDBySession[stableID] != OverlaySessionContent.reopenID(for: state)
else {
return nil
}
return stableID
})
}
private static func isDismissed(
_ state: ActiveAgentState,
dismissedSessionIDs: Set<String>
) -> Bool {
dismissedSessionIDs.contains(OverlaySessionContent.stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
))
}
}
enum OverlayGeometry {
static let minimumDisplayWidthPt = CGFloat(
OverlayPlacement.minimumDisplayWidthPt
)
static let maximumDisplayWidthPt = CGFloat(
OverlayPlacement.maximumDisplayWidthPt
)
static let defaultDisplayWidthPt = CGFloat(
OverlayPlacement.defaultDisplayWidthPt
)
static let displayAspectHeightRatio: CGFloat = 208 / 192
static let bubbleWidth: CGFloat = 344
static let bubbleAdaptiveMinimumWidth: CGFloat = 304
static let bubbleAdaptiveMaximumWidth: CGFloat = 360
static let bubbleMinimumHeight: CGFloat = 70
static let bubbleMaximumHeight: CGFloat = 680
static let bubbleGap: CGFloat = 3
static let bubbleMinimumWidth: CGFloat = 304
static let bubbleStackSpacing: CGFloat = 4
/// Keeps the multi-session surface visibly lens-like without turning the
/// variable-height product card into a copied activity-pill capsule.
static let bubbleCornerRadius: CGFloat = 20
static let bubbleLeadingPadding: CGFloat = 8
static let bubbleTrailingPadding: CGFloat = 8
static let bubbleVerticalPadding: CGFloat = 7
static func bubbleGroupHeaderHeight(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
max(
OverlayBubbleTypography.scaledControlMetric(17, scale: fontScale),
ceil(lineHeight(for: OverlayBubbleTypography.measurementFont(
.caption1,
scale: fontScale
))) + 2
)
}
static let bubbleGroupHeaderSpacing: CGFloat = 4
static func bubbleGroupToggleWidth(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
OverlayBubbleTypography.scaledControlMetric(44, scale: fontScale)
}
static let bubbleSessionHorizontalPadding: CGFloat = 8
static let bubbleSessionVerticalPadding: CGFloat = 5
static let bubbleSessionTitleSpacing: CGFloat = 2
static let bubbleDetailLineLimit = 2
static let bubbleStandaloneSummaryLineLimit = 2
static let bubbleStandaloneMetadataSpacing: CGFloat = 3
static func bubbleStandaloneMetadataHeight(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
max(
bubbleHeaderAvatarWidth,
ceil(lineHeight(for: OverlayBubbleTypography.measurementFont(
.caption1,
scale: fontScale
))),
ceil(lineHeight(for: OverlayBubbleTypography.measurementFont(
.caption2,
scale: fontScale
))) + 2
)
}
static func bubbleDetailTextHeight(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
ceil(
lineHeight(for: OverlayBubbleTypography.measurementFont(
.caption1,
scale: fontScale
)) * CGFloat(bubbleDetailLineLimit)
)
}
static func bubbleDetailedHeaderHeight(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
max(
ceil(lineHeight(for: OverlayBubbleTypography.measurementFont(
.callout,
scale: fontScale
))),
ceil(lineHeight(for: OverlayBubbleTypography.measurementFont(
.caption2,
scale: fontScale
))) + 2
)
}
static func bubbleStandaloneSummaryTextHeight(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
ceil(
lineHeight(for: OverlayBubbleTypography.measurementFont(
.callout,
scale: fontScale
)) * CGFloat(bubbleStandaloneSummaryLineLimit)
)
}
static let bubbleSessionDividerHeight: CGFloat = 1
static let bubbleHeaderAvatarWidth: CGFloat = 14
static func bubbleHeaderButtonSize(
fontScale: BubbleFontScale = .standard
) -> CGFloat {
max(
OverlayBubbleTypography.scaledControlMetric(15, scale: fontScale),
bubbleGroupHeaderHeight(fontScale: fontScale) - 2
)
}
static let bubbleHeaderGap: CGFloat = 5
static let bubbleCollapsedStackLayerCount = 2
static let bubbleCollapsedStackLayerOffset: CGFloat = 4
static let bubbleCollapsedStackLayerInset: CGFloat = 5
static let bubbleStandaloneStackLayerOffset: CGFloat = 10
static let bubbleStandaloneStackLayerInset: CGFloat = 10
static let menuVisualSize = CGSize(width: 24, height: 24)
static let menuHitSize = CGSize(width: 38, height: 38)
static let controlVisibilitySlop: CGFloat = 4
private static let petStagePadding: CGFloat = 8
private static let petShadowRadius: CGFloat = 10
private static let petShadowYOffset: CGFloat = 6
private static let panelContentPadding: CGFloat = 2
private static let petControlTrailingGap: CGFloat = 8
private static let petMenuToPetGap: CGFloat = 3
static func clampedPoint(_ base: CGPoint, in size: CGSize) -> CGPoint {
CGPoint(
x: min(max(base.x, 44), max(44, size.width - 44)),
y: min(max(base.y, 44), max(44, size.height - 44))
)
}
static func resolvedBubbleSize(
in size: CGSize,
content: OverlayBubbleContent = .measurementPlaceholder,
fontScale: BubbleFontScale = .standard
) -> CGSize {
let availableWidth = max(96, size.width - 32)
let maximumWidth = min(bubbleAdaptiveMaximumWidth, availableWidth)
let width = maximumWidth < bubbleAdaptiveMinimumWidth
? maximumWidth
: min(bubbleWidth, maximumWidth)
let measuredHeight = measuredBubbleHeight(
width: width,
content: content,
fontScale: fontScale
)
let availableHeight = max(44, size.height - 16)
return CGSize(
width: width,
height: min(
bubbleMaximumHeight,
availableHeight,
max(bubbleMinimumHeight, measuredHeight)
)
)
}
static func resolvedBubbleSizes(
in size: CGSize,
contents: [OverlayBubbleContent],
fontScale: BubbleFontScale = .standard
) -> [CGSize] {
contents.map {
resolvedBubbleSize(in: size, content: $0, fontScale: fontScale)
}
}
static func resolvedBubbleStackSize(
in size: CGSize,
contents: [OverlayBubbleContent],
fontScale: BubbleFontScale = .standard
) -> CGSize {
let bubbleSizes = resolvedBubbleSizes(
in: size,
contents: contents,
fontScale: fontScale
)
guard !bubbleSizes.isEmpty else { return .zero }
let totalHeight = bubbleSizes.map(\.height).reduce(0, +)
+ CGFloat(max(0, bubbleSizes.count - 1)) * bubbleStackSpacing
return CGSize(
width: bubbleSizes.map(\.width).max() ?? 0,
height: totalHeight
)
}
static func bubbleRects(
inPanelSize panelSize: CGSize,
visibleFrameSize: CGSize,
contents: [OverlayBubbleContent],
alignLeft: Bool,
fontScale: BubbleFontScale = .standard
) -> [CGRect] {
let bubbleSizes = resolvedBubbleSizes(
in: visibleFrameSize,
contents: contents,
fontScale: fontScale
)
var currentY: CGFloat = 0
return bubbleSizes.map { size in
defer { currentY += size.height + bubbleStackSpacing }
return CGRect(
x: alignLeft ? 0 : max(0, panelSize.width - size.width),
y: currentY,
width: size.width,
height: size.height
)
}
}
/// Bubble contents always use top-to-bottom reading order. The first card
/// is the same priority card shown in the folded foreground, independent
/// of whether the composition attaches above or below the pet. Keeping one
/// order also ensures SwiftUI rendering, accessibility, and AppKit hit
/// routing resolve the same card at every disclosure level.
static func visuallyOrderedBubbleContents(
_ contents: [OverlayBubbleContent],
anchorDirection _: OverlayBubbleAnchorDirection
) -> [OverlayBubbleContent] {
contents
}
static func bubbleCloseHitRect(
in bubbleRect: CGRect,
fontScale: BubbleFontScale = .standard
) -> CGRect {
let headerHitHeight = bubbleVerticalPadding
+ bubbleGroupHeaderHeight(fontScale: fontScale)
+ bubbleGroupHeaderSpacing
let headerTrailingControlWidth = bubbleTrailingPadding
+ bubbleHeaderButtonSize(fontScale: fontScale)
+ bubbleHeaderGap
return CGRect(
x: bubbleRect.maxX - headerTrailingControlWidth,
y: bubbleRect.minY,
width: headerTrailingControlWidth,
height: min(headerHitHeight, bubbleRect.height)
)
}
static func bubbleGroupToggleHitRect(
in bubbleRect: CGRect,
content: OverlayBubbleContent,
fontScale: BubbleFontScale = .standard
) -> CGRect {
guard content.hasMultipleSessions,
!content.isStandaloneSessionCard
else { return .zero }
let closeRect = bubbleCloseHitRect(in: bubbleRect, fontScale: fontScale)
let toggleWidth = bubbleGroupToggleWidth(fontScale: fontScale)
return CGRect(
x: closeRect.minX - toggleWidth,
y: bubbleRect.minY,
width: toggleWidth,
height: closeRect.height
)
}
static func bubbleSessionRects(
in bubbleRect: CGRect,
content: OverlayBubbleContent,
fontScale: BubbleFontScale = .standard
) -> [CGRect] {
let innerWidth = max(0, bubbleRect.width - bubbleLeadingPadding - bubbleTrailingPadding)
let rowHeights = bubbleSessionRowHeights(
bubbleWidth: bubbleRect.width,
content: content,
fontScale: fontScale
)
var y = bubbleRect.minY + bubbleVerticalPadding
if !content.isStandaloneSessionCard {
y += bubbleGroupHeaderHeight(fontScale: fontScale) + bubbleGroupHeaderSpacing
}
return rowHeights.map { height in
defer { y += height + bubbleSessionDividerHeight }
return CGRect(
x: bubbleRect.minX + bubbleLeadingPadding,
y: y,
width: innerWidth,
height: height
)
}
}
static func bubbleSessionRowHeights(
bubbleWidth: CGFloat,
content: OverlayBubbleContent,
fontScale: BubbleFontScale = .standard
) -> [CGFloat] {
content.visibleSessions.map { session in
measuredSessionRowHeight(
width: bubbleWidth,
session: session,
isStandaloneSessionCard: content.isStandaloneSessionCard,
fontScale: fontScale
)
}
}
static func defaultPetScreenCenter(
in visibleFrame: CGRect,
displayWidthPt: CGFloat
) -> CGPoint {
let petSize = petVisibleSize(displayWidthPt: displayWidthPt)
return clampedPetScreenCenter(
CGPoint(
x: visibleFrame.maxX - petSize.width * 0.72,
y: visibleFrame.minY + petSize.height * 0.62
),
displayWidthPt: displayWidthPt,
visibleFrame: visibleFrame
)
}
static func localPoint(
forScreenPoint screenPoint: CGPoint,
panelFrame: CGRect,
fallbackIn size: CGSize
) -> CGPoint {
guard !panelFrame.isEmpty else {
return CGPoint(x: size.width * 0.72, y: size.height * 0.64)
}
return CGPoint(
x: screenPoint.x - panelFrame.minX,
y: panelFrame.maxY - screenPoint.y
)
}
static func screenPoint(forLocalPoint localPoint: CGPoint, panelFrame: CGRect) -> CGPoint {
CGPoint(
x: panelFrame.minX + localPoint.x,
y: panelFrame.maxY - localPoint.y
)
}
static func topLeftPoint(forViewPoint point: CGPoint, in viewHeight: CGFloat, isFlipped: Bool) -> CGPoint {
CGPoint(
x: point.x,
y: isFlipped ? point.y : viewHeight - point.y
)
}
static func clampedPetScreenCenter(
_ proposedCenter: CGPoint,
displayWidthPt: CGFloat,
visibleFrame: CGRect,
clickMenuEnabled: Bool = true,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGPoint {
guard !visibleFrame.isEmpty else { return proposedCenter }
let relativeBounds = petMovementScreenBounds(
displayWidthPt: displayWidthPt,
petScreenCenter: .zero,
clickMenuEnabled: clickMenuEnabled,
petVisualEnvelope: petVisualEnvelope
)
let edgeInset: CGFloat = 1
let minX = visibleFrame.minX - relativeBounds.minX + edgeInset
let maxX = visibleFrame.maxX - relativeBounds.maxX - edgeInset
let minY = visibleFrame.minY - relativeBounds.minY + edgeInset
let maxY = visibleFrame.maxY - relativeBounds.maxY - edgeInset
return CGPoint(
x: inwardCanonicalClamp(
proposedCenter.x,
lower: minX,
upper: maxX
),
y: inwardCanonicalClamp(
proposedCenter.y,
lower: minY,
upper: maxY
)
)
}
/// Movement intentionally permits the pet to enter the Dock reservation at
/// the bottom or side of a display. The menu-bar strip remains protected so
/// the pet and its controls cannot become unreachable behind system chrome.
static func petMovementFrame(screenFrame: CGRect, visibleFrame: CGRect) -> CGRect {
guard !screenFrame.isEmpty else { return visibleFrame }
guard !visibleFrame.isEmpty else { return screenFrame }
let protectedTop = min(screenFrame.maxY, visibleFrame.maxY)
guard protectedTop > screenFrame.minY else { return screenFrame }
return CGRect(
x: screenFrame.minX,
y: screenFrame.minY,
width: screenFrame.width,
height: protectedTop - screenFrame.minY
)
}
static func bubblePosition(
bubbleSize: CGSize,
displayWidthPt: CGFloat,
petCenter: CGPoint,
panelFrame: CGRect,
screenFrame: CGRect,
in size: CGSize
) -> CGPoint {
let petScreenPoint = screenPoint(forLocalPoint: petCenter, panelFrame: panelFrame)
let screenCenter = bubbleScreenCenter(
bubbleSize: bubbleSize,
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenPoint,
screenFrame: screenFrame
)
return localPoint(forScreenPoint: screenCenter, panelFrame: panelFrame, fallbackIn: size)
}
static func bubbleScreenCenter(
bubbleSize: CGSize,
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
screenFrame: CGRect,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGPoint {
bubblePlacement(
bubbleSize: bubbleSize,
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
screenFrame: screenFrame,
petVisualEnvelope: petVisualEnvelope,
previousAnchor: nil
).center
}
static func bubblePlacement(
bubbleSize: CGSize,
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
screenFrame: CGRect,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil,
previousAnchor: OverlayBubbleAnchor?
) -> (center: CGPoint, anchor: OverlayBubbleAnchor) {
let petSize = petVisibleSize(displayWidthPt: displayWidthPt)
let verticalOffsets = petVisualVerticalOffsets(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
)
let petLeft = petScreenCenter.x - petSize.width / 2
let petRight = petScreenCenter.x + petSize.width / 2
let petTop = petScreenCenter.y + verticalOffsets.top
let petBottom = petScreenCenter.y + verticalOffsets.bottom
func attachedX(alignsLeft: Bool) -> CGFloat {
alignsLeft
? petLeft + bubbleSize.width / 2
: petRight - bubbleSize.width / 2
}
let midlineAlignsLeft = screenFrame.isEmpty
? false
: petScreenCenter.x < screenFrame.midX
// The screen midline alone would swap the attached edge — a jump of a
// full bubble width minus a pet width — the instant a drag crosses the
// middle of the display. Keep the established edge while it still fits
// the safe area so the bubble only changes sides when it has to.
let safeFrame = screenFrame.isEmpty
? screenFrame
: screenFrame.insetBy(dx: 8, dy: 8)
let alignLeft: Bool
if let previous = previousAnchor,
previous.alignsLeft != midlineAlignsLeft,
!screenFrame.isEmpty
{
let previousX = attachedX(alignsLeft: previous.alignsLeft)
let fits = previousX - bubbleSize.width / 2 >= safeFrame.minX
&& previousX + bubbleSize.width / 2 <= safeFrame.maxX
alignLeft = fits ? previous.alignsLeft : midlineAlignsLeft
} else {
alignLeft = midlineAlignsLeft
}
// Horizontal placement is the only part that adapts to a screen edge.
// The pet keeps its own position, so the bubble slides along the pet's
// top or bottom edge until it fits instead of being pushed away from it.
let attachedX = screenFrame.isEmpty
? attachedX(alignsLeft: alignLeft)
: clamp(
attachedX(alignsLeft: alignLeft),
lower: safeFrame.minX + bubbleSize.width / 2,
upper: safeFrame.maxX - bubbleSize.width / 2
)
// The bubble hangs off the pet's top or bottom edge and nowhere else,
// always at the same authored gap. Vertical position is therefore never
// clamped: an edge flips the side instead of shortening the distance,
// so the pair keeps one rigid shape wherever the pet is dragged.
func center(for direction: OverlayBubbleAnchorDirection) -> CGPoint {
switch direction {
case .above:
CGPoint(x: attachedX, y: petTop + bubbleGap + bubbleSize.height / 2)
case .below:
CGPoint(x: attachedX, y: petBottom - bubbleGap - bubbleSize.height / 2)
}
}
func fits(_ direction: OverlayBubbleAnchorDirection) -> Bool {
guard !screenFrame.isEmpty else { return true }
let candidate = center(for: direction)
return candidate.y - bubbleSize.height / 2 >= safeFrame.minY
&& candidate.y + bubbleSize.height / 2 <= safeFrame.maxY
}
/// How much of the bubble the safe area can show on one side. Used only
/// to break a tie when a session stack is taller than the room on both
/// sides; the gap itself is never traded away for visible area.
func visibleHeight(_ direction: OverlayBubbleAnchorDirection) -> CGFloat {
guard !screenFrame.isEmpty else { return bubbleSize.height }
let candidate = center(for: direction)
let overlap = min(candidate.y + bubbleSize.height / 2, safeFrame.maxY)
- max(candidate.y - bubbleSize.height / 2, safeFrame.minY)
return max(0, overlap)
}
let preferred = previousAnchor?.direction ?? .above
let direction: OverlayBubbleAnchorDirection = if fits(preferred) {
preferred
} else if fits(preferred.opposite) {
preferred.opposite
} else if visibleHeight(preferred.opposite) > visibleHeight(preferred) {
// A stack taller than the display fits on neither side. Show as much
// of it as possible at the authored distance instead of sliding it
// over the pet.
preferred.opposite
} else {
preferred
}
return (
center(for: direction),
OverlayBubbleAnchor(direction: direction, alignsLeft: alignLeft)
)
}
static func bubbleAlignsLeft(petScreenCenter: CGPoint, screenFrame: CGRect) -> Bool {
screenFrame.isEmpty ? false : petScreenCenter.x < screenFrame.midX
}
static func rect(center: CGPoint, size: CGSize) -> CGRect {
CGRect(
x: center.x - size.width / 2,
y: center.y - size.height / 2,
width: size.width,
height: size.height
)
}
static func screenRect(forTopLeftRect rect: CGRect, panelFrame: CGRect) -> CGRect {
CGRect(
x: panelFrame.minX + rect.minX,
y: panelFrame.maxY - rect.maxY,
width: rect.width,
height: rect.height
)
}
static func petDragSize(displayWidthPt: CGFloat) -> CGSize {
let visible = petVisibleSize(displayWidthPt: displayWidthPt)
return CGSize(
width: max(26, visible.width * 0.78),
height: max(38, visible.height * 0.90)
)
}
static func petVisibleSize(displayWidthPt: CGFloat) -> CGSize {
let width = clampedDisplayWidthPt(displayWidthPt)
return CGSize(
width: width,
height: width * displayAspectHeightRatio
)
}
static func petVisualVerticalOffsets(
displayWidthPt: CGFloat,
envelope: OverlayPetVisualEnvelope?
) -> (bottom: CGFloat, top: CGFloat) {
let bounds = fittedPetVisualBounds(
displayWidthPt: displayWidthPt,
envelope: envelope
)
return (bounds.minY, bounds.maxY)
}
static func petVisualHorizontalOffsets(
displayWidthPt: CGFloat,
envelope: OverlayPetVisualEnvelope?
) -> (left: CGFloat, right: CGFloat) {
let bounds = fittedPetVisualBounds(
displayWidthPt: displayWidthPt,
envelope: envelope
)
return (bounds.minX, bounds.maxX)
}
private static func fittedPetVisualBounds(
displayWidthPt: CGFloat,
envelope: OverlayPetVisualEnvelope?
) -> CGRect {
let spriteSize = petVisibleSize(displayWidthPt: displayWidthPt)
guard
let envelope,
envelope.canvasSize.width > 0,
envelope.canvasSize.height > 0,
!envelope.visibleBounds.isEmpty
else {
return rect(center: .zero, size: spriteSize)
}
let canvasBounds = CGRect(origin: .zero, size: envelope.canvasSize)
let visibleBounds = envelope.visibleBounds.intersection(canvasBounds)
guard !visibleBounds.isNull, !visibleBounds.isEmpty else {
return rect(center: .zero, size: spriteSize)
}
let fittedScale = min(
spriteSize.width / envelope.canvasSize.width,
spriteSize.height / envelope.canvasSize.height
)
let fittedCanvasWidth = envelope.canvasSize.width * fittedScale
let fittedCanvasHeight = envelope.canvasSize.height * fittedScale
let fittedCanvasLeft = -spriteSize.width / 2
+ (spriteSize.width - fittedCanvasWidth) / 2
let fittedCanvasBottom = -spriteSize.height / 2
+ (spriteSize.height - fittedCanvasHeight) / 2
return CGRect(
x: fittedCanvasLeft + visibleBounds.minX * fittedScale,
y: fittedCanvasBottom + visibleBounds.minY * fittedScale,
width: visibleBounds.width * fittedScale,
height: visibleBounds.height * fittedScale
)
}
static func clampedDisplayWidthPt(_ displayWidthPt: CGFloat) -> CGFloat {
guard displayWidthPt.isFinite else { return defaultDisplayWidthPt }
return min(
maximumDisplayWidthPt,
max(minimumDisplayWidthPt, displayWidthPt)
)
}
static func resolvedInitialDisplayWidthPt(
persistedDisplayWidthPt: CGFloat,
hasPersistedPosition: Bool
) -> CGFloat {
guard hasPersistedPosition,
persistedDisplayWidthPt.isFinite,
persistedDisplayWidthPt > 0 else {
return defaultDisplayWidthPt
}
return clampedDisplayWidthPt(persistedDisplayWidthPt)
}
static func bottomAnchoredCenter(
from center: CGPoint,
currentDisplayWidthPt: CGFloat,
proposedDisplayWidthPt: CGFloat
) -> CGPoint {
let currentHeight = petVisibleSize(
displayWidthPt: currentDisplayWidthPt
).height
let proposedHeight = petVisibleSize(
displayWidthPt: proposedDisplayWidthPt
).height
let bottomAnchorY = center.y - currentHeight / 2
return CGPoint(
x: center.x,
y: bottomAnchorY + proposedHeight / 2
)
}
static func menuCenter(
petCenter: CGPoint,
displayWidthPt: CGFloat,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGPoint {
let petRight = petVisualHorizontalOffsets(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
).right
return CGPoint(
x: petCenter.x + petRight + petControlTrailingGap,
y: petCenter.y + menuLocalVerticalOffset(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
)
)
}
static func menuScreenCenter(
petScreenCenter: CGPoint,
displayWidthPt: CGFloat,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGPoint {
let petRight = petVisualHorizontalOffsets(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
).right
return CGPoint(
x: petScreenCenter.x + petRight + petControlTrailingGap,
y: petScreenCenter.y - menuLocalVerticalOffset(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
)
)
}
private static func menuLocalVerticalOffset(
displayWidthPt: CGFloat,
envelope: OverlayPetVisualEnvelope?
) -> CGFloat {
let petTop = petVisualVerticalOffsets(
displayWidthPt: displayWidthPt,
envelope: envelope
).top
return -petTop + menuHitSize.height / 2 + petMenuToPetGap
}
static func petPanelScreenFrame(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
clickMenuEnabled: Bool
) -> CGRect {
petInteractiveScreenBounds(
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
clickMenuEnabled: clickMenuEnabled
).integral
}
static func petInteractiveScreenBounds(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
clickMenuEnabled: Bool
) -> CGRect {
let visibleSize = petVisibleSize(displayWidthPt: displayWidthPt)
let stageSize = CGSize(
width: visibleSize.width + petStagePadding,
height: visibleSize.height + petStagePadding
)
let stage = rect(center: petScreenCenter, size: stageSize)
var bounds = CGRect(
x: stage.minX - petShadowRadius,
y: stage.minY - petShadowRadius - petShadowYOffset,
width: stage.width + petShadowRadius * 2,
height: stage.height + petShadowRadius * 2
)
if clickMenuEnabled {
bounds = bounds.union(rect(
center: menuScreenCenter(
petScreenCenter: petScreenCenter,
displayWidthPt: displayWidthPt
),
size: menuHitSize
))
}
return bounds.insetBy(dx: -panelContentPadding, dy: -panelContentPadding)
}
/// Bounds used only for movement clamping. The render panel remains large
/// enough for shadows and animation, while dragging is constrained by the
/// pixels that are actually visible plus the two reachable controls. This
/// prevents transparent sprite padding from creating an invisible wall.
static func petMovementScreenBounds(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
clickMenuEnabled: Bool,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGRect {
var bounds = fittedPetVisualBounds(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
)
.offsetBy(dx: petScreenCenter.x, dy: petScreenCenter.y)
.insetBy(dx: -panelContentPadding, dy: -panelContentPadding)
if clickMenuEnabled {
bounds = bounds.union(rect(
center: menuScreenCenter(
petScreenCenter: petScreenCenter,
displayWidthPt: displayWidthPt,
petVisualEnvelope: petVisualEnvelope
),
size: menuHitSize
))
}
return bounds.insetBy(dx: -panelContentPadding, dy: -panelContentPadding)
}
static func shouldShowControls(
at screenPoint: CGPoint,
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
clickMenuEnabled: Bool,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> Bool {
var regions = [
petVisualScreenRect(
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
petVisualEnvelope: petVisualEnvelope
).insetBy(dx: -controlVisibilitySlop, dy: -controlVisibilitySlop)
]
if clickMenuEnabled {
regions.append(rect(
center: menuScreenCenter(
petScreenCenter: petScreenCenter,
displayWidthPt: displayWidthPt,
petVisualEnvelope: petVisualEnvelope
),
size: menuHitSize
).insetBy(dx: -controlVisibilitySlop, dy: -controlVisibilitySlop))
}
return regions.contains { $0.contains(screenPoint) }
}
static func petVisualScreenRect(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil
) -> CGRect {
let horizontal = petVisualHorizontalOffsets(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
)
let vertical = petVisualVerticalOffsets(
displayWidthPt: displayWidthPt,
envelope: petVisualEnvelope
)
return CGRect(
x: petScreenCenter.x + horizontal.left,
y: petScreenCenter.y + vertical.bottom,
width: max(0, horizontal.right - horizontal.left),
height: max(0, vertical.top - vertical.bottom)
)
}
/// Maps a top-left panel point through the same aspect-fit transform used
/// by `PetMetalFrameRenderer`. The frame image is centered horizontally in
/// its animation canvas and bottom-aligned; the resulting image point is
/// then sampled from the immutable one-bit alpha mask.
static func petFrameContainsOpaquePixel(
atTopLeftPoint point: CGPoint,
displayWidthPt: CGFloat,
petCenter: CGPoint,
frameHitTest: OverlayPetFrameHitTest
) -> Bool {
guard displayWidthPt.isFinite, displayWidthPt > 0,
point.x.isFinite, point.y.isFinite,
frameHitTest.canvasSize.width.isFinite,
frameHitTest.canvasSize.height.isFinite,
frameHitTest.canvasSize.width > 0,
frameHitTest.canvasSize.height > 0 else {
return false
}
let drawableSize = petVisibleSize(displayWidthPt: displayWidthPt)
let drawableRect = rect(center: petCenter, size: drawableSize)
guard drawableRect.contains(point) else { return false }
let fittedScale = min(
drawableSize.width / frameHitTest.canvasSize.width,
drawableSize.height / frameHitTest.canvasSize.height
)
guard fittedScale.isFinite, fittedScale > 0 else { return false }
let fittedCanvasSize = CGSize(
width: frameHitTest.canvasSize.width * fittedScale,
height: frameHitTest.canvasSize.height * fittedScale
)
let fittedCanvasOrigin = CGPoint(
x: (drawableSize.width - fittedCanvasSize.width) / 2,
y: (drawableSize.height - fittedCanvasSize.height) / 2
)
let localBottomLeftPoint = CGPoint(
x: point.x - drawableRect.minX,
y: drawableRect.maxY - point.y
)
let canvasPoint = CGPoint(
x: (localBottomLeftPoint.x - fittedCanvasOrigin.x) / fittedScale,
y: (localBottomLeftPoint.y - fittedCanvasOrigin.y) / fittedScale
)
let imageOriginInCanvas = CGPoint(
x: max(
0,
(frameHitTest.canvasSize.width
- CGFloat(frameHitTest.alphaMask.pixelWidth)) / 2
),
y: 0
)
return frameHitTest.alphaMask.containsOpaquePixel(atBottomLeftPoint: CGPoint(
x: canvasPoint.x - imageOriginInCanvas.x,
y: canvasPoint.y - imageOriginInCanvas.y
))
}
static func dragTargetDisplay(
pointer: CGPoint,
proposedPetCenter: CGPoint,
displays: [OverlayDisplayGeometry],
fallback: OverlayDisplayGeometry
) -> OverlayDisplayGeometry {
displays.first(where: { $0.frame.contains(pointer) })
?? displays.first(where: { $0.frame.contains(proposedPetCenter) })
?? fallback
}
static func bubblePanelScreenFrame(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
visibleFrame: CGRect,
content: OverlayBubbleContent = .measurementPlaceholder,
petVisualEnvelope: OverlayPetVisualEnvelope? = nil,
fontScale: BubbleFontScale = .standard
) -> CGRect {
bubblePanelScreenFrame(
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
visibleFrame: visibleFrame,
contents: [content],
petVisualEnvelope: petVisualEnvelope,
fontScale: fontScale
)
}
static func bubblePanelScreenFrame(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
visibleFrame: CGRect,
contents: [OverlayBubbleContent],
petVisualEnvelope: OverlayPetVisualEnvelope? = nil,
fontScale: BubbleFontScale = .standard
) -> CGRect {
bubblePanelLayout(
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
visibleFrame: visibleFrame,
contents: contents,
petVisualEnvelope: petVisualEnvelope,
previousAnchor: nil,
fontScale: fontScale
).frame
}
static func bubblePanelLayout(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
visibleFrame: CGRect,
contents: [OverlayBubbleContent],
petVisualEnvelope: OverlayPetVisualEnvelope? = nil,
previousAnchor: OverlayBubbleAnchor?,
fontScale: BubbleFontScale = .standard
) -> OverlayBubblePanelLayout {
let bubbleSize = resolvedBubbleStackSize(
in: visibleFrame.size,
contents: contents,
fontScale: fontScale
)
guard bubbleSize.width > 0, bubbleSize.height > 0 else {
return OverlayBubblePanelLayout(
frame: .zero,
anchor: previousAnchor
?? OverlayBubbleAnchor(direction: .above, alignsLeft: false)
)
}
let placement = bubblePlacement(
bubbleSize: bubbleSize,
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
screenFrame: visibleFrame,
petVisualEnvelope: petVisualEnvelope,
previousAnchor: previousAnchor
)
return OverlayBubblePanelLayout(
frame: rect(
center: placement.center,
size: bubbleSize
).integral,
anchor: placement.anchor
)
}
static func panelScreenFrame(
displayWidthPt: CGFloat,
petScreenCenter: CGPoint,
bubbleVisible: Bool,
clickMenuEnabled: Bool,
visibleFrame: CGRect,
bubbleContent: OverlayBubbleContent = .measurementPlaceholder
) -> CGRect {
let bubbleSize = resolvedBubbleSize(in: visibleFrame.size, content: bubbleContent)
var rects = [
rect(
center: petScreenCenter,
size: petVisibleSize(displayWidthPt: displayWidthPt)
)
]
if clickMenuEnabled {
rects.append(rect(
center: menuScreenCenter(
petScreenCenter: petScreenCenter,
displayWidthPt: displayWidthPt
),
size: menuHitSize
))
}
if bubbleVisible {
rects.append(rect(
center: bubbleScreenCenter(
bubbleSize: bubbleSize,
displayWidthPt: displayWidthPt,
petScreenCenter: petScreenCenter,
screenFrame: visibleFrame
),
size: bubbleSize
))
}
let union = rects.dropFirst().reduce(rects[0]) { partial, rect in
partial.union(rect)
}
return union.insetBy(dx: -8, dy: -8).integral
}
static func interactiveRects(
in containerSize: CGSize,
displayWidthPt: CGFloat,
petCenter: CGPoint,
bubbleVisible: Bool,
clickMenuEnabled: Bool,
panelFrame: CGRect,
screenFrame: CGRect,
includeBubble: Bool,
bubbleContent: OverlayBubbleContent = .measurementPlaceholder
) -> [CGRect] {
let displayPetCenter = petCenter
let menuCenter = menuCenter(
petCenter: displayPetCenter,
displayWidthPt: displayWidthPt
)
var rects: [CGRect] = [
rect(
center: displayPetCenter,
size: petVisibleSize(displayWidthPt: displayWidthPt)
)
]
if clickMenuEnabled {
rects.append(rect(center: menuCenter, size: menuHitSize))
}
if bubbleVisible && includeBubble {
let bubbleSize = resolvedBubbleSize(in: screenFrame.size, content: bubbleContent)
let center = bubblePosition(
bubbleSize: bubbleSize,
displayWidthPt: displayWidthPt,
petCenter: displayPetCenter,
panelFrame: panelFrame,
screenFrame: screenFrame,
in: containerSize
)
rects.append(rect(center: center, size: bubbleSize))
}
return rects
}
static func shouldHandleMouse(
atTopLeftPoint point: CGPoint,
in containerSize: CGSize,
displayWidthPt: CGFloat,
petCenter: CGPoint,
bubbleVisible: Bool,
clickMenuEnabled: Bool,
panelFrame: CGRect,
screenFrame: CGRect,
includeBubble: Bool,
bubbleContent: OverlayBubbleContent = .measurementPlaceholder,
petFrameHitTest: OverlayPetFrameHitTest? = nil,
overlayVisible: Bool = true,
activeInteractionID: UUID? = nil,
maskState: OverlayPointerMaskState? = nil
) -> Bool {
guard overlayVisible else { return false }
let rects = interactiveRects(
in: containerSize,
displayWidthPt: displayWidthPt,
petCenter: petCenter,
bubbleVisible: bubbleVisible,
clickMenuEnabled: clickMenuEnabled,
panelFrame: panelFrame,
screenFrame: screenFrame,
includeBubble: includeBubble,
bubbleContent: bubbleContent
)
let pointerInsidePanel = CGRect(
origin: .zero,
size: containerSize
).contains(point)
let petDragRect = rects.first ?? .zero
let auxiliaryHit = pointerInsidePanel
&& rects.dropFirst().contains(where: { $0.contains(point) })
let geometricPetHit = pointerInsidePanel && petDragRect.contains(point)
let resolvedMaskState = maskState
?? (petFrameHitTest == nil ? .missing : .valid)
let opaque = resolvedMaskState == .valid
&& geometricPetHit
&& petFrameHitTest.map {
petFrameContainsOpaquePixel(
atTopLeftPoint: point,
displayWidthPt: displayWidthPt,
petCenter: petCenter,
frameHitTest: $0
)
} == true
return OverlayPointerOwnershipPolicy.resolve(
OverlayPointerOwnershipInput(
overlayVisible: overlayVisible,
activeInteractionID: activeInteractionID,
maskState: resolvedMaskState,
validMaskPixelIsOpaque: opaque,
pointerInBubble: includeBubble && auxiliaryHit,
pointerInMenu: clickMenuEnabled && auxiliaryHit,
pointerInGeometricPetRegion: geometricPetHit
)
).isOwnedByOverlay
}
private static func measuredBubbleHeight(
width: CGFloat,
content: OverlayBubbleContent,
fontScale: BubbleFontScale
) -> CGFloat {
let rowHeights = bubbleSessionRowHeights(
bubbleWidth: width,
content: content,
fontScale: fontScale
)
if content.isStandaloneSessionCard {
return ceil(
bubbleVerticalPadding * 2
+ (rowHeights.first ?? 0)
+ content.stackDecorationDepth
)
}
let dividers = CGFloat(max(0, rowHeights.count - 1)) * bubbleSessionDividerHeight
return ceil(
bubbleVerticalPadding * 2
+ bubbleGroupHeaderHeight(fontScale: fontScale)
+ bubbleGroupHeaderSpacing
+ rowHeights.reduce(0, +)
+ dividers
+ content.stackDecorationDepth
)
}
private static func measuredSessionRowHeight(
width _: CGFloat,
session _: OverlaySessionContent,
isStandaloneSessionCard: Bool,
fontScale: BubbleFontScale
) -> CGFloat {
if isStandaloneSessionCard {
return ceil(
bubbleSessionVerticalPadding * 2
+ bubbleStandaloneMetadataHeight(fontScale: fontScale)
+ bubbleStandaloneMetadataSpacing
+ bubbleStandaloneSummaryTextHeight(fontScale: fontScale)
)
}
// Reserve the full two-line detail region. Tool activity often changes
// between one and two lines; allowing that to resize an NSPanel on
// every hook makes the whole bubble stack visibly jump.
return ceil(
bubbleSessionVerticalPadding * 2
+ bubbleDetailedHeaderHeight(fontScale: fontScale)
+ bubbleSessionTitleSpacing
+ bubbleDetailTextHeight(fontScale: fontScale)
)
}
private static func measuredSingleLineWidth(_ text: String, font: NSFont) -> CGFloat {
guard !text.isEmpty else { return 0 }
let attributes: [NSAttributedString.Key: Any] = [.font: font]
return ceil((text as NSString).size(withAttributes: attributes).width)
}
private static func measuredTextHeight(
_ text: String,
font: NSFont,
width: CGFloat,
lineHeight: CGFloat,
maximumLines: Int
) -> CGFloat {
guard !text.isEmpty else { return 0 }
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .byWordWrapping
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.paragraphStyle: paragraph
]
let bounds = (text as NSString).boundingRect(
with: CGSize(width: width, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: attributes
)
let measuredLines = max(1, Int(ceil(bounds.height / max(1, lineHeight))))
return CGFloat(min(maximumLines, measuredLines)) * lineHeight
}
private static func lineHeight(for font: NSFont) -> CGFloat {
ceil(font.ascender - font.descender + font.leading)
}
private static func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
guard lower <= upper else { return (lower + upper) / 2 }
return min(max(value, lower), upper)
}
/// Quantizes both hard bounds inward before clamping a canonical center.
/// This ordering prevents a rounded edge coordinate from escaping the
/// visible screen after an otherwise correct geometric clamp.
private static func inwardCanonicalClamp(
_ value: CGFloat,
lower: CGFloat,
upper: CGFloat
) -> CGFloat {
guard let canonicalLower = OverlayPlacementCanonicalization
.inwardLowerBound(Double(lower)),
let canonicalUpper = OverlayPlacementCanonicalization
.inwardUpperBound(Double(upper)),
let canonicalValue = OverlayPlacementCanonicalization.coordinate(
Double(value)
) else {
return clamp(value, lower: lower, upper: upper)
}
guard canonicalLower <= canonicalUpper else {
let midpoint = (lower + upper) / 2
return CGFloat(
OverlayPlacementCanonicalization.coordinate(Double(midpoint))
?? Double(midpoint)
)
}
return CGFloat(min(
max(canonicalValue, canonicalLower),
canonicalUpper
))
}
}
enum OverlaySessionGroupTone: Int, CaseIterable, Equatable {
case running = 0
case ready = 1
case failed = 2
case needsInput = 3
init(eventType: AgentEventKind?) {
self = switch eventType {
case .waiting: .needsInput
case .failed: .failed
case .done: .ready
case .start, .thinking, .plan, .tool, nil: .running
}
}
static func aggregate(_ sessions: [OverlaySessionContent]) -> OverlaySessionGroupTone {
sessions
.map { OverlaySessionGroupTone(eventType: $0.eventType) }
.max(by: { $0.rawValue < $1.rawValue })
?? .running
}
}
enum OverlayBubbleProjection {
static func contents(
states: [ActiveAgentState],
omittedCount: Int,
dismissedSessionIDs: Set<String>,
groupSessionsByAgent: Bool = false,
standaloneSessionOrder: [String] = [],
standaloneStackExpanded: Bool = true,
isExpanded: (AgentSource) -> Bool
) -> [OverlayBubbleContent] {
let visibleStates = states.filter {
!dismissedSessionIDs.contains(OverlaySessionContent.stableID(
source: $0.source,
sessionID: $0.sessionID ?? $0.event.sessionID,
anonymousSessionAlias: $0.anonymousSessionAlias,
fallbackEventID: $0.event.id
))
}
if !groupSessionsByAgent {
let stateByID = Dictionary(
visibleStates.map { state in
(
OverlaySessionContent.stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
),
state
)
},
uniquingKeysWith: { _, latest in latest }
)
var orderedStates = standaloneSessionOrder.compactMap { stateByID[$0] }
let orderedIDs = Set(orderedStates.map {
OverlaySessionContent.stableID(
source: $0.source,
sessionID: $0.sessionID ?? $0.event.sessionID,
anonymousSessionAlias: $0.anonymousSessionAlias,
fallbackEventID: $0.event.id
)
})
// Direct projection callers may not own App presentation state.
// Use PetCore's canonical attention/latest-first order as the
// fallback so the first expanded card matches the folded card.
orderedStates.append(contentsOf: visibleStates.filter {
!orderedIDs.contains(OverlaySessionContent.stableID(
source: $0.source,
sessionID: $0.sessionID ?? $0.event.sessionID,
anonymousSessionAlias: $0.anonymousSessionAlias,
fallbackEventID: $0.event.id
))
})
// The visual list is priority-first. Stable presentation order is
// retained inside a tone so ordinary thinking/tool churn cannot
// make concurrent cards hop.
orderedStates = orderedStates.enumerated().sorted { left, right in
let leftTone = OverlaySessionGroupTone(eventType: left.element.event.eventType)
let rightTone = OverlaySessionGroupTone(eventType: right.element.event.eventType)
if leftTone != rightTone {
return leftTone.rawValue > rightTone.rawValue
}
return left.offset < right.offset
}.map(\.element)
let representedSessionCount = orderedStates.count + omittedCount
if !standaloneStackExpanded,
representedSessionCount > 1,
let primaryState = orderedStates.first
{
return [OverlayBubbleContent(
standaloneState: primaryState,
stackSessionCount: representedSessionCount,
isStackExpanded: false
)]
}
var cards = orderedStates.enumerated().map { index, state in
OverlayBubbleContent(
standaloneState: state,
stackSessionCount: index == 0
? representedSessionCount
: 1,
isStackExpanded: true
)
}
if omittedCount > 0 {
cards.append(.omittedSummary(count: omittedCount))
}
return cards
}
var grouped = AgentSource.allCases.compactMap { source -> OverlayBubbleContent? in
let sourceStates = visibleStates.filter { $0.source == source }
return sourceStates.isEmpty ? nil : OverlayBubbleContent(
source: source,
states: sourceStates,
isExpanded: isExpanded(source)
)
}
if omittedCount > 0 {
grouped.append(.omittedSummary(count: omittedCount))
}
// The pet itself communicates idle. No session means no bubble.
return grouped
}
}
enum OverlaySessionSurfaceKind: Equatable {
case app
case cli
}
enum OverlaySessionNavigationNotice: Equatable, Sendable {
case unavailable
case failed
case degradedToHost
func localizedText(
locale: String = APCLocalization.interfaceLocaleIdentifier
) -> String {
switch self {
case .unavailable:
APCLocalization.text(.overlaySessionNavigationUnavailable, locale: locale)
case .failed:
APCLocalization.text(.overlaySessionNavigationFailed, locale: locale)
case .degradedToHost:
APCLocalization.text(.overlaySessionNavigationDegraded, locale: locale)
}
}
}
struct OverlaySessionContent: Equatable, Identifiable {
var id: String
var eventID: String
var source: AgentSource?
var sessionID: String?
var eventType: AgentEventKind?
var sessionTitle: String
var activityText: String
var messageText: String
var statusText: String
var acknowledgementID: String?
var navigation: AgentSessionNavigation
var navigationNotice: OverlaySessionNavigationNotice?
var needsUserAttention: Bool {
eventType == .waiting || eventType == .failed
}
var navigationCapability: NavigationCapability {
AgentSessionRouter.validatedCapability(
source: source,
sessionID: sessionID,
navigation: navigation
)
}
var canOpen: Bool {
source == nil || navigationCapability != .unavailable
}
var actionLabel: String {
actionLabel()
}
func actionLabel(
locale: String = APCLocalization.interfaceLocaleIdentifier
) -> String {
guard let source else {
return APCLocalization.text(.overlayActionOpen, locale: locale)
}
return APCLocalizedPresentation.navigationActionTitle(
navigationCapability,
source: source,
navigation: navigation,
locale: locale
) ?? APCLocalizedPresentation.navigationUnavailableTitle(locale: locale)
}
var surfaceKind: OverlaySessionSurfaceKind? {
switch navigation.surface {
case "chatgpt_app", "claude_app", "opencode_app":
.app
case "cli_terminal":
.cli
default:
nil
}
}
var surfaceLabel: String? {
surfaceLabel()
}
func surfaceLabel(
locale: String = APCLocalization.interfaceLocaleIdentifier
) -> String? {
surfaceKind.map {
APCLocalizedPresentation.sessionSurfaceTitle($0, locale: locale)
}
}
var primaryDetailText: String {
detailCandidates.first ?? ""
}
var standaloneSummaryText: String {
primaryDetailText
}
var detailText: String {
primaryDetailText
}
var accessibilityReadingOrder: [String] {
[
source?.title,
surfaceLabel,
sessionTitle,
statusText,
primaryDetailText,
accessibilityFallbackDetailText,
actionLabel,
]
.compactMap(Self.compactMessage)
}
var accessibilityLabel: String {
accessibilityReadingOrder.joined(separator: ", ")
}
private var detailCandidates: [String] {
if let navigationNotice {
return [navigationNotice.localizedText()]
}
if let message = Self.compactMessage(messageText) {
return [message]
}
if let activity = Self.compactMessage(activityText) {
return [activity]
}
return []
}
/// Preserves a complete VoiceOver state-to-action reading order when
/// PetCore intentionally omits displayable session copy. This text is
/// localized product guidance, never raw connector event detail, and is
/// not inserted into the visual bubble.
private var accessibilityFallbackDetailText: String? {
guard detailCandidates.isEmpty else { return nil }
let key: APCLocalizationKey? = switch eventType {
case .start:
.overlayDetailRunning
case .thinking:
.overlayActivityThinking
case .plan:
.overlayActivityPlan
case .tool:
.overlayActivityTool
case .waiting:
.overlayDetailNeedsInput
case .done:
.overlayDetailCompleted
case .failed:
.overlayDetailBlocked
case nil:
nil
}
guard let key else { return nil }
return Self.compactMessage(Self.nonredundantDetail(
APCLocalization.text(key),
title: sessionTitle,
status: statusText
))
}
var dismissesAfterActivation: Bool {
switch eventType {
case .done: true
case .start, .thinking, .plan, .tool, .waiting, .failed, nil: false
}
}
/// Geometry-only fixture for callers that do not have live bubble
/// content. AppStore never publishes this as a user-visible session.
static let measurementPlaceholder = OverlaySessionContent(
id: "measurement-placeholder",
eventID: "measurement-placeholder",
source: nil,
sessionID: nil,
eventType: nil,
sessionTitle: "Agent Pet Companion",
activityText: "",
messageText: "",
statusText: "",
navigation: AgentSessionNavigation()
)
static func omittedSummary(count: Int) -> OverlaySessionContent {
OverlaySessionContent(
id: "omitted-session-summary",
eventID: "omitted-session-summary",
source: nil,
sessionID: nil,
eventType: nil,
sessionTitle: APCLocalization.text(.overlayMoreSessionsTitle),
activityText: "",
messageText: APCLocalization.format(.overlayMoreSessionsDetailFormat, count),
statusText: "",
navigation: AgentSessionNavigation()
)
}
init(
id: String,
eventID: String? = nil,
source: AgentSource?,
sessionID: String?,
eventType: AgentEventKind?,
sessionTitle: String,
activityText: String? = nil,
messageText: String,
statusText: String,
acknowledgementID: String? = nil,
navigation: AgentSessionNavigation = AgentSessionNavigation(),
navigationNotice: OverlaySessionNavigationNotice? = nil
) {
self.id = id
self.eventID = eventID ?? id
self.source = source
self.sessionID = sessionID
self.eventType = eventType
self.sessionTitle = sessionTitle
self.activityText = activityText ?? ""
self.messageText = messageText
self.statusText = statusText
self.acknowledgementID = acknowledgementID
self.navigation = navigation
self.navigationNotice = navigationNotice
}
init(state: ActiveAgentState) {
let event = state.event
let resolvedSessionID = state.sessionID ?? event.sessionID
id = Self.stableID(
source: event.source,
sessionID: resolvedSessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: event.id
)
eventID = event.id
source = event.source
sessionID = resolvedSessionID
eventType = event.eventType
acknowledgementID = state.acknowledgementID
statusText = Self.displayStatus(
for: state.overlayDisplay?.summaryKind
?? Self.summaryKind(for: event.eventType)
)
let proposedTitle = Self.sessionTitle(for: state)
sessionTitle = Self.normalizedText(proposedTitle) == Self.normalizedText(statusText)
? Self.genericSessionTitle(for: state)
: proposedTitle
navigation = state.overlayDisplay?.navigation ?? AgentSessionNavigation()
navigationNotice = nil
activityText = Self.nonredundantDetail(
Self.activityDetail(for: state),
title: sessionTitle,
status: statusText
)
messageText = Self.nonredundantMessage(
Self.assistantMessage(for: state) ?? "",
title: sessionTitle,
status: statusText
)
}
init(event: AgentEvent) {
id = Self.stableID(
source: event.source,
sessionID: event.sessionID,
fallbackEventID: event.id
)
eventID = event.id
source = event.source
sessionID = event.sessionID
eventType = event.eventType
acknowledgementID = nil
sessionTitle = APCLocalization.format(.overlaySessionTitleFormat, event.source.shortTitle)
statusText = Self.displayStatus(for: Self.summaryKind(for: event.eventType))
navigation = event.sessionNavigation
navigationNotice = nil
activityText = ""
messageText = ""
}
static func stableID(
source: AgentSource,
sessionID: String?,
anonymousSessionAlias: String? = nil,
fallbackEventID _: String
) -> String {
let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let sessionID, !sessionID.isEmpty else {
if let anonymousSessionAlias = validatedAnonymousAlias(anonymousSessionAlias) {
return "session-\(source.rawValue)-\(anonymousSessionAlias)"
}
// PetCore groups unattributed events into one source-scoped session.
// Match that identity here so hook revisions update the existing row
// instead of removing/reinserting it and clearing manual dismissal.
return "session-\(source.rawValue)-unattributed"
}
return "session-\(source.rawValue)-\(sessionID)"
}
static func reopenID(for state: ActiveAgentState) -> String {
let stableID = stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
)
switch state.event.eventType {
case .waiting, .failed:
return "attention:\(stableID):\(state.event.id)"
case .start, .thinking, .plan, .tool, .done:
return "activation:\(stableID):\(state.sessionActivatedAt ?? "initial")"
}
}
private static func sessionTitle(for state: ActiveAgentState) -> String {
if let title = compactTitle(state.sessionTitle) {
return title
}
if state.sessionUserMessage?.role == "user",
let title = compactTitle(state.sessionUserMessage?.content)
{
return title
}
return genericSessionTitle(for: state)
}
private static func genericSessionTitle(for state: ActiveAgentState) -> String {
if let label = anonymousAliasLabel(state.anonymousSessionAlias) {
return APCLocalization.format(
.overlaySessionAliasTitleFormat,
state.source.shortTitle,
label
)
}
return APCLocalization.format(.overlaySessionTitleFormat, state.source.shortTitle)
}
static func anonymousAliasLabel(_ value: String?) -> String? {
guard let value = validatedAnonymousAlias(value),
value.hasPrefix("anon-"),
let sequence = UInt64(value.dropFirst(5), radix: 36),
sequence > 0
else {
return nil
}
var index = sequence
var characters: [Character] = []
while index > 0 {
index -= 1
let scalar = UnicodeScalar(65 + Int(index % 26))!
characters.append(Character(scalar))
index /= 26
}
return String(characters.reversed())
}
private static func validatedAnonymousAlias(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
value.range(
of: "^anon-[0-9a-z]{1,13}$",
options: .regularExpression
) != nil
else {
return nil
}
return value
}
private static func activityDetail(for state: ActiveAgentState) -> String? {
// Only PetCore's bounded, normalized projection is displayable. Raw
// connector payloads may contain commands, paths, or serialized JSON.
compactMessage(state.sessionActivity?.content)
}
private static func assistantMessage(for state: ActiveAgentState) -> String? {
guard state.sessionMessage?.role == "assistant" else { return nil }
return compactMessage(state.sessionMessage?.content)
}
private static func compactTitle(_ value: String?) -> String? {
guard let value = compactMessage(value) else { return nil }
let firstLine = value.split(whereSeparator: \.isNewline).first.map(String.init) ?? value
return firstLine.count > 80 ? "\(firstLine.prefix(79))…" : firstLine
}
private static func compactMessage(_ value: String?) -> String? {
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return value.flatMap { $0.isEmpty ? nil : $0 }
}
/// Compiled once instead of per call. `replacingOccurrences(options:
/// .regularExpression)` builds a fresh `NSRegularExpression` every time,
/// and this normalization runs several times per session on every bubble
/// projection. The pattern and match options are unchanged, so the
/// resulting text is identical.
private static let redundancyNormalizationPattern = try? NSRegularExpression(
pattern: "[\\s\\p{P}]+"
)
private static func normalizedText(_ value: String) -> String {
let folded = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
guard let pattern = redundancyNormalizationPattern else { return folded }
return pattern.stringByReplacingMatches(
in: folded,
range: NSRange(folded.startIndex..., in: folded),
withTemplate: ""
)
}
private static func nonredundantMessage(
_ proposed: String,
title: String,
status: String
) -> String {
let occupied = Set([title, status].map(normalizedText))
return occupied.contains(normalizedText(proposed)) ? "" : proposed
}
private static func nonredundantDetail(
_ proposed: String?,
title: String,
status: String
) -> String {
guard let proposed = compactMessage(proposed) else { return "" }
let occupied = Set([title, status].map(normalizedText))
return occupied.contains(normalizedText(proposed)) ? "" : proposed
}
static func displayStatus(for summaryKind: AgentOverlaySummaryKind) -> String {
APCLocalizedPresentation.overlayEventTitle(summaryKind)
}
static func summaryKind(for eventType: AgentEventKind) -> AgentOverlaySummaryKind {
switch eventType {
case .start: .start
case .thinking: .thinking
case .plan: .plan
case .tool: .tool
case .waiting: .needsInput
case .done: .done
case .failed: .failed
}
}
}
enum OverlayBubbleSessionPrimaryAction: Equatable {
case expandSessions
case activateSession
static func resolve(content: OverlayBubbleContent) -> Self {
content.isStacked ? .expandSessions : .activateSession
}
}
struct OverlayBubbleContent: Equatable, Identifiable {
var id: String
var source: AgentSource?
var agentName: String
var sessions: [OverlaySessionContent]
var isExpanded: Bool
var omittedSessionCount: Int
var isStandaloneSessionCard: Bool
var standaloneStackSessionCount: Int
var eventIDs: [String] { sessions.map(\.eventID) }
var dismissalIDs: [String] { canDismiss ? sessions.map(\.id) : [] }
var visibleSessions: [OverlaySessionContent] {
guard !sessions.isEmpty, !isOmittedSummary else { return sessions }
let ordered = sessions.enumerated().sorted { left, right in
let leftTone = OverlaySessionGroupTone(eventType: left.element.eventType)
let rightTone = OverlaySessionGroupTone(eventType: right.element.eventType)
if leftTone != rightTone {
return leftTone.rawValue > rightTone.rawValue
}
return left.offset < right.offset
}.map(\.element)
return isExpanded ? ordered : Array(ordered.prefix(1))
}
var sessionCount: Int { sessions.count }
var representedSessionCount: Int {
if isStandaloneSessionCard, !isExpanded {
return disclosureSessionCount
}
return omittedSessionCount > 0 ? omittedSessionCount : sessionCount
}
var disclosureSessionCount: Int {
isStandaloneSessionCard
? max(sessionCount, standaloneStackSessionCount)
: sessionCount
}
var isOmittedSummary: Bool { omittedSessionCount > 0 }
var canDismiss: Bool { !isOmittedSummary }
var hasMultipleSessions: Bool { disclosureSessionCount > 1 }
var isStacked: Bool { hasMultipleSessions && !isExpanded }
var stackDecorationLayerCount: Int {
guard isStacked else { return 0 }
return min(
OverlayGeometry.bubbleCollapsedStackLayerCount,
max(0, disclosureSessionCount - 1)
)
}
var showsStackDecoration: Bool { stackDecorationLayerCount > 0 }
var stackDecorationDepth: CGFloat {
let layerOffset = isStandaloneSessionCard
? OverlayGeometry.bubbleStandaloneStackLayerOffset
: OverlayGeometry.bubbleCollapsedStackLayerOffset
return CGFloat(stackDecorationLayerCount) * layerOffset
}
var statusTone: OverlaySessionGroupTone {
OverlaySessionGroupTone.aggregate(sessions)
}
/// Geometry-only fixture; idle product state intentionally emits no
/// bubble from AppStore.
static let measurementPlaceholder = OverlayBubbleContent(
id: "measurement-placeholder",
source: nil,
agentName: "Agent Pet Companion",
sessions: [.measurementPlaceholder],
isExpanded: true,
omittedSessionCount: 0,
isStandaloneSessionCard: false,
standaloneStackSessionCount: 0
)
static func omittedSummary(count: Int) -> OverlayBubbleContent {
OverlayBubbleContent(
id: "omitted-session-summary",
source: nil,
agentName: "Agent Pet Companion",
sessions: [.omittedSummary(count: count)],
isExpanded: true,
omittedSessionCount: count,
isStandaloneSessionCard: false,
standaloneStackSessionCount: 0
)
}
init(
id: String,
source: AgentSource?,
agentName: String,
sessions: [OverlaySessionContent],
isExpanded: Bool = true,
omittedSessionCount: Int = 0,
isStandaloneSessionCard: Bool = false,
standaloneStackSessionCount: Int = 0
) {
self.id = id
self.source = source
self.agentName = agentName
self.sessions = sessions
self.isExpanded = isExpanded
self.omittedSessionCount = omittedSessionCount
self.isStandaloneSessionCard = isStandaloneSessionCard
self.standaloneStackSessionCount = standaloneStackSessionCount
}
init(source: AgentSource, states: [ActiveAgentState], isExpanded: Bool = true) {
id = "agent-\(source.rawValue)"
self.source = source
agentName = source.title
let orderedStates = Array(states
.enumerated()
.sorted(by: Self.isMoreRecentlyActivated)
.prefix(8))
sessions = orderedStates.map { entry in
OverlaySessionContent(state: entry.element)
}
self.isExpanded = isExpanded
omittedSessionCount = 0
isStandaloneSessionCard = false
standaloneStackSessionCount = 0
}
init(state: ActiveAgentState) {
self.init(source: state.source, states: [state])
}
init(
standaloneState state: ActiveAgentState,
stackSessionCount: Int = 1,
isStackExpanded: Bool = true
) {
let session = OverlaySessionContent(state: state)
self.init(
id: "session-card-\(session.id)",
source: state.source,
agentName: state.source.title,
sessions: [session],
isExpanded: isStackExpanded,
isStandaloneSessionCard: true,
standaloneStackSessionCount: stackSessionCount
)
}
init(event: AgentEvent?) {
guard let event else {
self = .measurementPlaceholder
return
}
id = "agent-\(event.source.rawValue)"
source = event.source
agentName = event.source.title
sessions = [OverlaySessionContent(event: event)]
isExpanded = true
omittedSessionCount = 0
isStandaloneSessionCard = false
standaloneStackSessionCount = 0
}
private static func isMoreRecentlyActivated(
_ left: EnumeratedSequence<[ActiveAgentState]>.Element,
_ right: EnumeratedSequence<[ActiveAgentState]>.Element
) -> Bool {
if let leftTime = left.element.sessionActivatedAt,
let rightTime = right.element.sessionActivatedAt,
leftTime != rightTime
{
return leftTime > rightTime
}
// PetCore has already projected a stable first-seen order for legacy
// sessions without an activation timestamp. Preserve that order and
// do not let a later Waiting/Failed status edge promote an old session.
return left.offset < right.offset
}
}
struct OverlayBubbleAccessibilityModel: Equatable {
var sessionActionLabels: [String?]
var sessionCloseActionLabels: [String?]
var closeActionLabel: String?
var closeActionHint: String?
var groupActionLabel: String?
init(content: OverlayBubbleContent, locale: String? = nil) {
sessionActionLabels = content.visibleSessions.map { session in
guard session.canOpen else { return nil }
guard session.source != nil else {
return Self.text(.overlayActionOpen, locale: locale)
}
return session.actionLabel(
locale: locale ?? APCLocalization.interfaceLocaleIdentifier
)
}
sessionCloseActionLabels = content.visibleSessions.map { _ in
content.canDismiss
? Self.text(.overlayDismissSession, locale: locale)
: nil
}
closeActionLabel = content.canDismiss
? Self.text(.overlayCloseBubbleAccessibility, locale: locale)
: nil
closeActionHint = content.canDismiss
? Self.text(.overlayCloseBubbleHint, locale: locale)
: nil
groupActionLabel = content.hasMultipleSessions
? Self.format(
content.isExpanded
? .overlayCollapseSessionsFormat
: .overlayExpandSessionsFormat,
content.disclosureSessionCount,
locale: locale
)
: nil
}
private static func text(_ key: APCLocalizationKey, locale: String?) -> String {
locale.map { APCLocalization.text(key, locale: $0) }
?? APCLocalization.text(key)
}
private static func format(
_ key: APCLocalizationKey,
_ count: Int,
locale: String?
) -> String {
locale.map { APCLocalization.format(key, locale: $0, count) }
?? APCLocalization.format(key, count)
}
}