diff --git a/App/MenuBarController.swift b/App/MenuBarController.swift new file mode 100644 index 0000000..83e689a --- /dev/null +++ b/App/MenuBarController.swift @@ -0,0 +1,124 @@ +import AppKit +import Observation +import SwiftUI + +/// The StudyApp item in the menu bar, built with AppKit (`NSStatusItem` and a popover). +/// +/// SwiftUI's `MenuBarExtra` scene kept the app's scene graph changing at launch, so the main +/// window rebuilt its toolbar endlessly and the app hung. A status item owned by the app avoids +/// the extra scene; the popover still shows the SwiftUI `MenuBarContent`. +@MainActor +final class MenuBarController: NSObject { + private let calendar: StudyCalendarViewModel + private let today: TodayViewModel + private let navigation: AppNavigation + private let defaults: UserDefaults + private var statusItem: NSStatusItem? + private var defaultsObserver: NSObjectProtocol? + private lazy var popover: NSPopover = makePopover() + + init( + calendar: StudyCalendarViewModel, + today: TodayViewModel, + navigation: AppNavigation, + defaults: UserDefaults = .standard + ) { + self.calendar = calendar + self.today = today + self.navigation = navigation + self.defaults = defaults + } + + /// Shows the item when the setting is on and follows later changes of the setting. + func install() { + guard defaultsObserver == nil else { return } + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, object: defaults, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.applyVisibility() } + } + applyVisibility() + } + + // MARK: Visibility + + private var isEnabled: Bool { + defaults.object(forKey: ViewPreferences.showsMenuBarItemKey) as? Bool ?? true + } + + private func applyVisibility() { + if isEnabled, statusItem == nil { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.target = self + item.button?.action = #selector(togglePopover(_:)) + item.button?.imagePosition = .imageLeading + statusItem = item + observeSummary() + } else if !isEnabled, let item = statusItem { + popover.performClose(nil) + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + + // MARK: Label + + /// Updates the label now and again whenever the lecture or the due cards change. + private func observeSummary() { + guard statusItem != nil else { return } + let summary = withObservationTracking { + MenuBarSummary(calendar: calendar, dueCards: today.totalDue) + } onChange: { [weak self] in + Task { @MainActor in self?.observeSummary() } + } + updateLabel(summary) + } + + private func updateLabel(_ summary: MenuBarSummary) { + guard let button = statusItem?.button else { return } + let symbol = summary.title.isEmpty ? "graduationcap" : "record.circle" + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "StudyApp") + button.title = summary.title.isEmpty ? "" : " \(summary.title)" + } + + // MARK: Popover + + @objc private func togglePopover(_ sender: NSStatusBarButton) { + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController(rootView: MenuBarPopoverView( + calendar: calendar, + today: today, + onOpenLecture: { [weak self] moduleID in + self?.openMainWindow() + self?.navigation.show(module: moduleID, section: .slides, intent: .resumeSlides) + }, + onReviewCards: { [weak self] in + guard let self else { return } + openMainWindow() + // Starts with the module that has the most due cards, like „Lernen starten“ on Today. + if let moduleID = today.dueCounts.max(by: { $0.value < $1.value })?.key { + navigation.show(module: moduleID, section: .flashcards) + } + }, + onOpenApp: { [weak self] in self?.openMainWindow() } + )) + return popover + } + + /// Closes the popover and brings the main window to the front. + private func openMainWindow() { + popover.performClose(nil) + NSApplication.shared.activate() + NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) + } +} diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index 4416918..5cba309 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -8,6 +8,7 @@ struct StudyAppApp: App { private let autopilot: LectureAutopilot /// Installed in `init` so a click that launches the app is not lost. private let notificationResponder: NotificationResponder + private let menuBar: MenuBarController @State private var modulesViewModel: ModulesViewModel @State private var calendarViewModel: StudyCalendarViewModel @State private var todayViewModel: TodayViewModel @@ -22,7 +23,6 @@ struct StudyAppApp: App { @State private var mensaViewModel: MensaViewModel @State private var updater = AppUpdater() @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard - @AppStorage(ViewPreferences.showsMenuBarItemKey) private var showsMenuBarItem = true init() { do { @@ -33,6 +33,7 @@ struct StudyAppApp: App { let assignments = container.makeAssignmentsViewModel() let exams = container.makeExamsViewModel() let navigation = AppNavigation() + menuBar = MenuBarController(calendar: calendar, today: today, navigation: navigation) let studIP = container.makeStudIPViewModel() let announcements = container.makeAnnouncementsViewModel() studIP.onAnnouncementsSynced = { announcements.load() } @@ -155,33 +156,6 @@ struct StudyAppApp: App { BackupCommands(viewModel: backupViewModel) } - MenuBarExtra(isInserted: $showsMenuBarItem) { - MenuBarSummaryReader(calendar: calendarViewModel, today: todayViewModel) { summary in - MenuBarContent( - summary: summary, - onOpenLecture: { moduleID in - Self.bringToFront() - navigation.show(module: moduleID, section: .slides, intent: .resumeSlides) - }, - onReviewCards: { - Self.bringToFront() - // Starts with the module that has the most due cards, like „Lernen starten“ on Today. - if let moduleID = todayViewModel.dueCounts.max(by: { $0.value < $1.value })?.key { - navigation.show(module: moduleID, section: .flashcards) - } - }, - onOpenApp: { Self.bringToFront() } - ) - .tint(Theme.accent) - .preferredColorScheme(appearance.colorScheme) - } - } label: { - MenuBarSummaryReader(calendar: calendarViewModel, today: todayViewModel) { summary in - MenuBarLabel(summary: summary) - } - } - .menuBarExtraStyle(.window) - Settings { SettingsView( settings: container.preferences, @@ -210,6 +184,7 @@ struct StudyAppApp: App { if navigation.destination == nil { navigation.showToday() } + menuBar.install() autopilot.run(isLaunch: true) // Abonnements im Hintergrund auffrischen; eine dabei neu erkannte Vorlesung wird ebenfalls geöffnet. @@ -223,12 +198,6 @@ struct StudyAppApp: App { await notificationsViewModel.refresh() } - /// Shows the main window, e.g. after a click in the menu bar. - private static func bringToFront() { - NSApplication.shared.activate() - NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) - } - /// Keeps Spotlight in line with notes and study sheets; failures only cost search results. private func reindexSearch() async { try? await container.makeReindexSearch()() diff --git a/Presentation/MenuBar/MenuBarContent.swift b/Presentation/MenuBar/MenuBarContent.swift index 6896019..6a3982f 100644 --- a/Presentation/MenuBar/MenuBarContent.swift +++ b/Presentation/MenuBar/MenuBarContent.swift @@ -1,29 +1,25 @@ import AppKit import SwiftUI -/// Builds the summary inside a view, so only the menu bar item observes the calendar and the due cards. -/// Reading them in the App body would rebuild every scene on each change, already while the app launches. -struct MenuBarSummaryReader: View { +/// Content of the menu bar popover: builds the summary inside the view, so it follows the calendar +/// and the due cards, and applies the chosen appearance. +struct MenuBarPopoverView: View { let calendar: StudyCalendarViewModel let today: TodayViewModel - @ViewBuilder let content: (MenuBarSummary) -> Content - - var body: some View { - content(MenuBarSummary(calendar: calendar, dueCards: today.totalDue)) - } -} - -/// The menu bar item's label: an icon, plus the remaining minutes while a lecture runs. -struct MenuBarLabel: View { - let summary: MenuBarSummary + let onOpenLecture: (UUID) -> Void + let onReviewCards: () -> Void + let onOpenApp: () -> Void + @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard var body: some View { - if summary.title.isEmpty { - Image(systemName: "graduationcap") - } else { - Label(summary.title, systemImage: "record.circle") - .labelStyle(.titleAndIcon) - } + MenuBarContent( + summary: MenuBarSummary(calendar: calendar, dueCards: today.totalDue), + onOpenLecture: onOpenLecture, + onReviewCards: onReviewCards, + onOpenApp: onOpenApp + ) + .tint(Theme.accent) + .preferredColorScheme(appearance.colorScheme) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ce1b73..d561a86 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,7 +34,7 @@ Dependencies point **inward**: Presentation and Data both depend on Domain, Doma `AppContainer(inMemory: true)` swaps in in-memory persistence and preferences for previews and tests. -`AppLauncher` is the entry point: under XCTest it starts an empty host app instead of `StudyAppApp`, so launch work (database, sync, menu bar item) never runs during unit tests. On CI runners the menu bar scene kept the test host from finishing its launch. +`AppLauncher` is the entry point: under XCTest it starts an empty host app instead of `StudyAppApp`, so launch work (database, sync, menu bar item) never runs during unit tests. This keeps the unit tests independent of the window, the menu bar item and the network. ## Data flow example: calendar subscription → module diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e411c4b..902d000 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -84,7 +84,9 @@ Code: `Presentation/StudIP/`, `Domain/UseCases/StudIPUseCases.swift`, `Data/Stud The menu bar item shows the remaining minutes while a lecture runs. Its window lists the running or next lecture (with „Folien öffnen“) and the due flashcards, and opens the main window. It can be hidden in Settings → Allgemein. -Code: `Presentation/MenuBar/` +The item is an AppKit `NSStatusItem` with a popover (`App/MenuBarController.swift`), not a SwiftUI `MenuBarExtra` scene: that scene kept the scene graph changing at launch and the app hung. + +Code: `App/MenuBarController.swift`, `Presentation/MenuBar/` ## Shortcuts and Siri