codeburn/mac/Sources/CodeBurnMenubar/CapacityDockController.swift
Resham Joshi b8cc0edcbf
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Windows Menubar CI / check (windows-latest) (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Waiting to run
0.9.25 round 2: fixes found testing the built apps (#1517)
* fix: open menu bar Settings centered on the active screen

The window was positioned at a 520x380 placeholder while SwiftUI's own
minimum is 880x620, so the grown window kept its top-left corner and ran
off the bottom-right edge. Size the window from the view's minimum, place
it with a tested pure function, and honour a saved frame only while it
still fits on one of today's screens.

* fix: say where the telemetry toggle is managed when the desktop app owns it

A desktop telemetry.v1.json with enabled:false locks the menu bar toggle
off, so clicking it does nothing. Name the desktop app's Privacy & data
pane in the caption, and pin the four desktop/veto combinations plus the
standalone case in tests.

* fix(desktop): export the provider id, and never swallow a rejected setter

The export pane built its provider list from `current.providers`, which is
keyed on the lowercased display name. Five of the seventeen providers on a
real corpus ("cursor agent", "grok build", "kimi code", "hermes agent",
"grok bot") are not valid ids, so vProvider rejected them as bad-args.
`exportNow` had a try/finally and no catch, so the rejection produced no
toast and no file: the button did nothing. Use detectedProviders, which
already resolves ids from providerDetails, and toast on rejection.

The telemetry consent toggle swallowed its rejection the same way.

* fix(desktop): warm only the selected period, and only while visible

The post-paint sweep warmed all six standard periods x seven reports plus
the provider overviews: up to 48 serve-routed queries, ~18 CPU-seconds a
minute for five minutes and 2.79 GB peak RSS on a real corpus, and it ran
with the window hidden. It now warms the selected period's reports (its
own overview is already under that memo key from the visible poll) and
the other providers' headlines for that same period, holding before every
request while a user fetch is in flight or the window is hidden. Other
periods take the ordinary on-demand path.

Also corrects two comments that claimed background priority lets a click
jump ahead: spawnCli takes the serve fast path before priority is read,
and the resident child answers FIFO.

* fix(desktop): answer the click, report the write, tell the truth about the Claude login

Nine bridge setters in Settings attached only .then(), so a rejected
envelope produced no toast and the control read as if it had ignored the
click. One shared helper toasts the rejection, per pane fallback wording.

Telemetry save() swallowed write errors while setEnabled returned the
in-memory status, so the UI could show ON while the file the macOS menu
bar app reads still said OFF. The setters now carry persisted:boolean and
the renderer toasts when it is false; the write goes through the existing
atomic writeFileAtomic (temp file, rename) so a crash cannot tear it.

The Claude card called a logged-in Claude Code 2.x user "disconnected"
forever, because the credential lives only in the keychain and the first
poll never reads it. A new darwin-only keychainUnchecked state says we
have not looked yet and offers the forced read, keeping the login
instructions for the genuinely-logged-out case. ConnectAffordance's
toggle is renamed to what it does: it discloses instructions.

* fix: retry tax is the cost of retried edit turns, not a multiple of spend

* stop the change guard watching a Warp path it can never stat

* apply a menu bar language change in place instead of relaunching

* keep honoring WARP_DB_PATH in the change guard

* restart a menu bar too old to watch AppleLanguages

* one rebuild per language change, and no global bundle in tests

* fix(desktop): a forced quota refresh must not join the poll it just aborted

Check now / Refresh / ⌘R invalidated the quota service and then handed the
caller the background fetch it had just aborted, so every provider failed
its generation check and resolved disconnected: the click repainted every
card as logged out without ever reading the keychain, and Claude showed
"Not connected. Log in with the Claude CLI." A call now joins an in-flight
run only when that run is at least as privileged, so a keychain-allowed
call starts its own; a second forced click still joins the first, which
keeps the single macOS prompt. A superseded run hands its caller the run
that replaced it instead of its own abort artefacts.

retainOnFailure also holds accessDenied through a keychainUnchecked poll,
so the "Allow in keychain" guidance is not replaced by "Check now" one
cycle after the user denied the prompt.

* fix(desktop): stop the sweep churning, and never spend a query on an abandoned one

The prefetch effect depended on the detected-provider entries, whose costs
move on every overview poll, so it was torn down and restarted each cadence
tick: a warm slower than the interval was discarded and re-issued forever.
It now depends on the provider ids alone, and a finished warm is kept for
its own period/provider key even if the effect was torn down meanwhile —
only a config-generation change makes a result wrong.

Both loops re-test cancellation and the memo after the hold, which has no
bound, so an abandoned sweep issues nothing and a section the user visited
meanwhile is not parsed again. The hidden half of the hold waits on
visibilitychange instead of waking the renderer every two seconds for a
window nobody is looking at.

* fix(desktop): never offer a provider id the CLI will reject, and keep the keychain note

detectedProviders applied the round-trip filter only to the legacy
providers map; a providerDetails id with a space reached the picker and the
export as a --provider value the main process rejects.

The Plans settings row showed the "not read your login yet" line without
the note that macOS may ask once for keychain access, which the Plans card
does show.
2026-09-21 13:53:45 +02:00

1559 lines
62 KiB
Swift

import AppKit
import SwiftUI
@MainActor
final class CapacityDockController {
private let store: AppStore
private let defaults: UserDefaults
private let model: CapacityDockViewModel
private var railPanel: CapacityDockPanel?
private var detailPanel: CapacityDockPanel?
private var preferencesObserver: NSObjectProtocol?
private var enabledObserver: DefaultsKeyObserver?
private var screenObserver: NSObjectProtocol?
private var wakeObserver: NSObjectProtocol?
private var localEventMonitor: Any?
private var globalMouseMonitor: Any?
private var hoverPollTimer: Timer?
private var pointerMonitoringSession = CapacityDockPointerMonitoringSession()
private var expansionWork: DispatchWorkItem?
private var collapseWork: DispatchWorkItem?
private var detailWork: DispatchWorkItem?
private var detailExitWork: DispatchWorkItem?
private var pointerInsideRail = false
private var pointerInsideDetail = false
private var hoveredRowProvider: CapacityDockProvider?
private var dragStartFrame: CGRect?
private var dragVisibleFrame: CGRect?
private var dragScreenFrame: CGRect?
private var dragStartAttachmentProgress: CGFloat?
private var dragStartDockedEdge: CapacityDockEdge?
private var dragStartPointerLocation: CGPoint?
private var suppressProviderClicksUntil: TimeInterval = 0
private var railMotionGeneration = 0
private var detailMotionGeneration = 0
private var lastKnownRailTop: CGFloat?
private var reduceMotion = false
private var accessibilityObserver: NSObjectProtocol?
private var railMotion: CapacityDockMotionRun?
private var detailMotion: CapacityDockMotionRun?
private var detailIsDismissing = false
init(store: AppStore, defaults: UserDefaults = .standard) {
self.store = store
self.defaults = defaults
self.model = CapacityDockViewModel(preferences: CapacityDockPreferences.load(defaults: defaults))
}
func start() {
guard preferencesObserver == nil else { return }
preferencesObserver = NotificationCenter.default.addObserver(
forName: .capacityDockPreferencesDidChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.preferencesDidChange() }
}
// The desktop app's Plugins card turns the rail on and off by writing this app's own
// defaults domain from another process, which posts nothing in here:
// UserDefaults.didChangeNotification is in-process only. cfprefsd does deliver the
// outside write as a KVO change on the key, so that is the link.
enabledObserver = DefaultsKeyObserver(defaults: defaults, key: CapacityDockPreferences.enabledKey) { [weak self] in
Task { @MainActor [weak self] in self?.preferencesDidChange() }
}
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.reposition() }
}
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.screensDidWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.reposition() }
}
reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
accessibilityObserver = NotificationCenter.default.addObserver(
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
self.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
if self.reduceMotion {
self.stopRailMotion()
self.stopDetailMotion()
self.layoutRail(animate: false)
if let provider = self.model.hoveredProvider {
self.layoutDetail(for: provider, transaction: .immediate)
}
}
}
}
preferencesDidChange()
}
func stop() {
expansionWork?.cancel()
collapseWork?.cancel()
detailWork?.cancel()
detailExitWork?.cancel()
stopRailMotion()
stopDetailMotion()
if let preferencesObserver {
NotificationCenter.default.removeObserver(preferencesObserver)
self.preferencesObserver = nil
}
enabledObserver = nil
if let screenObserver {
NotificationCenter.default.removeObserver(screenObserver)
self.screenObserver = nil
}
if let wakeObserver {
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
self.wakeObserver = nil
}
if let accessibilityObserver {
NotificationCenter.default.removeObserver(accessibilityObserver)
self.accessibilityObserver = nil
}
stopEventMonitoring()
pointerInsideRail = false
pointerInsideDetail = false
hoveredRowProvider = nil
completeDetailDismissal()
railPanel?.orderOut(nil)
railPanel = nil
lastKnownRailTop = nil
}
func refreshQuotaPresentation() {
guard model.preferences.isEnabled else { return }
if let provider = model.hoveredProvider {
model.detailHeight = glanceDetailHeight(for: provider)
layoutDetail(for: provider, transaction: .immediate)
}
}
/// Panel height for the glance popover, measured from the same store content
/// `CapacityDockDetailView` renders.
private func glanceDetailHeight(for provider: CapacityDockProvider) -> CGFloat {
return CapacityDockMetrics.detailHeight(
quota: store.capacityDockQuotaSummary(for: provider),
provider: provider,
sessionCount: store.capacityDockLiveSessions(for: provider)?.count,
hasToday: store.capacityDockToday(for: provider) != nil,
tailEdge: model.detailTailEdge,
scale: model.detailScale
)
}
func reposition() {
guard model.preferences.isEnabled, !model.interaction.isDragging else { return }
layoutRail(preserveCurrentTop: false, animate: false)
if let provider = model.hoveredProvider {
layoutDetail(for: provider, transaction: .immediate)
}
}
private func preferencesDidChange() {
let snapshot = CapacityDockPreferences.load(defaults: defaults)
model.preferences = snapshot
if !model.interaction.isDragging {
model.dockedEdge = snapshot.dockedEdge
model.attachmentEdge = snapshot.attachmentEdge
}
if let hovered = model.hoveredProvider,
!snapshot.selectedProviders.contains(hovered) {
hideDetail(animated: false)
} else if let hovered = model.hoveredProvider {
model.detailHeight = glanceDetailHeight(for: hovered)
}
guard snapshot.isEnabled else {
stopEventMonitoring()
pointerInsideRail = false
pointerInsideDetail = false
hoveredRowProvider = nil
model.interaction.dismiss()
hideDetail(animated: false)
stopRailMotion()
model.railPresentationProgress = 0
model.isRailPresentationExpanded = false
railPanel?.orderOut(nil)
lastKnownRailTop = nil
return
}
startEventMonitoring()
ensureRailPanel()
layoutRail(animate: false)
railPanel?.orderFrontRegardless()
}
private func ensureRailPanel() {
guard railPanel == nil else { return }
let view = CapacityDockView(
model: model,
quota: { [weak self] provider in
self?.store.capacityDockQuotaSummary(for: provider)
},
onProviderClick: { [weak self] provider in
self?.providerClicked(provider)
},
onSwitchGlanceWindow: { [weak self] provider in
_ = self?.switchGlanceWindow(for: provider)
},
onHide: { [weak self] in
self?.hideDock()
},
onDock: { [weak self] edge in
self?.dock(to: edge)
},
onDragChanged: { [weak self] pointerLocation, translation in
self?.dragChanged(pointerLocation: pointerLocation, initialTranslation: translation)
},
onDragEnded: { [weak self] in
self?.dragEnded()
}
)
let panel = CapacityDockPanel()
let hosting = CapacityDockHostingView(rootView: view.environment(store))
// Panel geometry has one owner; intrinsic resizing races the frame animator.
hosting.sizingOptions = []
hosting.autoresizingMask = [.width, .height]
// At the top edge the menu-bar/notch safe-area inset would push the rail
// down, leaving a gap the bottom edge never shows. Opt the rail out of
// safe area so it sits flush at the physical top like the system notch.
hosting.safeAreaRegions = []
hosting.interactiveShapeContains = { [weak model] point, bounds in
guard let model else { return false }
let swiftUIPoint = CGPoint(x: point.x, y: bounds.height - point.y)
return CapacityDockRailShape(
bodyWidth: model.railWidth,
bodyLength: model.bodyLength,
shoulderDepth: CapacityDockMetrics.edgeShoulderDepth(scale: model.scale),
attachmentProgress: model.attachmentProgress,
edge: model.attachmentEdge
)
.path(in: bounds)
.contains(swiftUIPoint)
}
panel.contentView = hosting
panel.makeContentTransparent()
panel.acceptsMouseMovedEvents = true
hosting.clipsToBounds = true
railPanel = panel
}
/// The bubble is cheap to rebuild and expensive to keep: a hidden, ordered-out
/// bubble panel left the app re-laying it out at display cadence for as long
/// as it lived (0.1 percent idle CPU before the first hover, 6 percent after).
/// Every completed dismissal, animated or immediate, converges here.
private func completeDetailDismissal() {
model.hoveredProvider = nil
detailIsDismissing = false
detailPanel?.orderOut(nil)
detailPanel = nil
}
private func ensureDetailPanel() {
guard detailPanel == nil else { return }
let view = CapacityDockDetailView(
model: model,
quota: { [weak self] provider in
self?.store.capacityDockQuotaSummary(for: provider)
},
onConnect: { [weak self] provider in
self?.connect(provider)
}
)
let panel = CapacityDockPanel()
let hosting = CapacityDockHostingView(rootView: view.environment(store))
// Panel geometry has one owner; intrinsic resizing races the frame animator.
hosting.sizingOptions = []
hosting.autoresizingMask = [.width, .height]
hosting.interactiveShapeContains = { [weak model] point, bounds in
guard let model else { return false }
let swiftUIPoint = CGPoint(x: point.x, y: bounds.height - point.y)
return CapacityDockBubbleShape(
tailEdge: model.detailTailEdge,
tailPosition: model.detailTailPosition
)
.path(in: bounds)
.contains(swiftUIPoint)
}
panel.contentView = hosting
panel.makeContentTransparent()
panel.acceptsMouseMovedEvents = true
hosting.clipsToBounds = true
panel.alphaValue = 0
detailPanel = panel
}
private func startEventMonitoring() {
guard localEventMonitor == nil, globalMouseMonitor == nil else { return }
let monitoringGeneration = pointerMonitoringSession.start()
// Global .mouseMoved monitors only deliver while the frontmost app
// itself requests mouse-moved events (Chrome does, Finder and Terminal
// don't), so the monitors alone leave hover dead over most apps. Poll
// the pointer as the activation-independent fallback. All three paths
// feed one coalescer so a monitor event and its following poll do not
// publish the same point twice.
let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.enqueuePointerUpdate(
at: NSEvent.mouseLocation,
monitoringGeneration: monitoringGeneration
)
}
}
timer.tolerance = 0.05
RunLoop.main.add(timer, forMode: .common)
hoverPollTimer = timer
localEventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .keyDown, .mouseMoved]
) { [weak self] event in
guard let self,
self.pointerMonitoringSession.isCurrent(monitoringGeneration) else { return event }
if event.type == .mouseMoved {
self.enqueuePointerUpdate(
at: NSEvent.mouseLocation,
monitoringGeneration: monitoringGeneration
)
} else if event.type == .keyDown, event.keyCode == 53 {
if self.model.interaction.handleEscape() {
self.hideDetail(animated: false)
self.layoutRail(animate: false)
}
} else if event.type == .leftMouseDown || event.type == .rightMouseDown {
let point = NSEvent.mouseLocation
self.flushPendingPointerUpdate(
at: point,
monitoringGeneration: monitoringGeneration
)
self.dismissIfOutside(point: point)
}
return event
}
globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .mouseMoved]
) { [weak self] event in
DispatchQueue.main.async { [weak self] in
guard let self,
self.pointerMonitoringSession.isCurrent(monitoringGeneration) else { return }
let point = NSEvent.mouseLocation
if event.type == .mouseMoved {
self.enqueuePointerUpdate(
at: point,
monitoringGeneration: monitoringGeneration
)
} else {
self.flushPendingPointerUpdate(
at: point,
monitoringGeneration: monitoringGeneration
)
self.updateMouseEventPassthrough(at: point)
self.dismissIfOutside(point: point)
}
}
}
}
private func stopEventMonitoring() {
pointerMonitoringSession.stop()
railPanel?.ignoresMouseEvents = false
detailPanel?.ignoresMouseEvents = false
hoverPollTimer?.invalidate()
hoverPollTimer = nil
if let localEventMonitor {
NSEvent.removeMonitor(localEventMonitor)
self.localEventMonitor = nil
}
if let globalMouseMonitor {
NSEvent.removeMonitor(globalMouseMonitor)
self.globalMouseMonitor = nil
}
}
private func enqueuePointerUpdate(
at point: CGPoint,
monitoringGeneration: CapacityDockPointerMonitoringSession.Generation
) {
guard pointerMonitoringSession.enqueue(
point,
generation: monitoringGeneration
) == .scheduleDrain else { return }
DispatchQueue.main.async { [weak self] in
self?.drainPointerUpdate(monitoringGeneration: monitoringGeneration)
}
}
private func drainPointerUpdate(
monitoringGeneration: CapacityDockPointerMonitoringSession.Generation
) {
guard let point = pointerMonitoringSession.drain(
generation: monitoringGeneration
) else { return }
updateMouseEventPassthrough(at: point)
syncPointerHover(at: point)
}
/// Makes a mouse-down authoritative without publishing hover changes before
/// AppKit delivers the local event to its target view.
private func flushPendingPointerUpdate(
at point: CGPoint,
monitoringGeneration: CapacityDockPointerMonitoringSession.Generation
) {
_ = pointerMonitoringSession.flush(
with: point,
generation: monitoringGeneration
)
}
/// `NSHostingView.hitTest` can reject a transparent pixel, but AppKit still
/// routes that click to the owning window. Make the whole panel ignore mouse
/// events while the pointer is over one of those pixels; the global movement
/// monitor turns interaction back on as soon as the pointer reaches the
/// visible silhouette again.
private func updateMouseEventPassthrough(at point: CGPoint = NSEvent.mouseLocation) {
let dragging = model.interaction.isDragging
if let railPanel {
let shouldIgnore = railPanel.isVisible
&& railPanel.frame.contains(point)
&& !railContains(screenPoint: point)
&& !dragging
if railPanel.ignoresMouseEvents != shouldIgnore {
railPanel.ignoresMouseEvents = shouldIgnore
}
}
if let detailPanel {
let shouldIgnore = detailPanel.isVisible
&& detailPanel.frame.contains(point)
&& !detailContains(screenPoint: point)
&& !dragging
if detailPanel.ignoresMouseEvents != shouldIgnore {
detailPanel.ignoresMouseEvents = shouldIgnore
}
}
}
/// SwiftUI's onHover rides on NSTrackingAreas that only deliver while the
/// app is active. An accessory app behind a nonactivating panel loses
/// activation on the first outside click and can never win it back, so
/// hover enter/exit is synthesized here from the event monitors, which
/// observe the pointer regardless of activation.
private func syncPointerHover(at point: CGPoint = NSEvent.mouseLocation) {
guard model.preferences.isEnabled,
model.interaction.acceptsHoverTransitions,
railPanel != nil else { return }
let insideRail = railContains(screenPoint: point)
let insideDetail = detailPanel?.isVisible == true && detailContains(screenPoint: point)
let row = insideRail ? providerRow(at: point) : nil
if insideDetail != pointerInsideDetail {
pointerInsideDetail = insideDetail
detailHoverChanged(insideDetail)
}
if insideRail != pointerInsideRail {
railHoverChanged(insideRail)
}
if row != hoveredRowProvider {
let previous = hoveredRowProvider
hoveredRowProvider = row
if let previous { providerHoverChanged(previous, hovering: false) }
if let row { providerHoverChanged(row, hovering: true) }
}
}
private func providerRow(at screenPoint: CGPoint) -> CapacityDockProvider? {
guard let railPanel else { return nil }
let frame = railPanel.frame
let alongOffset: CGFloat = if model.isVertical {
model.expansionAnchor == .start
? frame.maxY - model.railTopPadding - screenPoint.y
: screenPoint.y - frame.minY - model.railBottomPadding
} else {
model.expansionAnchor == .start
? screenPoint.x - frame.minX - model.railTopPadding
: frame.maxX - model.railBottomPadding - screenPoint.x
}
let providers = model.displayedProviders
guard let index = CapacityDockPlacement.providerRowIndex(
alongOffset: alongOffset,
rowHeight: model.rowHeight,
rowSpacing: model.rowSpacing,
rowCount: providers.count,
expansionAnchor: model.expansionAnchor
) else { return nil }
return providers[index]
}
private func railHoverChanged(_ hovering: Bool) {
pointerInsideRail = hovering
guard model.interaction.acceptsHoverTransitions else { return }
expansionWork?.cancel()
collapseWork?.cancel()
if hovering {
let work = DispatchWorkItem { [weak self] in
guard let self, self.pointerInsideRail else { return }
self.model.interaction.setRailHovered(true)
self.layoutRail()
}
expansionWork = work
DispatchQueue.main.asyncAfter(
deadline: .now() + CapacityDockMotion.railHoverOpenDelay,
execute: work
)
} else {
model.interaction.beginRailExitGrace()
scheduleCollapse()
}
}
private func providerHoverChanged(_ provider: CapacityDockProvider, hovering: Bool) {
guard model.interaction.acceptsHoverTransitions else { return }
detailWork?.cancel()
detailExitWork?.cancel()
if hovering {
// Re-entering a row aborts a pending collapse; a row exit must NOT
// touch collapseWork, or it clobbers the collapse the rail exit
// just scheduled and strands the rail expanded.
collapseWork?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
self.showDetail(for: provider)
}
detailWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: work)
} else if model.hoveredProvider == provider {
let work = DispatchWorkItem { [weak self] in
guard let self, !self.model.interaction.isDetailHovered else { return }
self.hideDetail(animated: true)
self.scheduleCollapse()
}
detailExitWork = work
// Leave enough time to cross the narrow transparent gap into the
// separate detail panel. If the fade has already begun, entering
// the card reverses it from its current frame and alpha.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.24, execute: work)
}
}
private func detailHoverChanged(_ hovering: Bool) {
guard model.interaction.acceptsHoverTransitions else { return }
collapseWork?.cancel()
detailExitWork?.cancel()
model.interaction.setDetailHovered(hovering)
if hovering, model.interaction.isExpanded {
// Re-entering during a scheduled or in-flight collapse must restore
// the expanded host frame as well as reversing the card fade.
layoutRail()
}
if hovering, detailIsDismissing, let provider = model.hoveredProvider {
stopDetailMotion()
detailIsDismissing = false
layoutDetail(for: provider, transaction: .detailFollow)
} else if !hovering {
scheduleCollapse()
}
}
private func providerClicked(_ provider: CapacityDockProvider) {
guard !model.interaction.isDragging else { return }
guard ProcessInfo.processInfo.systemUptime >= suppressProviderClicksUntil else { return }
expansionWork?.cancel()
collapseWork?.cancel()
if provider != model.preferences.preferredProvider {
CapacityDockPreferences.setPreferredProvider(provider, defaults: defaults)
if !model.interaction.isPinned {
model.interaction.togglePinned()
}
} else if switchGlanceWindow(for: provider) {
// The resting gauge is the period switch once a provider publishes
// two horizons, so clicking the provider that already rests there
// changes the window rather than unpinning. Escape and an outside
// click still dismiss, and the hover expansion is untouched: this
// branch only ever pins, never collapses.
if !model.interaction.isPinned {
model.interaction.togglePinned()
}
} else {
model.interaction.togglePinned()
}
layoutRail()
showDetail(for: provider)
}
/// Moves one provider's gauge to its other quota horizon. Reports whether
/// anything changed so the click handler can tell a period switch from a
/// provider that has only one window to show.
@discardableResult
private func switchGlanceWindow(for provider: CapacityDockProvider) -> Bool {
let quota = store.capacityDockQuotaSummary(for: provider)
let current = model.preferences.glanceWindow(for: provider)
let next = CapacityDockGlanceWindow.next(after: current, quota: quota)
guard next != current else { return false }
// Persisting posts the preferences notification, which reloads the
// snapshot into the model and redraws the gauge at its new window.
CapacityDockPreferences.setGlanceWindow(next, for: provider, defaults: defaults)
return true
}
private func connect(_ provider: CapacityDockProvider) {
Task { [weak self] in
guard let self else { return }
guard provider.catalogEntry.hasLiveCodeBurnQuotaAdapter else {
NotificationCenter.default.post(
name: .capacityDockOpenProviderSettings,
object: provider.id
)
return
}
if provider.catalogEntry.authMethods == [.apiTokenOrCloudCredentials],
await self.store.capacityDockCredential(for: provider).apiKey
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
NotificationCenter.default.post(
name: .capacityDockOpenProviderSettings,
object: provider.id
)
return
}
await self.store.connectCapacityDockProvider(provider)
self.refreshQuotaPresentation()
guard self.store.capacityDockQuotaSummary(for: provider)?.connection != .connected else {
return
}
NotificationCenter.default.post(
name: .capacityDockOpenProviderSettings,
object: provider.id
)
}
}
private func showDetail(for provider: CapacityDockProvider) {
guard model.preferences.isEnabled,
model.preferences.selectedProviders.contains(provider) else { return }
let wasShowingDetail = detailPanel?.isVisible == true && model.hoveredProvider != nil
detailIsDismissing = false
model.hoveredProvider = provider
model.detailHeight = glanceDetailHeight(for: provider)
ensureDetailPanel()
layoutDetail(for: provider, transaction: wasShowingDetail ? .detailFollow : .detailPresent)
// A panel that lost its all-spaces membership returns to the desktop space alone,
// so hovering inside a full-screen app presented the popover where it could not be
// seen. The rail escapes this by never being ordered out.
detailPanel?.reassertSpaceMembership()
detailPanel?.orderFrontRegardless()
}
private func hideDetail(animated: Bool = true) {
let provider = model.hoveredProvider
model.interaction.setDetailHovered(false)
guard let provider, detailPanel?.isVisible == true else {
completeDetailDismissal()
return
}
if animated {
dismissDetail(for: provider)
} else {
stopDetailMotion()
completeDetailDismissal()
}
}
private func scheduleCollapse() {
collapseWork?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
self.model.interaction.completeCollapseGrace()
guard self.model.interaction.canCollapse else { return }
self.hideDetail()
self.layoutRail()
}
collapseWork = work
DispatchQueue.main.asyncAfter(
deadline: .now() + CapacityDockMotion.railHoverCloseDelay,
execute: work
)
}
private func dismissIfOutside(point: CGPoint) {
guard model.interaction.isExpanded else { return }
let insideRail = railContains(screenPoint: point)
let insideDetail = detailPanel?.isVisible == true && detailContains(screenPoint: point)
guard !insideRail, !insideDetail else { return }
model.interaction.dismiss()
hideDetail()
layoutRail()
}
private func dragChanged(pointerLocation: CGPoint, initialTranslation: CGSize) {
guard let railPanel else { return }
if dragStartFrame == nil {
guard let screen = targetScreen else { return }
expansionWork?.cancel()
collapseWork?.cancel()
detailWork?.cancel()
detailExitWork?.cancel()
stopRailMotion()
stopDetailMotion()
model.interaction.beginDragging()
railPanel.ignoresMouseEvents = false
hideDetail(animated: false)
hoveredRowProvider = nil
pointerInsideDetail = false
dragStartFrame = railPanel.frame
dragVisibleFrame = screen.visibleFrame
dragScreenFrame = screen.frame
dragStartAttachmentProgress = model.attachmentProgress
dragStartDockedEdge = model.dockedEdge
// The first SwiftUI drag update occurs before the panel moves, so
// its translation is safe to use exactly once to recover the
// mouse-down point in AppKit's stable screen coordinate space.
dragStartPointerLocation = CGPoint(
x: pointerLocation.x - initialTranslation.width,
y: pointerLocation.y + initialTranslation.height
)
}
guard let startFrame = dragStartFrame,
let startPointer = dragStartPointerLocation else { return }
// Follow the pointer across displays. Capturing the launch screen for
// the whole gesture made the panel stick to the first display's bounds.
let screens = NSScreen.screens
let activeScreen = CapacityDockPlacement.screenIndex(
containing: pointerLocation,
frames: screens.map(\.frame)
)
.map { screens[$0] }
?? railPanel.screen
?? targetScreen
guard let activeScreen else { return }
let visibleFrame = activeScreen.visibleFrame
let screenFrame = activeScreen.frame
dragVisibleFrame = visibleFrame
dragScreenFrame = screenFrame
let pointerDelta = CGSize(
width: pointerLocation.x - startPointer.x,
height: pointerLocation.y - startPointer.y
)
if hypot(pointerDelta.width, pointerDelta.height) > 3 {
suppressProviderClicksUntil = ProcessInfo.processInfo.systemUptime + 0.2
}
var frame = CapacityDockPlacement.pointerAnchoredDragFrame(
startFrame: startFrame,
startPointer: startPointer,
currentPointer: pointerLocation,
size: model.panelSize
)
frame = CapacityDockPlacement.clampedDragFrame(
frame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
)
if var candidate = CapacityDockPlacement.attachmentCandidate(
railFrame: frame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
) {
if model.attachmentEdge != candidate.edge {
model.attachmentEdge = candidate.edge
frame = CapacityDockPlacement.pointerAnchoredDragFrame(
startFrame: startFrame,
startPointer: startPointer,
currentPointer: pointerLocation,
size: model.panelSize
)
frame = CapacityDockPlacement.clampedDragFrame(
frame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
)
candidate = CapacityDockPlacement.attachmentCandidate(
railFrame: frame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
) ?? candidate
}
model.attachmentProgress = candidate.progress
model.dockedEdge = candidate.progress >= 0.999 ? candidate.edge : nil
} else {
model.attachmentProgress = 0
model.dockedEdge = nil
}
if model.attachmentProgress >= 0.999 {
frame = frame.aligned(
to: model.attachmentEdge,
screenFrame: screenFrame,
visibleFrame: visibleFrame
)
}
railPanel.setFrame(frame, display: false)
railPanel.contentView?.needsDisplay = true
updateMouseEventPassthrough(at: pointerLocation)
lastKnownRailTop = frame.maxY
}
private func hideDock() {
model.interaction.dismiss()
hideDetail(animated: false)
CapacityDockPreferences.setEnabled(false, defaults: defaults)
}
private func dock(to edge: CapacityDockEdge) {
guard let railPanel, let screen = targetScreen else { return }
model.dockedEdge = edge
model.attachmentEdge = edge
let normalizedHorizontal = CapacityDockPlacement.normalizedHorizontalOffset(
railFrame: railPanel.frame,
visibleFrame: screen.visibleFrame
)
let normalizedVertical = CapacityDockPlacement.normalizedTopOffset(
railFrame: railPanel.frame,
visibleFrame: screen.visibleFrame
)
CapacityDockPreferences.setPlacement(
dockedEdge: edge,
attachmentEdge: edge,
normalizedHorizontalOffset: normalizedHorizontal,
normalizedVerticalOffset: normalizedVertical,
defaults: defaults
)
}
/// Settles a drag that is in flight when something outside the gesture takes
/// the rail's SwiftUI view away — today that is a language change, which
/// rebuilds the rail and so destroys the `DragGesture` before it can call
/// `onDragEnded`. Without this the controller is left in the dragging
/// interaction state with a stale `dragStartFrame`, which only `dragEnded()`
/// clears, and the rail stops responding to hover and re-layout.
func settleActiveDrag() {
guard dragStartFrame != nil else { return }
dragEnded()
}
private func dragEnded() {
defer {
dragStartFrame = nil
dragVisibleFrame = nil
dragScreenFrame = nil
dragStartAttachmentProgress = nil
dragStartDockedEdge = nil
dragStartPointerLocation = nil
}
guard let railPanel,
let visibleFrame = dragVisibleFrame,
let screenFrame = dragScreenFrame else { return }
model.interaction.endDragging()
let dockedEdge = CapacityDockPlacement.nearestDockEdge(
railFrame: railPanel.frame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
)
var settledFrame = railPanel.frame
if dockedEdge == nil {
model.dockedEdge = nil
settledFrame.size = model.bodySize
settledFrame = CapacityDockPlacement.clampedDragFrame(
settledFrame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
)
} else if let dockedEdge {
model.dockedEdge = dockedEdge
model.attachmentEdge = dockedEdge
model.expansionAnchor = CapacityDockPlacement.expansionAnchor(
railFrame: settledFrame,
visibleFrame: visibleFrame,
edge: dockedEdge
)
}
// Keep direct dragging exactly mouse-anchored. Once the pointer is
// released, settle the panel onto physical pixels so a later hover
// animation does not begin with a one-pixel correction.
settledFrame = CapacityDockMotion.pixelAlignedRailFrame(
settledFrame,
backingScale: railPanel.screen?.backingScaleFactor ?? 2,
dockedEdge: dockedEdge,
expansionAnchor: model.expansionAnchor,
isVertical: model.isVertical
)
if railPanel.frame != settledFrame {
railPanel.setFrame(settledFrame, display: false)
}
lastKnownRailTop = settledFrame.maxY
let normalizedVertical = CapacityDockPlacement.normalizedTopOffset(
railFrame: settledFrame,
visibleFrame: visibleFrame
)
let normalizedHorizontal = CapacityDockPlacement.normalizedHorizontalOffset(
railFrame: settledFrame,
visibleFrame: visibleFrame
)
CapacityDockPreferences.setPlacement(
dockedEdge: dockedEdge,
attachmentEdge: model.attachmentEdge,
normalizedHorizontalOffset: normalizedHorizontal,
normalizedVerticalOffset: normalizedVertical,
defaults: defaults
)
let hovering = railContains(screenPoint: NSEvent.mouseLocation)
updateMouseEventPassthrough()
pointerInsideRail = hovering
if hovering {
if !model.interaction.isRailHovered {
railHoverChanged(true)
}
} else {
model.interaction.beginRailExitGrace()
scheduleCollapse()
}
// The drag cleared the row cache; a pointer resting on a row at drop
// gets no further mouseMoved event, so re-derive the row hover here.
syncPointerHover()
}
private func layoutRail(preserveCurrentTop: Bool = true, animate: Bool = true) {
guard let railPanel, let screen = targetScreen else { return }
let wantsExpandedPresentation = model.interaction.isExpanded
if wantsExpandedPresentation {
model.isRailPresentationExpanded = true
}
if let dockedEdge = model.dockedEdge {
model.attachmentEdge = dockedEdge
model.expansionAnchor = CapacityDockPlacement.expansionAnchor(
railFrame: railPanel.frame,
visibleFrame: screen.visibleFrame,
edge: dockedEdge
)
}
let floatingAnchors: CapacityDockMotion.FloatingRailAnchors? = if preserveCurrentTop,
model.dockedEdge == nil,
railPanel.frame.width > 0,
railPanel.frame.height > 0
{
CapacityDockMotion.floatingRailAnchors(
frame: railPanel.frame,
preservedTop: lastKnownRailTop,
isVertical: model.isVertical,
expansionAnchor: model.expansionAnchor
)
} else {
nil
}
let anchoredTop = floatingAnchors?.top
let anchoredLeading = floatingAnchors?.leading
let anchoredAxisCoordinate: CGFloat? = if preserveCurrentTop,
let dockedEdge = model.dockedEdge,
railPanel.frame.width > 0,
railPanel.frame.height > 0
{
if dockedEdge.isVertical {
model.expansionAnchor == .start ? railPanel.frame.maxY : railPanel.frame.minY
} else {
model.expansionAnchor == .start ? railPanel.frame.minX : railPanel.frame.maxX
}
} else {
floatingAnchors?.axisCoordinate
}
let targetAttachment: CGFloat = model.dockedEdge == nil ? 0 : 1
let targetPanelSize = model.targetPanelSize(forAttachmentProgress: targetAttachment)
let rawTarget = CapacityDockPlacement.railFrame(
screenFrame: screen.frame,
visibleFrame: screen.visibleFrame,
size: targetPanelSize,
dockedEdge: model.dockedEdge,
normalizedHorizontalOffset: model.preferences.normalizedHorizontalOffset,
normalizedTopOffset: model.preferences.normalizedVerticalOffset,
anchoredTop: anchoredTop,
anchoredLeading: anchoredLeading,
anchoredAxisCoordinate: anchoredAxisCoordinate,
expansionAnchor: model.expansionAnchor
)
let target = CapacityDockMotion.pixelAlignedRailFrame(
rawTarget,
backingScale: screen.backingScaleFactor,
dockedEdge: model.dockedEdge,
expansionAnchor: model.expansionAnchor,
isVertical: model.isVertical
)
lastKnownRailTop = target.maxY
let transaction = CapacityDockMotion.railTransaction(
fromFrame: railPanel.frame,
toFrame: target,
attachmentFrom: model.attachmentProgress,
attachmentTo: targetAttachment
)
if animate,
!model.interaction.isDragging,
railPanel.isVisible,
CapacityDockMotion.shouldAnimate(transaction, reduceMotion: reduceMotion) {
animateRail(to: target, transaction: transaction)
} else {
stopRailMotion()
model.isRailPresentationExpanded = wantsExpandedPresentation
model.railPresentationProgress = wantsExpandedPresentation ? 1 : 0
model.attachmentProgress = targetAttachment
railPanel.setFrame(target, display: true)
}
if model.preferences.isEnabled { railPanel.orderFrontRegardless() }
updateMouseEventPassthrough()
if !detailIsDismissing, let provider = model.hoveredProvider {
layoutDetail(for: provider)
}
// A stationary pointer gets no mouseMoved event when the rail resizes
// or moves beneath it; re-derive hover from the settled geometry.
syncPointerHover()
}
private func layoutDetail(
for provider: CapacityDockProvider,
transaction: CapacityDockMotion.Transaction = .detailFollow
) {
guard let detailPanel, let railPanel, let screen = targetScreen,
let index = model.displayedProviders.firstIndex(of: provider) else { return }
let providerAxisMidpoint: CGFloat
if model.isVertical {
if model.expansionAnchor == .start {
providerAxisMidpoint = railPanel.frame.maxY
- model.railTopPadding
- model.rowHeight / 2
- CGFloat(index) * (model.rowHeight + model.rowSpacing)
} else {
providerAxisMidpoint = railPanel.frame.minY
+ model.railBottomPadding
+ model.rowHeight / 2
+ CGFloat(model.displayedProviders.count - 1 - index)
* (model.rowHeight + model.rowSpacing)
}
} else {
if model.expansionAnchor == .start {
providerAxisMidpoint = railPanel.frame.minX
+ model.railTopPadding
+ model.rowHeight / 2
+ CGFloat(index) * (model.rowHeight + model.rowSpacing)
} else {
providerAxisMidpoint = railPanel.frame.maxX
- model.railBottomPadding
- model.rowHeight / 2
- CGFloat(model.displayedProviders.count - 1 - index)
* (model.rowHeight + model.rowSpacing)
}
}
let side = CapacityDockPlacement.preferredDetailSide(
railFrame: railPanel.frame,
visibleFrame: screen.visibleFrame,
dockedEdge: model.dockedEdge,
preferredEdge: model.attachmentEdge
)
model.detailTailEdge = side.opposite
// The tail's own allowance is part of the content insets, so the height
// is only exact once the side is known.
model.detailHeight = glanceDetailHeight(for: provider)
let target = CapacityDockPlacement.detailFrame(
size: CGSize(width: model.detailWidth, height: model.detailHeight),
railFrame: railPanel.frame,
providerRowMidY: providerAxisMidpoint,
visibleFrame: screen.visibleFrame,
side: side
)
let tailAxisPosition = side.isVertical
? providerAxisMidpoint - target.minY
: providerAxisMidpoint - target.minX
let tailAxisLength = side.isVertical ? target.height : target.width
model.detailTailPosition = tailAxisLength > 0
? min(max(tailAxisPosition / tailAxisLength, 0.18), 0.82)
: 0.5
if CapacityDockMotion.shouldAnimate(transaction, reduceMotion: reduceMotion),
!model.interaction.isDragging {
let start = detailPanel.isVisible
? detailPanel.frame
: CapacityDockMotion.detailPresentationStartFrame(from: target, side: side)
if !detailPanel.isVisible {
detailPanel.alphaValue = 0
detailPanel.setFrame(start, display: true)
detailPanel.orderFrontRegardless()
}
animateDetail(from: start, to: target, transaction: transaction, fadeOut: false)
} else {
stopDetailMotion()
detailPanel.alphaValue = 1
detailPanel.setFrame(target, display: true)
updateMouseEventPassthrough()
}
}
private func animateRail(to target: CGRect, transaction: CapacityDockMotion.Transaction) {
guard let railPanel else { return }
stopRailMotion()
railMotionGeneration &+= 1
let generation = railMotionGeneration
let start = railPanel.frame
let duration = CapacityDockMotion.duration(for: transaction, reduceMotion: reduceMotion)
let revealFrom = model.railPresentationProgress
let revealTo: CGFloat = model.interaction.isExpanded ? 1 : 0
let attachmentFrom = model.attachmentProgress
let attachmentTo: CGFloat = model.dockedEdge == nil ? 0 : 1
let backingScale = railPanel.screen?.backingScaleFactor ?? 2
let isVertical = model.isVertical
let expansionAnchor = model.expansionAnchor
let attachedEdge = if transaction == .dockAttach || transaction == .dockDetach {
model.attachmentEdge
} else {
model.dockedEdge
}
var lastAppliedFrame: CGRect?
var lastAppliedReveal: CGFloat?
var lastAppliedAttachment: CGFloat?
railMotion = CapacityDockMotionRun(
from: start,
to: target,
fromAlpha: railPanel.alphaValue,
toAlpha: 1,
duration: duration,
transaction: transaction,
topAnchored: true,
attachedEdge: attachedEdge,
expansionAnchor: expansionAnchor,
update: { [weak self, weak railPanel] frame, alpha, progress in
guard let self else { return }
let sample = CapacityDockMotion.alignedRailSample(
frame,
fromFrame: start,
toFrame: target,
fromPresentationProgress: revealFrom,
toPresentationProgress: revealTo,
backingScale: backingScale,
dockedEdge: attachedEdge,
expansionAnchor: expansionAnchor,
isVertical: isVertical
)
let attachment = CapacityDockMotion.interpolateAlpha(
from: attachmentFrom,
to: attachmentTo,
progress: progress
)
// Multiple timer callbacks can resolve to one backing-pixel
// frame near an ease curve's endpoints. Skip that duplicate
// publication unless the attachment silhouette is changing.
guard lastAppliedFrame != sample.frame
|| lastAppliedReveal != sample.presentationProgress
|| lastAppliedAttachment != attachment
else { return }
lastAppliedFrame = sample.frame
lastAppliedReveal = sample.presentationProgress
lastAppliedAttachment = attachment
// Publish geometry derived from the snapped frame before
// resizing the host. SwiftUI and AppKit now see one sample.
if self.model.railPresentationProgress != sample.presentationProgress {
self.model.railPresentationProgress = sample.presentationProgress
}
if self.model.attachmentProgress != attachment {
self.model.attachmentProgress = attachment
}
if railPanel?.frame != sample.frame {
railPanel?.setFrame(sample.frame, display: false)
}
railPanel?.contentView?.needsDisplay = true
railPanel?.alphaValue = alpha
self.updateMouseEventPassthrough()
},
completion: { [weak self] in
guard let self, generation == self.railMotionGeneration,
!self.model.interaction.isDragging else { return }
self.railMotion = nil
if self.railPanel?.frame != target {
self.railPanel?.setFrame(target, display: false)
self.railPanel?.contentView?.needsDisplay = true
}
self.lastKnownRailTop = target.maxY
if !self.model.interaction.isExpanded {
if self.model.railPresentationProgress != 0 {
self.model.railPresentationProgress = 0
}
self.model.isRailPresentationExpanded = false
} else {
if self.model.railPresentationProgress != 1 {
self.model.railPresentationProgress = 1
}
}
if self.model.attachmentProgress != attachmentTo {
self.model.attachmentProgress = attachmentTo
}
self.updateMouseEventPassthrough()
if !self.detailIsDismissing, let provider = self.model.hoveredProvider {
self.layoutDetail(for: provider, transaction: .detailFollow)
}
self.syncPointerHover()
}
)
railMotion?.start()
// Keep the exact top anchor even if AppKit rounds an intermediate frame.
if abs(start.maxY - target.maxY) < 0.5 { lastKnownRailTop = target.maxY }
}
private func animateDetail(
from start: CGRect,
to target: CGRect,
transaction: CapacityDockMotion.Transaction,
fadeOut: Bool
) {
guard let detailPanel else { return }
stopDetailMotion()
detailMotionGeneration &+= 1
let generation = detailMotionGeneration
detailPanel.setFrame(start, display: true)
detailMotion = CapacityDockMotionRun(
from: start,
to: target,
fromAlpha: detailPanel.alphaValue,
toAlpha: fadeOut ? 0 : 1,
duration: CapacityDockMotion.duration(for: transaction, reduceMotion: reduceMotion),
transaction: transaction,
topAnchored: false,
update: { [weak self, weak detailPanel] frame, alpha, _ in
detailPanel?.setFrame(frame, display: false)
detailPanel?.contentView?.needsDisplay = true
detailPanel?.alphaValue = alpha
self?.updateMouseEventPassthrough()
},
completion: { [weak self] in
guard let self, generation == self.detailMotionGeneration else { return }
self.detailMotion = nil
if fadeOut {
self.completeDetailDismissal()
} else {
self.detailPanel?.setFrame(target, display: true)
self.detailPanel?.alphaValue = 1
}
self.updateMouseEventPassthrough()
self.syncPointerHover()
}
)
detailMotion?.start()
}
private func dismissDetail(for _: CapacityDockProvider) {
guard let detailPanel else { return }
detailIsDismissing = true
let target = CapacityDockMotion.detailDismissalFrame(
from: detailPanel.frame,
side: model.detailTailEdge.opposite
)
if CapacityDockMotion.shouldAnimate(.detailDismiss, reduceMotion: reduceMotion),
!model.interaction.isDragging {
animateDetail(
from: detailPanel.frame,
to: target,
transaction: .detailDismiss,
fadeOut: true
)
} else {
stopDetailMotion()
completeDetailDismissal()
}
}
private func stopRailMotion() {
railMotionGeneration &+= 1
railMotion?.cancel()
railMotion = nil
}
private func stopDetailMotion() {
detailMotionGeneration &+= 1
detailMotion?.cancel()
detailMotion = nil
}
private var targetScreen: NSScreen? {
if let screen = railPanel?.screen { return screen }
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) } ?? NSScreen.main ?? NSScreen.screens.first
}
private func railContains(screenPoint: CGPoint) -> Bool {
guard let railPanel, railPanel.frame.contains(screenPoint) else { return false }
let local = swiftUILocalPoint(screenPoint, in: railPanel.frame)
return CapacityDockRailShape(
bodyWidth: model.railWidth,
bodyLength: model.bodyLength,
shoulderDepth: CapacityDockMetrics.edgeShoulderDepth(scale: model.scale),
attachmentProgress: model.attachmentProgress,
edge: model.attachmentEdge
)
.path(in: CGRect(origin: .zero, size: railPanel.frame.size))
.contains(local)
}
private func detailContains(screenPoint: CGPoint) -> Bool {
guard let detailPanel, detailPanel.frame.contains(screenPoint) else { return false }
let local = swiftUILocalPoint(screenPoint, in: detailPanel.frame)
return CapacityDockBubbleShape(
tailEdge: model.detailTailEdge,
tailPosition: model.detailTailPosition
)
.path(in: CGRect(origin: .zero, size: detailPanel.frame.size))
.contains(local)
}
private func swiftUILocalPoint(_ screenPoint: CGPoint, in frame: CGRect) -> CGPoint {
CGPoint(x: screenPoint.x - frame.minX, y: frame.maxY - screenPoint.y)
}
}
private extension CGRect {
func aligned(
to edge: CapacityDockEdge,
screenFrame: CGRect,
visibleFrame: CGRect
) -> CGRect {
var result = self
switch edge {
case .left: result.origin.x = screenFrame.minX
case .right: result.origin.x = screenFrame.maxX - width
case .top: result.origin.y = screenFrame.maxY - height
case .bottom: result.origin.y = screenFrame.minY
}
return result
}
}
/// Main-run-loop frame driver. It is explicitly invalidated on hover reversal,
/// drag, screen reposition, and teardown, so no stale AppKit animation or
/// completion can repaint a panel after the interaction state has changed.
@MainActor
private final class CapacityDockMotionRun: NSObject {
private let from: CGRect
private let to: CGRect
private let fromAlpha: CGFloat
private let toAlpha: CGFloat
private let duration: TimeInterval
private let transaction: CapacityDockMotion.Transaction
private let topAnchored: Bool
private let attachedEdge: CapacityDockEdge?
private let expansionAnchor: CapacityDockExpansionAnchor
private let update: (CGRect, CGFloat, CGFloat) -> Void
private let completion: () -> Void
private var startedAt: TimeInterval?
private var timer: Timer?
private var isFinished = false
init(
from: CGRect,
to: CGRect,
fromAlpha: CGFloat,
toAlpha: CGFloat,
duration: TimeInterval,
transaction: CapacityDockMotion.Transaction,
topAnchored: Bool,
attachedEdge: CapacityDockEdge? = nil,
expansionAnchor: CapacityDockExpansionAnchor = .start,
update: @escaping (CGRect, CGFloat, CGFloat) -> Void,
completion: @escaping () -> Void
) {
self.from = from
self.to = to
self.fromAlpha = fromAlpha
self.toAlpha = toAlpha
self.duration = duration
self.transaction = transaction
self.topAnchored = topAnchored
self.attachedEdge = attachedEdge
self.expansionAnchor = expansionAnchor
self.update = update
self.completion = completion
}
func start() {
guard !isFinished else { return }
if duration <= 0 {
finish()
return
}
startedAt = ProcessInfo.processInfo.systemUptime
update(from, fromAlpha, 0)
let timer = Timer(
timeInterval: 1.0 / 60.0,
target: self,
selector: #selector(step),
userInfo: nil,
repeats: true
)
self.timer = timer
RunLoop.main.add(timer, forMode: .common)
}
func cancel() {
guard !isFinished else { return }
isFinished = true
timer?.invalidate()
timer = nil
}
@objc private func step() {
guard !isFinished, let startedAt else { return }
let elapsed = ProcessInfo.processInfo.systemUptime - startedAt
let linear = min(max(elapsed / duration, 0), 1)
if linear >= 1 {
finish()
return
}
let progress = CapacityDockMotion.easedProgress(
for: transaction,
linear: CGFloat(linear)
)
let frame: CGRect
if let attachedEdge {
frame = CapacityDockMotion.interpolateAttachedEdge(
from: from,
to: to,
edge: attachedEdge,
expansionAnchor: expansionAnchor,
progress: progress
)
} else if topAnchored {
frame = CapacityDockMotion.interpolateRail(
from: from,
to: to,
dockedEdge: nil,
expansionAnchor: expansionAnchor,
progress: progress
)
} else {
frame = CapacityDockMotion.interpolate(from: from, to: to, progress: progress)
}
let alpha = CapacityDockMotion.interpolateAlpha(
from: fromAlpha,
to: toAlpha,
progress: progress
)
update(frame, alpha, progress)
}
private func finish() {
guard !isFinished else { return }
isFinished = true
timer?.invalidate()
timer = nil
update(to, toAlpha, 1)
completion()
}
}
private final class CapacityDockPanel: NSPanel {
init() {
super.init(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
// The system menu bar sits above .mainMenu levels on recent macOS, so a
// top-docked rail was clipped below it. Use the shielding level (as the
// system notch apps do) so the rail can render flush at the very top edge.
isFloatingPanel = true
level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
backgroundColor = .clear
isOpaque = false
hasShadow = false
hidesOnDeactivate = false
isReleasedWhenClosed = false
isMovable = false
isExcludedFromWindowsMenu = true
becomesKeyOnlyIfNeeded = true
animationBehavior = .none
collectionBehavior = Self.spaceBehavior
}
/// Membership of every space, including the one a full-screen app owns. The window
/// server drops it from a panel across a display sleep or a display reconfiguration,
/// and writing it again is what re-registers the window, so this is re-asserted
/// before each presentation rather than only at init.
static let spaceBehavior: NSWindow.CollectionBehavior =
[.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
func reassertSpaceMembership() {
collectionBehavior = Self.spaceBehavior
}
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
func makeContentTransparent() {
contentView?.wantsLayer = true
contentView?.layer?.backgroundColor = NSColor.clear.cgColor
}
}
/// Floating nonactivating panels do not get an ordinary first click. Opting the
/// SwiftUI host into first-mouse delivery keeps provider rows and the direct
/// Connect button responsive without making CodeBurn the active application.
private final class CapacityDockHostingView<Content: View>: NSHostingView<Content> {
var interactiveShapeContains: ((CGPoint, CGRect) -> Bool)?
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? {
if let interactiveShapeContains,
!interactiveShapeContains(point, bounds) {
return nil
}
return super.hitTest(point)
}
}
/// One UserDefaults key, watched with KVO so a write from another process is seen. Its own
/// NSObject because that is what `addObserver(_:forKeyPath:options:context:)` takes.
final class DefaultsKeyObserver: NSObject {
private let defaults: UserDefaults
private let key: String
private let onChange: () -> Void
init(defaults: UserDefaults, key: String, onChange: @escaping () -> Void) {
self.defaults = defaults
self.key = key
self.onChange = onChange
super.init()
defaults.addObserver(self, forKeyPath: key, options: [.new], context: nil)
}
deinit {
defaults.removeObserver(self, forKeyPath: key)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
onChange()
}
}