mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 23:29:35 +00:00
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
* 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.
1559 lines
62 KiB
Swift
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()
|
|
}
|
|
}
|