Themes !49

merged merged by cmc on 2026-10-05 20:29 UTC · krz/orgstar:theme into main

14 files changed, +541 −48

Layout: unified · split

Sources/OrgApp/ConfigSync.swift +42 −2
@@ -1,6 +1,7 @@
11#if os(macOS)
22import Foundation
33import OrgCore
4import OrgPresentation
45import OrgWorkspace
56
67/// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch
@@ -13,12 +14,16 @@ public final class ConfigSync {
1314
1415 /// Problems in the file, from the last time it was read.
1516 public private(set) var problems: [String] = []
17 /// The colors of `[theme]`, `[theme.light]`, `[theme.dark]` and `[theme.todo]` over the
18 /// default theme, with `theme-file`'s under them.
19 public private(set) var theme = ThemeSpec.default
1620 /// The last file of the configuration folder that changed, with a serial number.
1721 public private(set) var changedFile: (name: String, serial: Int)?
1822
1923 private let url: URL
2024 private let defaults: UserDefaults
2125 @ObservationIgnored private var text: String?
26 @ObservationIgnored private var configProblems: [String] = []
2227 @ObservationIgnored private var applying = false
2328 @ObservationIgnored private var watcher: FSEventsWatcher?
2429 @ObservationIgnored private var observer: NSObjectProtocol?
@@ -32,13 +37,24 @@ public final class ConfigSync {
3237 public func start(watch: Bool = true) {
3338 guard observer == nil else { return }
3439 try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
40 let reference = url.deletingLastPathComponent().appendingPathComponent("default-theme.toml")
41 if (try? String(contentsOf: reference, encoding: .utf8)) != ThemeSpec.defaultThemeFile() {
42 try? ThemeSpec.defaultThemeFile().write(to: reference, atomically: true, encoding: .utf8)
43 }
3544 if FileManager.default.fileExists(atPath: url.path) {
3645 reload()
37 // A file from before settings took Emacs's names is written again with them.
46 // A file from before settings took Emacs's names is written again with them; one
47 // from before a section existed gets the section.
3848 if let text, UserConfig.usesLegacyNames(text) {
3949 let template = UserConfig.template(from: defaults)
4050 try? template.write(to: url, atomically: true, encoding: .utf8)
4151 self.text = template
52 } else if let text {
53 let completed = UserConfig.addingMissingSections(text, from: defaults)
54 if completed != text {
55 try? completed.write(to: url, atomically: true, encoding: .utf8)
56 self.text = completed
57 }
4258 }
4359 } else {
4460 let template = UserConfig.template(from: defaults)
@@ -59,6 +75,7 @@ public final class ConfigSync {
5975 guard let self else { return }
6076 for name in names.sorted() {
6177 if name == self.url.lastPathComponent { self.reload() }
78 if name == self.defaults.string(forKey: "themeFile") { self.readTheme() }
6279 self.serial += 1
6380 self.changedFile = (name, self.serial)
6481 }
@@ -72,8 +89,31 @@ public final class ConfigSync {
7289 guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return }
7390 text = new
7491 applying = true
75 problems = UserConfig.apply(new, to: defaults)
92 configProblems = UserConfig.apply(new, to: defaults)
7693 applying = false
94 readTheme()
95 }
96
97 /// The theme from the file read last and its theme file.
98 func readTheme() {
99 guard let text, let tables = try? TOML.parse(text) else { return }
100 var spec = ThemeSpec.default
101 var themeProblems: [String] = []
102 if let name = defaults.string(forKey: "themeFile"), !name.isEmpty {
103 let file = url.deletingLastPathComponent().appendingPathComponent(name)
104 if let themeText = try? String(contentsOf: file, encoding: .utf8) {
105 do {
106 (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
107 } catch {
108 themeProblems.append("\(name): \(error)")
109 }
110 } else {
111 themeProblems.append("config.toml: theme-file \(name) can't be read")
112 }
113 }
114 let (merged, more) = ThemeSpec.reading(tables, over: spec)
115 if merged != theme { theme = merged }
116 problems = configProblems + themeProblems + more
77117 }
78118
79119 /// Writes settings that differ from the file into it.
Sources/OrgApp/UserConfig.swift +33
@@ -1,5 +1,6 @@
11import Foundation
22import OrgCore
3import OrgPresentation
34
45/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and
56/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a
@@ -80,6 +81,16 @@ public enum UserConfig {
8081 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
8182 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
8283 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
84 Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""),
85 comment: "a monospaced font family; \"\" for the system's", legacy: ""),
86 Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13),
87 comment: "points", legacy: ""),
88 Setting(section: "theme", key: "line-spacing", defaultsKey: "themeLineSpacing", kind: .integer, fallback: .integer(2),
89 comment: "points between lines", legacy: ""),
90 Setting(section: "theme", key: "heading-size-step", defaultsKey: "themeHeadingStep", kind: .integer, fallback: .integer(1),
91 comment: "points a heading is larger than the level below; level 4 and deeper are body size", legacy: ""),
92 Setting(section: "theme", key: "theme-file", defaultsKey: "themeFile", kind: .string, fallback: .string(""),
93 comment: "a theme in its own file in this folder, under the colors set here; colors: see default-theme.toml", legacy: ""),
8394 Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
8495 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"),
8596 Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
@@ -162,7 +173,10 @@ public enum UserConfig {
162173 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
163174 }
164175 for table in tables {
176 // Colors are the theme's to read.
177 if ThemeSpec.isThemeTable(table.name), table.name != "theme" { continue }
165178 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
179 if table.name == "theme", !ThemeSpec.typeKeys.contains(key) { continue }
166180 let name = path(table.name, key)
167181 guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
168182 problems.append("config.toml: unknown setting \(name)")
@@ -228,6 +242,25 @@ public enum UserConfig {
228242 return out
229243 }
230244
245 /// `text` with the sections it lacks entirely added at the end, every setting at its
246 /// value in `defaults`: how a file from an earlier version gets settings added since.
247 /// Single lines left out stay out; they mean the default.
248 public static func addingMissingSections(_ text: String, from defaults: UserDefaults) -> String {
249 let tables = (try? TOML.parse(text)) ?? []
250 let present = Set(tables.filter { !$0.values.isEmpty || $0.name != "" }.map(\.name))
251 var out = text
252 var section: String?
253 for setting in settings where !setting.section.isEmpty && !present.contains(setting.section) {
254 if setting.section != section {
255 section = setting.section
256 if !out.hasSuffix("\n") { out += "\n" }
257 out += "\n[\(setting.section)]\n"
258 }
259 out += "\(setting.key) = \(format(value(setting, in: defaults))) # \(setting.comment)\n"
260 }
261 return out
262 }
263
231264 /// `text` with `section.key` set to `value`: the line's value is replaced (its comment
232265 /// stays), or a line is added at the end of the section, or the section at the end.
233266 public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String {
Sources/OrgEditorAppKit/Keys.swift +3
@@ -15,6 +15,9 @@ final class OrgTextView: NSTextView {
1515 /// Returns true when the key was handled.
1616 var keyHandler: ((NSEvent) -> Bool)?
1717 var onMouseDown: (() -> Void)?
18 var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
19 var lineNumberColor = NSColor.tertiaryLabelColor
20 var currentLineNumberColor = NSColor.labelColor
1821 var showsLineNumbers = false {
1922 didSet { if showsLineNumbers != oldValue { updateGutter() } }
2023 }
Sources/OrgEditorAppKit/LineNumbers.swift +3 −5
@@ -5,15 +5,13 @@ import AppKit
55/// per line of the file (folded lines skipped, wrapped lines numbered once), the caret's line
66/// bright. Drawn by the text view itself: an `NSRulerView` stops TextKit 2 from drawing text.
77extension OrgTextView {
8 static let lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
9
108 /// Width of the margin for the largest line number, or 0 when numbers are off.
119 func updateGutter() {
1210 var width: CGFloat = 0
1311 if showsLineNumbers {
1412 let lines = (string as NSString).components(separatedBy: "\n").count
1513 let digits = max(2, String(lines).count)
16 width = ceil(("0" as NSString).size(withAttributes: [.font: Self.lineNumberFont]).width * CGFloat(digits) + 16)
14 width = ceil(("0" as NSString).size(withAttributes: [.font: lineNumberFont]).width * CGFloat(digits) + 16)
1715 }
1816 if textContainerInset.width != width {
1917 textContainerInset = NSSize(width: width, height: textContainerInset.height)
@@ -44,8 +42,8 @@ extension OrgTextView {
4442 counted += 1
4543 }
4644 let label = NSAttributedString(string: "\(line)", attributes: [
47 .font: Self.lineNumberFont,
48 .foregroundColor: offset == caretLine ? NSColor.labelColor : NSColor.tertiaryLabelColor,
45 .font: lineNumberFont,
46 .foregroundColor: offset == caretLine ? currentLineNumberColor : lineNumberColor,
4947 ])
5048 let lineHeight = fragment.textLineFragments.first?.typographicBounds.height ?? frame.height
5149 let size = label.size()
Sources/OrgEditorAppKit/OrgEditor.swift +29 −3
@@ -54,7 +54,17 @@ public final class OrgEditor: NSObject {
5454 let folded = FoldedHeadings()
5555 /// Blocks drawn on a shaded band.
5656 let shaded = HiddenRanges()
57 let theme = Theme()
57 private(set) var theme = Theme()
58 /// Colors and type; a change restyles the file.
59 public var themeSpec: ThemeSpec = .default {
60 didSet {
61 guard themeSpec != oldValue else { return }
62 theme = Theme(themeSpec)
63 applyThemeToView()
64 restyleEverything()
65 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
66 }
67 }
5868 private var isLoading = false
5969 private var previousSelection = 0
6070 /// Text before this offset is styled; after it, only base attributes until the background
@@ -113,8 +123,7 @@ public final class OrgEditor: NSObject {
113123 textView.isAutomaticDashSubstitutionEnabled = false
114124 textView.isAutomaticTextReplacementEnabled = false
115125 textView.isAutomaticSpellingCorrectionEnabled = false
116 textView.font = theme.font
117 textView.typingAttributes = theme.base
126 applyThemeToView()
118127 textView.isVerticallyResizable = true
119128 textView.autoresizingMask = [.width]
120129 textView.textContainer?.widthTracksTextView = true
@@ -151,6 +160,23 @@ public final class OrgEditor: NSObject {
151160 styledUpTo = end
152161 }
153162
163 /// The text view's own colors and font, and the line numbers'.
164 private func applyThemeToView() {
165 textView.font = theme.font
166 textView.typingAttributes = theme.base
167 textView.drawsBackground = true
168 textView.backgroundColor = theme.color("background") ?? .textBackgroundColor
169 textView.insertionPointColor = theme.color("cursor") ?? .textColor
170 textView.selectedTextAttributes = [.backgroundColor: theme.color("selection") ?? .selectedTextBackgroundColor]
171 if let view = orgTextView {
172 view.lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: max(8, theme.font.pointSize - 2), weight: .regular)
173 view.lineNumberColor = theme.color("line-number") ?? .tertiaryLabelColor
174 view.currentLineNumberColor = theme.color("line-number-current") ?? .labelColor
175 view.updateGutter()
176 view.needsDisplay = true
177 }
178 }
179
154180 /// Styles the whole file again, a screenful now and the rest in the background.
155181 private func restyleEverything() {
156182 styledUpTo = 0
Sources/OrgEditorAppKit/Theme.swift +79 −37
@@ -2,13 +2,46 @@
22import AppKit
33import OrgPresentation
44
5/// Attributes for each style role. A fixed theme for now; the app's theme settings replace it.
5/// Attributes for each style role, from a `ThemeSpec`.
66@MainActor
77struct Theme {
8 let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
8 let spec: ThemeSpec
9 let font: NSFont
10
11 init(_ spec: ThemeSpec = .default) {
12 self.spec = spec
13 let size = CGFloat(max(6, spec.fontSize))
14 font = spec.font.isEmpty ? NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
15 : NSFontManager.shared.font(withFamily: spec.font, traits: [], weight: 5, size: size)
16 ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
17 Self.blockBackground = color("block-background") ?? .clear
18 }
19
20 /// A color that follows the appearance, or nil when the theme has none for `key`.
21 func color(_ key: String) -> NSColor? {
22 let light = spec.light[key] ?? spec.dark[key]
23 let dark = spec.dark[key] ?? spec.light[key]
24 guard let light, let dark else { return nil }
25 return Self.dynamic(light, dark)
26 }
27
28 /// One color object per pair, so equal styling compares equal.
29 private static var colors: [[RGBA]: NSColor] = [:]
30
31 static func dynamic(_ light: RGBA, _ dark: RGBA) -> NSColor {
32 if let cached = colors[[light, dark]] { return cached }
33 let color = NSColor(name: nil) { appearance in
34 let c = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light
35 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
36 }
37 colors[[light, dark]] = color
38 return color
39 }
40
41 var foreground: NSColor { color("foreground") ?? .textColor }
942
1043 var base: [NSAttributedString.Key: Any] {
11 [.font: font, .foregroundColor: NSColor.textColor]
44 [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)]
1245 }
1346
1447 /// Width of one column of the monospaced font.
@@ -16,10 +49,8 @@ struct Theme {
1649 ("0" as NSString).size(withAttributes: [.font: font]).width
1750 }
1851
19 /// The band behind blocks.
20 nonisolated(unsafe) static let blockBackground = NSColor(name: nil) { appearance in
21 appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 1, alpha: 0.06) : NSColor(white: 0, alpha: 0.045)
22 }
52 /// The band behind blocks, read by layout fragments off the main actor.
53 nonisolated(unsafe) static var blockBackground = NSColor.clear
2354
2455 /// Markup hidden from view: no width, no color.
2556 var hidden: [NSAttributedString.Key: Any] {
@@ -30,24 +61,35 @@ struct Theme {
3061 let style = NSMutableParagraphStyle()
3162 style.firstLineHeadIndent = CGFloat(firstLine) * column
3263 style.headIndent = CGFloat(wrapped) * column
64 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
3365 return style
3466 }
3567
3668 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
69 func fg(_ key: String) {
70 if let color = color(key) { storage.addAttribute(.foregroundColor, value: color, range: range) }
71 }
3772 switch role {
3873 case .heading(let level):
39 let size = font.pointSize + CGFloat(max(0, 4 - level))
40 storage.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: size, weight: .semibold), range: range)
74 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
75 let heading = NSFontManager.shared.convert(font.withSize(size), toHaveTrait: .boldFontMask)
76 storage.addAttribute(.font, value: heading, range: range)
77 fg("heading-\(min(max(level, 1), 8))")
4178 case .leadingStars:
4279 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
43 case .todo:
44 storage.addAttributes([.foregroundColor: NSColor.systemRed, .font: bold(storage, range)], range: range)
45 case .done:
46 storage.addAttributes([.foregroundColor: NSColor.systemGreen, .font: bold(storage, range)], range: range)
47 case .priority:
48 storage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: range)
49 case .tags, .comment, .keyword, .metadata:
50 storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: range)
80 case .todo, .done:
81 let keyword = (storage.string as NSString).substring(with: range)
82 storage.addAttribute(.font, value: bold(storage, range), range: range)
83 if let custom = spec.todo[keyword] {
84 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
85 } else {
86 fg(role == .todo ? "todo" : "done")
87 }
88 case .priority: fg("priority")
89 case .tags: fg("tags")
90 case .comment: fg("comment")
91 case .keyword: fg("keyword")
92 case .metadata: fg("metadata")
5193 case .bold:
5294 storage.addAttribute(.font, value: bold(storage, range), range: range)
5395 case .italic:
@@ -57,33 +99,33 @@ struct Theme {
5799 case .strikeThrough:
58100 storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
59101 case .verbatim, .code, .inlineSource:
60 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range)
61 case .markup, .linkMarkup, .blockDelimiter:
62 storage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: range)
102 if let background = color("inline-background") { storage.addAttribute(.backgroundColor, value: background, range: range) }
103 fg(role == .verbatim ? "verbatim" : "code")
104 case .markup, .linkMarkup: fg("markup")
105 case .blockDelimiter: fg("block-delimiter")
63106 case .link:
64 storage.addAttributes([.foregroundColor: NSColor.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue], range: range)
65 case .timestamp:
66 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range)
67 case .footnote, .cookie, .target, .macro, .latex:
68 storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range)
69 case .block, .table, .rule:
107 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
108 fg("link")
109 case .timestamp: fg("timestamp")
110 case .footnote, .cookie, .target, .macro, .latex: fg("special")
111 case .table, .rule: fg("table")
112 case .block:
70113 break
71114 case .syntax(let category):
72 if let color = Self.color(category) { storage.addAttribute(.foregroundColor, value: color, range: range) }
115 if let key = Self.slot(category) { fg(key) }
73116 }
74117 }
75118
76 /// Code colors, close to Xcode's default theme.
77 static func color(_ category: SyntaxCategory) -> NSColor? {
119 static func slot(_ category: SyntaxCategory) -> String? {
78120 switch category {
79 case .keyword: .systemPink
80 case .string: .systemRed
81 case .comment: .secondaryLabelColor
82 case .function: .systemBlue
83 case .type: .systemTeal
84 case .number, .constant, .escape: .systemOrange
85 case .property, .attribute, .tag: .systemIndigo
86 case .label: .systemPurple
121 case .keyword: "syntax-keyword"
122 case .string: "syntax-string"
123 case .comment: "syntax-comment"
124 case .function: "syntax-function"
125 case .type: "syntax-type"
126 case .number, .constant, .escape: "syntax-number"
127 case .property, .attribute, .tag: "syntax-property"
128 case .label: "syntax-label"
87129 case .variable, .operator, .punctuation: nil
88130 }
89131 }
Sources/OrgPresentation/ThemeSpec.swift added +201
@@ -0,0 +1,201 @@
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 type. Read from `[theme]`, `[theme.light]`, `[theme.dark]` and
37/// `[theme.todo]` 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
51 public init(light: [String: RGBA], dark: [String: RGBA]) {
52 self.light = light
53 self.dark = dark
54 }
55
56 public func color(_ key: String, dark isDark: Bool) -> RGBA? {
57 (isDark ? dark : light)[key]
58 }
59
60 // MARK: - Slots
61
62 /// Every color key, with what it colors.
63 public static let slots: [(key: String, description: String)] = [
64 ("background", "the editor's background"),
65 ("foreground", "body text"),
66 ("cursor", "the caret"),
67 ("selection", "selected text's background"),
68 ("heading-1", "level 1 headings"), ("heading-2", "level 2 headings"), ("heading-3", "level 3 headings"),
69 ("heading-4", "level 4 headings"), ("heading-5", "level 5 headings"), ("heading-6", "level 6 headings"),
70 ("heading-7", "level 7 headings"), ("heading-8", "level 8 and deeper headings"),
71 ("todo", "TODO keywords not yet done"),
72 ("done", "DONE keywords"),
73 ("priority", "[#A] cookies"),
74 ("tags", ":tags:"),
75 ("link", "links"),
76 ("timestamp", "timestamps"),
77 ("code", "~code~ and inline source"),
78 ("verbatim", "=verbatim="),
79 ("inline-background", "behind ~code~ and =verbatim="),
80 ("markup", "link brackets and emphasis markers"),
81 ("comment", "comments"),
82 ("keyword", "#+KEYWORD lines"),
83 ("metadata", "planning lines, drawers, properties and clocks"),
84 ("special", "footnotes, statistics cookies, targets, macros and LaTeX"),
85 ("block-background", "the band behind blocks"),
86 ("block-delimiter", "#+begin_ and #+end_ lines"),
87 ("table", "tables"),
88 ("line-number", "line numbers"),
89 ("line-number-current", "the caret's line number"),
90 ("syntax-keyword", "code: keywords"),
91 ("syntax-string", "code: strings"),
92 ("syntax-comment", "code: comments"),
93 ("syntax-function", "code: functions"),
94 ("syntax-type", "code: types and modules"),
95 ("syntax-number", "code: numbers, constants and escapes"),
96 ("syntax-property", "code: properties, attributes and tags"),
97 ("syntax-label", "code: labels"),
98 ]
99
100 // MARK: - The default theme
101
102 private static func palette(_ pairs: [String: String]) -> [String: RGBA] {
103 pairs.compactMapValues { RGBA(hex: $0) }
104 }
105
106 /// Calm colors after GitHub's light and dark themes: headings colored by level, keywords
107 /// clear without shouting, code in the colors developers know.
108 public static let `default` = ThemeSpec(
109 light: palette([
110 "background": "#ffffff", "foreground": "#1f2328", "cursor": "#0969da", "selection": "#0969da33",
111 "heading-1": "#0550ae", "heading-2": "#8250df", "heading-3": "#116329", "heading-4": "#953800",
112 "heading-5": "#0550ae", "heading-6": "#8250df", "heading-7": "#116329", "heading-8": "#953800",
113 "todo": "#cf222e", "done": "#1a7f37", "priority": "#bc4c00", "tags": "#6e7781",
114 "link": "#0969da", "timestamp": "#8250df", "code": "#953800", "verbatim": "#0a3069", "inline-background": "#afb8c133",
115 "markup": "#8c959f", "comment": "#6e7781", "keyword": "#6e7781", "metadata": "#6e7781", "special": "#1b7c83",
116 "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328",
117 "line-number": "#8c959f", "line-number-current": "#1f2328",
118 "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df",
119 "syntax-type": "#953800", "syntax-number": "#0550ae", "syntax-property": "#0550ae", "syntax-label": "#8250df",
120 ]),
121 dark: palette([
122 "background": "#0d1117", "foreground": "#e6edf3", "cursor": "#58a6ff", "selection": "#388bfd66",
123 "heading-1": "#79c0ff", "heading-2": "#d2a8ff", "heading-3": "#7ee787", "heading-4": "#ffa657",
124 "heading-5": "#79c0ff", "heading-6": "#d2a8ff", "heading-7": "#7ee787", "heading-8": "#ffa657",
125 "todo": "#ff7b72", "done": "#3fb950", "priority": "#ffa657", "tags": "#8b949e",
126 "link": "#58a6ff", "timestamp": "#d2a8ff", "code": "#ffa657", "verbatim": "#a5d6ff", "inline-background": "#6e768166",
127 "markup": "#6e7681", "comment": "#8b949e", "keyword": "#8b949e", "metadata": "#8b949e", "special": "#56d4dd",
128 "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3",
129 "line-number": "#6e7681", "line-number-current": "#e6edf3",
130 "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff",
131 "syntax-type": "#ffa657", "syntax-number": "#79c0ff", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff",
132 ])
133 )
134
135 // MARK: - Reading
136
137 /// Keys of `[theme]` that aren't colors; config.toml's settings handle them.
138 public static let typeKeys: Set<String> = ["font", "font-size", "line-spacing", "heading-size-step", "theme-file"]
139
140 /// Whether a table holds theme colors.
141 public static func isThemeTable(_ name: String) -> Bool {
142 name == "theme" || name.hasPrefix("theme.")
143 }
144
145 /// `base` with the colors of `tables` laid over it, in order; problems for keys and
146 /// values it can't use.
147 public static func reading(_ tables: [TOML.Table], over base: ThemeSpec = .default, file: String = "config.toml") -> (ThemeSpec, [String]) {
148 var spec = base
149 var problems: [String] = []
150 let known = Set(slots.map(\.key))
151 for table in tables where isThemeTable(table.name) {
152 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
153 if table.name == "theme", typeKeys.contains(key) { continue }
154 guard let text = value.string, let color = RGBA(hex: text) else {
155 problems.append("\(file): \(table.name).\(key) must be a color such as \"#1f2328\"")
156 continue
157 }
158 switch table.name {
159 case "theme.todo":
160 spec.todo[key] = color
161 case "theme", "theme.light", "theme.dark":
162 guard known.contains(key) else {
163 problems.append("\(file): unknown theme color \(key)")
164 continue
165 }
166 if table.name != "theme.dark" { spec.light[key] = color }
167 if table.name != "theme.light" { spec.dark[key] = color }
168 default:
169 problems.append("\(file): unknown table [\(table.name)]")
170 }
171 }
172 }
173 return (spec, problems)
174 }
175
176 /// The default theme written out, to copy colors from.
177 public static func defaultThemeFile() -> String {
178 var out = """
179 # Orgstar's default theme, for reference: Orgstar writes this file again each time it
180 # starts, so change colors in config.toml. A key set under [theme] colors both
181 # appearances; [theme.light] and [theme.dark] set one. Colors are "#rrggbb" or
182 # "#rrggbbaa". [theme.todo] colors TODO keywords by name, as org-todo-keyword-faces:
183 #
184 # [theme.todo]
185 # WAIT = "#bf8700"
186 #
187 # A theme kept in its own file (with the same tables) is used with
188 # `theme-file = "name.toml"` under [theme] in config.toml.
189
190 """
191 for (name, colors) in [("theme.light", ThemeSpec.default.light), ("theme.dark", ThemeSpec.default.dark)] {
192 out += "\n[\(name)]\n"
193 for slot in slots {
194 guard let color = colors[slot.key] else { continue }
195 let line = "\(slot.key) = \"\(color.hex)\""
196 out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n"
197 }
198 }
199 return out
200 }
201}
Sources/Orgstar/ContentView.swift +16 −1
@@ -2,6 +2,7 @@ import AppKit
22import OrgApp
33import OrgCore
44import OrgEditorAppKit
5import OrgPresentation
56import OrgIndex
67import SwiftUI
78import UniformTypeIdentifiers
@@ -45,6 +46,20 @@ struct ContentView: View {
4546 @AppStorage("outlineShown") private var orgOutlineShown = true
4647 @AppStorage("showMarkup") private var showMarkup = false
4748 @AppStorage("showLineNumbers") private var showLineNumbers = true
49 @AppStorage("themeFont") private var themeFont = ""
50 @AppStorage("themeFontSize") private var themeFontSize = 13
51 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
52 @AppStorage("themeHeadingStep") private var themeHeadingStep = 1
53
54 /// The theme's colors from config.toml with the type settings.
55 private var theme: ThemeSpec {
56 var spec = ConfigSync.shared.theme
57 spec.font = themeFont
58 spec.fontSize = themeFontSize
59 spec.lineSpacing = themeLineSpacing
60 spec.headingSizeStep = themeHeadingStep
61 return spec
62 }
4863 /// The outline for a file that isn't org, off until asked for.
4964 @State private var plainOutlineShown = false
5065 @State private var showingImport = false
@@ -68,7 +83,7 @@ struct ContentView: View {
6883 if let previewPath {
6984 FilePreview(path: previewPath)
7085 } else if session.path != nil {
71 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, onAppCommand: perform, onExternal: runExternal)
86 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal)
7287 .safeAreaInset(edge: .bottom, spacing: 0) {
7388 VStack(spacing: 0) {
7489 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
Sources/Orgstar/DevelopmentHooks.swift +4
@@ -7,6 +7,7 @@ import AppKit
77/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it.
88/// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is
99/// what the snapshot renders.
10/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's.
1011/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
1112/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
1213/// permission.
@@ -19,6 +20,9 @@ enum DevelopmentHooks {
1920 @MainActor
2021 static func scheduleSnapshot() {
2122 let environment = ProcessInfo.processInfo.environment
23 if let appearance = environment["ORGSTAR_APPEARANCE"] {
24 NSApp.appearance = NSAppearance(named: appearance == "dark" ? .darkAqua : .aqua)
25 }
2226 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
2327 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
2428 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Sources/Orgstar/EditorView.swift +3
@@ -2,6 +2,7 @@ import AppKit
22import OrgApp
33import OrgCore
44import OrgEditorAppKit
5import OrgPresentation
56import OrgHighlight
67import SwiftUI
78
@@ -19,6 +20,7 @@ struct EditorView: NSViewRepresentable {
1920 let options: EditingOptions
2021 let showMarkup: Bool
2122 let showLineNumbers: Bool
23 let theme: ThemeSpec
2224 let onAppCommand: (String) -> Void
2325 let onExternal: (ExternalRequest) -> Void
2426
@@ -71,6 +73,7 @@ struct EditorView: NSViewRepresentable {
7173 editor.options = options
7274 editor.showMarkup = showMarkup
7375 editor.showLineNumbers = showLineNumbers
76 editor.themeSpec = theme
7477 }
7578 container.window?.isDocumentEdited = session.isDirty
7679 if let request = session.revealRequest, request.id != coordinator.revealID {
Sources/Orgstar/OrgstarApp.swift +45
@@ -343,11 +343,21 @@ struct SettingsView: View {
343343 @AppStorage("reminderLead") private var reminderLead = 12
344344 @AppStorage("globalCapture") private var globalCapture = true
345345 @AppStorage("settingsTab") private var tab = "general"
346 @AppStorage("themeFont") private var themeFont = ""
347 @AppStorage("themeFontSize") private var themeFontSize = 13
348 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
349 @AppStorage("themeHeadingStep") private var themeHeadingStep = 1
350
351 /// Installed monospaced font families.
352 private static let monospacedFamilies: [String] = NSFontManager.shared.availableFontFamilies.filter { family in
353 NSFontManager.shared.font(withFamily: family, traits: [], weight: 5, size: 13)?.isFixedPitch == true
354 }
346355
347356 var body: some View {
348357 TabView(selection: $tab) {
349358 Tab("General", systemImage: "gearshape", value: "general") { general }
350359 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
360 Tab("Appearance", systemImage: "paintpalette", value: "appearance") { appearance }
351361 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
352362 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
353363 }
@@ -434,6 +444,41 @@ struct SettingsView: View {
434444 .frame(height: 450)
435445 }
436446
447 private var appearance: some View {
448 Form {
449 Section {
450 Picker("Font", selection: $themeFont) {
451 Text("System monospaced").tag("")
452 Divider()
453 ForEach(Self.monospacedFamilies, id: \.self) { Text($0).tag($0) }
454 if !themeFont.isEmpty, !Self.monospacedFamilies.contains(themeFont) { Text(themeFont).tag(themeFont) }
455 }
456 Stepper("Size: \(themeFontSize) pt", value: $themeFontSize, in: 8...36)
457 Stepper("Line spacing: \(themeLineSpacing) pt", value: $themeLineSpacing, in: 0...16)
458 Stepper("Headings grow by \(themeHeadingStep) pt a level", value: $themeHeadingStep, in: 0...8)
459 } footer: {
460 Text("Tags and tables line up in columns, so a monospaced font keeps them straight.")
461 .settingsFooter()
462 }
463 Section {
464 LabeledContent("Colors") {
465 HStack {
466 Button("Edit in config.toml") { editConfig() }
467 Button("Show Default Theme") {
468 openWindow(id: "main")
469 commands.jump(path: UserConfig.directory.appendingPathComponent("default-theme.toml").path, offset: 0)
470 }
471 }
472 }
473 } footer: {
474 Text("Set colors under [theme] (both appearances), [theme.light] or [theme.dark] in config.toml, and TODO keyword colors under [theme.todo]. default-theme.toml lists every color with the default theme's values.")
475 .settingsFooter()
476 }
477 }
478 .formStyle(.grouped)
479 .frame(height: 380)
480 }
481
437482 private var agenda: some View {
438483 Form {
439484 Section {
Tests/OrgAppTests/UserConfigTests.swift +33
@@ -1,5 +1,6 @@
11import Foundation
22import OrgCore
3import OrgPresentation
34import Testing
45@testable import OrgApp
56
@@ -55,6 +56,14 @@ struct UserConfigTests {
5556 == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n")
5657 }
5758
59 @Test func missingSectionsAreAdded() {
60 let text = "# mine\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n"
61 let completed = UserConfig.addingMissingSections(text, from: defaults())
62 #expect(completed.hasPrefix(text))
63 #expect(completed.contains("\n[theme]\nfont = \"\"") && !completed.contains("keymap"))
64 #expect(UserConfig.addingMissingSections(completed, from: defaults()) == completed)
65 }
66
5867 @Test func legacyFilesAreRewritten() throws {
5968 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
6069 defer { try? FileManager.default.removeItem(at: folder) }
@@ -90,3 +99,27 @@ struct UserConfigTests {
9099 #expect(sync.problems.isEmpty)
91100 }
92101}
102
103@MainActor
104struct ThemeConfigTests {
105 @Test func themeTablesAreNotSettingsProblems() throws {
106 let name = "orgstar-theme-\(UUID().uuidString)"
107 let defaults = UserDefaults(suiteName: name)!
108 defer { defaults.removePersistentDomain(forName: name) }
109 let problems = UserConfig.apply("[theme]\nfont-size = 15\nlink = \"#123456\"\n[theme.dark]\nlink = \"#654321\"\n", to: defaults)
110 #expect(problems.isEmpty)
111 #expect(defaults.integer(forKey: "themeFontSize") == 15)
112
113 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-theme-\(UUID().uuidString)")
114 defer { try? FileManager.default.removeItem(at: folder) }
115 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
116 try "[theme.light]\nlink = \"#000001\"\ntodo = \"#000002\"\n".write(to: folder.appendingPathComponent("mine.toml"), atomically: true, encoding: .utf8)
117 try "[theme]\ntheme-file = \"mine.toml\"\nlink = \"#000003\"\n".write(to: folder.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8)
118 let sync = ConfigSync(url: folder.appendingPathComponent("config.toml"), defaults: defaults)
119 sync.start(watch: false)
120 #expect(sync.problems.isEmpty, "\(sync.problems)")
121 #expect(sync.theme.light["link"]?.hex == "#000003")
122 #expect(sync.theme.light["todo"]?.hex == "#000002")
123 #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path))
124 }
125}
Tests/OrgEditorAppKitTests/MarkupTests.swift +20
@@ -1,5 +1,6 @@
11import AppKit
22import OrgCore
3import OrgPresentation
34import Testing
45@testable import OrgEditorAppKit
56
@@ -91,3 +92,22 @@ struct PrettyEntityTests {
9192 #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) == nil)
9293 }
9394}
95
96@MainActor
97struct EditorThemeTests {
98 @Test func themeChangesFontAndColors() {
99 let h = Harness("* Heading\nbody\n")
100 var spec = ThemeSpec.default
101 spec.fontSize = 17
102 spec.light["heading-1"] = RGBA(hex: "#ff0000")
103 spec.dark["heading-1"] = RGBA(hex: "#ff0000")
104 spec.todo["WAIT"] = RGBA(hex: "#00ff00")
105 h.editor.themeSpec = spec
106 h.drainStyling()
107 let storage = h.textView.textStorage!
108 #expect((storage.attribute(.font, at: 11, effectiveRange: nil) as? NSFont)?.pointSize == 17)
109 let color = (storage.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor)?.usingColorSpace(.sRGB)
110 #expect(color.map { ($0.redComponent * 255).rounded() } == 255)
111 #expect((storage.attribute(.font, at: 3, effectiveRange: nil) as? NSFont)?.pointSize == 20)
112 }
113}
Tests/OrgPresentationTests/PresentationTests.swift +30
@@ -130,3 +130,33 @@ struct CyclingTests {
130130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
131131 }
132132}
133
134struct ThemeSpecTests {
135 @Test func readsColorsOverTheDefault() throws {
136 let tables = try TOML.parse("""
137 [theme]
138 font = "Menlo"
139 link = "#112233"
140 [theme.dark]
141 link = "#445566cc"
142 heading-1 = "blue"
143 sparkle = "#000000"
144 [theme.todo]
145 WAIT = "#bf8700"
146 """)
147 let (spec, problems) = ThemeSpec.reading(tables)
148 #expect(spec.light["link"] == RGBA(hex: "#112233"))
149 #expect(spec.dark["link"]?.hex == "#445566cc")
150 #expect(spec.dark["heading-1"] == ThemeSpec.default.dark["heading-1"])
151 #expect(spec.todo["WAIT"]?.hex == "#bf8700")
152 #expect(problems.count == 2)
153 }
154
155 @Test func defaultThemeFileReadsBackAsTheDefault() throws {
156 let (spec, problems) = ThemeSpec.reading(try TOML.parse(ThemeSpec.defaultThemeFile()), over: ThemeSpec(light: [:], dark: [:]))
157 #expect(problems.isEmpty)
158 #expect(spec.light == ThemeSpec.default.light && spec.dark == ThemeSpec.default.dark)
159 #expect(Set(ThemeSpec.default.light.keys) == Set(ThemeSpec.slots.map(\.key)))
160 #expect(Set(ThemeSpec.default.dark.keys) == Set(ThemeSpec.slots.map(\.key)))
161 }
162}