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>
252 lines
11 KiB
Swift
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
|
|
}
|
|
}
|