From d8844cef70810c0882648c91b92d9bc08e933f87 Mon Sep 17 00:00:00 2001 From: Hasin Mahtab Date: Mon, 12 Oct 2026 13:12:08 +0600 Subject: [PATCH 1/5] Menu bar: hold-to-end works on macOS 27 and shows on a light bar macOS 27 delivers a status item click at the middle of the item, with the release already sent while the button is still down. So a press on the end ring landed on the countdown and a hold never lasted. Read the pointer and button state ourselves, and draw the ring and pills in the label colour so they show on a light menu bar. --- Sources/Insomnia/UI/BrandPalette.swift | 2 - Sources/Insomnia/UI/HoldToEndButton.swift | 9 +- Sources/Insomnia/UI/PillView.swift | 16 +- .../Insomnia/UI/StatusItemController.swift | 109 ++++++++++++- Tests/InsomniaTests/HoldToEndEventTests.swift | 152 ++++++++++++++++++ .../MenuBarAppearanceTests.swift | 48 ++++++ docs/releasing.md | 4 + 7 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 Tests/InsomniaTests/HoldToEndEventTests.swift create mode 100644 Tests/InsomniaTests/MenuBarAppearanceTests.swift diff --git a/Sources/Insomnia/UI/BrandPalette.swift b/Sources/Insomnia/UI/BrandPalette.swift index 8c5c478c..383be989 100644 --- a/Sources/Insomnia/UI/BrandPalette.swift +++ b/Sources/Insomnia/UI/BrandPalette.swift @@ -27,8 +27,6 @@ enum BrandPalette { /// App icon tile: a mid charcoal grey. static let midnight = RGB(hex: 0x303336) - /// Pill background in the menu bar: a deep charcoal. - static let charcoal = RGB(hex: 0x17191B) /// The mark on the app icon: a warm off-white. static let moonWhite = RGB(hex: 0xE6E3DD) /// Active state: focus and hold rings. The menu bar mark itself stays monochrome. diff --git a/Sources/Insomnia/UI/HoldToEndButton.swift b/Sources/Insomnia/UI/HoldToEndButton.swift index 6e8071d3..e4351a1e 100644 --- a/Sources/Insomnia/UI/HoldToEndButton.swift +++ b/Sources/Insomnia/UI/HoldToEndButton.swift @@ -27,15 +27,18 @@ struct HoldToEndButton: View { var body: some View { ZStack { + // The label colour, like the eye mark: the menu bar is light or + // dark with the wallpaper, so a fixed white ring vanishes on a + // light bar and a pale brand tint has no contrast there either. Circle() - .stroke(Color.white.opacity(0.14), lineWidth: lineWidth) + .stroke(Color.primary.opacity(0.25), lineWidth: lineWidth) Circle() .trim(from: 0, to: progress) - .stroke(Color(brand: BrandPalette.violet), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) Image(systemName: "xmark") .font(.system(size: 7, weight: .bold)) - .foregroundStyle(Color.white.opacity(0.85)) + .foregroundStyle(Color.primary.opacity(0.85)) } .frame(width: ringDiameter, height: ringDiameter) .scaleEffect(holding && !reduceMotion ? 1.12 : 1) diff --git a/Sources/Insomnia/UI/PillView.swift b/Sources/Insomnia/UI/PillView.swift index ba06063d..63f1afd6 100644 --- a/Sources/Insomnia/UI/PillView.swift +++ b/Sources/Insomnia/UI/PillView.swift @@ -1,8 +1,9 @@ import SwiftUI -/// One of the Days / Hours / Minutes fields in the menu bar. Flat dark -/// rounded rectangle, unit name as placeholder, a crisp blue-grey ring when -/// focused, snappy bounce on focus and on a rejected key. +/// One of the Days / Hours / Minutes fields in the menu bar. Flat rounded +/// rectangle tinted with the label colour (so it reads on a light or a dark +/// menu bar), unit name as placeholder, a crisp ring when focused, snappy +/// bounce on focus and on a rejected key. /// /// The pill is a fixed slot: its width is the placeholder's at the typed /// weight and padding, and the visible text is centred in it. Typing never @@ -38,25 +39,24 @@ struct PillView: View { Text(text ?? field.placeholder) .font(.system(size: 12, weight: isPlaceholder ? .regular : .semibold, design: .rounded)) .monospacedDigit() - .foregroundStyle(isPlaceholder ? Color.white.opacity(0.42) : Color.white) + .foregroundStyle(isPlaceholder ? Color.primary.opacity(0.45) : Color.primary) .contentTransition(.numericText()) .animation(Motion.base(reduceMotion: reduceMotion), value: text) } .frame(minWidth: 28) .frame(height: 19) - .background(shape.fill(Color(brand: BrandPalette.charcoal).opacity(0.92))) + .background(shape.fill(Color.primary.opacity(0.12))) .overlay { - shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) + shape.strokeBorder(Color.primary.opacity(0.18), lineWidth: 0.5) } .overlay { shape - .strokeBorder(Color(brand: BrandPalette.violet).opacity(ringVisible ? 1 : 0), lineWidth: 1.5) + .strokeBorder(Color.primary.opacity(ringVisible ? 0.75 : 0), lineWidth: 1.5) .animation(Motion.base(reduceMotion: reduceMotion), value: ringVisible) } .overlay { shape.strokeBorder(Color.red.opacity(valid ? 0 : 0.8), lineWidth: 1) } - .environment(\.colorScheme, .dark) .contentShape(Rectangle()) .help(field.help) .onTapGesture(perform: onTap) diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index 7c0f3f18..1f674f34 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -847,11 +847,112 @@ final class StatusItemController: NSObject { /// The SwiftUI host covers the status bar button, so the right click has to /// be caught here rather than on the button. Ctrl-click is the same gesture /// on a one-button mouse. +private struct EventBox: @unchecked Sendable { + let event: NSEvent? + init(_ event: NSEvent?) { self.event = event } +} + final class StatusHostingView: NSHostingView { var onRightMouseDown: (() -> Void)? + /// Where the pointer really is, in the window's coordinates. A test + /// replaces it; the app reads the live pointer. + var pointerInWindow: (NSWindow) -> NSPoint = { $0.convertPoint(fromScreen: NSEvent.mouseLocation) } + /// Whether the left button is physically down right now. + var buttonIsDown: () -> Bool = { NSEvent.pressedMouseButtons & 1 != 0 } + private var pointerMonitor: Any? + private var releaseWatch: Timer? + + /// On macOS 27 a click on a status item reaches the app with its location + /// at the middle of the item's window, wherever the pointer was (every + /// press logged the window's exact centre, at three different widths). + /// SwiftUI hit-tests that point, so every press landed on the countdown: + /// the hold-to-end ring never saw a press and the pills opened instead. + /// SwiftUI reads the location from the window's current event, not from + /// the one passed to `mouseDown`, so the event has to be replaced before + /// dispatch: a local monitor may return a different event. Where the + /// system already reports the pointer's position the point does not move + /// and the event passes through as it came. + /// + /// The release has the same problem: the system sends the mouse-up at + /// once, while the button is still down, so a press-and-hold never lasts. + /// That release is held back, and sent when the button really comes up, + /// with the pointer's movement in between sent as drags. + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + stopWatchingRelease() + if let monitor = pointerMonitor { NSEvent.removeMonitor(monitor) } + pointerMonitor = nil + guard window != nil else { return } + pointerMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { [weak self] event in + // Local monitors always run on the main thread; `NSEvent` is not + // Sendable, so it crosses `assumeIsolated` in a box. + let incoming = EventBox(event) + return MainActor.assumeIsolated { + // nil is a release held back, so it must not fall back to the original. + guard let self else { return incoming } + return EventBox(self.route(incoming.event!)) + }.event + } + } + + /// What the monitor hands on for `event`: nil holds a release back. + private func route(_ event: NSEvent) -> NSEvent? { + guard let window, event.window === window else { return event } + switch event.type { + case .leftMouseDown: + stopWatchingRelease() + case .leftMouseUp: + if buttonIsDown() { + watchRelease(like: event, in: window) + return nil + } + stopWatchingRelease() + default: + break + } + return atPointer(event, in: window) + } - override func rightMouseDown(with event: NSEvent) { - onRightMouseDown?() + private func atPointer(_ event: NSEvent, in window: NSWindow) -> NSEvent { + let real = pointerInWindow(window) + let reported = event.locationInWindow + guard hypot(real.x - reported.x, real.y - reported.y) > 2 else { return event } + return mouseEvent(event.type, at: real, like: event) ?? event + } + + private func mouseEvent(_ type: NSEvent.EventType, at point: NSPoint, like event: NSEvent) -> NSEvent? { + NSEvent.mouseEvent( + with: type, location: point, modifierFlags: event.modifierFlags, + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: event.windowNumber, context: nil, + eventNumber: event.eventNumber, clickCount: event.clickCount, pressure: type == .leftMouseUp ? 0 : 1 + ) + } + + /// Poll the button and the pointer until the button is really released. + private func watchRelease(like event: NSEvent, in window: NSWindow) { + stopWatchingRelease() + var last = pointerInWindow(window) + let template = EventBox(event) + let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let window = self.window else { return } + let now = self.pointerInWindow(window) + if !self.buttonIsDown() { + self.stopWatchingRelease() + if let up = self.mouseEvent(.leftMouseUp, at: now, like: template.event!) { NSApp.sendEvent(up) } + } else if hypot(now.x - last.x, now.y - last.y) >= 1 { + last = now + if let drag = self.mouseEvent(.leftMouseDragged, at: now, like: template.event!) { NSApp.sendEvent(drag) } + } + } + } + RunLoop.main.add(timer, forMode: .common) + releaseWatch = timer + } + + private func stopWatchingRelease() { + releaseWatch?.invalidate() + releaseWatch = nil } override func mouseDown(with event: NSEvent) { @@ -861,4 +962,8 @@ final class StatusHostingView: NSHostingView { } super.mouseDown(with: event) } + + override func rightMouseDown(with event: NSEvent) { + onRightMouseDown?() + } } diff --git a/Tests/InsomniaTests/HoldToEndEventTests.swift b/Tests/InsomniaTests/HoldToEndEventTests.swift new file mode 100644 index 00000000..6e88311c --- /dev/null +++ b/Tests/InsomniaTests/HoldToEndEventTests.swift @@ -0,0 +1,152 @@ +import AppKit +import XCTest +@testable import Insomnia + +/// Real mouse events, delivered in-process to the SwiftUI status view, so +/// the press-and-hold ring is exercised without a menu bar or the +/// Accessibility permission. +/// +/// macOS 27 delivers a status item click as an instant down and up, both +/// located at the middle of the item's window whatever the pointer is over +/// and however long the button stays down. `press` reproduces that: the +/// events carry the window's centre and the up follows the down at once, +/// while the pointer's real position and the button's real state are given +/// separately, as the live app reads them (`NSEvent.mouseLocation`, +/// `NSEvent.pressedMouseButtons`). +final class HoldToEndEventTests: XCTestCase { + private final class Hits { + var icon = 0, countdown = 0, hold = 0 + var summary: String { "icon=\(icon) countdown=\(countdown) hold=\(hold)" } + } + + /// What the hardware is doing, read by the host through its injected closures. + private final class Hardware { + var pointerX: CGFloat = 0 + var buttonDown = false + } + + private struct Rig { + let window: NSWindow + let host: StatusHostingView + let hits: Hits + let hardware: Hardware + var size: CGSize { host.frame.size } + /// Window coordinates of the three targets, from the laid-out running item. + var icon: CGFloat { 14 } + var countdown: CGFloat { 50 } + var cross: CGFloat { size.width - 16 } + } + + @MainActor + private func makeRig(_ harness: Harness) async -> Rig { + _ = NSApplication.shared + let hits = Hits() + let hardware = Hardware() + let manager = harness.makeManager() + let model = MenuBarModel() + await manager.start(duration: 3600) + model.phase = .running + let root = StatusRootView( + model: model, manager: manager, + onTapIcon: { hits.icon += 1 }, onTapPill: { _ in }, + onTapCountdown: { hits.countdown += 1 }, onHoldEnd: { hits.hold += 1 }, + onWidthChange: { _ in } + ) + let host = StatusItemController.makeHostingView(root) + let size = host.fittingSize + host.frame = NSRect(origin: .zero, size: size) + host.pointerInWindow = { _ in NSPoint(x: hardware.pointerX, y: size.height / 2) } + host.buttonIsDown = { hardware.buttonDown } + // Off screen: the window only needs to exist to deliver events. + let window = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: size.width, height: size.height), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + window.orderFrontRegardless() + try? await Task.sleep(for: .seconds(0.5)) + return Rig(window: window, host: host, hits: hits, hardware: hardware) + } + + @MainActor + private func deliver(_ type: NSEvent.EventType, _ rig: Rig, number: Int) { + let centre = NSPoint(x: rig.size.width / 2, y: rig.size.height / 2) + let event = NSEvent.mouseEvent( + with: type, location: centre, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: rig.window.windowNumber, + context: nil, eventNumber: number, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1 + )! + NSApp.sendEvent(event) + } + + /// Put the button down over `x`; the system reports the click at once. + @MainActor + private func pressDown(over x: CGFloat, _ rig: Rig) { + rig.hardware.pointerX = x + rig.hardware.buttonDown = true + deliver(.leftMouseDown, rig, number: 1) + deliver(.leftMouseUp, rig, number: 2) + } + + @MainActor + private func letGo(_ rig: Rig) async { + rig.hardware.buttonDown = false + try? await Task.sleep(for: .seconds(0.15)) + } + + @MainActor + private func press(over x: CGFloat, for seconds: TimeInterval, _ rig: Rig) async { + pressDown(over: x, rig) + try? await Task.sleep(for: .seconds(seconds)) + await letGo(rig) + } + + @MainActor + func testHoldingTheCrossEndsTheSessionWhateverTheSystemReports() async { + let harness = Harness() + defer { harness.home.destroy() } + let rig = await makeRig(harness) + defer { rig.window.orderOut(nil) } + pressDown(over: rig.cross, rig) + try? await Task.sleep(for: .seconds(0.4)) + XCTAssertEqual(rig.hits.hold, 0, "the ring has not closed yet") + try? await Task.sleep(for: .seconds(0.5)) + XCTAssertEqual(rig.hits.hold, 1, "the session ends while the button is still down") + await letGo(rig) + XCTAssertEqual(rig.hits.summary, "icon=0 countdown=0 hold=1") + } + + @MainActor + func testAShortPressOnTheCrossDoesNothingAndDoesNotOpenThePills() async { + let harness = Harness() + defer { harness.home.destroy() } + let rig = await makeRig(harness) + defer { rig.window.orderOut(nil) } + await press(over: rig.cross, for: 0.1, rig) + XCTAssertEqual(rig.hits.summary, "icon=0 countdown=0 hold=0") + } + + @MainActor + func testSlidingOffTheCrossWhileHoldingCancelsTheHold() async { + let harness = Harness() + defer { harness.home.destroy() } + let rig = await makeRig(harness) + defer { rig.window.orderOut(nil) } + pressDown(over: rig.cross, rig) + try? await Task.sleep(for: .seconds(0.2)) + rig.hardware.pointerX = rig.icon + try? await Task.sleep(for: .seconds(0.9)) + await letGo(rig) + XCTAssertEqual(rig.hits.hold, 0) + } + + @MainActor + func testTheCountdownAndTheEyeStillTakeTheirOwnClicks() async { + let harness = Harness() + defer { harness.home.destroy() } + let rig = await makeRig(harness) + defer { rig.window.orderOut(nil) } + await press(over: rig.countdown, for: 0.1, rig) + XCTAssertEqual(rig.hits.summary, "icon=0 countdown=1 hold=0") + await press(over: rig.icon, for: 0.1, rig) + XCTAssertEqual(rig.hits.summary, "icon=1 countdown=1 hold=0") + } +} diff --git a/Tests/InsomniaTests/MenuBarAppearanceTests.swift b/Tests/InsomniaTests/MenuBarAppearanceTests.swift new file mode 100644 index 00000000..ae590201 --- /dev/null +++ b/Tests/InsomniaTests/MenuBarAppearanceTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import XCTest +@testable import Insomnia + +/// The status item sits on the system menu bar, which is light or dark with +/// the wallpaper and appearance. Everything drawn in it has to take the +/// label colour from the environment (`.primary`), as the eye mark does; a +/// hard-coded white ring, glyph or text vanishes on a light bar. +final class MenuBarAppearanceTests: XCTestCase { + /// Solid ink under `scheme` must be dark on a light bar and light on a dark one. + @MainActor + private func assertInkFollowsScheme( + _ view: some View, name: String, file: StaticString = #filePath, line: UInt = #line + ) throws { + for scheme in [ColorScheme.light, .dark] { + let px = try Raster.render(view, scheme: scheme, scale: 2) + var ink = 0 + var worst: CGFloat = scheme == .light ? 0 : 1 + for y in 0.. 0.5 { + ink += 1 + let lum = px.rgb((x, y)).luminance + worst = scheme == .light ? max(worst, lum) : min(worst, lum) + } + } + XCTAssertGreaterThan(ink, 10, "\(name) \(scheme): nothing drawn", file: file, line: line) + switch scheme { + case .light: XCTAssertLessThan(worst, 0.4, "\(name) light: ink must be dark on a light menu bar", file: file, line: line) + case .dark: XCTAssertGreaterThan(worst, 0.6, "\(name) dark: ink must be light on a dark menu bar", file: file, line: line) + @unknown default: XCTFail("unexpected scheme", file: file, line: line) + } + } + } + + @MainActor + func testTheHoldToEndButtonIsVisibleOnALightAndADarkMenuBar() throws { + try assertInkFollowsScheme(HoldToEndButton(reduceMotion: true, action: {}), name: "hold-to-end button") + } + + @MainActor + func testAPillIsVisibleOnALightAndADarkMenuBar() throws { + let pill = PillView( + field: .minutes, text: "30", focused: false, valid: true, glowVisible: false, + focusBounce: 0, rejectBounce: 0, reduceMotion: true, onTap: {} + ) + try assertInkFollowsScheme(pill, name: "pill") + } +} diff --git a/docs/releasing.md b/docs/releasing.md index ecdcff5f..5fa4c39c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -284,6 +284,10 @@ below cover what only a real Mac shows. - The same entry across a relaunch (as in the row above, quit Insomnia after the close with the lid still closed, open the lid, and relaunch Insomnia while the panel still reads 0): the launch logs "only a reading above 0 tells them apart" while the panel reads 0, nothing is written, and once the panel reads above 0, "set since its restore" with that level - Backstop and uninstall with a hand-edited state.json whose `keptDisplayReadLit` is `1e-400` (a copy of a journal from the restart row above, in a test home): the backstop logs "is unreadable or malformed; nothing undone, evidence kept", uninstall prints "state.json is unreadable or malformed" and removes nothing, and state.json is unchanged +### Menu bar + +- On macOS 27, with a light and a dark menu bar: the eye, countdown, end ring and pills read on both; holding the ring ends the session after about 0.6 s with the button still down; a short tap or sliding off the ring does nothing; the countdown and the eye still open the pills + ### Hotspot - Hotspot permission, association, cancellation, and reconnect From ac5f2e52369ab3cee436948063dedf12e41c818d Mon Sep 17 00:00:00 2001 From: Hasin Mahtab Date: Mon, 12 Oct 2026 13:52:51 +0600 Subject: [PATCH 2/5] Menu bar: only move relayed clicks, and check the button when the hold closes Only an event at the middle of the item's window is moved to the pointer, so an accurate click that sat in the queue keeps its place. The hold now checks that the button is still down when the ring closes, so a release just before the deadline can't still end the session. Tests for both. --- Sources/Insomnia/UI/HoldToEndButton.swift | 7 +++ .../Insomnia/UI/StatusItemController.swift | 12 ++-- Sources/Insomnia/UI/StatusRootView.swift | 4 +- Tests/InsomniaTests/HoldToEndEventTests.swift | 58 ++++++++++++++++++- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/Sources/Insomnia/UI/HoldToEndButton.swift b/Sources/Insomnia/UI/HoldToEndButton.swift index e4351a1e..10d9cea0 100644 --- a/Sources/Insomnia/UI/HoldToEndButton.swift +++ b/Sources/Insomnia/UI/HoldToEndButton.swift @@ -10,6 +10,9 @@ import SwiftUI struct HoldToEndButton: View { let reduceMotion: Bool let action: () -> Void + /// Whether the button is physically down. Checked when the ring closes, + /// so a release just before the deadline cannot still end the session. + var isPressed: () -> Bool = { NSEvent.pressedMouseButtons & 1 != 0 } @State private var progress: CGFloat = 0 /// Mouse is down and the ring is drawing. @@ -77,6 +80,10 @@ struct HoldToEndButton: View { holdTask = Task { @MainActor in try? await Task.sleep(for: .seconds(duration)) guard !Task.isCancelled, holding else { return } + guard isPressed() else { + release() + return + } fire() } } diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index 1f674f34..8f8ce769 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -869,9 +869,8 @@ final class StatusHostingView: NSHostingView { /// the hold-to-end ring never saw a press and the pills opened instead. /// SwiftUI reads the location from the window's current event, not from /// the one passed to `mouseDown`, so the event has to be replaced before - /// dispatch: a local monitor may return a different event. Where the - /// system already reports the pointer's position the point does not move - /// and the event passes through as it came. + /// dispatch: a local monitor may return a different event. Only an event + /// at the window's middle is replaced; any other passes through as it came. /// /// The release has the same problem: the system sends the mouse-up at /// once, while the button is still down, so a press-and-hold never lasts. @@ -914,8 +913,13 @@ final class StatusHostingView: NSHostingView { } private func atPointer(_ event: NSEvent, in window: NSWindow) -> NSEvent { - let real = pointerInWindow(window) let reported = event.locationInWindow + // Only the relayed click: it sits at the exact middle of the window. + // An event anywhere else is accurate (macOS 26), and the pointer may + // have moved since it was queued, so it passes through as it is. + let middle = NSPoint(x: window.frame.width / 2, y: window.frame.height / 2) + guard abs(reported.x - middle.x) < 1.5, abs(reported.y - middle.y) < 1.5 else { return event } + let real = pointerInWindow(window) guard hypot(real.x - reported.x, real.y - reported.y) > 2 else { return event } return mouseEvent(event.type, at: real, like: event) ?? event } diff --git a/Sources/Insomnia/UI/StatusRootView.swift b/Sources/Insomnia/UI/StatusRootView.swift index 9552ba6b..3fd075eb 100644 --- a/Sources/Insomnia/UI/StatusRootView.swift +++ b/Sources/Insomnia/UI/StatusRootView.swift @@ -20,6 +20,8 @@ struct StatusRootView: View { let onTapCountdown: () -> Void let onHoldEnd: () -> Void let onWidthChange: (CGFloat) -> Void + /// Whether the left button is physically down; a test replaces it. + var buttonIsDown: () -> Bool = { NSEvent.pressedMouseButtons & 1 != 0 } @Environment(\.accessibilityReduceMotion) private var reduceMotionEnv @@ -80,7 +82,7 @@ struct StatusRootView: View { } else if showsCountdown { countdown if showsRunningControls { - HoldToEndButton(reduceMotion: reduceMotion, action: onHoldEnd) + HoldToEndButton(reduceMotion: reduceMotion, action: onHoldEnd, isPressed: buttonIsDown) .transition(Motion.ringTransition(reduceMotion: reduceMotion)) } } diff --git a/Tests/InsomniaTests/HoldToEndEventTests.swift b/Tests/InsomniaTests/HoldToEndEventTests.swift index 6e88311c..1ce7aa84 100644 --- a/Tests/InsomniaTests/HoldToEndEventTests.swift +++ b/Tests/InsomniaTests/HoldToEndEventTests.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI import XCTest @testable import Insomnia @@ -50,7 +51,8 @@ final class HoldToEndEventTests: XCTestCase { model: model, manager: manager, onTapIcon: { hits.icon += 1 }, onTapPill: { _ in }, onTapCountdown: { hits.countdown += 1 }, onHoldEnd: { hits.hold += 1 }, - onWidthChange: { _ in } + onWidthChange: { _ in }, + buttonIsDown: { hardware.buttonDown } ) let host = StatusItemController.makeHostingView(root) let size = host.fittingSize @@ -67,10 +69,10 @@ final class HoldToEndEventTests: XCTestCase { } @MainActor - private func deliver(_ type: NSEvent.EventType, _ rig: Rig, number: Int) { + private func deliver(_ type: NSEvent.EventType, _ rig: Rig, number: Int, at point: NSPoint? = nil) { let centre = NSPoint(x: rig.size.width / 2, y: rig.size.height / 2) let event = NSEvent.mouseEvent( - with: type, location: centre, modifierFlags: [], + with: type, location: point ?? centre, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: rig.window.windowNumber, context: nil, eventNumber: number, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1 )! @@ -149,4 +151,54 @@ final class HoldToEndEventTests: XCTestCase { await press(over: rig.icon, for: 0.1, rig) XCTAssertEqual(rig.hits.summary, "icon=1 countdown=1 hold=0") } + + /// An event that is not at the item's middle is accurate (macOS 26). If + /// the pointer has moved since it was queued, it must not be moved with it. + @MainActor + func testAnAccurateEventIsNotMovedToWhereThePointerIsNow() async { + let harness = Harness() + defer { harness.home.destroy() } + let rig = await makeRig(harness) + defer { rig.window.orderOut(nil) } + let y = rig.size.height / 2 + rig.hardware.buttonDown = true + rig.hardware.pointerX = rig.icon + deliver(.leftMouseDown, rig, number: 1, at: NSPoint(x: rig.cross, y: y)) + try? await Task.sleep(for: .seconds(0.9)) + rig.hardware.buttonDown = false + deliver(.leftMouseUp, rig, number: 2, at: NSPoint(x: rig.cross, y: y)) + try? await Task.sleep(for: .seconds(0.1)) + XCTAssertEqual(rig.hits.summary, "icon=0 countdown=0 hold=1", "the press stayed on the cross") + } + + /// The ring fires on its own timer; the release is only noticed on the + /// next poll. A release in between must not end the session. + @MainActor + func testReleasingBeforeTheRingClosesDoesNotEndTheSessionEvenIfNoReleaseArrived() async { + _ = NSApplication.shared + for (stillDown, expectedHolds) in [(false, 0), (true, 1)] { + var holds = 0 + var down = true + let button = HoldToEndButton(reduceMotion: true, action: { holds += 1 }, isPressed: { down }) + let host = NSHostingView(rootView: button) + let size = host.fittingSize + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: size.width, height: size.height), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + window.orderFrontRegardless() + try? await Task.sleep(for: .seconds(0.3)) + let centre = NSPoint(x: size.width / 2, y: size.height / 2) + NSApp.sendEvent(NSEvent.mouseEvent( + with: .leftMouseDown, location: centre, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 1, clickCount: 1, pressure: 1 + )!) + try? await Task.sleep(for: .seconds(0.3)) + down = stillDown // the button comes up here; no release event is delivered + try? await Task.sleep(for: .seconds(0.5)) + XCTAssertEqual(holds, expectedHolds, "button still down at the deadline: \(stillDown)") + window.orderOut(nil) + } + } } From a2cec5ddfb2b8639e833a620a9cda230e57be5f1 Mon Sep 17 00:00:00 2001 From: Hasin Mahtab Date: Tue, 13 Oct 2026 01:41:58 +0600 Subject: [PATCH 3/5] Menu bar: a click on the item itself no longer collapses the pills On macOS 27 a click on the status item also reaches the global mouse monitor, which collapsed the open pills before a pill could take the click. Ignore global clicks that land on the item's own window. --- .../Insomnia/UI/StatusItemController.swift | 18 +++++++++++-- .../GlobalClickFilterTests.swift | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 Tests/InsomniaTests/GlobalClickFilterTests.swift diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index 8f8ce769..cc8de3fe 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -771,8 +771,13 @@ final class StatusItemController: NSObject { } return event } - globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in - Task { @MainActor in self?.collapse() } + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in + // Read now: by the time the task runs the pointer may have moved. + let point = NSEvent.mouseLocation + Task { @MainActor in + guard let self, !Self.isClickOnItem(at: point, itemFrame: self.button?.window?.frame) else { return } + self.collapse() + } } } @@ -787,6 +792,15 @@ final class StatusItemController: NSObject { keyCatcher = nil } + /// On macOS 27 a click on the status item reaches the global monitor as + /// well, so it would collapse the pills before a pill could take the + /// click. Only a click away from the item's window is a click away. On + /// macOS 26 the monitor never sees a click on the item, so this changes + /// nothing there. + static func isClickOnItem(at point: NSPoint, itemFrame: NSRect?) -> Bool { + itemFrame?.contains(point) ?? false + } + /// A click anywhere in this app that is not the status item collapses /// the pills. private func handleLocalMouse(window: NSWindow?) { diff --git a/Tests/InsomniaTests/GlobalClickFilterTests.swift b/Tests/InsomniaTests/GlobalClickFilterTests.swift new file mode 100644 index 00000000..7cbe3c8f --- /dev/null +++ b/Tests/InsomniaTests/GlobalClickFilterTests.swift @@ -0,0 +1,27 @@ +import AppKit +import XCTest +@testable import Insomnia + +/// On macOS 27 a click on the status item reaches the app's global mouse +/// monitor as well, so it would collapse the open pills before a pill could +/// take the click. Only a click away from the item is a click away. +final class GlobalClickFilterTests: XCTestCase { + private let item = NSRect(x: 982, y: 870, width: 242, height: 30) + + @MainActor + func testAClickOnTheItemItselfIsNotAClickAway() { + XCTAssertTrue(StatusItemController.isClickOnItem(at: NSPoint(x: 1103, y: 885), itemFrame: item)) + XCTAssertTrue(StatusItemController.isClickOnItem(at: NSPoint(x: 983, y: 871), itemFrame: item)) + } + + @MainActor + func testAClickBesideOrBelowTheItemIsAClickAway() { + XCTAssertFalse(StatusItemController.isClickOnItem(at: NSPoint(x: 900, y: 885), itemFrame: item), "another menu bar item") + XCTAssertFalse(StatusItemController.isClickOnItem(at: NSPoint(x: 1103, y: 500), itemFrame: item), "the desktop") + } + + @MainActor + func testAnItemWithNoWindowCountsEverythingAsAClickAway() { + XCTAssertFalse(StatusItemController.isClickOnItem(at: NSPoint(x: 1103, y: 885), itemFrame: nil)) + } +} From 2ffa4159d1ea7d298518c14e7ceb968c3c1210c8 Mon Sep 17 00:00:00 2001 From: Hasin Mahtab Date: Wed, 14 Oct 2026 15:16:28 +0600 Subject: [PATCH 4/5] Tests: run the hold-to-end rig on the main actor Removes a build warning about reading the host view's frame off the main actor. --- Tests/InsomniaTests/HoldToEndEventTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/InsomniaTests/HoldToEndEventTests.swift b/Tests/InsomniaTests/HoldToEndEventTests.swift index 1ce7aa84..e9a7c165 100644 --- a/Tests/InsomniaTests/HoldToEndEventTests.swift +++ b/Tests/InsomniaTests/HoldToEndEventTests.swift @@ -26,6 +26,7 @@ final class HoldToEndEventTests: XCTestCase { var buttonDown = false } + @MainActor private struct Rig { let window: NSWindow let host: StatusHostingView From e537b3a83bcb9691acab938b3378d646739e830d Mon Sep 17 00:00:00 2001 From: Hasin Mahtab Date: Tue, 20 Oct 2026 13:47:52 +0600 Subject: [PATCH 5/5] Menu bar: the click correction is macOS 27 only, and sliding off at the deadline cancels the hold On macOS 26 an event at the middle of the item is a real click, so the local monitor now exists on macOS 27 and later only. When the ring closes it also checks how far the pointer moved since the hold began, so a slide made after the last poll still cancels. Tests cover both, and the release checklist gets a macOS 26 line. --- Sources/Insomnia/UI/HoldToEndButton.swift | 11 ++- .../Insomnia/UI/StatusItemController.swift | 8 +- Sources/Insomnia/UI/StatusRootView.swift | 4 +- Tests/InsomniaTests/HoldToEndEventTests.swift | 92 ++++++++++++++----- docs/releasing.md | 1 + 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/Sources/Insomnia/UI/HoldToEndButton.swift b/Sources/Insomnia/UI/HoldToEndButton.swift index 10d9cea0..81fb1729 100644 --- a/Sources/Insomnia/UI/HoldToEndButton.swift +++ b/Sources/Insomnia/UI/HoldToEndButton.swift @@ -13,6 +13,9 @@ struct HoldToEndButton: View { /// Whether the button is physically down. Checked when the ring closes, /// so a release just before the deadline cannot still end the session. var isPressed: () -> Bool = { NSEvent.pressedMouseButtons & 1 != 0 } + /// Where the pointer is now, in screen points. Read when the hold begins + /// and again when the ring closes. + var pointer: () -> CGPoint = { NSEvent.mouseLocation } @State private var progress: CGFloat = 0 /// Mouse is down and the ring is drawing. @@ -21,6 +24,8 @@ struct HoldToEndButton: View { /// so a single gesture never restarts the hold. @State private var pressed = false @State private var holdTask: Task? + /// Where the pointer was when the hold began. + @State private var startPointer: CGPoint = .zero private let ringDiameter: CGFloat = 18 private let lineWidth: CGFloat = 1.6 @@ -72,6 +77,7 @@ struct HoldToEndButton: View { /// Start drawing the ring and arm the completion. private func begin() { holding = true + startPointer = pointer() let duration = Motion.holdDuration withAnimation(.linear(duration: duration)) { progress = 1 @@ -80,7 +86,10 @@ struct HoldToEndButton: View { holdTask = Task { @MainActor in try? await Task.sleep(for: .seconds(duration)) guard !Task.isCancelled, holding else { return } - guard isPressed() else { + // The drags that cancel a hold arrive from a poll, so a slide made + // after the last poll has not been reported yet: look for it. + let now = pointer() + guard isPressed(), hypot(now.x - startPointer.x, now.y - startPointer.y) <= cancelDistance else { release() return } diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index cc8de3fe..708d7526 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -873,6 +873,8 @@ final class StatusHostingView: NSHostingView { var pointerInWindow: (NSWindow) -> NSPoint = { $0.convertPoint(fromScreen: NSEvent.mouseLocation) } /// Whether the left button is physically down right now. var buttonIsDown: () -> Bool = { NSEvent.pressedMouseButtons & 1 != 0 } + /// The system's major version. A test replaces it. + var osMajorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion private var pointerMonitor: Any? private var releaseWatch: Timer? @@ -885,6 +887,8 @@ final class StatusHostingView: NSHostingView { /// the one passed to `mouseDown`, so the event has to be replaced before /// dispatch: a local monitor may return a different event. Only an event /// at the window's middle is replaced; any other passes through as it came. + /// The monitor exists on macOS 27 and later only: earlier systems report a + /// click where it happened, even one at the middle. /// /// The release has the same problem: the system sends the mouse-up at /// once, while the button is still down, so a press-and-hold never lasts. @@ -895,7 +899,9 @@ final class StatusHostingView: NSHostingView { stopWatchingRelease() if let monitor = pointerMonitor { NSEvent.removeMonitor(monitor) } pointerMonitor = nil - guard window != nil else { return } + // Only macOS 27 relays the click: on macOS 26 every event is already + // where it happened, and the pointer may have moved since it was queued. + guard window != nil, osMajorVersion >= 27 else { return } pointerMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { [weak self] event in // Local monitors always run on the main thread; `NSEvent` is not // Sendable, so it crosses `assumeIsolated` in a box. diff --git a/Sources/Insomnia/UI/StatusRootView.swift b/Sources/Insomnia/UI/StatusRootView.swift index 3fd075eb..49049031 100644 --- a/Sources/Insomnia/UI/StatusRootView.swift +++ b/Sources/Insomnia/UI/StatusRootView.swift @@ -22,6 +22,8 @@ struct StatusRootView: View { let onWidthChange: (CGFloat) -> Void /// Whether the left button is physically down; a test replaces it. var buttonIsDown: () -> Bool = { NSEvent.pressedMouseButtons & 1 != 0 } + /// Where the pointer is; a test replaces it. + var pointerLocation: () -> CGPoint = { NSEvent.mouseLocation } @Environment(\.accessibilityReduceMotion) private var reduceMotionEnv @@ -82,7 +84,7 @@ struct StatusRootView: View { } else if showsCountdown { countdown if showsRunningControls { - HoldToEndButton(reduceMotion: reduceMotion, action: onHoldEnd, isPressed: buttonIsDown) + HoldToEndButton(reduceMotion: reduceMotion, action: onHoldEnd, isPressed: buttonIsDown, pointer: pointerLocation) .transition(Motion.ringTransition(reduceMotion: reduceMotion)) } } diff --git a/Tests/InsomniaTests/HoldToEndEventTests.swift b/Tests/InsomniaTests/HoldToEndEventTests.swift index e9a7c165..2647f1fc 100644 --- a/Tests/InsomniaTests/HoldToEndEventTests.swift +++ b/Tests/InsomniaTests/HoldToEndEventTests.swift @@ -40,7 +40,7 @@ final class HoldToEndEventTests: XCTestCase { } @MainActor - private func makeRig(_ harness: Harness) async -> Rig { + private func makeRig(_ harness: Harness, osMajorVersion: Int = 27) async -> Rig { _ = NSApplication.shared let hits = Hits() let hardware = Hardware() @@ -53,9 +53,11 @@ final class HoldToEndEventTests: XCTestCase { onTapIcon: { hits.icon += 1 }, onTapPill: { _ in }, onTapCountdown: { hits.countdown += 1 }, onHoldEnd: { hits.hold += 1 }, onWidthChange: { _ in }, - buttonIsDown: { hardware.buttonDown } + buttonIsDown: { hardware.buttonDown }, + pointerLocation: { CGPoint(x: hardware.pointerX, y: 0) } ) let host = StatusItemController.makeHostingView(root) + host.osMajorVersion = osMajorVersion let size = host.fittingSize host.frame = NSRect(origin: .zero, size: size) host.pointerInWindow = { _ in NSPoint(x: hardware.pointerX, y: size.height / 2) } @@ -172,34 +174,78 @@ final class HoldToEndEventTests: XCTestCase { XCTAssertEqual(rig.hits.summary, "icon=0 countdown=0 hold=1", "the press stayed on the cross") } + /// macOS 26 reports every click where it happened, so an event at the + /// item's middle is a real one. It must not follow the pointer, which has + /// moved on to the ring while the click waited in the queue. + @MainActor + func testOnMacOS26AClickAtTheMiddleIsNotMovedToWhereThePointerIs() async { + let harness = Harness() + defer { harness.home.destroy() } + let rig = await makeRig(harness, osMajorVersion: 26) + defer { rig.window.orderOut(nil) } + rig.hardware.pointerX = rig.cross + deliver(.leftMouseDown, rig, number: 1) + deliver(.leftMouseUp, rig, number: 2) + try? await Task.sleep(for: .seconds(0.2)) + XCTAssertEqual(rig.hits.summary, "icon=0 countdown=1 hold=0", "the click stayed in the middle") + } + + /// Press a bare hold-to-end button, let `midway` change the hardware 0.3 s + /// in, and return how many times the ring ended the session. No release + /// and no drag event is ever delivered: the ring's own timer is the only + /// thing that can notice the change. + @MainActor + private func holdBareButton( + isPressed: @escaping () -> Bool, pointer: @escaping () -> CGPoint, midway: () -> Void + ) async -> Int { + _ = NSApplication.shared + var holds = 0 + let button = HoldToEndButton(reduceMotion: true, action: { holds += 1 }, isPressed: isPressed, pointer: pointer) + let host = NSHostingView(rootView: button) + let size = host.fittingSize + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: size.width, height: size.height), + styleMask: [.borderless], backing: .buffered, defer: false) + defer { window.orderOut(nil) } + window.contentView = host + window.orderFrontRegardless() + try? await Task.sleep(for: .seconds(0.3)) + let centre = NSPoint(x: size.width / 2, y: size.height / 2) + NSApp.sendEvent(NSEvent.mouseEvent( + with: .leftMouseDown, location: centre, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 1, clickCount: 1, pressure: 1 + )!) + // The ring closes 0.6 s after the press: leave room on both sides for + // a slow runner. + try? await Task.sleep(for: .seconds(0.2)) + midway() + try? await Task.sleep(for: .seconds(0.6)) + return holds + } + /// The ring fires on its own timer; the release is only noticed on the /// next poll. A release in between must not end the session. @MainActor func testReleasingBeforeTheRingClosesDoesNotEndTheSessionEvenIfNoReleaseArrived() async { - _ = NSApplication.shared for (stillDown, expectedHolds) in [(false, 0), (true, 1)] { - var holds = 0 var down = true - let button = HoldToEndButton(reduceMotion: true, action: { holds += 1 }, isPressed: { down }) - let host = NSHostingView(rootView: button) - let size = host.fittingSize - host.frame = NSRect(origin: .zero, size: size) - let window = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: size.width, height: size.height), - styleMask: [.borderless], backing: .buffered, defer: false) - window.contentView = host - window.orderFrontRegardless() - try? await Task.sleep(for: .seconds(0.3)) - let centre = NSPoint(x: size.width / 2, y: size.height / 2) - NSApp.sendEvent(NSEvent.mouseEvent( - with: .leftMouseDown, location: centre, modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, - context: nil, eventNumber: 1, clickCount: 1, pressure: 1 - )!) - try? await Task.sleep(for: .seconds(0.3)) - down = stillDown // the button comes up here; no release event is delivered - try? await Task.sleep(for: .seconds(0.5)) + let holds = await holdBareButton(isPressed: { down }, pointer: { .zero }, midway: { down = stillDown }) XCTAssertEqual(holds, expectedHolds, "button still down at the deadline: \(stillDown)") - window.orderOut(nil) + } + } + + /// The drags that cancel a hold come from a poll. A slide off the ring + /// after the last poll has not been reported when the ring closes, so the + /// ring has to look at the pointer itself. + @MainActor + func testSlidingOffBeforeTheRingClosesDoesNotEndTheSessionEvenIfNoDragArrived() async { + for (slideTo, expectedHolds) in [(CGFloat(8), 1), (CGFloat(20), 0)] { + var pointerX: CGFloat = 100 + let holds = await holdBareButton( + isPressed: { true }, pointer: { CGPoint(x: pointerX, y: 50) }, midway: { pointerX += slideTo } + ) + XCTAssertEqual(holds, expectedHolds, "pointer moved \(slideTo) pt (the cancel distance is 12)") } } } diff --git a/docs/releasing.md b/docs/releasing.md index 5fa4c39c..848d3233 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -287,6 +287,7 @@ below cover what only a real Mac shows. ### Menu bar - On macOS 27, with a light and a dark menu bar: the eye, countdown, end ring and pills read on both; holding the ring ends the session after about 0.6 s with the button still down; a short tap or sliding off the ring does nothing; the countdown and the eye still open the pills +- On macOS 26: a click on the countdown opens the pills even if the pointer moves on right after it; sliding off the ring just before it closes does not end the session ### Hotspot