krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgPresentation/ThemeSpec.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgPresentation/ThemeSpec.swift history · blame · raw

360 lines · 18791 bytes

  1import OrgCore
  2
  3/// A color as `#rrggbb` or `#rrggbbaa`.
  4public struct RGBA: Hashable, Sendable {
  5    public var red, green, blue, alpha: Double
  6
  7    public init(red: Double, green: Double, blue: Double, alpha: Double = 1) {
  8        self.red = red
  9        self.green = green
 10        self.blue = blue
 11        self.alpha = alpha
 12    }
 13
 14    public init?(hex: String) {
 15        var digits = Substring(hex.trimmingCharacters(in: .whitespaces))
 16        guard digits.first == "#" else { return nil }
 17        digits = digits.dropFirst()
 18        guard digits.count == 6 || digits.count == 8, let value = UInt64(digits, radix: 16) else { return nil }
 19        let full = digits.count == 6 ? value << 8 | 0xFF : value
 20        red = Double(full >> 24 & 0xFF) / 255
 21        green = Double(full >> 16 & 0xFF) / 255
 22        blue = Double(full >> 8 & 0xFF) / 255
 23        alpha = Double(full & 0xFF) / 255
 24    }
 25
 26    public var hex: String {
 27        func byte(_ d: Double) -> String {
 28            let s = String(Int((d * 255).rounded()), radix: 16)
 29            return s.count == 1 ? "0" + s : s
 30        }
 31        return "#" + byte(red) + byte(green) + byte(blue) + (alpha < 1 ? byte(alpha) : "")
 32    }
 33}
 34
 35/// The editor's look: colors for each kind of text in light and dark appearance, colors for
 36/// TODO keywords and categories, and type. Read from `[theme]`, `[theme.light]`, `[theme.dark]`,
 37/// `[theme.todo]` and `[theme.category]` in config.toml, on top of the default theme.
 38public struct ThemeSpec: Equatable, Sendable {
 39    /// A font family; empty for the system's monospaced font.
 40    public var font = ""
 41    public var fontSize = 13
 42    /// Points between lines.
 43    public var lineSpacing = 2
 44    /// Points a heading is larger than the next level down (level 4 and deeper are body size).
 45    public var headingSizeStep = 1
 46    public var light: [String: RGBA]
 47    public var dark: [String: RGBA]
 48    /// `org-todo-keyword-faces`: a color per keyword, the same in both appearances.
 49    public var todo: [String: RGBA] = [:]
 50    /// A color per agenda category, the same in both appearances, over `categoryColor`'s hue.
 51    public var category: [String: RGBA] = [:]
 52
 53    public init(light: [String: RGBA], dark: [String: RGBA]) {
 54        self.light = light
 55        self.dark = dark
 56    }
 57
 58    public func color(_ key: String, dark isDark: Bool) -> RGBA? {
 59        (isDark ? dark : light)[key]
 60    }
 61
 62    // MARK: - Slots
 63
 64    /// Every color key, with what it colors.
 65    public static let slots: [(key: String, description: String)] = [
 66        ("background", "the editor's background"),
 67        ("foreground", "body text"),
 68        ("cursor", "the caret"),
 69        ("selection", "selected text's background"),
 70        ("heading-1", "level 1 headings"), ("heading-2", "level 2 headings"), ("heading-3", "level 3 headings"),
 71        ("heading-4", "level 4 headings"), ("heading-5", "level 5 headings"), ("heading-6", "level 6 headings"),
 72        ("heading-7", "level 7 headings"), ("heading-8", "level 8 and deeper headings"),
 73        ("todo", "TODO keywords not yet done"),
 74        ("todo-next", "keywords of work under way: NEXT, STRT, STARTED, DOING, ACTIVE"),
 75        ("todo-waiting", "keywords of work on hold: WAIT, WAITING, HOLD, BLOCKED, DEFERRED, SOMEDAY, MAYBE"),
 76        ("done", "DONE keywords"),
 77        ("todo-cancelled", "done keywords that cancel: KILL, CANCELLED, CANCELED, CANCEL, SKIPPED, SKIP, ABORTED, NO, WONTFIX"),
 78        ("priority", "[#A] cookies"),
 79        ("priority-a", "agenda: priority A"),
 80        ("priority-b", "agenda: priority B"),
 81        ("priority-c", "agenda: priority C"),
 82        ("tags", ":tags:"),
 83        ("link", "links"),
 84        ("timestamp", "timestamps"),
 85        ("code", "~code~ and inline source"),
 86        ("verbatim", "=verbatim="),
 87        ("inline-background", "behind ~code~ and =verbatim="),
 88        ("markup", "link brackets and emphasis markers"),
 89        ("comment", "comments"),
 90        ("keyword", "#+KEYWORD lines"),
 91        ("metadata", "planning lines, drawers, properties and clocks"),
 92        ("special", "footnotes, statistics cookies, targets, macros and LaTeX"),
 93        ("block-background", "the band behind blocks"),
 94        ("block-delimiter", "#+begin_ and #+end_ lines"),
 95        ("table", "tables"),
 96        ("line-number", "line numbers"),
 97        ("line-number-current", "the caret's line number"),
 98        ("syntax-keyword", "code: keywords"),
 99        ("syntax-string", "code: strings"),
100        ("syntax-comment", "code: comments"),
101        ("syntax-function", "code: functions"),
102        ("syntax-type", "code: types and modules"),
103        ("syntax-number", "code: numbers, constants and escapes"),
104        ("syntax-property", "code: properties, attributes and tags"),
105        ("syntax-label", "code: labels"),
106        ("sidebar-background", "the folder sidebar and the outline; unset: the system's"),
107        ("sidebar-foreground", "file and heading names there; unset: the system's"),
108        ("sidebar-header", "folder names there; unset: the system's"),
109        ("modeline-background", "the modeline and message line; unset: the system's"),
110        ("modeline-foreground", "modeline text; unset: the system's"),
111        ("modeline-highlight", "the outline path and the clock in the modeline"),
112        ("state-normal", "the NORMAL tag"),
113        ("state-insert", "the INSERT tag"),
114        ("state-visual", "the VISUAL and V-LINE tags"),
115        ("agenda-background", "the agenda and board; unset: the system's"),
116        ("agenda-date", "agenda day headers"),
117        ("agenda-today", "today's header and the current time"),
118        ("agenda-time", "times and the time grid"),
119        ("agenda-category", "categories"),
120        ("agenda-deadline", "deadlines due"),
121        ("agenda-upcoming", "deadlines coming up"),
122        ("agenda-scheduled", "scheduled items"),
123        ("agenda-scheduled-past", "items scheduled on an earlier day"),
124        ("agenda-overdue", "agenda: the status of entries past their deadline or scheduled date"),
125        ("agenda-due-soon", "agenda: the status of deadlines due today or coming up"),
126        ("agenda-event", "agenda: calendar events whose calendar has no color"),
127        ("habit-clear", "habit graph: not due yet"),
128        ("habit-ready", "habit graph: due"),
129        ("habit-alert", "habit graph: due today, last chance"),
130        ("habit-overdue", "habit graph: overdue"),
131    ]
132
133    /// Keys the default theme leaves to the system, so windows keep the macOS look.
134    public static let systemDefaults: Set<String> = [
135        "sidebar-background", "sidebar-foreground", "sidebar-header", "modeline-background", "modeline-foreground", "agenda-background",
136    ]
137
138    // MARK: - The default theme
139
140    private static func palette(_ pairs: [String: String]) -> [String: RGBA] {
141        pairs.compactMapValues { RGBA(hex: $0) }
142    }
143
144    /// Calm colors after GitHub's light and dark themes: headings colored by level, keywords
145    /// clear without shouting, code in the colors developers know.
146    public static let `default` = ThemeSpec(
147        light: palette([
148            "background": "#ffffff", "foreground": "#1f2328", "cursor": "#0969da", "selection": "#0969da33",
149            "heading-1": "#0550ae", "heading-2": "#8250df", "heading-3": "#116329", "heading-4": "#953800",
150            "heading-5": "#0550ae", "heading-6": "#8250df", "heading-7": "#116329", "heading-8": "#953800",
151            "todo": "#cf222e", "done": "#1a7f37", "priority": "#bc4c00", "tags": "#6e7781",
152            "todo-next": "#0969da", "todo-waiting": "#9a6700", "todo-cancelled": "#6e7781",
153            "priority-a": "#cf222e", "priority-b": "#bc4c00", "priority-c": "#57606a",
154            "link": "#0969da", "timestamp": "#8250df", "code": "#953800", "verbatim": "#0a3069", "inline-background": "#afb8c133",
155            "markup": "#8c959f", "comment": "#6e7781", "keyword": "#6e7781", "metadata": "#6e7781", "special": "#1b7c83",
156            "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328",
157            "line-number": "#8c959f", "line-number-current": "#1f2328",
158            "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df",
159            "syntax-type": "#953800", "syntax-number": "#1b7c83", "syntax-property": "#0550ae", "syntax-label": "#8250df",
160            "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00",
161            "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781",
162            "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800",
163            "agenda-overdue": "#cf222e", "agenda-due-soon": "#bc4c00", "agenda-event": "#8250df",
164            "habit-clear": "#54aeff", "habit-ready": "#4ac26b", "habit-alert": "#d4a72c", "habit-overdue": "#ff8182",
165        ]),
166        dark: palette([
167            "background": "#0d1117", "foreground": "#e6edf3", "cursor": "#58a6ff", "selection": "#388bfd66",
168            "heading-1": "#79c0ff", "heading-2": "#d2a8ff", "heading-3": "#7ee787", "heading-4": "#ffa657",
169            "heading-5": "#79c0ff", "heading-6": "#d2a8ff", "heading-7": "#7ee787", "heading-8": "#ffa657",
170            "todo": "#ff7b72", "done": "#3fb950", "priority": "#ffa657", "tags": "#8b949e",
171            "todo-next": "#58a6ff", "todo-waiting": "#d29922", "todo-cancelled": "#8b949e",
172            "priority-a": "#ff7b72", "priority-b": "#ffa657", "priority-c": "#8b949e",
173            "link": "#58a6ff", "timestamp": "#d2a8ff", "code": "#ffa657", "verbatim": "#a5d6ff", "inline-background": "#6e768166",
174            "markup": "#6e7681", "comment": "#8b949e", "keyword": "#8b949e", "metadata": "#8b949e", "special": "#56d4dd",
175            "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3",
176            "line-number": "#6e7681", "line-number-current": "#e6edf3",
177            "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff",
178            "syntax-type": "#ffa657", "syntax-number": "#56d4dd", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff",
179            "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03",
180            "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e",
181            "agenda-deadline": "#ff7b72", "agenda-upcoming": "#ffa657", "agenda-scheduled": "#8b949e", "agenda-scheduled-past": "#d29922",
182            "agenda-overdue": "#ff7b72", "agenda-due-soon": "#ffa657", "agenda-event": "#d2a8ff",
183            "habit-clear": "#1f6feb", "habit-ready": "#238636", "habit-alert": "#9e6a03", "habit-overdue": "#da3633",
184        ])
185    )
186
187    // MARK: - Reading
188
189    /// Keys of `[theme]` that aren't colors; config.toml's settings handle them.
190    public static let typeKeys: Set<String> = ["font", "font-size", "line-spacing", "heading-size-step", "theme-file"]
191
192    /// Whether a table holds theme colors.
193    public static func isThemeTable(_ name: String) -> Bool {
194        name == "theme" || name.hasPrefix("theme.")
195    }
196
197    /// `base` with the colors of `tables` laid over it, in order; problems for keys and
198    /// values it can't use.
199    public static func reading(_ tables: [TOML.Table], over base: ThemeSpec = .default, file: String = "config.toml") -> (ThemeSpec, [String]) {
200        var spec = base
201        var problems: [String] = []
202        let known = Set(slots.map(\.key))
203        for table in tables where isThemeTable(table.name) {
204            for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
205                if table.name == "theme", typeKeys.contains(key) { continue }
206                guard let text = value.string, let color = RGBA(hex: text) else {
207                    problems.append("\(file): \(table.name).\(key) must be a color such as \"#1f2328\"")
208                    continue
209                }
210                switch table.name {
211                case "theme.todo":
212                    spec.todo[key] = color
213                case "theme.category":
214                    spec.category[key] = color
215                case "theme", "theme.light", "theme.dark":
216                    guard known.contains(key) else {
217                        problems.append("\(file): unknown theme color \(key)")
218                        continue
219                    }
220                    if table.name != "theme.dark" { spec.light[key] = color }
221                    if table.name != "theme.light" { spec.dark[key] = color }
222                default:
223                    problems.append("\(file): unknown table [\(table.name)]")
224                }
225            }
226        }
227        return (spec, problems)
228    }
229
230    /// The default theme written out, to copy colors from.
231    public static func defaultThemeFile() -> String {
232        var out = """
233            # Orgstar's default theme, for reference: Orgstar writes this file again each time it
234            # starts, so change colors in config.toml. A key set under [theme] colors both
235            # appearances; [theme.light] and [theme.dark] set one. Colors are "#rrggbb" or
236            # "#rrggbbaa". [theme.todo] colors TODO keywords by name, as org-todo-keyword-faces:
237            #
238            #   [theme.todo]
239            #   WAIT = "#bf8700"
240            #
241            # Otherwise a keyword takes its class's color: todo, todo-next, todo-waiting, done or
242            # todo-cancelled. [theme.category] colors agenda categories by name the same way;
243            # categories without one get a hue of their own from their name.
244            #
245            # A theme kept in its own file (with the same tables) is used with
246            # `theme-file = "name.toml"` under [theme] in config.toml.
247
248            """
249        for (name, colors) in [("theme.light", ThemeSpec.default.light), ("theme.dark", ThemeSpec.default.dark)] {
250            out += "\n[\(name)]\n"
251            for slot in slots {
252                guard let color = colors[slot.key] else {
253                    let line = "# \(slot.key) = \"#rrggbb\""
254                    out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n"
255                    continue
256                }
257                let line = "\(slot.key) = \"\(color.hex)\""
258                out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n"
259            }
260        }
261        return out
262    }
263}
264
265// MARK: - Agenda colors
266
267extension ThemeSpec {
268    /// The color classes of TODO keywords, by name: keywords of work under way and on hold,
269    /// and done keywords that cancel; the others are `todo` or `done`.
270    public enum TodoClass: String, Sendable {
271        case todo, next, waiting, done, cancelled
272
273        /// The theme key.
274        public var key: String {
275            switch self {
276            case .todo: "todo"
277            case .next: "todo-next"
278            case .waiting: "todo-waiting"
279            case .done: "done"
280            case .cancelled: "todo-cancelled"
281            }
282        }
283
284        static let nextWords: Set<String> = ["NEXT", "STRT", "STARTED", "START", "DOING", "ACTIVE", "INPROGRESS", "IN-PROGRESS"]
285        static let waitingWords: Set<String> = ["WAIT", "WAITING", "HOLD", "ONHOLD", "BLOCKED", "DEFERRED", "SOMEDAY", "MAYBE"]
286        static let cancelledWords: Set<String> = ["KILL", "KILLED", "CANCELLED", "CANCELED", "CANCEL", "SKIPPED", "SKIP", "ABORTED", "NO", "WONTFIX"]
287
288        public static func of(_ keyword: String, isDone: Bool) -> TodoClass {
289            let word = keyword.uppercased()
290            if isDone { return cancelledWords.contains(word) ? .cancelled : .done }
291            if nextWords.contains(word) { return .next }
292            if waitingWords.contains(word) { return .waiting }
293            return .todo
294        }
295    }
296
297    /// `[theme.todo]`'s color for the keyword, else its class's.
298    public func todoColor(_ keyword: String, isDone: Bool, dark isDark: Bool) -> RGBA? {
299        todo[keyword] ?? color(TodoClass.of(keyword, isDone: isDone).key, dark: isDark)
300    }
301
302    /// `priority-a`, `-b`, `-c`; `priority` for the others.
303    public func priorityColor(_ priority: String, dark isDark: Bool) -> RGBA? {
304        let key = ["A": "priority-a", "B": "priority-b", "C": "priority-c"][priority] ?? "priority"
305        return color(key, dark: isDark) ?? color("priority", dark: isDark)
306    }
307
308    /// The gutter's color: `agenda-overdue`, `agenda-due-soon`, `done`, else `agenda-time`.
309    public func statusColor(_ status: AgendaRow.Status, dark isDark: Bool) -> RGBA? {
310        switch status {
311        case .overdue: color("agenda-overdue", dark: isDark)
312        case .dueSoon: color("agenda-due-soon", dark: isDark)
313        case .done: color("done", dark: isDark)
314        case .normal: color("agenda-time", dark: isDark)
315        }
316    }
317
318    /// An event's calendar color, else `agenda-event`.
319    public func eventColor(_ event: AgendaEvent, dark isDark: Bool) -> RGBA? {
320        event.color ?? color("agenda-event", dark: isDark)
321    }
322
323    /// `[theme.category]`'s color for the category, else its hue (`categoryHue`) at a
324    /// saturation and lightness that read on the appearance's background.
325    public func categoryColor(_ name: String, dark isDark: Bool) -> RGBA {
326        if let set = category[name] { return set }
327        let hue = Self.categoryHue(name)
328        return isDark ? RGBA(hue: hue, saturation: 0.65, lightness: 0.70) : RGBA(hue: hue, saturation: 0.60, lightness: 0.40)
329    }
330
331    /// A hue in degrees from the category's name (FNV-1a of its UTF-8), the same on every run
332    /// and device.
333    public static func categoryHue(_ name: String) -> Double {
334        var hash: UInt32 = 2166136261
335        for byte in name.utf8 {
336            hash ^= UInt32(byte)
337            hash = hash &* 16777619
338        }
339        return Double(hash % 360)
340    }
341}
342
343extension RGBA {
344    /// From HSL: hue in degrees, saturation and lightness 0 to 1.
345    public init(hue: Double, saturation s: Double, lightness l: Double, alpha: Double = 1) {
346        let c = (1 - abs(2 * l - 1)) * s
347        let h = (hue.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) / 60
348        let x = c * (1 - abs(h.truncatingRemainder(dividingBy: 2) - 1))
349        let (r, g, b): (Double, Double, Double) = switch Int(h) {
350        case 0: (c, x, 0)
351        case 1: (x, c, 0)
352        case 2: (0, c, x)
353        case 3: (0, x, c)
354        case 4: (x, 0, c)
355        default: (c, 0, x)
356        }
357        let m = l - c / 2
358        self.init(red: r + m, green: g + m, blue: b + m, alpha: alpha)
359    }
360}