krz/orgstar

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

Commit 0cfdac07f7

0cfdac07f7253b4bc92725e377a834511f786697

parent: b5449dec59

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 20:29 UTC

Themes

ThemeSpec holds a color per kind of text (headings by level, TODO and
DONE, links, timestamps, tags, code and verbatim, markup, comments,
metadata, blocks, tables, line numbers, background, cursor, selection
and code token types) for light and dark appearance, and colors per
TODO keyword. The default theme follows GitHub's light and dark
themes. config.toml's [theme] sets font, size, line spacing, heading
size step and an optional theme-file; colors go under [theme],
[theme.light], [theme.dark] and [theme.todo], over the theme file and
the default. default-theme.toml is written beside config.toml as a
reference. Settings has an Appearance tab. A config.toml without a
section gets it added. ORGSTAR_APPEARANCE forces light or dark.

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}