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