Skip to content

Switch menu bar to NSPopover and redesign settings - #13

Merged
nkootstra merged 4 commits into
mainfrom
chore/popover-and-settings-redesign
Apr 30, 2026
Merged

Switch menu bar to NSPopover and redesign settings#13
nkootstra merged 4 commits into
mainfrom
chore/popover-and-settings-redesign

Conversation

@nkootstra

Copy link
Copy Markdown
Owner

Summary

  • Menu bar surface: replace MenuBarExtra(.window) with NSStatusItem + NSPopover so the dropdown gets the native upward-pointing arrow centered under the icon. Status item width tracks the SwiftUI label's intrinsic content size.
  • Menu bar layout option: new Display setting picks between 5-hour (single percent) and 5-hour + 7-day (current dual). Defaults to 5-hour for new installs.
  • Settings redesign — 4 tabs:
    • General — Poll interval · Launch at login
    • Display — Menu bar layout · Color mode · Warning · Critical (with caption clarifying these drive both colors and notification triggers) · Enterprise display ($/%) when applicable
    • Notifications — Threshold alerts · Burn rate alert (both default on)
    • Account — Identity · Sign Out · Quit
  • Notification fix: checkAndNotify no longer overwrites earlier crossings when multiple thresholds fire in a single call. The persisted notified array is now updated in-place, so a 95% reading correctly records both 50% and 80% instead of just 80%.
  • Notification gating: the new toggles in Settings actually short-circuit checkAndNotify and shouldNotifyBurnRate. Defaults preserve current behaviour.
  • Tests: 4 new gating tests using isolated UserDefaults(suiteName:) so they don't leak into other parallel notification suites. Includes a regression guard for the multi-threshold record bug.

Notable refactor

NotificationService.init(defaults: UserDefaults = .standard) — production unchanged, tests inject a hermetic suite. Field is nonisolated(unsafe) since UserDefaults is documented thread-safe but isn't formally Sendable in Swift 6.

Test plan

  • swift build clean
  • swift test --filter "BurnRate|Notification" — 21/21 pass across 4 suites
  • Manual: status item shows centered top arrow; popover toggles; focus ring no longer auto-applied to the version-update Link on open
  • Manual: menu bar layout picker collapses status item width when set to single
  • Manual: Notifications toggles persist across relaunch

Replace SwiftUI MenuBarExtra(.window) with a manual NSStatusItem +
NSPopover. The popover renders the system upward-pointing arrow
centered under the status item via show(relativeTo:of:preferredEdge:.minY).

The status button hosts MenuBarLabel via NSHostingView, and re-measures
intrinsicContentSize on viewModel updates and UserDefaults changes so
the bar resizes when content width changes (single → dual percent,
$/% toggle, etc.). NSApp.activate is called before show so focusable
controls (e.g. OAuth code field) receive keyboard input, and the
first responder is cleared after open to suppress AppKit's auto-focus
on the version-update Link.
Settings is now General / Display / Notifications / Account:

- Display owns menu bar layout (5-hour vs 5-hour + 7-day), color mode,
  warning + critical sliders (with footer caption clarifying they drive
  both colors and notifications), and a conditional Enterprise display
  picker ($/%) shown only when viewModel.isEnterprise.
- Notifications has Threshold alerts and Burn rate alert toggles, each
  with explanatory footer text. Both default to true.
- General is reduced to Poll interval and Launch at login.
- Account keeps identity, sign-out, quit (still useful given no dock).

MenuBarLabel reads the new menuBarLayout preference and collapses to
a single percent when set to fiveHourOnly. Default for new installs is
fiveHourOnly to match the simpler glance use case.
…a settings

Two changes to NotificationService:

1. checkAndNotify previously read the persisted notified array once
   before the loop and each iteration overwrote it with a fresh copy
   plus its own threshold. When 50% and 80% were crossed in the same
   call only [80] survived, and on the next call 50 would re-fire.
   Track the array in-place via var so each append is preserved.

2. Honour the new Notifications settings: checkAndNotify short-circuits
   when notificationThresholdAlerts is false, and shouldNotifyBurnRate
   returns false when notificationBurnRateAlerts is false. Both default
   to enabled so existing installs see no behaviour change.

UserDefaults is now injected (init(defaults: UserDefaults = .standard))
so tests can pass an isolated suite-scoped instance without leaking
gate keys into other parallel tests. Production call sites are unchanged.
The injected property is nonisolated(unsafe) since UserDefaults is
documented thread-safe but isn't formally Sendable in Swift 6.
Four new tests in NotificationGatingTests using per-test
UserDefaults(suiteName:) instances so the gate keys never leak into
other parallel notification suites:

- Threshold alerts disabled blocks side effects (notifiedKey stays nil)
- Threshold alerts default (key unset) records both 50 and 80 crossings
  in a single 95% call (regression guard for the overwrite bug)
- Burn rate alerts disabled returns false even when criteria met
- Burn rate alerts default (key unset) returns true when criteria met
@nkootstra
nkootstra merged commit e9b2e1c into main Apr 30, 2026
3 checks passed
@nkootstra
nkootstra deleted the chore/popover-and-settings-redesign branch April 30, 2026 17:56
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.

1 participant