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..81fb1729 100644 --- a/Sources/Insomnia/UI/HoldToEndButton.swift +++ b/Sources/Insomnia/UI/HoldToEndButton.swift @@ -10,6 +10,12 @@ 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 } + /// 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. @@ -18,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 @@ -27,15 +35,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) @@ -66,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 @@ -74,6 +86,13 @@ struct HoldToEndButton: View { holdTask = Task { @MainActor in try? await Task.sleep(for: .seconds(duration)) guard !Task.isCancelled, holding else { return } + // 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 + } fire() } } 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..708d7526 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?) { @@ -847,11 +861,122 @@ 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 } + /// The system's major version. A test replaces it. + var osMajorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + 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. 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. + /// 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 + // 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. + 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 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 + } + + 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 +986,8 @@ final class StatusHostingView: NSHostingView { } super.mouseDown(with: event) } + + override func rightMouseDown(with event: NSEvent) { + onRightMouseDown?() + } } diff --git a/Sources/Insomnia/UI/StatusRootView.swift b/Sources/Insomnia/UI/StatusRootView.swift index 9552ba6b..49049031 100644 --- a/Sources/Insomnia/UI/StatusRootView.swift +++ b/Sources/Insomnia/UI/StatusRootView.swift @@ -20,6 +20,10 @@ 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 } + /// Where the pointer is; a test replaces it. + var pointerLocation: () -> CGPoint = { NSEvent.mouseLocation } @Environment(\.accessibilityReduceMotion) private var reduceMotionEnv @@ -80,7 +84,7 @@ struct StatusRootView: View { } else if showsCountdown { countdown if showsRunningControls { - HoldToEndButton(reduceMotion: reduceMotion, action: onHoldEnd) + HoldToEndButton(reduceMotion: reduceMotion, action: onHoldEnd, isPressed: buttonIsDown, pointer: pointerLocation) .transition(Motion.ringTransition(reduceMotion: reduceMotion)) } } 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)) + } +} diff --git a/Tests/InsomniaTests/HoldToEndEventTests.swift b/Tests/InsomniaTests/HoldToEndEventTests.swift new file mode 100644 index 00000000..2647f1fc --- /dev/null +++ b/Tests/InsomniaTests/HoldToEndEventTests.swift @@ -0,0 +1,251 @@ +import AppKit +import SwiftUI +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 + } + + @MainActor + 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, osMajorVersion: Int = 27) 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 }, + 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) } + 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, at point: NSPoint? = nil) { + let centre = NSPoint(x: rig.size.width / 2, y: rig.size.height / 2) + let event = NSEvent.mouseEvent( + 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 + )! + 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") + } + + /// 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") + } + + /// 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 { + for (stillDown, expectedHolds) in [(false, 0), (true, 1)] { + var down = true + let holds = await holdBareButton(isPressed: { down }, pointer: { .zero }, midway: { down = stillDown }) + XCTAssertEqual(holds, expectedHolds, "button still down at the deadline: \(stillDown)") + } + } + + /// 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/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..848d3233 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -284,6 +284,11 @@ 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 +- 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 - Hotspot permission, association, cancellation, and reconnect