Fix Capacity Dock pointer coalescing and dismissal

This commit is contained in:
Aditya Vikram Singh 2026-09-01 16:51:00 +05:30
parent fdd18e7bb4
commit 5fee00ab9c
4 changed files with 317 additions and 35 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
releaseDetailPanel()
completeDetailDismissal()
railPanel?.orderOut(nil)
detailPanel = nil
railPanel = nil
lastKnownRailTop = nil
}
@ -243,7 +242,10 @@ final class CapacityDockController {
/// 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).
private func releaseDetailPanel() {
/// Every completed dismissal, animated or immediate, converges here.
private func completeDetailDismissal() {
model.hoveredProvider = nil
detailIsDismissing = false
detailPanel?.orderOut(nil)
detailPanel = nil
}
@ -285,19 +287,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
@ -306,18 +309,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
}
@ -325,12 +335,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)
}
}
@ -338,11 +356,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
@ -353,6 +371,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
@ -571,19 +624,14 @@ final class CapacityDockController {
let provider = model.hoveredProvider
model.interaction.setDetailHovered(false)
guard let provider, detailPanel?.isVisible == true else {
model.hoveredProvider = nil
detailIsDismissing = false
releaseDetailPanel()
completeDetailDismissal()
return
}
if animated {
dismissDetail(for: provider)
} else {
stopDetailMotion()
model.hoveredProvider = nil
detailIsDismissing = false
releaseDetailPanel()
detailPanel?.alphaValue = 1
completeDetailDismissal()
}
}
@ -1140,10 +1188,7 @@ final class CapacityDockController {
guard let self, generation == self.detailMotionGeneration else { return }
self.detailMotion = nil
if fadeOut {
self.releaseDetailPanel()
self.detailPanel?.alphaValue = 1
self.model.hoveredProvider = nil
self.detailIsDismissing = false
self.completeDetailDismissal()
} else {
self.detailPanel?.setFrame(target, display: true)
self.detailPanel?.alphaValue = 1
@ -1172,10 +1217,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

@ -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,47 @@ 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)
let detailHeight = CapacityDockMetrics.detailHeight(quota: nil, scale: model.detailScale)
#expect(detailHeight.rounded() == detailHeight)
}
}
}
@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)