Merge remote-tracking branch 'origin/main' into fix/glance-notice-section

This commit is contained in:
iamtoruk 2026-09-03 07:32:50 -07:00
commit 7e74dcd426
6 changed files with 353 additions and 36 deletions

View file

@ -15,7 +15,7 @@ final class CapacityDockController {
private var localEventMonitor: Any?
private var globalMouseMonitor: Any?
private var hoverPollTimer: Timer?
private var lastPolledPointer: CGPoint?
private var pointerMonitoringSession = CapacityDockPointerMonitoringSession()
private var expansionWork: DispatchWorkItem?
private var collapseWork: DispatchWorkItem?
@ -122,9 +122,8 @@ final class CapacityDockController {
pointerInsideDetail = false
hoveredRowProvider = nil
detailPanel?.orderOut(nil)
completeDetailDismissal()
railPanel?.orderOut(nil)
detailPanel = nil
railPanel = nil
lastKnownRailTop = nil
}
@ -246,6 +245,17 @@ final class CapacityDockController {
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 }
@ -283,19 +293,20 @@ final class CapacityDockController {
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; the diff guard
// makes idle ticks free.
// 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 }
let point = NSEvent.mouseLocation
if point == self.lastPolledPointer { return }
self.lastPolledPointer = point
self.updateMouseEventPassthrough(at: point)
self.syncPointerHover(at: point)
self.enqueuePointerUpdate(
at: NSEvent.mouseLocation,
monitoringGeneration: monitoringGeneration
)
}
}
timer.tolerance = 0.05
@ -304,18 +315,25 @@ final class CapacityDockController {
localEventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .keyDown, .mouseMoved]
) { [weak self] event in
guard let self else { return event }
guard let self,
self.pointerMonitoringSession.isCurrent(monitoringGeneration) else { return event }
if event.type == .mouseMoved {
let point = NSEvent.mouseLocation
self.updateMouseEventPassthrough(at: point)
self.syncPointerHover(at: point)
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 {
self.dismissIfOutside(point: NSEvent.mouseLocation)
let point = NSEvent.mouseLocation
self.flushPendingPointerUpdate(
at: point,
monitoringGeneration: monitoringGeneration
)
self.dismissIfOutside(point: point)
}
return event
}
@ -323,12 +341,20 @@ final class CapacityDockController {
matching: [.leftMouseDown, .rightMouseDown, .mouseMoved]
) { [weak self] event in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let self,
self.pointerMonitoringSession.isCurrent(monitoringGeneration) else { return }
let point = NSEvent.mouseLocation
self.updateMouseEventPassthrough(at: point)
if event.type == .mouseMoved {
self.syncPointerHover(at: point)
self.enqueuePointerUpdate(
at: point,
monitoringGeneration: monitoringGeneration
)
} else {
self.flushPendingPointerUpdate(
at: point,
monitoringGeneration: monitoringGeneration
)
self.updateMouseEventPassthrough(at: point)
self.dismissIfOutside(point: point)
}
}
@ -336,11 +362,11 @@ final class CapacityDockController {
}
private func stopEventMonitoring() {
pointerMonitoringSession.stop()
railPanel?.ignoresMouseEvents = false
detailPanel?.ignoresMouseEvents = false
hoverPollTimer?.invalidate()
hoverPollTimer = nil
lastPolledPointer = nil
if let localEventMonitor {
NSEvent.removeMonitor(localEventMonitor)
self.localEventMonitor = nil
@ -351,6 +377,41 @@ final class CapacityDockController {
}
}
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
@ -570,19 +631,14 @@ final class CapacityDockController {
let provider = model.hoveredProvider
model.interaction.setDetailHovered(false)
guard let provider, detailPanel?.isVisible == true else {
model.hoveredProvider = nil
detailIsDismissing = false
detailPanel?.orderOut(nil)
completeDetailDismissal()
return
}
if animated {
dismissDetail(for: provider)
} else {
stopDetailMotion()
model.hoveredProvider = nil
detailIsDismissing = false
detailPanel?.orderOut(nil)
detailPanel?.alphaValue = 1
completeDetailDismissal()
}
}
@ -1142,10 +1198,7 @@ final class CapacityDockController {
guard let self, generation == self.detailMotionGeneration else { return }
self.detailMotion = nil
if fadeOut {
self.detailPanel?.orderOut(nil)
self.detailPanel?.alphaValue = 1
self.model.hoveredProvider = nil
self.detailIsDismissing = false
self.completeDetailDismissal()
} else {
self.detailPanel?.setFrame(target, display: true)
self.detailPanel?.alphaValue = 1
@ -1174,10 +1227,7 @@ final class CapacityDockController {
)
} else {
stopDetailMotion()
model.hoveredProvider = nil
detailIsDismissing = false
detailPanel.orderOut(nil)
detailPanel.alphaValue = 1
completeDetailDismissal()
}
}

View file

@ -0,0 +1,95 @@
import CoreGraphics
/// Coalesces pointer samples from the event monitors and fallback poller into
/// one update per main-runloop turn. Repeated coordinates are ignored even
/// after a drain, so the poller does not replay the point a monitor delivered.
struct CapacityDockPointerUpdateCoalescer {
enum EnqueueResult: Equatable {
case scheduleDrain
case coalesced
case duplicate
}
private var latestPoint: CGPoint?
private var pendingPoint: CGPoint?
private var hasScheduledDrain = false
mutating func enqueue(_ point: CGPoint) -> EnqueueResult {
guard point != latestPoint else { return .duplicate }
latestPoint = point
pendingPoint = point
guard !hasScheduledDrain else { return .coalesced }
hasScheduledDrain = true
return .scheduleDrain
}
mutating func drain() -> CGPoint? {
guard hasScheduledDrain else { return nil }
defer {
pendingPoint = nil
hasScheduledDrain = false
}
return pendingPoint
}
/// Makes an immediate interaction point authoritative and cancels the
/// queued drain that held an older sample.
mutating func flush(with point: CGPoint) -> CGPoint {
latestPoint = point
pendingPoint = nil
hasScheduledDrain = false
return point
}
mutating func reset() {
latestPoint = nil
pendingPoint = nil
hasScheduledDrain = false
}
}
/// Owns one generation of pointer monitoring so callbacks queued by a stopped
/// monitor cannot publish into a later session or drain that session's point.
struct CapacityDockPointerMonitoringSession {
typealias Generation = UInt
private var generation: Generation = 0
private var coalescer = CapacityDockPointerUpdateCoalescer()
mutating func start() -> Generation {
generation &+= 1
coalescer.reset()
return generation
}
mutating func stop() {
generation &+= 1
coalescer.reset()
}
func isCurrent(_ candidate: Generation) -> Bool {
candidate == generation
}
mutating func enqueue(
_ point: CGPoint,
generation candidate: Generation
) -> CapacityDockPointerUpdateCoalescer.EnqueueResult? {
guard isCurrent(candidate) else { return nil }
return coalescer.enqueue(point)
}
mutating func drain(generation candidate: Generation) -> CGPoint? {
guard isCurrent(candidate) else { return nil }
return coalescer.drain()
}
mutating func flush(
with point: CGPoint,
generation candidate: Generation
) -> CGPoint? {
guard isCurrent(candidate) else { return nil }
return coalescer.flush(with: point)
}
}

View file

@ -1398,12 +1398,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
popover.animates = true
popover.delegate = self
popover.contentViewController = makePopoverContent()
}
/// Built fresh for every show and dropped on close: a popover content view
/// that outlives its window kept re-laying itself out at display cadence
/// after being shown once during the first load (6 percent idle CPU until
/// relaunch). Rebuilding a 360pt view on open is far cheaper than that.
private func makePopoverContent() -> NSViewController {
let content = MenuBarContent()
.environment(store)
.environment(updateChecker)
.frame(width: popoverWidth)
popover.contentViewController = NSHostingController(rootView: content)
let hosting = NSHostingController(rootView: content)
// Let the popover size itself from preferredContentSize only. With the
// default options the hosting view also pushes min/max content sizes at
// its window on every pass, and once the popover has been shown while
// loading (content 520pt tall, then the real height), the closed
// popover keeps re-laying itself out at display cadence: 6 percent idle
// CPU until relaunch.
hosting.sizingOptions = [.preferredContentSize]
return hosting
}
@objc private func handleButtonClick(_ sender: AnyObject?) {
@ -1427,6 +1443,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// closes. The popover's window takes keyboard focus on its own
// via makeKeyAndOrderFront, which is enough for keystrokes to
// reach the SwiftUI content.
if popover.contentViewController == nil {
popover.contentViewController = makePopoverContent()
}
store.menuPopoverVisible = true
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
if let window = popover.contentViewController?.view.window {
@ -1642,6 +1661,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
func popoverDidClose(_ notification: Notification) {
store.menuPopoverVisible = false
popover.contentViewController = nil
// Catch up on any menubar title updates that were skipped while the
// popover was anchored.
refreshStatusButton()

View file

@ -327,7 +327,13 @@ final class CapacityDockViewModel {
let eased = p * p * (3 - 2 * p)
return CapacityDockMetrics.edgeShoulderDepth(scale: scale) * 0.6 * eased
}
var railAlongPad: CGFloat { CapacityDockMetrics.railAlongPad(scale: scale) + flareCompensation }
// Rounded after the flare is added, not before: the panel's length is built from this
// padding, and a fractional length re-runs the window's layout at display cadence for
// as long as the dock is up. `points` rounds the base, the eased flare then reintroduced
// the fraction for the whole attach animation.
var railAlongPad: CGFloat {
(CapacityDockMetrics.railAlongPad(scale: scale) + flareCompensation).rounded()
}
var railCrossPad: CGFloat { CapacityDockMetrics.railCrossPad(scale: scale) }
var railTopPadding: CGFloat { railAlongPad }
var railBottomPadding: CGFloat { railAlongPad }

View file

@ -0,0 +1,104 @@
import CoreGraphics
import Testing
@testable import CodeBurnMenubar
@Suite("Capacity Dock pointer update coalescer")
struct CapacityDockPointerUpdateCoalescerTests {
@Test("A burst schedules once and drains its latest point")
func burstCoalescesToLatestPoint() {
var coalescer = CapacityDockPointerUpdateCoalescer()
let first = CGPoint(x: 10, y: 20)
let second = CGPoint(x: 11, y: 21)
let latest = CGPoint(x: 12, y: 22)
#expect(coalescer.enqueue(first) == .scheduleDrain)
#expect(coalescer.enqueue(second) == .coalesced)
#expect(coalescer.enqueue(latest) == .coalesced)
#expect(coalescer.drain() == latest)
#expect(coalescer.drain() == nil)
}
@Test("A poll duplicate of a monitor point is suppressed across a drain")
func duplicatePointIsSuppressed() {
var coalescer = CapacityDockPointerUpdateCoalescer()
let point = CGPoint(x: 40, y: 80)
#expect(coalescer.enqueue(point) == .scheduleDrain)
#expect(coalescer.enqueue(point) == .duplicate)
#expect(coalescer.drain() == point)
#expect(coalescer.enqueue(point) == .duplicate)
#expect(coalescer.drain() == nil)
}
@Test("Reset cancels pending work and forgets duplicate history")
func resetStopsPendingUpdate() {
var coalescer = CapacityDockPointerUpdateCoalescer()
let point = CGPoint(x: 5, y: 9)
#expect(coalescer.enqueue(point) == .scheduleDrain)
coalescer.reset()
#expect(coalescer.drain() == nil)
#expect(coalescer.enqueue(point) == .scheduleDrain)
#expect(coalescer.drain() == point)
}
@Test("A new point schedules another drain after the prior drain")
func reschedulesAfterDrain() {
var coalescer = CapacityDockPointerUpdateCoalescer()
let first = CGPoint(x: 1, y: 2)
let second = CGPoint(x: 2, y: 3)
#expect(coalescer.enqueue(first) == .scheduleDrain)
#expect(coalescer.drain() == first)
#expect(coalescer.enqueue(second) == .scheduleDrain)
#expect(coalescer.drain() == second)
}
}
@Suite("Capacity Dock pointer monitoring session")
struct CapacityDockPointerMonitoringSessionTests {
@Test("A click flushes an older movement before its queued drain")
func clickFlushInvalidatesQueuedDrain() {
var session = CapacityDockPointerMonitoringSession()
let insidePoint = CGPoint(x: 40, y: 80)
let outsideClick = CGPoint(x: 400, y: 800)
let generation = session.start()
#expect(session.enqueue(insidePoint, generation: generation) == .scheduleDrain)
#expect(session.flush(with: outsideClick, generation: generation) == outsideClick)
#expect(session.drain(generation: generation) == nil)
#expect(session.enqueue(outsideClick, generation: generation) == .duplicate)
}
@Test("Stop invalidates queued callbacks and drains across restart")
func staleWorkCannotCrossRestart() {
var session = CapacityDockPointerMonitoringSession()
let point = CGPoint(x: 40, y: 80)
let stalePoint = CGPoint(x: 41, y: 81)
let firstGeneration = session.start()
#expect(session.enqueue(point, generation: firstGeneration) == .scheduleDrain)
session.stop()
#expect(session.enqueue(stalePoint, generation: firstGeneration) == nil)
#expect(session.drain(generation: firstGeneration) == nil)
let restartedGeneration = session.start()
#expect(restartedGeneration != firstGeneration)
#expect(session.enqueue(point, generation: restartedGeneration) == .scheduleDrain)
// A drain queued by the stopped generation must not consume the
// restarted session's current stationary point.
#expect(session.drain(generation: firstGeneration) == nil)
#expect(session.drain(generation: restartedGeneration) == point)
}
@Test("Stopped generations reject click and key callback tokens")
func lifecycleTokenInvalidation() {
var session = CapacityDockPointerMonitoringSession()
let generation = session.start()
#expect(session.isCurrent(generation))
session.stop()
#expect(!session.isCurrent(generation))
}
}

View file

@ -73,6 +73,48 @@ struct CapacityDockPresentationTests {
#expect(horizontal.height == CapacityDockMetrics.horizontalRailWidth(scale: 1.2))
}
@MainActor
@Test("Supported settled scales and edges compose integral panel dimensions")
func supportedSettledGeometryIsIntegral() {
let suite = "CodeBurnMenubarTests.CapacityDock.IntegralGeometry.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
CapacityDockPreferences.setSelectedProviders([.codex, .claude, .gemini], defaults: defaults)
for step in 0...12 {
let scale = 0.6 + Double(step) * 0.05
CapacityDockPreferences.setScale(scale, defaults: defaults)
for edge in CapacityDockEdge.allCases {
CapacityDockPreferences.setPlacement(
dockedEdge: edge,
attachmentEdge: edge,
normalizedHorizontalOffset: 0.5,
normalizedVerticalOffset: 0.5,
defaults: defaults
)
let model = CapacityDockViewModel(
preferences: CapacityDockPreferences.load(defaults: defaults)
)
let resting = model.targetPanelSize(forAttachmentProgress: 1)
#expect(resting.width.rounded() == resting.width)
#expect(resting.height.rounded() == resting.height)
#expect(model.railAlongPad.rounded() == model.railAlongPad)
model.interaction.setRailHovered(true)
let expanded = model.targetPanelSize(forAttachmentProgress: 1)
#expect(expanded.width.rounded() == expanded.width)
#expect(expanded.height.rounded() == expanded.height)
#expect(model.detailWidth.rounded() == model.detailWidth)
// The glance popover's own height is asserted whole across scales by
// CapacityDockGlanceTests.heightIsAlwaysWhole, against the signature it
// grew when the glance replaced the old row-counted popover.
}
}
}
@Test("Docked silhouette flares smoothly into one flush contact chord without horns")
func dockedRailSilhouette() {
let path = CapacityDockRailShape(bodyWidth: 88, bodyLength: 356, attachmentProgress: 1, edge: .right)