Skip to content

Menu bar: route status item clicks by the cursor on macOS 27 - #76

Closed
BurakErdemci wants to merge 2 commits into
krishhgg:mainfrom
BurakErdemci:fix/issue-60-menubar-clicks
Closed

BurakErdemci wants to merge 2 commits into
krishhgg:mainfrom
BurakErdemci:fix/issue-60-menubar-clicks

Conversation

@BurakErdemci

@BurakErdemci BurakErdemci commented Oct 11, 2026 •

Copy link
Copy Markdown

Fixes #60.

Why

On macOS 27 a left click on the status item never reaches the hosting view the way it does on macOS 26. Measured on a MacBook Air M4, macOS 27.0.1, with temporary logging in StatusHostingView and the monitors:

click real cursor (host) event.locationInWindow SwiftUI sent it to
eye, idle item 32 pt wide on the eye {16, 11} eye
Minutes, pills open, item 226 pt x 203 and x 189 {113, 11} both times Hours pill
end ring, running, item 118 pt {99.4, 18.5} {59, 11} countdown (extend)
end ring held 3 s {105.0, 16.1} {59, 11} countdown (extend)
  • The pair arrives at release, at the item's center: mouseDown and mouseUp land in the same second with timestamps 5 to 46 ms old, even for a 3 s hold, so a press-and-hold cannot be measured and the hold ring never starts.
  • NSEvent.mouseLocation read in that mouseDown is the real cursor.
  • With the pills open, the same click first reaches the global monitor, which collapses the pills.

Replaying the pair at the real cursor does not work: a replacement NSEvent passed to super.mouseDown, the same with the host window's number, and window.sendEvent with the replacement (which comes back to the host at the right location) all left SwiftUI firing no tap at all.

What

Everything new is gated on osMajorVersion >= 27 (injected, as DisplayPower does). On macOS 26 taps, hold-to-end and the menu behave as before.

  • StatusRootView reports the frames of the eye, each pill, the countdown, the armed "Click again to end" label and the end control in a named coordinate space (hitFrames on the model, not observed).
  • StatusHostingView with a leftClickRouter reads NSEvent.mouseLocation, converts it to the host, and does not call super for left clicks. routeClick picks the smallest frame under the point and calls the existing action: eye -> iconTapped, pill -> focus, countdown -> customExtend, end or label -> endTapped. One log line per click: status item click at (x, y) -> target.
  • The center-located click still reaches SwiftUI once, so its tap gestures are inert under routing (the eye otherwise toggled twice). Explicit accessibilityActions call the real actions so VoiceOver presses keep working.
  • The global monitor ignores clicks inside the item's own window, only under routing.
  • Ending a session on macOS 27+: click the end control, "Click again to end" replaces the countdown, and a second click within 2 s (checked against a deadline from the injected clock, not only the timer) ends it. README step 3 says so.

Tests

  • New UIStatusTests: hitTarget(at:frames:), routeClick per phase with injected frames, the confirm flow (arm, end, expiry re-arms, deadline passed with the flag still set re-arms, session end and extend disarm), the global-filter decision on 26 and 27, a hosted check that frames survive arm/expire cycles, and that macOS 26 builds no router.
  • Local, macOS 27.0.1, Xcode 27.0: swift build -c release -Xswiftc -warnings-as-errors passes; swift test --filter UIStatusTests --skip testHotspotPasswordProblemCopy and --filter UIStartupTests pass. testHotspotPasswordProblemCopy fails here on untouched main too (the Keychain message comes back in Turkish, this Mac's locale).

Validation

A debug build of this branch on the same Mac, clicked by hand, with the new log line:

step log
eye -> icon, pills open and stay open
Minutes, Days -> pill(minutes), -> pill(days), focus moves
2 + Enter session started (120 s)
end once -> end, "Click again to end"
wait 3 s, click the countdown -> countdown, extend pills open
Minutes, 5 + Enter session extended by 300s
end twice -> end, -> end, session end (user), sleep restored

Not exercised live: a click on the armed label itself and VoiceOver. macOS 26 was not run on hardware; the 26 path is covered by the unchanged gestures and by tests with osMajorVersion: 26.

Written with Claude Code (code by Codex), checked by two independent automated audits, a fix round and a narrow re-audit before opening.

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with the previous test-coverage finding addressed and no new blocking issues found.

Summary

The PR routes menu bar clicks using the cursor position on macOS 27 and later. Ending a session uses two clicks within 2 seconds. macOS 26 keeps its existing gestures.

  • Menu bar clicks reach the control under the cursor on macOS 27.
  • Ending a session on macOS 27 takes two clicks.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Status item left click] --> B{macOS 27 or later?}
  B -->|No| C[Existing SwiftUI gestures]
  B -->|Yes| D[Read cursor screen position]
  D --> E[hostPoint converts to host coordinates]
  E --> F[routeClick selects the control]
  F --> G[Eye, pill or countdown action]
  F --> H[End control or confirmation label]
  H --> I{Confirmation deadline still valid?}
  I -->|No| J[Arm confirmation for 2 seconds]
  I -->|Yes| K[End the session]
Loading

Reviews (2) · Last reviewed commit: "Menu bar: test the screen-to-host click ..." · Reviewed by Greptile

On macOS 27 a click on the status item reaches the hosting view only on
release, as a mouseDown/mouseUp pair located at the item's center, so every
click hit whatever sat in the middle: the countdown instead of the end
control, the Hours pill instead of Minutes. The same click also reached the
global monitor, which collapsed the pills. Replaying the pair at the real
cursor does not help: SwiftUI fires no gesture for a replayed event.

On macOS 27 and later the hosting view now routes each left click itself:
the root view reports the frames of the eye, the pills, the countdown and
the end control, and the click goes to the element under the cursor, read
from NSEvent.mouseLocation. SwiftUI's own gestures do nothing there. Global
clicks inside the item's window no longer collapse the pills, and since a
hold cannot be measured, the end control ends a session on a click followed
by a confirming click within two seconds. macOS 26 keeps its gestures and
press-and-hold unchanged. Fixes krishhgg#60.

Co-authored-by: Claude <[email protected]>
@BurakErdemci
BurakErdemci marked this pull request as ready for review October 11, 2026 17:07
Comment thread Tests/InsomniaTests/UIStatusTests.swift
@nsd97

nsd97 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Tested on a second macOS 27 Mac: MacBook Pro (Mac14,6), Apple M2 Max, macOS 27.0 (26A428), Xcode 27.1.

Build: main bb5cf2b with this PR's head af6bd6e cherry-picked, plus #58, #74 and a local change that adds Comet to the browser lists. The only conflict was README.md with #74, kept as this PR's text. On that tree swift test ran 1,256 tests with 0 failures, UIStatusTests included. Installed with scripts/install.sh, plus a one-line /usr/bin/stat fix in backstop.sh that this Mac needed.

By hand, with the new log line:

  • The eye logged -> icon and the pills opened. A click on Minutes logged -> pill(Insomnia.DurationInput.Field.minutes) and focus went to Minutes, not Hours.
  • During a session, a click on the countdown logged -> countdown and the add-time fields opened.
  • End: a click logged -> end and did not end the session. A second click 3 s later also logged -> end and did not end it, since the 2 s window had passed. A third click 1 s after that ((150.0, 13.0) -> end) ended it: "session end (user)", "sleep restored".
  • Clicks just off the item logged -> none and did nothing.

Not exercised: VoiceOver, and macOS 26.

@paoValle paoValle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Source review only. This machine has no Xcode (macOS 27.0.1, Command Line Tools only), so I could not run swift test or the -warnings-as-errors release build. Everything below is read from the diff at af6bd6e and the files it touches, and the one test claim I could not verify by running is named as such.

What I checked, and what reads right. The routing reaches a real action for every target, and all five SwiftUI tap sites that could otherwise fire a second time are guarded by routesClicksManually — eye, pill, countdown, confirmation label, end control — each with an explicit accessibilityAction left in place, so VoiceOver keeps working. That double-fire is the failure mode I would have looked for here, and the guards cover it. osMajorVersion is injected the way DisplayPower does it, so the macOS 26 path is testable rather than assumed, and testMacOS26KeepsTheHoldToEndPath and testMacOS26HostKeepsSwiftUIMouseHandling pin it. The confirmation is not a bare two-click: endTapped re-arms when the deadline has passed even if the clearing task has not run yet, and testLateConfirmingClickRearmsBeforeTheTimerClearsTheFlag pins that specifically. testRouteClickOnTheEndControlArmsThenEndsTheSession asserts endConfirmArmed and manager.isActive after the first click, so it fails if the confirm is removed — it bites. I also traced the two mutations Greptile asked for earlier: dropping window.convertPoint(fromScreen:) from hostPoint changes the result by the window and host origins (the test window is at 400,300), and dropping the isFlipped flip breaks the round trip, so the new hosted test does catch both.

Two findings and one nit, most important first.

1. The confirmation label changes the status item's width, which docs/spec.md says this layout does not do

StatusRootView.swift:94-101 replaces the countdown with a wider text while the confirmation is armed. Measured on this Mac with AppKit's own metrics (NSFont 11 pt medium rounded for the label, 13 pt medium rounded with monospaced digits for the countdown):

"Click again to end"  11 medium rounded          = 93.84
"59:59"               13 medium rounded+mDigit   = 38.18   (+55.66)
"3:59:41"             13 medium rounded+mDigit   = 50.67   (+43.18)
"30d 00:00"           13 medium rounded+mDigit   = 66.35   (+27.49)

So arming grows the item by 27–56 pt and expiry shrinks it back: two NSStatusItem.length writes per end attempt, on a control a user can reach by accident. docs/spec.md:1159 enumerates the layout-changing state as "the phase, the pill slots, the error label", and the StatusRootView docstring repeats that enumeration; the confirmation is now a fourth. The page's own note at docs/spec.md:1153-1160 is that every length write makes Control Center re-lay out the menu bar and blocks the next render commit for 8–130 ms. The existing width budget test (Tests/InsomniaTests/UIStatusTests.swift:1149-1184) pins an exact widthTargetChangeCount per layout change but never arms the confirmation, so the two new writes are not in that sequence.

Cheapest check if the branch is installed: log widthTargetChangeCount across one arm/expire cycle. If it moves by two, sizing that slot to the wider of the countdown and the label for the whole running phase would remove both writes and keep the enumeration true — an invisible placeholder is already the pattern for this in PillView.swift:31-36. I cannot measure the item here (no build), so I am not claiming it does move; the text widths above are what I measured.

2. The README still captions and illustrates the hold

Step 3 was updated for the click-and-confirm, but the image directly above it in the same section was not. README.md:37's alt text still reads "hold the end control to finish early", and docs/assets/session-flow.svg (:3 and the caption at :70-71) still draws a mid-filled ring labelled "Hold to end". The install page therefore teaches a gesture that on macOS 27 does nothing, one paragraph after correcting it. The same text is in docs/spec.md:100, docs/spec.md:1137 and docs/mockups/menubar-ux.html:174; the spec is what a later contributor reads before touching this control, so it should name both behaviours the way the README now does.

3. Nit: hostPoint drops bounds.origin.y

StatusItemController.swift:928 — bounds.height - point.y is only right while bounds.origin is zero. bounds.maxY - point.y is the same line and origin-safe.

Not exercised by me: swift test, VoiceOver, and macOS 26 on hardware. One note, not a finding: #93 changes the same file and #74 adds a menu action on top of this, so the owner has to pick.

@BurakErdemci

Copy link
Copy Markdown
Author

Closing in favour of #93. Its latest head (a2cec5d) covers everything this PR fixed (pills, countdown and end ring clicks on macOS 27; clean-checkout test: #93 (comment)), and it brings back press-and-hold instead of the double-click fallback here. Thanks to everyone who tested this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hold-to-end control opens extension fields instead of ending session

3 participants