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 @@
1#if os(macOS) 1#if os(macOS)
2import Foundation 2import Foundation
3import OrgCore 3import OrgCore
4import OrgPresentation
4import OrgWorkspace 5import OrgWorkspace
5 6
6/// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch 7/// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch
@@ -13,12 +14,16 @@ public final class ConfigSync {
13 14
14 /// Problems in the file, from the last time it was read. 15 /// Problems in the file, from the last time it was read.
15 public private(set) var problems: [String] = [] 16 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
16 /// The last file of the configuration folder that changed, with a serial number. 20 /// The last file of the configuration folder that changed, with a serial number.
17 public private(set) var changedFile: (name: String, serial: Int)? 21 public private(set) var changedFile: (name: String, serial: Int)?
18 22
19 private let url: URL 23 private let url: URL
20 private let defaults: UserDefaults 24 private let defaults: UserDefaults
21 @ObservationIgnored private var text: String? 25 @ObservationIgnored private var text: String?
26 @ObservationIgnored private var configProblems: [String] = []
22 @ObservationIgnored private var applying = false 27 @ObservationIgnored private var applying = false
23 @ObservationIgnored private var watcher: FSEventsWatcher? 28 @ObservationIgnored private var watcher: FSEventsWatcher?
24 @ObservationIgnored private var observer: NSObjectProtocol? 29 @ObservationIgnored private var observer: NSObjectProtocol?
@@ -32,13 +37,24 @@ public final class ConfigSync {
32 public func start(watch: Bool = true) { 37 public func start(watch: Bool = true) {
33 guard observer == nil else { return } 38 guard observer == nil else { return }
34 try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) 39 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 }
35 if FileManager.default.fileExists(atPath: url.path) { 44 if FileManager.default.fileExists(atPath: url.path) {
36 reload() 45 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.
38 if let text, UserConfig.usesLegacyNames(text) { 48 if let text, UserConfig.usesLegacyNames(text) {
39 let template = UserConfig.template(from: defaults) 49 let template = UserConfig.template(from: defaults)
40 try? template.write(to: url, atomically: true, encoding: .utf8) 50 try? template.write(to: url, atomically: true, encoding: .utf8)
41 self.text = template 51 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 }
42 } 58 }
43 } else { 59 } else {
44 let template = UserConfig.template(from: defaults) 60 let template = UserConfig.template(from: defaults)
@@ -59,6 +75,7 @@ public final class ConfigSync {
59 guard let self else { return } 75 guard let self else { return }
60 for name in names.sorted() { 76 for name in names.sorted() {
61 if name == self.url.lastPathComponent { self.reload() } 77 if name == self.url.lastPathComponent { self.reload() }
78 if name == self.defaults.string(forKey: "themeFile") { self.readTheme() }
62 self.serial += 1 79 self.serial += 1
63 self.changedFile = (name, self.serial) 80 self.changedFile = (name, self.serial)
64 } 81 }
@@ -72,8 +89,31 @@ public final class ConfigSync {
72 guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return } 89 guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return }
73 text = new 90 text = new
74 applying = true 91 applying = true
75 problems = UserConfig.apply(new, to: defaults) 92 configProblems = UserConfig.apply(new, to: defaults)
76 applying = false 93 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
77 } 117 }
78 118
79 /// Writes settings that differ from the file into it. 119 /// Writes settings that differ from the file into it.
Sources/OrgApp/UserConfig.swift +33
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3import OrgPresentation
3 4
4/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and 5/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and
5/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a 6/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a
@@ -80,6 +81,16 @@ public enum UserConfig {
80 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"), 81 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
81 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), 82 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
82 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), 83 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: ""),
83 Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]), 94 Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
84 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"), 95 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"),
85 Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]), 96 Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
@@ -162,7 +173,10 @@ public enum UserConfig {
162 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } 173 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
163 } 174 }
164 for table in tables { 175 for table in tables {
176 // Colors are the theme's to read.
177 if ThemeSpec.isThemeTable(table.name), table.name != "theme" { continue }
165 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) { 178 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
179 if table.name == "theme", !ThemeSpec.typeKeys.contains(key) { continue }
166 let name = path(table.name, key) 180 let name = path(table.name, key)
167 guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else { 181 guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
168 problems.append("config.toml: unknown setting \(name)") 182 problems.append("config.toml: unknown setting \(name)")
@@ -228,6 +242,25 @@ public enum UserConfig {
228 return out 242 return out
229 } 243 }
230 244
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
231 /// `text` with `section.key` set to `value`: the line's value is replaced (its comment 264 /// `text` with `section.key` set to `value`: the line's value is replaced (its comment
232 /// stays), or a line is added at the end of the section, or the section at the end. 265 /// stays), or a line is added at the end of the section, or the section at the end.
233 public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String { 266 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 {
15 /// Returns true when the key was handled. 15 /// Returns true when the key was handled.
16 var keyHandler: ((NSEvent) -> Bool)? 16 var keyHandler: ((NSEvent) -> Bool)?
17 var onMouseDown: (() -> Void)? 17 var onMouseDown: (() -> Void)?
18 var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
19 var lineNumberColor = NSColor.tertiaryLabelColor
20 var currentLineNumberColor = NSColor.labelColor
18 var showsLineNumbers = false { 21 var showsLineNumbers = false {
19 didSet { if showsLineNumbers != oldValue { updateGutter() } } 22 didSet { if showsLineNumbers != oldValue { updateGutter() } }
20 } 23 }
Sources/OrgEditorAppKit/LineNumbers.swift +3 −5
@@ -5,15 +5,13 @@ import AppKit
5/// per line of the file (folded lines skipped, wrapped lines numbered once), the caret's line 5/// per line of the file (folded lines skipped, wrapped lines numbered once), the caret's line
6/// bright. Drawn by the text view itself: an `NSRulerView` stops TextKit 2 from drawing text. 6/// bright. Drawn by the text view itself: an `NSRulerView` stops TextKit 2 from drawing text.
7extension OrgTextView { 7extension OrgTextView {
8 static let lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
9
10 /// Width of the margin for the largest line number, or 0 when numbers are off. 8 /// Width of the margin for the largest line number, or 0 when numbers are off.
11 func updateGutter() { 9 func updateGutter() {
12 var width: CGFloat = 0 10 var width: CGFloat = 0
13 if showsLineNumbers { 11 if showsLineNumbers {
14 let lines = (string as NSString).components(separatedBy: "\n").count 12 let lines = (string as NSString).components(separatedBy: "\n").count
15 let digits = max(2, String(lines).count) 13 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)
17 } 15 }
18 if textContainerInset.width != width { 16 if textContainerInset.width != width {
19 textContainerInset = NSSize(width: width, height: textContainerInset.height) 17 textContainerInset = NSSize(width: width, height: textContainerInset.height)
@@ -44,8 +42,8 @@ extension OrgTextView {
44 counted += 1 42 counted += 1
45 } 43 }
46 let label = NSAttributedString(string: "\(line)", attributes: [ 44 let label = NSAttributedString(string: "\(line)", attributes: [
47 .font: Self.lineNumberFont, 45 .font: lineNumberFont,
48 .foregroundColor: offset == caretLine ? NSColor.labelColor : NSColor.tertiaryLabelColor, 46 .foregroundColor: offset == caretLine ? currentLineNumberColor : lineNumberColor,
49 ]) 47 ])
50 let lineHeight = fragment.textLineFragments.first?.typographicBounds.height ?? frame.height 48 let lineHeight = fragment.textLineFragments.first?.typographicBounds.height ?? frame.height
51 let size = label.size() 49 let size = label.size()
Sources/OrgEditorAppKit/OrgEditor.swift +29 −3
@@ -54,7 +54,17 @@ public final class OrgEditor: NSObject {
54 let folded = FoldedHeadings() 54 let folded = FoldedHeadings()
55 /// Blocks drawn on a shaded band. 55 /// Blocks drawn on a shaded band.
56 let shaded = HiddenRanges() 56 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 }
58 private var isLoading = false 68 private var isLoading = false
59 private var previousSelection = 0 69 private var previousSelection = 0
60 /// Text before this offset is styled; after it, only base attributes until the background 70 /// Text before this offset is styled; after it, only base attributes until the background
@@ -113,8 +123,7 @@ public final class OrgEditor: NSObject {
113 textView.isAutomaticDashSubstitutionEnabled = false 123 textView.isAutomaticDashSubstitutionEnabled = false
114 textView.isAutomaticTextReplacementEnabled = false 124 textView.isAutomaticTextReplacementEnabled = false
115 textView.isAutomaticSpellingCorrectionEnabled = false 125 textView.isAutomaticSpellingCorrectionEnabled = false
116 textView.font = theme.font 126 applyThemeToView()
117 textView.typingAttributes = theme.base
118 textView.isVerticallyResizable = true 127 textView.isVerticallyResizable = true
119 textView.autoresizingMask = [.width] 128 textView.autoresizingMask = [.width]
120 textView.textContainer?.widthTracksTextView = true 129 textView.textContainer?.widthTracksTextView = true
@@ -151,6 +160,23 @@ public final class OrgEditor: NSObject {
151 styledUpTo = end 160 styledUpTo = end
152 } 161 }
153 162
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
154 /// Styles the whole file again, a screenful now and the rest in the background. 180 /// Styles the whole file again, a screenful now and the rest in the background.
155 private func restyleEverything() { 181 private func restyleEverything() {
156 styledUpTo = 0 182 styledUpTo = 0
Sources/OrgEditorAppKit/Theme.swift +79 −37
@@ -2,13 +2,46 @@
2import AppKit 2import AppKit
3import OrgPresentation 3import OrgPresentation
4 4
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`.
6@MainActor 6@MainActor
7struct Theme { 7struct 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 }
9 42
10 var base: [NSAttributedString.Key: Any] { 43 var base: [NSAttributedString.Key: Any] {
11 [.font: font, .foregroundColor: NSColor.textColor] 44 [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)]
12 } 45 }
13 46
14 /// Width of one column of the monospaced font. 47 /// Width of one column of the monospaced font.
@@ -16,10 +49,8 @@ struct Theme {
16 ("0" as NSString).size(withAttributes: [.font: font]).width 49 ("0" as NSString).size(withAttributes: [.font: font]).width
17 } 50 }
18 51
19 /// The band behind blocks. 52 /// The band behind blocks, read by layout fragments off the main actor.
20 nonisolated(unsafe) static let blockBackground = NSColor(name: nil) { appearance in 53 nonisolated(unsafe) static var blockBackground = NSColor.clear
21 appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 1, alpha: 0.06) : NSColor(white: 0, alpha: 0.045)
22 }
23 54
24 /// Markup hidden from view: no width, no color. 55 /// Markup hidden from view: no width, no color.
25 var hidden: [NSAttributedString.Key: Any] { 56 var hidden: [NSAttributedString.Key: Any] {
@@ -30,24 +61,35 @@ struct Theme {
30 let style = NSMutableParagraphStyle() 61 let style = NSMutableParagraphStyle()
31 style.firstLineHeadIndent = CGFloat(firstLine) * column 62 style.firstLineHeadIndent = CGFloat(firstLine) * column
32 style.headIndent = CGFloat(wrapped) * column 63 style.headIndent = CGFloat(wrapped) * column
64 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
33 return style 65 return style
34 } 66 }
35 67
36 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) { 68 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 }
37 switch role { 72 switch role {
38 case .heading(let level): 73 case .heading(let level):
39 let size = font.pointSize + CGFloat(max(0, 4 - level)) 74 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
40 storage.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: size, weight: .semibold), range: range) 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))")
41 case .leadingStars: 78 case .leadingStars:
42 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range) 79 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
43 case .todo: 80 case .todo, .done:
44 storage.addAttributes([.foregroundColor: NSColor.systemRed, .font: bold(storage, range)], range: range) 81 let keyword = (storage.string as NSString).substring(with: range)
45 case .done: 82 storage.addAttribute(.font, value: bold(storage, range), range: range)
46 storage.addAttributes([.foregroundColor: NSColor.systemGreen, .font: bold(storage, range)], range: range) 83 if let custom = spec.todo[keyword] {
47 case .priority: 84 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
48 storage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: range) 85 } else {
49 case .tags, .comment, .keyword, .metadata: 86 fg(role == .todo ? "todo" : "done")
50 storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: range) 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")
51 case .bold: 93 case .bold:
52 storage.addAttribute(.font, value: bold(storage, range), range: range) 94 storage.addAttribute(.font, value: bold(storage, range), range: range)
53 case .italic: 95 case .italic:
@@ -57,33 +99,33 @@ struct Theme {
57 case .strikeThrough: 99 case .strikeThrough:
58 storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) 100 storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
59 case .verbatim, .code, .inlineSource: 101 case .verbatim, .code, .inlineSource:
60 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range) 102 if let background = color("inline-background") { storage.addAttribute(.backgroundColor, value: background, range: range) }
61 case .markup, .linkMarkup, .blockDelimiter: 103 fg(role == .verbatim ? "verbatim" : "code")
62 storage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: range) 104 case .markup, .linkMarkup: fg("markup")
105 case .blockDelimiter: fg("block-delimiter")
63 case .link: 106 case .link:
64 storage.addAttributes([.foregroundColor: NSColor.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue], range: range) 107 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
65 case .timestamp: 108 fg("link")
66 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range) 109 case .timestamp: fg("timestamp")
67 case .footnote, .cookie, .target, .macro, .latex: 110 case .footnote, .cookie, .target, .macro, .latex: fg("special")
68 storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range) 111 case .table, .rule: fg("table")
69 case .block, .table, .rule: 112 case .block:
70 break 113 break
71 case .syntax(let category): 114 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) }
73 } 116 }
74 } 117 }
75 118
76 /// Code colors, close to Xcode's default theme. 119 static func slot(_ category: SyntaxCategory) -> String? {
77 static func color(_ category: SyntaxCategory) -> NSColor? {
78 switch category { 120 switch category {
79 case .keyword: .systemPink 121 case .keyword: "syntax-keyword"
80 case .string: .systemRed 122 case .string: "syntax-string"
81 case .comment: .secondaryLabelColor 123 case .comment: "syntax-comment"
82 case .function: .systemBlue 124 case .function: "syntax-function"
83 case .type: .systemTeal 125 case .type: "syntax-type"
84 case .number, .constant, .escape: .systemOrange 126 case .number, .constant, .escape: "syntax-number"
85 case .property, .attribute, .tag: .systemIndigo 127 case .property, .attribute, .tag: "syntax-property"
86 case .label: .systemPurple 128 case .label: "syntax-label"
87 case .variable, .operator, .punctuation: nil 129 case .variable, .operator, .punctuation: nil
88 } 130 }
89 } 131 }
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
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import OrgEditorAppKit 4import OrgEditorAppKit
5import OrgPresentation
5import OrgIndex 6import OrgIndex
6import SwiftUI 7import SwiftUI
7import UniformTypeIdentifiers 8import UniformTypeIdentifiers
@@ -45,6 +46,20 @@ struct ContentView: View {
45 @AppStorage("outlineShown") private var orgOutlineShown = true 46 @AppStorage("outlineShown") private var orgOutlineShown = true
46 @AppStorage("showMarkup") private var showMarkup = false 47 @AppStorage("showMarkup") private var showMarkup = false
47 @AppStorage("showLineNumbers") private var showLineNumbers = true 48 @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 }
48 /// The outline for a file that isn't org, off until asked for. 63 /// The outline for a file that isn't org, off until asked for.
49 @State private var plainOutlineShown = false 64 @State private var plainOutlineShown = false
50 @State private var showingImport = false 65 @State private var showingImport = false
@@ -68,7 +83,7 @@ struct ContentView: View {
68 if let previewPath { 83 if let previewPath {
69 FilePreview(path: previewPath) 84 FilePreview(path: previewPath)
70 } else if session.path != nil { 85 } 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)
72 .safeAreaInset(edge: .bottom, spacing: 0) { 87 .safeAreaInset(edge: .bottom, spacing: 0) {
73 VStack(spacing: 0) { 88 VStack(spacing: 0) {
74 ModeLine(session: session, commands: commands, clock: clock, perform: perform) 89 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
Sources/Orgstar/DevelopmentHooks.swift +4
@@ -7,6 +7,7 @@ import AppKit
7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it. 7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it.
8/// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is 8/// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is
9/// what the snapshot renders. 9/// what the snapshot renders.
10/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's.
10/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main 11/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
11/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording 12/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
12/// permission. 13/// permission.
@@ -19,6 +20,9 @@ enum DevelopmentHooks {
19 @MainActor 20 @MainActor
20 static func scheduleSnapshot() { 21 static func scheduleSnapshot() {
21 let environment = ProcessInfo.processInfo.environment 22 let environment = ProcessInfo.processInfo.environment
23 if let appearance = environment["ORGSTAR_APPEARANCE"] {
24 NSApp.appearance = NSAppearance(named: appearance == "dark" ? .darkAqua : .aqua)
25 }
22 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } 26 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
23 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 27 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
24 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 28 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Sources/Orgstar/EditorView.swift +3
@@ -2,6 +2,7 @@ import AppKit
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import OrgEditorAppKit 4import OrgEditorAppKit
5import OrgPresentation
5import OrgHighlight 6import OrgHighlight
6import SwiftUI 7import SwiftUI
7 8
@@ -19,6 +20,7 @@ struct EditorView: NSViewRepresentable {
19 let options: EditingOptions 20 let options: EditingOptions
20 let showMarkup: Bool 21 let showMarkup: Bool
21 let showLineNumbers: Bool 22 let showLineNumbers: Bool
23 let theme: ThemeSpec
22 let onAppCommand: (String) -> Void 24 let onAppCommand: (String) -> Void
23 let onExternal: (ExternalRequest) -> Void 25 let onExternal: (ExternalRequest) -> Void
24 26
@@ -71,6 +73,7 @@ struct EditorView: NSViewRepresentable {
71 editor.options = options 73 editor.options = options
72 editor.showMarkup = showMarkup 74 editor.showMarkup = showMarkup
73 editor.showLineNumbers = showLineNumbers 75 editor.showLineNumbers = showLineNumbers
76 editor.themeSpec = theme
74 } 77 }
75 container.window?.isDocumentEdited = session.isDirty 78 container.window?.isDocumentEdited = session.isDirty
76 if let request = session.revealRequest, request.id != coordinator.revealID { 79 if let request = session.revealRequest, request.id != coordinator.revealID {
Sources/Orgstar/OrgstarApp.swift +45
@@ -343,11 +343,21 @@ struct SettingsView: View {
343 @AppStorage("reminderLead") private var reminderLead = 12 343 @AppStorage("reminderLead") private var reminderLead = 12
344 @AppStorage("globalCapture") private var globalCapture = true 344 @AppStorage("globalCapture") private var globalCapture = true
345 @AppStorage("settingsTab") private var tab = "general" 345 @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 }
346 355
347 var body: some View { 356 var body: some View {
348 TabView(selection: $tab) { 357 TabView(selection: $tab) {
349 Tab("General", systemImage: "gearshape", value: "general") { general } 358 Tab("General", systemImage: "gearshape", value: "general") { general }
350 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing } 359 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
360 Tab("Appearance", systemImage: "paintpalette", value: "appearance") { appearance }
351 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda } 361 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
352 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture } 362 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
353 } 363 }
@@ -434,6 +444,41 @@ struct SettingsView: View {
434 .frame(height: 450) 444 .frame(height: 450)
435 } 445 }
436 446
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
437 private var agenda: some View { 482 private var agenda: some View {
438 Form { 483 Form {
439 Section { 484 Section {
Tests/OrgAppTests/UserConfigTests.swift +33
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3import OrgPresentation
3import Testing 4import Testing
4@testable import OrgApp 5@testable import OrgApp
5 6
@@ -55,6 +56,14 @@ struct UserConfigTests {
55 == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n") 56 == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n")
56 } 57 }
57 58
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
58 @Test func legacyFilesAreRewritten() throws { 67 @Test func legacyFilesAreRewritten() throws {
59 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") 68 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
60 defer { try? FileManager.default.removeItem(at: folder) } 69 defer { try? FileManager.default.removeItem(at: folder) }
@@ -90,3 +99,27 @@ struct UserConfigTests {
90 #expect(sync.problems.isEmpty) 99 #expect(sync.problems.isEmpty)
91 } 100 }
92} 101}
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 @@
1import AppKit 1import AppKit
2import OrgCore 2import OrgCore
3import OrgPresentation
3import Testing 4import Testing
4@testable import OrgEditorAppKit 5@testable import OrgEditorAppKit
5 6
@@ -91,3 +92,22 @@ struct PrettyEntityTests {
91 #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) == nil) 92 #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) == nil)
92 } 93 }
93} 94}
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 {
130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) 130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
131 } 131 }
132} 132}
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}