@@ -29,7 +29,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
2929 private lazy var workSession = WorkSessionController ( prefs: prefs)
3030 private let systemMonitor = SystemMonitor ( )
3131 private let dailyGoals = DailyGoalStore ( )
32+ private let popoverNavigation = PopoverNavigation ( )
3233 private var breakWindows : [ NSWindow ] = [ ]
34+ private var breakSceneSeed : UInt64 ?
3335 private var breakWatchdogTimer : Timer ?
3436 private var petStateTimer : Timer ?
3537 private var cancellables = Set < AnyCancellable > ( )
@@ -86,6 +88,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
8688 self ? . rebuildPopoverContentIfShown ( )
8789 }
8890 . store ( in: & cancellables)
91+ dailyGoals. objectWillChange
92+ . receive ( on: RunLoop . main)
93+ . sink { [ weak self] _ in
94+ DispatchQueue . main. async {
95+ self ? . updateStatusItem ( )
96+ }
97+ }
98+ . store ( in: & cancellables)
8999 NSWorkspace . shared. notificationCenter. publisher ( for: NSWorkspace . didActivateApplicationNotification)
90100 . receive ( on: RunLoop . main)
91101 . sink { [ weak self] _ in self ? . publishPetState ( ) }
@@ -193,6 +203,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
193203 // Re-check on reactivation; the checker's own once/6h throttle keeps this
194204 // from hitting the network on every menubar interaction.
195205 updateChecker. checkIfDue ( )
206+ dailyGoals. refreshPeriodBoundaries ( )
196207 }
197208
198209 // MARK: - Status item
@@ -204,7 +215,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
204215 let view = StatusItemView ( providers: visibleProviders,
205216 showRemaining: prefs. showRemaining,
206217 updateAvailable: updateChecker. available != nil ,
207- workSlotLabel: workStatusSlotLabel)
218+ workSlotLabel: workStatusSlotLabel,
219+ goalsSlotLabel: goalsStatusSlotLabel,
220+ onQuotaClick: { [ weak self] in self ? . showPopover ( . quota) } ,
221+ onWorkClick: { [ weak self] in self ? . showPopover ( . work) } ,
222+ onGoalsClick: { [ weak self] in self ? . showPopover ( . goalsToday) } )
208223 hostingView = KajiHostingView ( rootView: view)
209224 hostingView. configureKajiHost ( )
210225 hostingView. translatesAutoresizingMaskIntoConstraints = false
@@ -215,17 +230,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
215230 hostingView. topAnchor. constraint ( equalTo: button. topAnchor) ,
216231 hostingView. bottomAnchor. constraint ( equalTo: button. bottomAnchor) ,
217232 ] )
218-
219- button. target = self
220- button. action = #selector( statusButtonClicked ( _: ) )
221- button. sendAction ( on: [ . leftMouseUp, . rightMouseUp] )
222233 }
223234
224235 private func updateStatusItem( ) {
225236 hostingView? . rootView = StatusItemView ( providers: visibleProviders,
226237 showRemaining: prefs. showRemaining,
227238 updateAvailable: updateChecker. available != nil ,
228- workSlotLabel: workStatusSlotLabel)
239+ workSlotLabel: workStatusSlotLabel,
240+ goalsSlotLabel: goalsStatusSlotLabel,
241+ onQuotaClick: { [ weak self] in self ? . showPopover ( . quota) } ,
242+ onWorkClick: { [ weak self] in self ? . showPopover ( . work) } ,
243+ onGoalsClick: { [ weak self] in self ? . showPopover ( . goalsToday) } )
229244 statusItem. length = statusItemLength
230245 }
231246
@@ -246,20 +261,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
246261 )
247262 }
248263
264+ private var goalsStatusSlotLabel : String ? {
265+ MenuBarSlotLogic . goalsLabel (
266+ enabled: prefs. isModuleEnabled ( . goals) ,
267+ goals: dailyGoals. goals
268+ )
269+ }
270+
249271 private var statusItemLength : CGFloat {
250272 let count = max ( 1 , min ( 4 , visibleProviders. count) )
251273 var length = CGFloat ( count) * 26 + 6
252274 // Compact monospaced `MM:SS` to the right of the rings (~40pt).
253275 if workStatusSlotLabel != nil {
254276 length += 40
255277 }
278+ if let goalsStatusSlotLabel {
279+ length += 8 + CGFloat( goalsStatusSlotLabel. count) * 7
280+ }
256281 return length
257282 }
258283
259- @objc private func statusButtonClicked( _ sender: NSStatusBarButton ) {
260- togglePopover ( sender)
261- }
262-
263284 // MARK: - Popover
264285
265286 private func setupPopover( ) {
@@ -269,11 +290,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
269290 popover = pop
270291 }
271292
272- private func togglePopover( _ sender: NSStatusBarButton ) {
293+ private func showPopover( _ destination: MenuBarDestination ) {
294+ guard let sender = statusItem. button else { return }
295+ dailyGoals. refreshPeriodBoundaries ( )
273296 if popover. isShown {
274- popover. performClose ( sender)
297+ if destinationMatchesCurrentPanel ( destination) {
298+ popover. performClose ( sender)
299+ return
300+ }
301+ applyPopoverDestination ( destination)
275302 return
276303 }
304+
305+ // AppKit's first fitting pass can leave the initial SwiftUI page offset.
306+ // Fit from another enabled page, then switch after placement so every
307+ // direct menu-bar destination follows the same path as normal paging.
308+ let stagingDestination = popoverStagingDestination ( for: destination)
309+ applyPopoverDestination ( stagingDestination)
310+ let requestedDestination = destination
311+
277312 // Rebuild content each open. Width is pinned to `prefs.panelSize`
278313 // so the popover follows S/M; height auto-fits since the popover
279314 // also shows the settings footer (which the HUD doesn't).
@@ -287,6 +322,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
287322 popover. contentViewController = controller
288323 popover. show ( relativeTo: sender. bounds, of: sender, preferredEdge: . minY)
289324 popover. contentViewController? . view. window? . makeKey ( )
325+
326+ if requestedDestination != stagingDestination {
327+ DispatchQueue . main. async { [ weak self] in
328+ self ? . applyPopoverDestination ( requestedDestination)
329+ }
330+ }
331+ }
332+
333+ private func popoverStagingDestination( for destination: MenuBarDestination ) -> MenuBarDestination {
334+ switch destination {
335+ case . quota:
336+ if prefs. isModuleEnabled ( . work) { return . work }
337+ if prefs. isModuleEnabled ( . goals) { return . goalsToday }
338+ return . quota
339+ case . work, . goalsToday:
340+ return . quota
341+ }
342+ }
343+
344+ private func destinationMatchesCurrentPanel( _ destination: MenuBarDestination ) -> Bool {
345+ switch destination {
346+ case . quota:
347+ return popoverNavigation. panel == . quota
348+ case . work:
349+ return popoverNavigation. panel == . work
350+ case . goalsToday:
351+ return popoverNavigation. panel == . goals
352+ }
353+ }
354+
355+ private func applyPopoverDestination( _ destination: MenuBarDestination ) {
356+ switch destination {
357+ case . quota:
358+ popoverNavigation. panel = . quota
359+ case . work:
360+ guard prefs. isModuleEnabled ( . work) else {
361+ popoverNavigation. panel = . quota
362+ break
363+ }
364+ popoverNavigation. panel = . work
365+ case . goalsToday:
366+ guard prefs. isModuleEnabled ( . goals) else {
367+ popoverNavigation. panel = . quota
368+ break
369+ }
370+ popoverNavigation. panel = . goals
371+ popoverNavigation. goalHorizon = . today
372+ }
290373 }
291374
292375 private func makePopoverContentController( maxContentHeight: CGFloat ? = nil ) -> NSHostingController < AnyView > {
@@ -303,6 +386,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
303386 workSession: workSession,
304387 systemMonitor: systemMonitor,
305388 dailyGoals: dailyGoals,
389+ navigation: popoverNavigation,
306390 controls: controls,
307391 maxContentHeight: maxContentHeight ?? maxPopoverHeight ( on: statusItem. button? . window? . screen) ,
308392 onContentSizeChange: { [ weak self] size in
@@ -463,14 +547,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
463547 return
464548 }
465549 let mainScreen = NSScreen . main
550+ let sceneSeed = breakSceneSeed ?? UInt64 . random ( in: UInt64 . min... UInt64 . max)
551+ breakSceneSeed = sceneSeed
552+ let scene = BreakSceneModel . scene ( sessionSeed: sceneSeed)
466553 breakWindows = NSScreen . screens. map { screen in
467554 let isPrimary = screen == mainScreen
468555 let view = BreakOverlayView ( prefs: prefs,
469556 workSession: workSession,
470- petCatalog: petCatalog,
471- dailyGoals: dailyGoals,
557+ scene: scene,
472558 isPrimary: isPrimary,
473- onStartBreak: { [ weak self] in self ? . workSession. startBreak ( ) } ,
474559 onSkip: { [ weak self] in self ? . workSession. skipBreak ( ) } )
475560 . ignoresSafeArea ( )
476561 let hostingView = KajiHostingView ( rootView: view)
@@ -509,6 +594,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
509594 private func closeBreakOverlay( ) {
510595 breakWindows. forEach { $0. close ( ) }
511596 breakWindows. removeAll ( )
597+ breakSceneSeed = nil
512598 }
513599}
514600
0 commit comments