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 onShowsIdleIslandChanged: ((Bool) -> Void)? var onNotchStyleChanged: ((NotchStyle) -> Void)? var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> 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) } } /// Abweichende Inselbreiten, je Bildschirm — leer heißt „Standardbreite". /// /// Als Wörterbuch über die `CGDirectDisplayID` und nicht als Liste über den /// Index in `NSScreen.screens`: der Index ändert sich beim Umstecken, die /// Kennung nicht. Sonst gälte die Einstellung des einen Monitors plötzlich /// für den anderen. private(set) var notchWidths: [CGDirectDisplayID: CGFloat] = [:] /// Die eingestellte Breite dieses Bildschirms, falls es eine gibt. func notchWidth(for displayID: CGDirectDisplayID) -> CGFloat? { notchWidths[displayID] } /// Setzt eine abweichende Breite — oder nimmt sie mit `nil` zurück. func setNotchWidth(_ width: CGFloat?, for displayID: CGDirectDisplayID) { if let width { notchWidths[displayID] = width } else { notchWidths[displayID] = nil } // Als Zeichenkette gespeichert, weil `UserDefaults` keine Wörterbücher // mit Zahlenschlüsseln kann. let raw = notchWidths.reduce(into: [String: Double]()) { $0[String($1.key)] = Double($1.value) } defaults.set(raw, forKey: Keys.notchWidths) onNotchWidthsChanged?(notchWidths) } /// Was links neben der Notch steht. var bandLeading: BandAccessory { didSet { guard bandLeading != oldValue else { return } defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading) } } /// Was rechts daneben steht. var bandTrailing: BandAccessory { didSet { guard bandTrailing != oldValue else { return } defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing) } } /// Notch oder Insel — die Form, die Onyx oben annimmt. var notchStyle: NotchStyle { didSet { guard notchStyle != oldValue else { return } defaults.set(notchStyle.rawValue, forKey: Keys.notchStyle) onNotchStyleChanged?(notchStyle) } } /// Ob die eingeklappte Insel sichtbar stehen bleibt. /// /// Standardmäßig an: ohne sie wächst das Panel auf externen Bildschirmen /// aus dem Nichts. Abschaltbar, weil ein schwarzer Balken auf vier /// Monitoren eine Geschmacksfrage ist und keine Voreinstellung sein darf, /// von der man nicht wegkommt. var showsIdleIsland: Bool { didSet { guard showsIdleIsland != oldValue else { return } defaults.set(showsIdleIsland, forKey: Keys.showsIdleIsland) onShowsIdleIslandChanged?(showsIdleIsland) } } 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 showsIdleIsland = "onyx.showsIdleIsland" static let notchStyle = "onyx.notchStyle" static let notchWidths = "onyx.notchWidths" static let bandLeading = "onyx.band.leading" static let bandTrailing = "onyx.band.trailing" 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 self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true self.notchStyle = defaults.string(forKey: Keys.notchStyle) .flatMap(NotchStyle.init(rawValue:)) ?? .automatic // Die bisherige feste Verdrahtung als Voreinstellung: wer nichts // einstellt, sieht, was vorher auch dastand. self.bandLeading = defaults.string(forKey: Keys.bandLeading) .flatMap(BandAccessory.init(rawValue:)) ?? .weather self.bandTrailing = defaults.string(forKey: Keys.bandTrailing) .flatMap(BandAccessory.init(rawValue:)) ?? .battery self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:]) .reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in guard let id = CGDirectDisplayID(entry.key) else { return } result[id] = CGFloat(entry.value) } // 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)") } } }