Files
onyx/Onyx/SettingsWindowController.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

252 lines
11 KiB
Swift

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
}
}