Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel, Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie. Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein 9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten. Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form, damit auch die animierende Notch-Silhouette es benutzen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
6.0 KiB
Swift
143 lines
6.0 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
/// Was am Aussehen von Onyx einstellbar ist: die Farben und das
|
|
/// Bewegungsprofil.
|
|
///
|
|
/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt
|
|
/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
|
|
/// Lüfter und Fehlermeldungen. Wer sie umstellen kann, kann ihre Bedeutung
|
|
/// zerstören; ein rotes „alles gut" liest niemand richtig. Einstellbar ist
|
|
/// deshalb die Akzentfarbe, mit der Onyx sich selbst zeichnet, und das Paar für
|
|
/// Herunter- und Hochladen — dort ist die Zuordnung willkürlich und nur eine
|
|
/// Gewohnheit.
|
|
@MainActor
|
|
@Observable
|
|
public final class OnyxTheme {
|
|
|
|
/// Die Instanz, aus der `Onyx.Color` liest.
|
|
public static let shared = OnyxTheme()
|
|
|
|
/// Kühl und entsättigt — Farbe ist in Onyx ein Signal, keine Dekoration.
|
|
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
|
|
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
|
|
/// aber eine verbreitete.
|
|
///
|
|
/// **Wärmer als das Signalgrün, und das ist der Punkt.** Vorher war dieser
|
|
/// Wert Zeichen für Zeichen `Onyx.Color.positive` — derselbe grüne Balken
|
|
/// hieß im Netzwerk „lädt herunter" und in den Messwerten „alles in
|
|
/// Ordnung". Zwei Bedeutungen in einer Farbe sind eine zu viel.
|
|
///
|
|
/// Wärmer und nicht kühler, weil das Gegenstück rosa bis cyan sein darf:
|
|
/// ein Grün, das ins Türkise zieht, wäre vom Hochladen kaum noch zu
|
|
/// unterscheiden. Nach Gelb hin ist von beidem Abstand.
|
|
public static let defaultDownload = Color(red: 0.561, green: 0.835, blue: 0.408)
|
|
public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
|
|
|
|
public var accent: Color {
|
|
didSet { store(accent, as: Keys.accent) }
|
|
}
|
|
public var download: Color {
|
|
didSet { store(download, as: Keys.download) }
|
|
}
|
|
public var upload: Color {
|
|
didSet { store(upload, as: Keys.upload) }
|
|
}
|
|
|
|
/// Wie sich das Panel bewegt.
|
|
///
|
|
/// Getrennt von den Farben gespeichert und **nicht** von `reset()` erfasst:
|
|
/// der Knopf dort heißt „Farben zurücksetzen", und ein Knopf, der nebenbei
|
|
/// die Animationen umstellt, ist eine Falle.
|
|
public var motion: MotionProfile = .snappy {
|
|
didSet { defaults.set(motion.rawValue, forKey: Keys.motion) }
|
|
}
|
|
|
|
/// Woraus die Panelfläche gemacht ist.
|
|
///
|
|
/// Wie `motion` von den Farben getrennt und **nicht** von `reset()`
|
|
/// erfasst: „Farben zurücksetzen" soll Farben zurücksetzen.
|
|
public var material: NotchMaterial = .stone {
|
|
didSet { defaults.set(material.rawValue, forKey: Keys.material) }
|
|
}
|
|
|
|
private let defaults: UserDefaults
|
|
|
|
private enum Keys {
|
|
static let accent = "onyx.theme.accent"
|
|
static let download = "onyx.theme.download"
|
|
static let upload = "onyx.theme.upload"
|
|
static let motion = "onyx.theme.motion"
|
|
static let material = "onyx.theme.material"
|
|
}
|
|
|
|
public init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
|
|
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
|
|
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
|
|
// Unbekannte Profilnamen fallen auf die Voreinstellung zurück, statt die
|
|
// Bewegung ganz ausfallen zu lassen — eine App ohne Animation sieht aus,
|
|
// als hinge sie.
|
|
motion = MotionProfile(rawValue: defaults.string(forKey: Keys.motion) ?? "") ?? .snappy
|
|
material = NotchMaterial(rawValue: defaults.string(forKey: Keys.material) ?? "") ?? .stone
|
|
}
|
|
|
|
/// Ob **die Farben** auf den Werkseinstellungen stehen — für den
|
|
/// Zurücksetzen-Knopf, der sonst auch dann anböte, etwas zu tun, wenn es
|
|
/// nichts zu tun gibt. Das Bewegungsprofil zählt bewusst nicht mit; siehe
|
|
/// `motion`.
|
|
public var isDefault: Bool {
|
|
accent.onyxHex == Self.defaultAccent.onyxHex
|
|
&& download.onyxHex == Self.defaultDownload.onyxHex
|
|
&& upload.onyxHex == Self.defaultUpload.onyxHex
|
|
}
|
|
|
|
public func reset() {
|
|
accent = Self.defaultAccent
|
|
download = Self.defaultDownload
|
|
upload = Self.defaultUpload
|
|
}
|
|
|
|
/// Übernimmt die Akzentfarbe des Systems.
|
|
///
|
|
/// Genau die stand hier vorher an vielen Stellen — unfreiwillig, weil
|
|
/// `.tint` und `.accentColor` ohne eigene Angabe dorthin durchgereicht
|
|
/// werden. Wem das gefiel, soll es behalten können.
|
|
public func adoptSystemAccent() {
|
|
accent = Color(nsColor: .controlAccentColor)
|
|
}
|
|
|
|
private func store(_ color: Color, as key: String) {
|
|
defaults.set(color.onyxHex, forKey: key)
|
|
}
|
|
}
|
|
|
|
public extension Color {
|
|
|
|
/// `#RRGGBB`, immer in sRGB.
|
|
///
|
|
/// Der Umweg über `NSColor` ist nötig, weil eine `Color` aus dem Farbwähler
|
|
/// in einem beliebigen Farbraum liegen kann — ohne Umrechnung ergäbe die
|
|
/// Zerlegung in Komponenten je nach Bildschirm etwas anderes.
|
|
var onyxHex: String {
|
|
guard let srgb = NSColor(self).usingColorSpace(.sRGB) else { return "#7FA8FF" }
|
|
let red = Int((srgb.redComponent * 255).rounded())
|
|
let green = Int((srgb.greenComponent * 255).rounded())
|
|
let blue = Int((srgb.blueComponent * 255).rounded())
|
|
return String(format: "#%02X%02X%02X", red, green, blue)
|
|
}
|
|
|
|
/// Liest `#RRGGBB` und `RRGGBB`.
|
|
init?(onyxHex: String?) {
|
|
guard let onyxHex else { return nil }
|
|
var text = onyxHex.trimmingCharacters(in: .whitespaces)
|
|
if text.hasPrefix("#") { text.removeFirst() }
|
|
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
|
|
self.init(.sRGB,
|
|
red: Double((value >> 16) & 0xFF) / 255,
|
|
green: Double((value >> 8) & 0xFF) / 255,
|
|
blue: Double(value & 0xFF) / 255)
|
|
}
|
|
}
|