import AppKit import SwiftUI import OnyxDesign import CalendarProvider import WeatherProvider import MetricsProvider import ShelfProvider /// Führt das Einstellungsfenster selbst. /// /// SwiftUIs `Settings`-Szene wird über `NSApp.sendAction(showSettingsWindow:)` /// geöffnet — ein privater Selektor, dessen Name sich zwischen macOS-Versionen /// schon geändert hat (`showPreferencesWindow:` davor) und der bei /// `.accessory`-Apps ohne Menüleiste unzuverlässig ankommt. Genau das ist hier /// passiert: der Menüeintrag tat nichts. /// /// Ein eigenes `NSWindow` hat diese Abhängigkeit nicht. Es kostet ein paar /// Zeilen mehr und funktioniert dafür. /// /// `@Observable`, und das ist keine Kür: der gewählte Reiter liegt hier, weil /// ein erneutes Öffnen ihn setzen können muss. Ohne Beobachtung bemerkt die /// View die Änderung nicht — weder die von außen noch die eigene. Genau daran /// hing das Fenster, in dem der Reiter oben umsprang und der Inhalt stehen /// blieb. @MainActor @Observable final class SettingsWindowController: NSObject, NSWindowDelegate { @ObservationIgnored private var window: NSWindow? /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Bereich /// setzen kann. private var section: SettingsSection = .general /// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen /// zurückgeschaltet wird. var keepsDockIcon = false func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl, launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void, section: SettingsSection = .general) { self.section = section keepsDockIcon = model.showsDockIcon // Solange ein Fenster offen ist, gehört Onyx in den Dock. // // Als reine `.accessory`-App hat sie dort keinen Eintrag — das ist im // Hintergrund richtig, aber mit sichtbarem Fenster verwirrend: das // Fenster lässt sich dann weder über den Dock noch über ⌘-Tab // zurückholen, wenn es hinter etwas anderes rutscht. NSApp.setActivationPolicy(.regular) if window == nil { let hosting = NSHostingController( rootView: SettingsView(model: model, calendarModel: calendarModel, weatherModel: weatherModel, metricsModel: metricsModel, shelfStore: shelfStore, fanControl: fanControl, launchAtLogin: launchAtLogin, showOnboarding: showOnboarding, section: Binding( get: { [weak self] in self?.section ?? .general }, set: { [weak self] in self?.section = $0 }))) let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting)) window.title = "Onyx" // **Kein durchsichtiger Titelbalken mehr.** // // Er war für die selbstgebaute Reiterleiste da: macOS zieht unter // dem Titelbalken einen Trenner quer durchs Fenster, und die // Leiste saß mittig darauf. Die Leiste gibt es nicht mehr — und // durchsichtig ist er jetzt schädlich: Seitenleiste und Inhalt // zeichnen darunter weiter, statt darunter zu beginnen. Sichtbar // wurde das im Widgets-Bereich, wo die Liste unter den // Fensterknöpfen hindurchlief. window.titlebarSeparatorStyle = .automatic // **Kein `isOpaque = false`, kein durchsichtiger Hintergrund.** // // Beides klingt nach dem Weg zur Durchlässigkeit und ist ihr // Gegenteil, sobald die Vibrancy-Ebene selbst der Fensterinhalt // ist: erklärt sich das Fenster für durchsichtig, stellt der // Fensterserver ihm keinen Hintergrund mehr zum Abtasten bereit, // und `behindWindow` hat nichts, woraus es Unschärfe machen könnte. // Der Titelbalken teilt sich die Fläche mit dem Inhalt. Ohne das // zeichnet macOS dort seine **eigene**, deutlich durchlässigere // Vibrancy — sichtbar als heller Streifen quer über den oberen // Rand, durch den man den Hintergrund lesen kann. window.titlebarAppearsTransparent = true // Veränderbar, und das ist keine Kür: der Bereich „Displays" mit // der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht // mehr, und ein Fenster, in dem man scrollen muss, obwohl der // Bildschirm frei ist, ist eine Zumutung. window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.setContentSize(NSSize(width: 940, height: 700)) // Seit die Bereiche in einer Seitenleiste stehen, hängt die // Mindestbreite am Inhalt statt an der Beschriftung: das // Reiterband brauchte 900 Punkte, nur damit der erste und der // letzte Reiter nicht über die Kante hinausragten. window.contentMinSize = NSSize(width: 780, height: 480) window.isReleasedWhenClosed = false window.delegate = self OnyxWindowPlacement.center(window) // Über die Position freuen sich Nutzer mit mehreren Displays: // beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben. window.setFrameAutosaveName("onyx.settings") self.window = window } // Ohne `activate` bleibt das Fenster einer .accessory-App hinter der // aktiven App liegen und wirkt, als wäre nichts passiert. NSApp.activate(ignoringOtherApps: true) window?.makeKeyAndOrderFront(nil) } /// Der rote Knopf blendet aus, statt zu schließen. /// /// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie /// „fertig". Manche Hilfsprogramme erzwingen aber genau diese Gleichung und /// beenden eine App, sobald ihr letztes Fenster verschwindet — dann wäre /// Onyx nach einem Blick in die Einstellungen weg, samt Menüleiste und /// Notch. Ein Fenster, das nie wirklich schließt, gibt dafür keinen Anlass. func windowShouldClose(_ sender: NSWindow) -> Bool { hide() return false } private func hide() { window?.orderOut(nil) // Zurück in den Hintergrund — es sei denn, der Dock-Eintrag ist // ausdrücklich gewünscht. if !keepsDockIcon { NSApp.setActivationPolicy(.accessory) } NSApp.hide(nil) } } /// Legt eine Vibrancy-Ebene **unter das ganze Fenster**, Titelbalken /// eingeschlossen. /// /// In SwiftUI ginge das nicht: ein `.background` dort endet an der sicheren /// Fläche, und über dem oberen Rand blieb ein Streifen, durch den man den /// Hintergrund lesen konnte. Hier liegt die Ebene im Fensterinhalt und wächst /// mit ihm. /// /// Zwei Schichten, nicht eine: die Vibrancy allein ist zu durchlässig — man /// liest den Schreibtisch mit, statt die Einstellung. Der getönte Grund darüber /// lässt die Unschärfe stehen und nimmt ihr die Lesbarkeit. @MainActor final class OnyxBackdropController: NSViewController { /// Wie viel vom Fensterhintergrund über der Vibrancy stehen bleibt. /// /// 1 wäre undurchlässig, 0 ließe das nackte Material stehen — dann ist der /// Hintergrund stellenweise mitzulesen. **Das ist die Schraube**, wenn es /// zu durchlässig oder zu deckend wirkt. static let tintOpacity: CGFloat = 0 private let content: NSViewController init(content: NSViewController) { self.content = content super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") } override func loadView() { // Die Vibrancy-Ebene **ist** der Fensterinhalt, sie liegt nicht darin. // // Als Unteransicht in einem gewöhnlichen `NSView` blieb sie wirkungslos // — sichtbar wurde nur der Titelbalken, den macOS selbst zeichnet. Als // `contentView` greift `behindWindow` zuverlässig, und zusammen mit // `.fullSizeContentView` am Fenster reicht sie auch unter den // Titelbalken. let container = NSVisualEffectView() // `.sidebar` und nicht `.underWindowBackground`: letzteres ist so // deckend, dass man von der Unschärfe nichts sieht, sobald hinter dem // Fenster etwas Dunkles liegt. `.sidebar` ist das Material, das man von // Finder und Systemeinstellungen kennt. container.material = .sidebar container.blendingMode = .behindWindow // `.active` statt `.followsWindowActiveState`: sonst fällt die Ebene // zurück, sobald das Fenster nicht vorn ist — dann sieht dasselbe // Fenster je nach Aufmerksamkeit verschieden aus, und das liest sich // als Fehler, nicht als Zustand. container.state = .active let tint = TintView() tint.autoresizingMask = [.width, .height] container.addSubview(tint) addChild(content) content.view.autoresizingMask = [.width, .height] // Ohne das bleibt die Vibrancy unsichtbar: `NSHostingView` legt einen // deckenden Grund unter den SwiftUI-Inhalt, und der steht genau // dazwischen. Nur der Titelbalken kam vorbei — er wird von macOS // gezeichnet, nicht von SwiftUI. content.view.wantsLayer = true content.view.layer?.backgroundColor = NSColor.clear.cgColor container.addSubview(content.view) self.view = container } override func viewDidLayout() { super.viewDidLayout() for subview in view.subviews { subview.frame = view.bounds } } /// Der Titelbalken bekommt keinen eigenen Grund — er liegt auf derselben /// Fläche wie alles andere. override func viewDidAppear() { super.viewDidAppear() view.window?.styleMask.insert(.fullSizeContentView) } } /// Der getönte Grund über der Vibrancy. /// /// Eine eigene Klasse, weil die Farbe erscheinungsabhängig ist: eine einmal /// gelesene `CGColor` bliebe beim Wechsel auf Hell dunkel stehen. /// `viewDidChangeEffectiveAppearance` gibt es nur an der View, nicht am /// Controller — daran ist der erste Versuch gescheitert. private final class TintView: NSView { override var wantsUpdateLayer: Bool { true } override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true } required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") } override func updateLayer() { effectiveAppearance.performAsCurrentDrawingAppearance { layer?.backgroundColor = NSColor.windowBackgroundColor .withAlphaComponent(OnyxBackdropController.tintOpacity).cgColor } } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() needsDisplay = true } }