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>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
+149 -47
View File
@@ -27,8 +27,9 @@ import ShelfProvider
final class SettingsWindowController: NSObject, NSWindowDelegate {
@ObservationIgnored private var window: NSWindow?
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
private var selectedTab: SettingsTab = .widgets
/// 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.
@@ -37,8 +38,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
tab: SettingsTab = .widgets) {
selectedTab = tab
section: SettingsSection = .general) {
self.section = section
keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
@@ -59,39 +60,48 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
fanControl: fanControl,
launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding,
selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets },
set: { [weak self] in self?.selectedTab = $0 })))
section: Binding(
get: { [weak self] in self?.section ?? .general },
set: { [weak self] in self?.section = $0 })))
let window = NSWindow(contentViewController: hosting)
let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting))
window.title = "Onyx"
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
// verschwindet.
// **Kein durchsichtiger Titelbalken mehr.**
//
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
// Titel auf eine Trennlinie.
// 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
window.titlebarSeparatorStyle = .none
// 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: 920, height: 680))
// Breit genug für die Reiterleiste.
//
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880
// Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner
// dazu. Ist das Fenster schmaler, ragt die Leiste über beide
// Kanten hinaus, und der erste wie der letzte Reiter sind nicht
// mehr erreichbar.
window.contentMinSize = NSSize(width: 900, height: 460)
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
Self.place(window)
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")
@@ -104,27 +114,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window?.makeKeyAndOrderFront(nil)
}
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
/// klebt.
///
/// `center()` setzt etwas oberhalb der Mitte — bei einem 452 Punkt hohen
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
/// plötzlich das Panel vor sich.
private static func place(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
// Wirklich mittig, nicht „etwas unterhalb der Notch".
//
// `center()` setzt oberhalb der Bildschirmmitte, und mein erster
// Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag
// weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die
// Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete.
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
/// Der rote Knopf blendet aus, statt zu schließen.
///
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
@@ -147,3 +136,116 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
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
}
}