| @@ -31,12 +31,16 @@ public enum UserConfig { |
| 31 | 31 | public static var configFile: URL { directory.appendingPathComponent("config.toml") } |
| 32 | 32 | |
| 33 | 33 | /// 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]`. |
| 34 | 36 | public struct Setting: Sendable { |
| 35 | 37 | public enum Kind: Sendable { |
| 36 | 38 | case bool, integer, string |
| 37 | 39 | /// A string in the file for each stored value. |
| 38 | 40 | case choice([(name: String, stored: String)]) |
| 39 | 41 | case integerChoice([(name: String, stored: Int)]) |
| 42 | /// `org-agenda-start-day`: `"-3d"` in the file, -3 stored. |
| 43 | case dayOffset |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | 46 | public let section: String |
| @@ -45,47 +49,60 @@ public enum UserConfig { |
| 45 | 49 | public let kind: Kind |
| 46 | 50 | public let fallback: TOML.Value |
| 47 | 51 | 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 } |
| 48 | 56 | } |
| 49 | 57 | |
| 50 | 58 | 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"), |
| 87 | 98 | ] |
| 88 | 99 | |
| 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 | |
| 89 | 106 | // MARK: - Values |
| 90 | 107 | |
| 91 | 108 | /// The file value for what `defaults` holds, or the fallback. |
| @@ -101,6 +118,9 @@ public enum UserConfig { |
| 101 | 118 | case .integerChoice(let choices): |
| 102 | 119 | let stored = defaults.integer(forKey: setting.defaultsKey) |
| 103 | 120 | 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") |
| 104 | 124 | } |
| 105 | 125 | } |
| 106 | 126 | |
| @@ -121,8 +141,11 @@ public enum UserConfig { |
| 121 | 141 | return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))" |
| 122 | 142 | } |
| 123 | 143 | 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) |
| 124 | 147 | default: |
| 125 | | return "\(setting.section).\(setting.key) has the wrong kind of value" |
| 148 | return "\(setting.path) has the wrong kind of value" |
| 126 | 149 | } |
| 127 | 150 | return nil |
| 128 | 151 | } |
| @@ -134,14 +157,15 @@ public enum UserConfig { |
| 134 | 157 | let tables: [TOML.Table] |
| 135 | 158 | do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] } |
| 136 | 159 | 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) { |
| 139 | 162 | if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } |
| 140 | 163 | } |
| 141 | 164 | for table in tables { |
| 142 | 165 | 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)") |
| 145 | 169 | continue |
| 146 | 170 | } |
| 147 | 171 | if let current = currentValue(setting, in: defaults), current == value { continue } |
| @@ -151,6 +175,14 @@ public enum UserConfig { |
| 151 | 175 | return problems |
| 152 | 176 | } |
| 153 | 177 | |
| 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 | |
| 154 | 186 | private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { |
| 155 | 187 | defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) |
| 156 | 188 | } |
| @@ -159,7 +191,7 @@ public enum UserConfig { |
| 159 | 191 | static func values(in text: String) -> [String: TOML.Value] { |
| 160 | 192 | guard let tables = try? TOML.parse(text) else { return [:] } |
| 161 | 193 | 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 } } |
| 163 | 195 | return result |
| 164 | 196 | } |
| 165 | 197 | |
| @@ -180,6 +212,8 @@ public enum UserConfig { |
| 180 | 212 | # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml |
| 181 | 213 | # and saved agenda views in views.toml, all in this folder. |
| 182 | 214 | |
| 215 | # Emacs variables, by their Emacs names. |
| 216 | |
| 183 | 217 | """ |
| 184 | 218 | var section = "" |
| 185 | 219 | for setting in settings { |
| @@ -215,6 +249,9 @@ public enum UserConfig { |
| 215 | 249 | } |
| 216 | 250 | if let lastInSection { |
| 217 | 251 | 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) |
| 218 | 255 | } else { |
| 219 | 256 | if lines.last == "" { lines.removeLast() } |
| 220 | 257 | lines += ["", "[\(section)]", "\(key) = \(format(value))", ""] |