Mini Timer Window & Tray Icon
Mac Catalyst only. Plan and decision record: 916-plan-pip-mini-timer.
A compact always-on-top companion window showing the running timers, plus a menu bar (tray) icon with a play symbol. Both are triggers for the same window.
Mini timer window
A second UIWindowScene backed by WindowGroup(id: "miniTimers") (MinutaApp.swift), showing MiniTimerView — one compact row per running timer (tag color dot, tag name, ticking elapsed, a drop button that discards the timer, and a stop button that saves it), plus a play button that starts an untagged timer. Tapping a row opens the full RecordEditor in place (narrow, iPhone-like layout) with a Back button; a “close pip” button (pip.exit) in the header closes the window — there are no traffic lights. The scene shares AppState with the main window, so timers started/stopped anywhere appear everywhere.
Opening and routing
MiniWindowManager.toggle():
- open:
requestSceneSessionActivationwith anNSUserActivityof typetools.minuta.app.miniTimers; the miniWindowGroup’shandlesExternalEvents(matching:)routes the new scene to it - close:
requestSceneSessionDestructionof the mini scene’s session
SceneDelegate.willConnectTo detects the mini scene by the activity (first open) or by the minutaSceneRole stamp in session.userInfo (state restoration) and skips all main-window setup (toolbar, test-window sizing). Requires UIApplicationSupportsMultipleScenes in Info.plist.
Window configuration and sizing
Sizing is fully declarative: the mini WindowGroup uses .windowResizability(.contentSize) and MiniTimerView declares a fixed 360pt width with its natural height, so the window always fits the content (empty state, N rows, editor). Never resize this window imperatively — mutating sizeRestrictions or the AppKit frame on the visible window leaves newly inserted SwiftUI content permanently blank on macOS 15 Catalyst (hittable but unpainted; cost a long debugging session).
Two rendering quirks and their fixes, both in place:
- The window frame can lag a beat behind a content shrink, and the hosting view bottom-aligns its ideal-height content in the taller frame — a white strip appears at the top. Fix in
MiniTimerView: a trailingSpacer(minLength: 0)in the root VStack (contributes nothing to the ideal height, absorbs the slack so the gray background covers the whole frame). - The titlebar strip is a separate AppKit layer with a white backdrop. Fix in
MiniWindowManager.configureNSWindow:titlebarAppearsTransparent+fullSizeContentViewstyle bit + the NSWindow’s ownbackgroundColorset to the content gray (systemGroupedBackgroundresolved and rebuilt asNSColorthrough the class-method IMP), plusseparatorStyle = .noneon the UIKit titlebar.
AppKit side otherwise (via NSWindowBridge, the same _hostWindowForUIWindow: KVC bridge the pin button uses): floating level, canJoinAllSpaces, resizable style bit stripped, all three traffic lights hidden.
The window always floats — independent of the main window’s pin state.
Origin persistence
Manually persisted origin (miniWindowOrigin UserDefault, AppKit screen coordinates) — never AppKit frame autosave, which the main window already owns. Saved on scene resign-active/disconnect/app termination; restored on connect, clamped to the screen’s visible frame. When opened from the tray icon the window is instead placed under the status item once. Both save and restore are disabled in UI-test runs (tests share the real app’s UserDefaults).
Multi-scene side effects
TimerCommandsusesCommandGroup(replacing: .newItem): with multiple scenes enabled SwiftUI contributes its own “New Window” Cmd+N, which collides with Start Timer’s Cmd+N and crashes menu building at launch (NSInternalInconsistencyException). Replacing the group drops New Window.WindowPinManagerresolves the main scene by role (UIApplication.mainWindowScene), neverconnectedScenes.first— with two scenes connected,.firston the unordered set could hand the pin the wrong window.- In test mode a restored mini scene is destroyed on connect so a previous run’s window cannot leak into the next test; a restored second main scene is destroyed too (sessions persist across test launches and duplicate main windows make single-element queries ambiguous).
Tray icon
NSStatusBar is AppKit-only, out of a Catalyst app’s reach, so the icon lives in MinutaTray.bundle — a small AppKit plugin (Minuta/Tray/, own XcodeGen target, embedded under Contents/PlugIns/, Catalyst builds only via platformFilter). The app loads it at runtime through @objc protocols in TrayBridge.swift, the only file both targets compile; nothing else crosses the boundary.
- Icon: SF Symbol
play(outline) when idle,play.fillwhile any timer runs; template image, so it adapts to menu bar appearance. - Left click: summon/hide the mini window, positioned under the icon on first open.
- Right click: menu with Start Timer, Stop All Timers (disabled when idle), Open Minuta.
- The plugin holds no timer data and no logic —
TrayIconManager(app side) loads the bundle, observesappState.runningTimersviawithObservationTracking, pushes a running-or-not boolean, and implements the delegate actions againstAppStateandMiniWindowManager.
Settings > Menu bar > “Show menu bar icon” (default on) installs/removes the status item live. The toggle binds through @AppStorage — a manual Binding into the non-observable manager would not re-render the control when clicked.
Testing
MiniTimerTests(UI, Catalyst-only): open/close via pip button, start/stop from the mini window with cross-window assertions, main-window timer appearing in the mini window, Settings toggle round-trip.TrayIconManagerTests(unit, Catalyst-only): real plugin-bundle load path (the test host is the app), enabled-flag default and persistence.- The status item itself is unreachable from XCUITest (system menu bar) — click behavior is verified manually.