Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Sources/Insomnia/UI/BrandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions Sources/Insomnia/UI/HoldToEndButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Void, Never>?
/// 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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
Expand Down
16 changes: 8 additions & 8 deletions Sources/Insomnia/UI/PillView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
137 changes: 133 additions & 4 deletions Sources/Insomnia/UI/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand All @@ -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?) {
Expand Down Expand Up @@ -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<StatusRootView> {
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) }
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} 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) {
Expand All @@ -861,4 +986,8 @@ final class StatusHostingView: NSHostingView<StatusRootView> {
}
super.mouseDown(with: event)
}

override func rightMouseDown(with event: NSEvent) {
onRightMouseDown?()
}
}
6 changes: 5 additions & 1 deletion Sources/Insomnia/UI/StatusRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
}
}
Expand Down
27 changes: 27 additions & 0 deletions Tests/InsomniaTests/GlobalClickFilterTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading