krz/orgstar

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

Commit f00fcfad5a

f00fcfad5a1c851204ac36fe0f968a34a11cadf6

parent: 3dd5b060e7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 19:17 UTC

config.toml: Emacs variable names

Settings Emacs has a variable for sit at the top of config.toml under
that name (fill-column, org-tags-column, org-agenda-start-day as "-3d",
appt-message-warning-time, ...); the rest go in [orgstar]. A file with
the earlier names is read and written again with the new ones.

Layout: unified · split

Sources/OrgApp/ConfigSync.swift +7 −1
@@ -34,6 +34,12 @@ public final class ConfigSync {
3434 try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
3535 if FileManager.default.fileExists(atPath: url.path) {
3636 reload()
37 // A file from before settings took Emacs's names is written again with them.
38 if let text, UserConfig.usesLegacyNames(text) {
39 let template = UserConfig.template(from: defaults)
40 try? template.write(to: url, atomically: true, encoding: .utf8)
41 self.text = template
42 }
3743 } else {
3844 let template = UserConfig.template(from: defaults)
3945 try? template.write(to: url, atomically: true, encoding: .utf8)
@@ -77,7 +83,7 @@ public final class ConfigSync {
7783 let original = current
7884 for setting in UserConfig.settings where defaults.object(forKey: setting.defaultsKey) != nil {
7985 let value = UserConfig.value(setting, in: defaults)
80 if values[setting.section + "." + setting.key] != value {
86 if values[setting.path] != value {
8187 current = UserConfig.setting(current, section: setting.section, key: setting.key, to: value)
8288 }
8389 }
Sources/OrgApp/UserConfig.swift +79 −42
@@ -31,12 +31,16 @@ public enum UserConfig {
3131 public static var configFile: URL { directory.appendingPathComponent("config.toml") }
3232
3333 /// One setting: where it sits in the file, its user-defaults key, and how values convert.
34 /// Settings Emacs has a variable for sit at the top of the file under that variable's
35 /// name; the rest go in `[orgstar]`.
3436 public struct Setting: Sendable {
3537 public enum Kind: Sendable {
3638 case bool, integer, string
3739 /// A string in the file for each stored value.
3840 case choice([(name: String, stored: String)])
3941 case integerChoice([(name: String, stored: Int)])
42 /// `org-agenda-start-day`: `"-3d"` in the file, -3 stored.
43 case dayOffset
4044 }
4145
4246 public let section: String
@@ -45,47 +49,60 @@ public enum UserConfig {
4549 public let kind: Kind
4650 public let fallback: TOML.Value
4751 public let comment: String
52 /// Where earlier versions of the file kept it, `section.key`.
53 public let legacy: String
54
55 public var path: String { section.isEmpty ? key : section + "." + key }
4856 }
4957
5058 public static let settings: [Setting] = [
51 Setting(section: "general", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
52 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)"),
53 Setting(section: "general", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
54 fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml"),
55 Setting(section: "general", key: "option-as-meta", defaultsKey: "optionAsMeta",
56 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"), comment: "left, right, both or none"),
57 Setting(section: "editor", key: "tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77),
58 comment: "org-tags-column: negative aligns tags to end there, 0 puts one space before them"),
59 Setting(section: "editor", key: "insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true),
60 comment: "M-RET adds the new heading after the subtree"),
61 Setting(section: "editor", key: "meta-return-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false),
62 comment: "M-RET splits the line at the caret"),
63 Setting(section: "editor", key: "list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true),
64 comment: "lists can use a. b. c."),
65 Setting(section: "editor", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80), comment: "M-q fills to this column"),
66 Setting(section: "editor", key: "hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true),
67 comment: "org-hide-emphasis-markers: hide *bold* and =code= markers when markup is hidden"),
68 Setting(section: "editor", key: "pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
69 comment: "org-pretty-entities: \\alpha shows as α and x_{1} is lowered when markup is hidden"),
70 Setting(section: "editor", key: "todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"),
71 comment: "for files without #+TODO, in #+TODO syntax; read at launch"),
72 Setting(section: "editor", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
73 comment: "show link brackets and emphasis markers (View ▸ Show Markup)"),
74 Setting(section: "editor", key: "line-numbers", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
75 comment: "View ▸ Show Line Numbers"),
76 Setting(section: "agenda", key: "span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10), comment: "days shown"),
77 Setting(section: "agenda", key: "start-offset", defaultsKey: "agendaStartOffset", kind: .integer, fallback: .integer(-3),
78 comment: "first day, relative to today"),
79 Setting(section: "agenda", key: "include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
80 comment: "read org files in subfolders too"),
81 Setting(section: "agenda", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
82 comment: "notify before timed entries"),
83 Setting(section: "agenda", key: "reminder-lead", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
84 comment: "minutes before; APPT_WARNTIME overrides"),
85 Setting(section: "capture", key: "global-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
86 comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml"),
59 Setting(section: "", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80),
60 comment: "M-q fills to this column", legacy: "editor.fill-column"),
61 Setting(section: "", key: "org-tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77),
62 comment: "negative: tags end at that column; 0: one space before them", legacy: "editor.tags-column"),
63 Setting(section: "", key: "org-insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true),
64 comment: "M-RET adds the new heading after the subtree", legacy: "editor.insert-heading-respect-content"),
65 Setting(section: "", key: "org-M-RET-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false),
66 comment: "M-RET splits the line at the caret", legacy: "editor.meta-return-may-split-line"),
67 Setting(section: "", key: "org-list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true),
68 comment: "lists can use a. b. c.", legacy: "editor.list-allow-alphabetical"),
69 Setting(section: "", key: "org-hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true),
70 comment: "hide *bold* and =code= markers when markup is hidden", legacy: "editor.hide-emphasis-markers"),
71 Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
72 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
73 Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"),
74 comment: "one sequence, in #+TODO syntax, for files without #+TODO; read at launch", legacy: "editor.todo-keywords"),
75 Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
76 comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
77 Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
78 comment: "days the agenda shows", legacy: "agenda.span"),
79 Setting(section: "", key: "org-agenda-start-day", defaultsKey: "agendaStartOffset", kind: .dayOffset, fallback: .string("-3d"),
80 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 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
83 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"),
85 Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
86 fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml", legacy: "general.keymap"),
87 Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
88 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
89 comment: "left, right, both or none", legacy: "general.option-as-meta"),
90 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
91 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
92 Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
93 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
94 Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
95 comment: "notify before timed entries", legacy: "agenda.reminders"),
96 Setting(section: "orgstar", key: "global-capture-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
97 comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml", legacy: "capture.global-hotkey"),
8798 ]
8899
100 /// `"-3d"` as -3; nil for anything else.
101 static func dayOffset(_ s: String) -> Int? {
102 guard s.hasSuffix("d"), let n = Int(s.dropLast().replacingOccurrences(of: "+", with: "")) else { return nil }
103 return n
104 }
105
89106 // MARK: - Values
90107
91108 /// The file value for what `defaults` holds, or the fallback.
@@ -101,6 +118,9 @@ public enum UserConfig {
101118 case .integerChoice(let choices):
102119 let stored = defaults.integer(forKey: setting.defaultsKey)
103120 return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
121 case .dayOffset:
122 let days = defaults.integer(forKey: setting.defaultsKey)
123 return .string((days < 0 ? "" : "+") + "\(days)d")
104124 }
105125 }
106126
@@ -121,8 +141,11 @@ public enum UserConfig {
121141 return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
122142 }
123143 defaults.set(choice.stored, forKey: key)
144 case (.dayOffset, .string(let s)):
145 guard let days = dayOffset(s) else { return "\(setting.key) must look like \"-3d\" or \"+0d\"" }
146 defaults.set(days, forKey: key)
124147 default:
125 return "\(setting.section).\(setting.key) has the wrong kind of value"
148 return "\(setting.path) has the wrong kind of value"
126149 }
127150 return nil
128151 }
@@ -134,14 +157,15 @@ public enum UserConfig {
134157 let tables: [TOML.Table]
135158 do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] }
136159 var problems: [String] = []
137 let present = Set(tables.flatMap { table in table.values.keys.map { table.name + "." + $0 } })
138 for setting in settings where !present.contains(setting.section + "." + setting.key) {
160 let present = Set(tables.flatMap { table in table.values.keys.map { path(table.name, $0) } })
161 for setting in settings where !present.contains(setting.path) && !present.contains(setting.legacy) {
139162 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
140163 }
141164 for table in tables {
142165 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
143 guard let setting = settings.first(where: { $0.section == table.name && $0.key == key }) else {
144 problems.append("config.toml line \(table.line): unknown setting \(table.name.isEmpty ? "" : table.name + ".")\(key)")
166 let name = path(table.name, key)
167 guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
168 problems.append("config.toml: unknown setting \(name)")
145169 continue
146170 }
147171 if let current = currentValue(setting, in: defaults), current == value { continue }
@@ -151,6 +175,14 @@ public enum UserConfig {
151175 return problems
152176 }
153177
178 static func path(_ section: String, _ key: String) -> String { section.isEmpty ? key : section + "." + key }
179
180 /// Whether the file uses names from before settings took Emacs's names.
181 public static func usesLegacyNames(_ text: String) -> Bool {
182 let names = Set(settings.map(\.legacy))
183 return values(in: text).keys.contains { names.contains($0) }
184 }
185
154186 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
155187 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
156188 }
@@ -159,7 +191,7 @@ public enum UserConfig {
159191 static func values(in text: String) -> [String: TOML.Value] {
160192 guard let tables = try? TOML.parse(text) else { return [:] }
161193 var result: [String: TOML.Value] = [:]
162 for table in tables { for (key, value) in table.values { result[table.name + "." + key] = value } }
194 for table in tables { for (key, value) in table.values { result[path(table.name, key)] = value } }
163195 return result
164196 }
165197
@@ -180,6 +212,8 @@ public enum UserConfig {
180212 # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml
181213 # and saved agenda views in views.toml, all in this folder.
182214
215 # Emacs variables, by their Emacs names.
216
183217 """
184218 var section = ""
185219 for setting in settings {
@@ -215,6 +249,9 @@ public enum UserConfig {
215249 }
216250 if let lastInSection {
217251 lines.insert("\(key) = \(format(value))", at: lastInSection + 1)
252 } else if section.isEmpty {
253 let firstHeader = lines.firstIndex { $0.trimmingCharacters(in: .whitespaces).hasPrefix("[") } ?? lines.count
254 lines.insert(contentsOf: ["\(key) = \(format(value))", ""], at: firstHeader)
218255 } else {
219256 if lines.last == "" { lines.removeLast() }
220257 lines += ["", "[\(section)]", "\(key) = \(format(value))", ""]
Tests/OrgAppTests/UserConfigTests.swift +24 −5
@@ -20,6 +20,8 @@ struct UserConfigTests {
2020 source.set(false, forKey: "showLineNumbers")
2121 let text = UserConfig.template(from: source)
2222 #expect(text.contains("keymap = \"doom\""))
23 #expect(text.contains("\nfill-column = 72"))
24 #expect(text.contains("org-agenda-start-day = \"-3d\""))
2325 #expect(text.contains("option-as-meta = \"both\""))
2426 let target = defaults()
2527 #expect(UserConfig.apply(text, to: target).isEmpty)
@@ -32,10 +34,10 @@ struct UserConfigTests {
3234 @Test func problemsAndRemovedLines() {
3335 let target = defaults()
3436 target.set(60, forKey: "fillColumn")
35 let problems = UserConfig.apply("[general]\nkeymap = \"vim\"\ncolour = 1\n[editor]\nfill-column = \"wide\"\n", to: target)
36 #expect(problems.count == 3)
37 let problems = UserConfig.apply("fill-column = \"wide\"\norg-agenda-start-day = \"soon\"\n[orgstar]\nkeymap = \"vim\"\ncolour = 1\n", to: target)
38 #expect(problems.count == 4)
3739 #expect(target.integer(forKey: "fillColumn") == 60)
38 #expect(UserConfig.apply("[editor]\n", to: target).isEmpty)
40 #expect(UserConfig.apply("[orgstar]\n", to: target).isEmpty)
3941 #expect(target.object(forKey: "fillColumn") == nil)
4042 }
4143
@@ -49,6 +51,21 @@ struct UserConfigTests {
4951 == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n\n[capture]\nglobal-hotkey = false\n")
5052 #expect(UserConfig.setting("[general]\nkeymap = \"emacs#1\" # c\n", section: "general", key: "keymap", to: .string("doom"))
5153 == "[general]\nkeymap = \"doom\" # c\n")
54 #expect(UserConfig.setting("# top\n\n[orgstar]\nsave = \"explicit\"\n", section: "", key: "fill-column", to: .integer(70))
55 == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n")
56 }
57
58 @Test func legacyFilesAreRewritten() throws {
59 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
60 defer { try? FileManager.default.removeItem(at: folder) }
61 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
62 let url = folder.appendingPathComponent("config.toml")
63 try "[editor]\nfill-column = 64\n[general]\nkeymap = \"doom\"\n".write(to: url, atomically: true, encoding: .utf8)
64 let store = defaults()
65 ConfigSync(url: url, defaults: store).start(watch: false)
66 #expect(store.integer(forKey: "fillColumn") == 64)
67 let text = try String(contentsOf: url, encoding: .utf8)
68 #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]"))
5269 }
5370
5471 @Test func syncsBothWays() throws {
@@ -58,16 +75,18 @@ struct UserConfigTests {
5875 let store = defaults()
5976 let sync = ConfigSync(url: url, defaults: store)
6077 sync.start(watch: false)
61 #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 80"))
78 #expect(try String(contentsOf: url, encoding: .utf8).contains("\nfill-column = 80"))
6279
6380 store.set(66, forKey: "fillColumn")
6481 #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 66"))
6582
6683 var text = try String(contentsOf: url, encoding: .utf8)
67 text = text.replacingOccurrences(of: "span = 10", with: "span = 7")
84 text = text.replacingOccurrences(of: "org-agenda-span = 10", with: "org-agenda-span = 7")
85 .replacingOccurrences(of: "org-agenda-start-day = \"-3d\"", with: "org-agenda-start-day = \"+0d\"")
6886 try text.write(to: url, atomically: true, encoding: .utf8)
6987 sync.reload()
7088 #expect(store.integer(forKey: "agendaSpan") == 7)
89 #expect(store.integer(forKey: "agendaStartOffset") == 0)
7190 #expect(sync.problems.isEmpty)
7291 }
7392}