mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Fix Capacity Dock pointer coalescing and dismissal
This commit is contained in:
parent
fdd18e7bb4
commit
5fee00ab9c
4 changed files with 317 additions and 35 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue