import SwiftUI import AppKit import OnyxNotch import OnyxWidgetKit import OnyxMenuBar /// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays. /// /// Änderungen wirken sofort. Eine Einstellung, die erst nach einem Neustart /// greift, ist in einer App ohne Fenster praktisch unauffindbar — man sieht ja /// nicht, dass etwas passiert ist. @MainActor @Observable final class AppModel { private let store: LayoutStore private let defaults: UserDefaults /// Wird gerufen, wenn sich die Panelgröße geändert haben könnte. var onLayoutChanged: (([WidgetPlacement]) -> Void)? /// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen. var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)? var layout: [WidgetPlacement] { didSet { guard layout != oldValue else { return } persistLayout() onLayoutChanged?(layout) } } var displayPolicy: DisplayPolicy { didSet { guard displayPolicy != oldValue else { return } defaults.set(displayPolicy.rawValue, forKey: Keys.displayPolicy) onDisplayPolicyChanged?(displayPolicy) } } var showsMenuBarIcon: Bool { didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) } } /// Ob Onyx im Dock auftaucht. /// /// Standardmäßig nicht: Onyx ist ein Werkzeug, das in der Notch und in der /// Menüleiste lebt, hat kein Hauptfenster und wäre im Dock ein Symbol, das /// beim Anklicken nichts öffnet. Es taucht dann auch im Programmumschalter /// auf und steht dort zwischen den Programmen, mit denen man arbeitet. var showsDockIcon: Bool { didSet { guard showsDockIcon != oldValue else { return } defaults.set(showsDockIcon, forKey: Keys.showsDockIcon) NSApp.setActivationPolicy(showsDockIcon ? .regular : .accessory) } } /// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit /// Änderungen aus den Einstellungen sofort durchschlagen. weak var menuBar: MenuBarController? /// Die gespeicherten Einstellungen eines Menüleisten-Moduls. /// /// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start /// wären eine Zumutung — wer sie will, schaltet sie einzeln ein. func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings { guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)), let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data) else { return MenuBarModuleSettings() } return decoded } func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) { if let data = try? JSONEncoder().encode(settings) { defaults.set(data, forKey: Keys.menuBarModule(moduleID)) } menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID) menuBar?.setPresentation(settings.presentation, forModuleID: moduleID) menuBarRevision += 1 } /// Zählt hoch, wenn sich ein Modul ändert — die Einstellungen zeichnen sich /// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste. private(set) var menuBarRevision = 0 private enum Keys { static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" } static let displayPolicy = "onyx.displayPolicy" static let showsMenuBarIcon = "onyx.showsMenuBarIcon" static let showsDockIcon = "onyx.showsDockIcon" static let triggerArea = "onyx.triggerArea" } init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) { self.store = store self.defaults = defaults let known = Set(WidgetRegistry.shared.all.map(\.id)) self.layout = store.load(knownWidgetIDs: known) self.displayPolicy = defaults.string(forKey: Keys.displayPolicy) .flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly // Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg // zurück, wenn die Notch einmal nicht reagiert. self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false self.triggerArea = (defaults.data(forKey: Keys.triggerArea) .flatMap { try? JSONDecoder().decode(TriggerArea.self, from: $0) }) ?? .standard } // MARK: - Widgets /// Die Auslösefläche der Notch. Wirkt sofort. var triggerArea: TriggerArea { didSet { guard triggerArea != oldValue else { return } if let data = try? JSONEncoder().encode(triggerArea) { defaults.set(data, forKey: Keys.triggerArea) } onTriggerAreaChanged?(triggerArea) } } var onTriggerAreaChanged: ((TriggerArea) -> Void)? /// Zählt Bildschirmwechsel mit. /// /// Nur dafür da, beobachtbar zu sein: die Breite kommt aus `NSScreen`, und /// davon erfährt SwiftUI von sich aus nichts. Ohne diesen Zähler bliebe ein /// Panel, das einmal für einen schmalen Fernseher gerechnet wurde, für /// immer schmal — auch nach dem Abstecken. private(set) var screenGeneration = 0 func noteScreensChanged() { screenGeneration += 1 } /// Wie breit das Panel auf **diesem** Bildschirm werden darf. /// /// Je Bildschirm und nicht für alle zusammen. Vorher gab der schmalste /// aktive Bildschirm den Ausschlag — mit der Begründung, dasselbe Panel /// erscheine überall. Der Fall, der das widerlegt: ein Fernseher mit 1280 /// Punkten Breite drückte das Panel auch auf dem Notebook zusammen, die /// Kacheln brachen um, und es wurde hoch statt breit. Ein Panel, das auf /// dem eigenen Bildschirm nicht mehr passt, weil woanders ein Gerät hängt, /// ist der schlechtere Kompromiss. /// /// Der Abzug lässt Luft zum Rand — ein Panel, das exakt bis zur Kante /// geht, wirkt eingeklemmt. func availableWidth(forDisplay id: CGDirectDisplayID) -> CGFloat { _ = screenGeneration let width = NSScreen.screens.first { $0.displayID == id }?.frame.width ?? NSScreen.screens.map(\.frame.width).min() ?? 1440 return width - 120 } func panelContentSize(forDisplay id: CGDirectDisplayID) -> CGSize { WidgetGrid.panelSize(for: layout, availableWidth: availableWidth(forDisplay: id)) } func isInPanel(_ widgetID: String) -> Bool { layout.contains { $0.widgetID == widgetID } } func add(_ widget: any OnyxWidget) { guard !isInPanel(widget.id) else { return } layout.append(WidgetPlacement(widgetID: widget.id)) } func remove(_ widgetID: String) { layout.removeAll { $0.widgetID == widgetID } } func move(fromOffsets source: IndexSet, toOffset destination: Int) { layout.move(fromOffsets: source, toOffset: destination) } private func persistLayout() { do { try store.save(layout) } catch { // Ein fehlgeschlagener Schreibvorgang darf die App nicht anhalten; // beim nächsten Start greift dann das Standardlayout. NSLog("Onyx: Layout konnte nicht gespeichert werden — \(error.localizedDescription)") } } }