import SwiftUI import UIKit /// The colours a theme draws the app in: a `dev.knotless.theme#palette`, each /// named for what it colours, with what it leaves out filled in as the pages do /// (`web/src/tokens.css`). struct Palette: Equatable, Sendable { var dark: Bool var bg: UInt32 var panel: UInt32 var soft: UInt32 var line: UInt32 var fg: UInt32 var mute: UInt32 var faint: UInt32 var primary: UInt32 var onPrimary: UInt32 var accent: UInt32 var success: UInt32 var danger: UInt32 var done: UInt32 var attention: UInt32 var neutral: UInt32 /// Code's colours, by the names `Highlight` paints: comment, string, keyword… var syntax: [String: UInt32] var scheme: ColorScheme { dark ? .dark : .light } } extension Palette { /// Light, as the pages draw it: what a light theme of someone's own falls back on. static let light = Palette( dark: false, bg: 0xffffff, panel: 0xfafafa, soft: 0xf4f4f4, line: 0xe8e8e8, fg: 0x171717, mute: 0x525252, faint: 0x6e6e6e, primary: 0x2563eb, onPrimary: 0xffffff, accent: 0x2f5fb3, success: 0x1a7f37, danger: 0xcf222e, done: 0x7048b5, attention: 0x9a6700, neutral: 0x636363, syntax: ["comment": 0x59636e, "string": 0x0a3069, "keyword": 0xcf222e, "number": 0x0550ae, "function": 0x6639ba, "type": 0x953800, "tag": 0x116329, "attr": 0x0550ae] ) /// Dark, as the pages draw it. static let dark = Palette( dark: true, bg: 0x111111, panel: 0x161616, soft: 0x1a1a1a, line: 0x262626, fg: 0xededed, mute: 0xa1a1a1, faint: 0x848484, primary: 0x2563eb, onPrimary: 0xffffff, accent: 0x7aa7f0, success: 0x3fb950, danger: 0xf85149, done: 0xb39af0, attention: 0xd29922, neutral: 0x9198a1, syntax: ["comment": 0x9198a1, "string": 0xa5d6ff, "keyword": 0xff7b72, "number": 0x79c0ff, "function": 0xd2a8ff, "type": 0xffa657, "tag": 0x7ee787, "attr": 0x79c0ff] ) /// The themes the pages offer besides light and dark, by the name /// `appearancePref` gives each, as `web/src/tokens.css` draws them. static let named: [String: Palette] = [ "latte": Palette( dark: false, bg: 0xeff1f5, panel: 0xe6e9ef, soft: 0xdce0e8, line: 0xccd0da, fg: 0x3c3f58, mute: 0x53566c, faint: 0x60626e, primary: 0x8839ef, onPrimary: 0xffffff, accent: 0x1955cc, success: 0x317c21, danger: 0xd20f39, done: 0x8839ef, attention: 0x996214, neutral: 0x696c81, syntax: ["comment": 0x5d5f6b, "string": 0x2d701e, "keyword": 0x7b33d7, "number": 0xa64108, "function": 0x1a58d5, "type": 0x865511, "tag": 0xc40e35, "attr": 0xa64108] ), "mocha": Palette( dark: true, bg: 0x1e1e2e, panel: 0x24243a, soft: 0x313244, line: 0x363749, fg: 0xcdd6f4, mute: 0xa6adc8, faint: 0x989caf, primary: 0xcba6f7, onPrimary: 0x1e1e2e, accent: 0x89b4fa, success: 0xa6e3a1, danger: 0xf38ba8, done: 0xcba6f7, attention: 0xf9e2af, neutral: 0x9399b2, syntax: ["comment": 0x9a9eb1, "string": 0xa6e3a1, "keyword": 0xcba6f7, "number": 0xfab387, "function": 0x89b4fa, "type": 0xf9e2af, "tag": 0xf38ba8, "attr": 0xfab387] ), "dracula": Palette( dark: true, bg: 0x282a36, panel: 0x2d2f3d, soft: 0x363849, line: 0x3c3f52, fg: 0xf8f8f2, mute: 0xb9bcd6, faint: 0x98a1c4, primary: 0xbd93f9, onPrimary: 0x282a36, accent: 0xff79c6, success: 0x50fa7b, danger: 0xff5555, done: 0xbd93f9, attention: 0xf1fa8c, neutral: 0xa9adc9, syntax: ["comment": 0xa1aac9, "string": 0xf1fa8c, "keyword": 0xff92d1, "number": 0xc9a7fa, "function": 0x50fa7b, "type": 0x8be9fd, "tag": 0x50fa7b, "attr": 0x50fa7b] ), "pckt": Palette( dark: true, bg: 0x101828, panel: 0x172133, soft: 0x1e2939, line: 0x2b3648, fg: 0xf9fafb, mute: 0xb4bac5, faint: 0x8a92a0, primary: 0x58a7ff, onPrimary: 0x101828, accent: 0x58a7ff, success: 0x21dc66, danger: 0xff5d5b, done: 0xb39dfa, attention: 0xffca46, neutral: 0x99a1af, syntax: ["comment": 0x8a92a0, "string": 0x21dc66, "keyword": 0xff5d5b, "number": 0xffca46, "function": 0x58a7ff, "type": 0xffca46, "tag": 0xff5d5b, "attr": 0x58a7ff] ), "plyr": Palette( dark: true, bg: 0x0a0a0a, panel: 0x141414, soft: 0x1a1a1a, line: 0x282828, fg: 0xe8e8e8, mute: 0xb0b0b0, faint: 0x848484, primary: 0x6a9fff, onPrimary: 0x0a0a0a, accent: 0x6a9fff, success: 0x4ade80, danger: 0xef4444, done: 0xa78bfa, attention: 0xfbbf24, neutral: 0x808080, syntax: ["comment": 0x888888, "string": 0x4ade80, "keyword": 0x6a9fff, "number": 0xfbbf24, "function": 0x8ab3ff, "type": 0xa78bfa, "tag": 0xf16060, "attr": 0xfbbf24] ), "blacksky": Palette( dark: true, bg: 0x070c0c, panel: 0x0e1212, soft: 0x1f2020, line: 0x262828, fg: 0xf8faf9, mute: 0xc8cac9, faint: 0x9c9e9e, primary: 0x8686ff, onPrimary: 0x070c0c, accent: 0x8686ff, success: 0xd2fc51, danger: 0xff4b78, done: 0xb0b3f0, attention: 0xffd166, neutral: 0x9c9e9e, syntax: ["comment": 0x9c9e9e, "string": 0xd2fc51, "keyword": 0x8686ff, "number": 0xff7396, "function": 0xb0b3f0, "type": 0xaefa59, "tag": 0xff7396, "attr": 0xb0b3f0] ), ] /// A palette of someone's own: light's or dark's colours, as its scheme says, /// under its own; the surfaces and texts it leaves out mixed from its /// background and text, as `[data-theme^='custom-']` mixes them. init(record: PaletteRecord) { let base: Palette = record.colorScheme == "dark" ? .dark : .light let bg = Self.hex(record.background) ?? base.bg let fg = Self.hex(record.text) ?? base.fg let mixed = { (part: Double) in Self.mix(fg, part, bg) } self.init( dark: base.dark, bg: bg, panel: Self.hex(record.panel) ?? mixed(0.04), soft: Self.hex(record.soft) ?? mixed(0.08), line: Self.hex(record.border) ?? mixed(0.18), fg: fg, mute: Self.hex(record.textMuted) ?? mixed(0.80), faint: Self.hex(record.textFaint) ?? mixed(0.70), primary: Self.hex(record.primary) ?? base.primary, onPrimary: Self.hex(record.onPrimary) ?? bg, accent: Self.hex(record.accent) ?? base.accent, success: Self.hex(record.success) ?? base.success, danger: Self.hex(record.danger) ?? base.danger, done: Self.hex(record.done) ?? base.done, attention: Self.hex(record.attention) ?? base.attention, neutral: Self.hex(record.neutral) ?? base.neutral, syntax: base.syntax ) let given: [(String, String?)] = [ ("comment", record.syntax?.comments), ("string", record.syntax?.strings), ("keyword", record.syntax?.keywords), ("number", record.syntax?.numbers), ("function", record.syntax?.functions), ("type", record.syntax?.types), ("tag", record.syntax?.tags), ] for (name, value) in given { if let v = Self.hex(value) { syntax[name] = v } } syntax["attr"] = syntax["number"] } /// `#rrggbb` as a number; anything else, nil, which readers pass over. static func hex(_ string: String?) -> UInt32? { guard var s = string, s.hasPrefix("#") else { return nil } s.removeFirst() guard s.count == 6 else { return nil } return UInt32(s, radix: 16) } /// `part` of `a` over `b`, as CSS's `color-mix(in srgb, a part, b)`. static func mix(_ a: UInt32, _ part: Double, _ b: UInt32) -> UInt32 { func channel(_ shift: UInt32) -> UInt32 { let x = Double(a >> shift & 255), y = Double(b >> shift & 255) return UInt32((x * part + y * (1 - part)).rounded()) << shift } return channel(16) | channel(8) | channel(0) } } extension ThemeHalf { /// Its colours: none for light, dark, or one the app doesn't know, which /// iOS's own colours draw. var resolved: Palette? { switch theme { case "custom": palette.map(Palette.init(record:)) case let name?: Palette.named[name] case nil: nil } } /// The light or dark it holds the app in, if it holds it in one. var scheme: ColorScheme? { switch theme { case "light": .light case "dark": .dark default: resolved?.scheme } } } extension ThemeChoice { func palette(dark: Bool) -> Palette? { (dark ? night ?? day : day).resolved } /// A pair follows the system, which picks its half. var scheme: ColorScheme? { night == nil ? day.scheme : nil } } /// The theme the app is in now, for colours to read as they are drawn. /// `generation` changes with it, carried to them as a trait, so each is drawn again. enum Themes { nonisolated(unsafe) private(set) static var current: ThemeChoice? nonisolated(unsafe) private(set) static var generation = 0 @MainActor static func set(_ choice: ThemeChoice?) { guard choice != current else { return } current = choice generation += 1 } static func palette(_ traits: UITraitCollection) -> Palette? { _ = traits[ThemeTrait.self] return current?.palette(dark: traits.userInterfaceStyle == .dark) } } /// Which theme a view is drawn in, so a colour drawn again when it changes asks anew. struct ThemeTrait: UITraitDefinition { static let defaultValue = 0 static let affectsColorAppearance = true } private struct ThemeKey: EnvironmentKey { static let defaultValue = 0 } extension ThemeKey: UITraitBridgedEnvironmentKey { static func read(from traits: UITraitCollection) -> Int { traits[ThemeTrait.self] } static func write(to traits: inout any UIMutableTraits, value: Int) { traits[ThemeTrait.self] = value } } extension EnvironmentValues { var themeGeneration: Int { get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue } } } extension Color { /// A colour of the theme, or iOS's own where there is none. init(themed: @escaping @Sendable (Palette) -> UInt32, otherwise: UIColor) { self.init(uiColor: UIColor { traits in Themes.palette(traits).map { UIColor(hex: themed($0)) } ?? otherwise.resolvedColor(with: traits) }) } /// A colour of the theme, or its light and dark values where there is none. init(themed: @escaping @Sendable (Palette) -> UInt32, light: UInt32, dark: UInt32) { self.init(themed: themed, otherwise: UIColor { $0.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light) }) } // Surfaces, from the page up, each standing for the iOS colour drawn where there is no theme. /// A screen's own: `systemBackground`. static let page = Color(themed: \.bg, otherwise: .systemBackground) /// A screen of cards: `systemGroupedBackground`. static let groupedPage = Color(themed: \.bg, otherwise: .systemGroupedBackground) /// A card on a screen of them: `secondarySystemGroupedBackground`. static let card = Color(themed: \.panel, otherwise: .secondarySystemGroupedBackground) /// Something set apart on a page, as a field or a file's header: `secondarySystemBackground`. static let raised = Color(themed: \.soft, otherwise: .secondarySystemBackground) /// A chip's or a box's fill: `tertiarySystemFill`. static let fill = Color(themed: \.soft, otherwise: .tertiarySystemFill) /// Within a card: `tertiarySystemGroupedBackground`. static let inset = Color(themed: \.soft, otherwise: .tertiarySystemGroupedBackground) static let line = Color(themed: \.line, otherwise: .separator) // Text. static let fg = Color(themed: \.fg, otherwise: .label) static let mute = Color(themed: \.mute, otherwise: .secondaryLabel) static let faint = Color(themed: \.faint, otherwise: .tertiaryLabel) }