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