Settings in config.toml !46
10 files changed, +448 −4
Layout: unified · split
Sources/OrgApp/AgendaViews.swift +1 −1
| @@ -36,7 +36,7 @@ public struct AgendaViewDefinition: Hashable, Sendable, Identifiable { | |||
| 36 | /// (`WAIT|HOLD`). | 36 | /// (`WAIT|HOLD`). |
| 37 | @MainActor | 37 | @MainActor |
| 38 | public enum AgendaViewLoader { | 38 | public enum AgendaViewLoader { |
| 39 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("views.toml") } | 39 | public static var userFile: URL { UserConfig.file("views.toml") } |
| 40 | 40 | ||
| 41 | public static let builtIn = [ | 41 | public static let builtIn = [ |
| 42 | AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)), | 42 | AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)), |
Sources/OrgApp/CaptureModel.swift +1 −1
| @@ -17,7 +17,7 @@ import OrgDocument | |||
| 17 | /// ``` | 17 | /// ``` |
| 18 | @MainActor | 18 | @MainActor |
| 19 | public enum CaptureTemplates { | 19 | public enum CaptureTemplates { |
| 20 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("capture.toml") } | 20 | public static var userFile: URL { UserConfig.file("capture.toml") } |
| 21 | 21 | ||
| 22 | /// The templates in your Doom configuration. | 22 | /// The templates in your Doom configuration. |
| 23 | public static let defaults = [ | 23 | public static let defaults = [ |
Sources/OrgApp/ConfigSync.swift added +89
| @@ -0,0 +1,89 @@ | |||
| 1 | #if os(macOS) | ||
| 2 | import Foundation | ||
| 3 | import OrgCore | ||
| 4 | import OrgWorkspace | ||
| 5 | |||
| 6 | /// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch | ||
| 7 | /// and whenever it changes on disk, and a change made in the app is written into the file. A | ||
| 8 | /// missing file is created with every setting at its current value. | ||
| 9 | @MainActor | ||
| 10 | @Observable | ||
| 11 | public final class ConfigSync { | ||
| 12 | public static let shared = ConfigSync() | ||
| 13 | |||
| 14 | /// Problems in the file, from the last time it was read. | ||
| 15 | public private(set) var problems: [String] = [] | ||
| 16 | /// The last file of the configuration folder that changed, with a serial number. | ||
| 17 | public private(set) var changedFile: (name: String, serial: Int)? | ||
| 18 | |||
| 19 | private let url: URL | ||
| 20 | private let defaults: UserDefaults | ||
| 21 | @ObservationIgnored private var text: String? | ||
| 22 | @ObservationIgnored private var applying = false | ||
| 23 | @ObservationIgnored private var watcher: FSEventsWatcher? | ||
| 24 | @ObservationIgnored private var observer: NSObjectProtocol? | ||
| 25 | @ObservationIgnored private var serial = 0 | ||
| 26 | |||
| 27 | public init(url: URL = UserConfig.configFile, defaults: UserDefaults = .standard) { | ||
| 28 | self.url = url | ||
| 29 | self.defaults = defaults | ||
| 30 | } | ||
| 31 | |||
| 32 | public func start(watch: Bool = true) { | ||
| 33 | guard observer == nil else { return } | ||
| 34 | try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 35 | if FileManager.default.fileExists(atPath: url.path) { | ||
| 36 | reload() | ||
| 37 | } else { | ||
| 38 | let template = UserConfig.template(from: defaults) | ||
| 39 | try? template.write(to: url, atomically: true, encoding: .utf8) | ||
| 40 | text = template | ||
| 41 | } | ||
| 42 | observer = NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: defaults, queue: .main) { [weak self] _ in | ||
| 43 | MainActor.assumeIsolated { self?.writeBack() } | ||
| 44 | } | ||
| 45 | guard watch else { return } | ||
| 46 | let directory = url.deletingLastPathComponent() | ||
| 47 | watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in | ||
| 48 | let names = Set(events.compactMap { event -> String? in | ||
| 49 | if case .changed(let url) = event { return url.lastPathComponent } | ||
| 50 | return nil | ||
| 51 | }) | ||
| 52 | Task { @MainActor [weak self] in | ||
| 53 | guard let self else { return } | ||
| 54 | for name in names.sorted() { | ||
| 55 | if name == self.url.lastPathComponent { self.reload() } | ||
| 56 | self.serial += 1 | ||
| 57 | self.changedFile = (name, self.serial) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | watcher?.start() | ||
| 62 | } | ||
| 63 | |||
| 64 | /// Reads the file and applies it when it changed since the last read or write. | ||
| 65 | public func reload() { | ||
| 66 | guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return } | ||
| 67 | text = new | ||
| 68 | applying = true | ||
| 69 | problems = UserConfig.apply(new, to: defaults) | ||
| 70 | applying = false | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Writes settings that differ from the file into it. | ||
| 74 | func writeBack() { | ||
| 75 | guard !applying, var current = text ?? (try? String(contentsOf: url, encoding: .utf8)) else { return } | ||
| 76 | let values = UserConfig.values(in: current) | ||
| 77 | let original = current | ||
| 78 | for setting in UserConfig.settings where defaults.object(forKey: setting.defaultsKey) != nil { | ||
| 79 | let value = UserConfig.value(setting, in: defaults) | ||
| 80 | if values[setting.section + "." + setting.key] != value { | ||
| 81 | current = UserConfig.setting(current, section: setting.section, key: setting.key, to: value) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | guard current != original else { return } | ||
| 85 | text = current | ||
| 86 | try? current.write(to: url, atomically: true, encoding: .utf8) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | #endif | ||
Sources/OrgApp/KeymapLoader.swift +1 −1
| @@ -4,7 +4,7 @@ import OrgCore | |||
| 4 | @MainActor | 4 | @MainActor |
| 5 | public enum KeymapLoader { | 5 | public enum KeymapLoader { |
| 6 | /// `<data dir>/keymap.toml`. | 6 | /// `<data dir>/keymap.toml`. |
| 7 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("keymap.toml") } | 7 | public static var userFile: URL { UserConfig.file("keymap.toml") } |
| 8 | 8 | ||
| 9 | /// The preset with the user's file on top, and the problems found in the file. A missing | 9 | /// The preset with the user's file on top, and the problems found in the file. A missing |
| 10 | /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. | 10 | /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. |
Sources/OrgApp/UserConfig.swift added +242
| @@ -0,0 +1,242 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | |||
| 4 | /// 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 | /// dotfiles repository and share. The file is the source of truth: Settings writes its changes | ||
| 7 | /// into it, keeping comments and order, and edits to it apply while the app runs. | ||
| 8 | public enum UserConfig { | ||
| 9 | public static var directory: URL { | ||
| 10 | let environment = ProcessInfo.processInfo.environment | ||
| 11 | if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) } | ||
| 12 | // Development runs keep everything in their data folder. | ||
| 13 | if let data = environment["ORGSTAR_DATA_DIR"] { return URL(fileURLWithPath: data) } | ||
| 14 | if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty { | ||
| 15 | return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar") | ||
| 16 | } | ||
| 17 | return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/orgstar") | ||
| 18 | } | ||
| 19 | |||
| 20 | /// A file of the configuration folder; one that exists only where earlier versions kept | ||
| 21 | /// it (Application Support) is still read from there. | ||
| 22 | public static func file(_ name: String) -> URL { | ||
| 23 | let url = directory.appendingPathComponent(name) | ||
| 24 | let legacy = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | ||
| 25 | .appendingPathComponent("Orgstar").appendingPathComponent(name) | ||
| 26 | let manager = FileManager.default | ||
| 27 | if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy } | ||
| 28 | return url | ||
| 29 | } | ||
| 30 | |||
| 31 | public static var configFile: URL { directory.appendingPathComponent("config.toml") } | ||
| 32 | |||
| 33 | /// One setting: where it sits in the file, its user-defaults key, and how values convert. | ||
| 34 | public struct Setting: Sendable { | ||
| 35 | public enum Kind: Sendable { | ||
| 36 | case bool, integer, string | ||
| 37 | /// A string in the file for each stored value. | ||
| 38 | case choice([(name: String, stored: String)]) | ||
| 39 | case integerChoice([(name: String, stored: Int)]) | ||
| 40 | } | ||
| 41 | |||
| 42 | public let section: String | ||
| 43 | public let key: String | ||
| 44 | public let defaultsKey: String | ||
| 45 | public let kind: Kind | ||
| 46 | public let fallback: TOML.Value | ||
| 47 | public let comment: String | ||
| 48 | } | ||
| 49 | |||
| 50 | 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"), | ||
| 87 | ] | ||
| 88 | |||
| 89 | // MARK: - Values | ||
| 90 | |||
| 91 | /// The file value for what `defaults` holds, or the fallback. | ||
| 92 | public static func value(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value { | ||
| 93 | guard defaults.object(forKey: setting.defaultsKey) != nil else { return setting.fallback } | ||
| 94 | switch setting.kind { | ||
| 95 | case .bool: return .bool(defaults.bool(forKey: setting.defaultsKey)) | ||
| 96 | case .integer: return .integer(defaults.integer(forKey: setting.defaultsKey)) | ||
| 97 | case .string: return .string(defaults.string(forKey: setting.defaultsKey) ?? "") | ||
| 98 | case .choice(let choices): | ||
| 99 | let stored = defaults.string(forKey: setting.defaultsKey) | ||
| 100 | return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback | ||
| 101 | case .integerChoice(let choices): | ||
| 102 | let stored = defaults.integer(forKey: setting.defaultsKey) | ||
| 103 | return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Stores a file value; a value of the wrong kind is a problem. | ||
| 108 | static func store(_ value: TOML.Value, _ setting: Setting, in defaults: UserDefaults) -> String? { | ||
| 109 | let key = setting.defaultsKey | ||
| 110 | switch (setting.kind, value) { | ||
| 111 | case (.bool, .bool(let b)): defaults.set(b, forKey: key) | ||
| 112 | case (.integer, .integer(let i)): defaults.set(i, forKey: key) | ||
| 113 | case (.string, .string(let s)): defaults.set(s, forKey: key) | ||
| 114 | case (.choice(let choices), .string(let s)): | ||
| 115 | guard let choice = choices.first(where: { $0.name == s }) else { | ||
| 116 | return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))" | ||
| 117 | } | ||
| 118 | defaults.set(choice.stored, forKey: key) | ||
| 119 | case (.integerChoice(let choices), .string(let s)): | ||
| 120 | guard let choice = choices.first(where: { $0.name == s }) else { | ||
| 121 | return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))" | ||
| 122 | } | ||
| 123 | defaults.set(choice.stored, forKey: key) | ||
| 124 | default: | ||
| 125 | return "\(setting.section).\(setting.key) has the wrong kind of value" | ||
| 126 | } | ||
| 127 | return nil | ||
| 128 | } | ||
| 129 | |||
| 130 | /// Applies a file to `defaults`; settings it leaves out go back to their defaults. | ||
| 131 | /// Returns problems. | ||
| 132 | @discardableResult | ||
| 133 | public static func apply(_ text: String, to defaults: UserDefaults) -> [String] { | ||
| 134 | let tables: [TOML.Table] | ||
| 135 | do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] } | ||
| 136 | 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) { | ||
| 139 | if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } | ||
| 140 | } | ||
| 141 | for table in tables { | ||
| 142 | 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)") | ||
| 145 | continue | ||
| 146 | } | ||
| 147 | if let current = currentValue(setting, in: defaults), current == value { continue } | ||
| 148 | if let problem = store(value, setting, in: defaults) { problems.append("config.toml: \(problem)") } | ||
| 149 | } | ||
| 150 | } | ||
| 151 | return problems | ||
| 152 | } | ||
| 153 | |||
| 154 | private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { | ||
| 155 | defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) | ||
| 156 | } | ||
| 157 | |||
| 158 | /// The values a file sets, by `section.key`. | ||
| 159 | static func values(in text: String) -> [String: TOML.Value] { | ||
| 160 | guard let tables = try? TOML.parse(text) else { return [:] } | ||
| 161 | var result: [String: TOML.Value] = [:] | ||
| 162 | for table in tables { for (key, value) in table.values { result[table.name + "." + key] = value } } | ||
| 163 | return result | ||
| 164 | } | ||
| 165 | |||
| 166 | // MARK: - Text | ||
| 167 | |||
| 168 | static func format(_ value: TOML.Value) -> String { | ||
| 169 | switch value { | ||
| 170 | case .bool(let b): b ? "true" : "false" | ||
| 171 | case .integer(let i): String(i) | ||
| 172 | case .string(let s): "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 176 | /// A commented file with every setting at its current value. | ||
| 177 | public static func template(from defaults: UserDefaults) -> String { | ||
| 178 | var out = """ | ||
| 179 | # Orgstar settings. The Settings window writes its changes here, and edits made here | ||
| 180 | # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml | ||
| 181 | # and saved agenda views in views.toml, all in this folder. | ||
| 182 | |||
| 183 | """ | ||
| 184 | var section = "" | ||
| 185 | for setting in settings { | ||
| 186 | if setting.section != section { | ||
| 187 | section = setting.section | ||
| 188 | out += "\n[\(section)]\n" | ||
| 189 | } | ||
| 190 | out += "\(setting.key) = \(format(value(setting, in: defaults))) # \(setting.comment)\n" | ||
| 191 | } | ||
| 192 | return out | ||
| 193 | } | ||
| 194 | |||
| 195 | /// `text` with `section.key` set to `value`: the line's value is replaced (its comment | ||
| 196 | /// stays), or a line is added at the end of the section, or the section at the end. | ||
| 197 | public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String { | ||
| 198 | var lines = text.components(separatedBy: "\n") | ||
| 199 | var current = "" | ||
| 200 | var lastInSection: Int? | ||
| 201 | for (index, line) in lines.enumerated() { | ||
| 202 | let trimmed = line.trimmingCharacters(in: .whitespaces) | ||
| 203 | if trimmed.hasPrefix("[") && !trimmed.hasPrefix("[[") { | ||
| 204 | current = String(trimmed.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) | ||
| 205 | if current == section { lastInSection = index } | ||
| 206 | continue | ||
| 207 | } | ||
| 208 | guard current == section else { continue } | ||
| 209 | if !trimmed.isEmpty { lastInSection = index } | ||
| 210 | guard let equals = line.firstIndex(of: "="), | ||
| 211 | line[..<equals].trimmingCharacters(in: .whitespaces) == key else { continue } | ||
| 212 | let comment = commentStart(line, after: equals).map { " " + line[$0...] } ?? "" | ||
| 213 | lines[index] = line[..<equals] + "= " + format(value) + comment | ||
| 214 | return lines.joined(separator: "\n") | ||
| 215 | } | ||
| 216 | if let lastInSection { | ||
| 217 | lines.insert("\(key) = \(format(value))", at: lastInSection + 1) | ||
| 218 | } else { | ||
| 219 | if lines.last == "" { lines.removeLast() } | ||
| 220 | lines += ["", "[\(section)]", "\(key) = \(format(value))", ""] | ||
| 221 | } | ||
| 222 | return lines.joined(separator: "\n") | ||
| 223 | } | ||
| 224 | |||
| 225 | /// Where a `#` comment starts after the value, outside quotes. | ||
| 226 | private static func commentStart(_ line: String, after equals: String.Index) -> String.Index? { | ||
| 227 | var quote: Character? | ||
| 228 | var index = line.index(after: equals) | ||
| 229 | while index < line.endIndex { | ||
| 230 | let c = line[index] | ||
| 231 | if let q = quote { | ||
| 232 | if c == "\\" && q == "\"" { index = line.index(after: index) } else if c == q { quote = nil } | ||
| 233 | } else if c == "\"" || c == "'" { | ||
| 234 | quote = c | ||
| 235 | } else if c == "#" { | ||
| 236 | return index | ||
| 237 | } | ||
| 238 | if index < line.endIndex { index = line.index(after: index) } | ||
| 239 | } | ||
| 240 | return nil | ||
| 241 | } | ||
| 242 | } | ||
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -42,6 +42,7 @@ public enum EditorCommands { | |||
| 42 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), | 42 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), |
| 43 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), | 43 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 44 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), | 44 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
| 45 | EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), | ||
| 45 | EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app), | 46 | EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app), |
| 46 | EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), | 47 | EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), |
| 47 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), | 48 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), |
Sources/Orgstar/ContentView.swift +9
| @@ -197,6 +197,14 @@ struct ContentView: View { | |||
| 197 | .onChange(of: commands.runRequest?.serial) { | 197 | .onChange(of: commands.runRequest?.serial) { |
| 198 | if let id = commands.runRequest?.id { perform(id) } | 198 | if let id = commands.runRequest?.id { perform(id) } |
| 199 | } | 199 | } |
| 200 | .onChange(of: ConfigSync.shared.problems, initial: true) { _, problems in | ||
| 201 | if let first = problems.first { | ||
| 202 | commands.show(first + (problems.count > 1 ? " (and \(problems.count - 1) more)" : "")) | ||
| 203 | } | ||
| 204 | } | ||
| 205 | .onChange(of: ConfigSync.shared.changedFile?.serial) { | ||
| 206 | if ConfigSync.shared.changedFile?.name == "keymap.toml" { loadKeymap() } | ||
| 207 | } | ||
| 200 | .onChange(of: commands.jumpRequest?.id) { | 208 | .onChange(of: commands.jumpRequest?.id) { |
| 201 | guard let request = commands.jumpRequest else { return } | 209 | guard let request = commands.jumpRequest else { return } |
| 202 | if request.path != session.path { | 210 | if request.path != session.path { |
| @@ -386,6 +394,7 @@ struct ContentView: View { | |||
| 386 | case "app.toggle-outline": toggleOutline() | 394 | case "app.toggle-outline": toggleOutline() |
| 387 | case "app.toggle-markup": showMarkup.toggle() | 395 | case "app.toggle-markup": showMarkup.toggle() |
| 388 | case "app.toggle-line-numbers": showLineNumbers.toggle() | 396 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
| 397 | case "app.edit-config": commands.jump(path: UserConfig.configFile.path, offset: 0) | ||
| 389 | case "app.quick-open": commands.showingQuickOpen = true | 398 | case "app.quick-open": commands.showingQuickOpen = true |
| 390 | case "app.palette": commands.showingPalette = true | 399 | case "app.palette": commands.showingPalette = true |
| 391 | case "app.search": commands.focusSearch = true | 400 | case "app.search": commands.focusSearch = true |
Sources/Orgstar/OrgstarApp.swift +27 −1
| @@ -245,8 +245,14 @@ struct OrgstarApp: App { | |||
| 245 | OrgMenuItems(commands: commands) | 245 | OrgMenuItems(commands: commands) |
| 246 | } | 246 | } |
| 247 | } | 247 | } |
| 248 | .commands { | ||
| 249 | CommandGroup(after: .appSettings) { | ||
| 250 | Button("Edit Config File") { commands.jump(path: UserConfig.configFile.path, offset: 0) } | ||
| 251 | .keyboardShortcut(",", modifiers: [.command, .option]) | ||
| 252 | } | ||
| 253 | } | ||
| 248 | Settings { | 254 | Settings { |
| 249 | SettingsView() | 255 | SettingsView(commands: commands) |
| 250 | } | 256 | } |
| 251 | } | 257 | } |
| 252 | } | 258 | } |
| @@ -315,6 +321,8 @@ struct AgendaMenuItem: View { | |||
| 315 | } | 321 | } |
| 316 | 322 | ||
| 317 | struct SettingsView: View { | 323 | struct SettingsView: View { |
| 324 | let commands: AppCommands | ||
| 325 | @Environment(\.openWindow) private var openWindow | ||
| 318 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 326 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 319 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | 327 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| 320 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue | 328 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue |
| @@ -344,8 +352,26 @@ struct SettingsView: View { | |||
| 344 | .frame(width: 600) | 352 | .frame(width: 600) |
| 345 | } | 353 | } |
| 346 | 354 | ||
| 355 | private func editConfig() { | ||
| 356 | ConfigSync.shared.start() | ||
| 357 | openWindow(id: "main") | ||
| 358 | commands.jump(path: UserConfig.configFile.path, offset: 0) | ||
| 359 | } | ||
| 360 | |||
| 347 | private var general: some View { | 361 | private var general: some View { |
| 348 | Form { | 362 | Form { |
| 363 | Section { | ||
| 364 | LabeledContent("config.toml") { | ||
| 365 | HStack { | ||
| 366 | Button("Edit in Orgstar") { editConfig() } | ||
| 367 | Button("Open with Default App") { NSWorkspace.shared.open(UserConfig.configFile) } | ||
| 368 | Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) } | ||
| 369 | } | ||
| 370 | } | ||
| 371 | } footer: { | ||
| 372 | Text("Every setting here is also in \(UserConfig.configFile.path), which you can edit, share or keep with your dotfiles. Changes in either place show up in the other.") | ||
| 373 | .settingsFooter() | ||
| 374 | } | ||
| 349 | Section { | 375 | Section { |
| 350 | Picker("Save files", selection: $saveMode) { | 376 | Picker("Save files", selection: $saveMode) { |
| 351 | Text("Automatically, when typing stops").tag(SaveMode.automatic) | 377 | Text("Automatically, when typing stops").tag(SaveMode.automatic) |
Sources/Orgstar/Preferences.swift +4
| @@ -1,4 +1,5 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import OrgApp | ||
| 2 | import OrgCore | 3 | import OrgCore |
| 3 | 4 | ||
| 4 | /// Org preferences, stored in user defaults by the Settings window. | 5 | /// Org preferences, stored in user defaults by the Settings window. |
| @@ -6,7 +7,10 @@ enum Preferences { | |||
| 6 | static let defaultTodoKeywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" | 7 | static let defaultTodoKeywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" |
| 7 | 8 | ||
| 8 | /// TODO keywords for files without `#+TODO`, read at launch. | 9 | /// TODO keywords for files without `#+TODO`, read at launch. |
| 10 | @MainActor | ||
| 9 | static var orgSettings: OrgSettings { | 11 | static var orgSettings: OrgSettings { |
| 12 | // config.toml applies before anything reads settings. | ||
| 13 | ConfigSync.shared.start() | ||
| 10 | let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords | 14 | let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords |
| 11 | return OrgSettings.default.withTodoKeywords(line) ?? .default | 15 | return OrgSettings.default.withTodoKeywords(line) ?? .default |
| 12 | } | 16 | } |
Tests/OrgAppTests/UserConfigTests.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgApp | ||
| 5 | |||
| 6 | @MainActor | ||
| 7 | struct UserConfigTests { | ||
| 8 | func defaults() -> UserDefaults { | ||
| 9 | let name = "orgstar-config-\(UUID().uuidString)" | ||
| 10 | let defaults = UserDefaults(suiteName: name)! | ||
| 11 | defaults.removePersistentDomain(forName: name) | ||
| 12 | return defaults | ||
| 13 | } | ||
| 14 | |||
| 15 | @Test func templateRoundTrips() { | ||
| 16 | let source = defaults() | ||
| 17 | source.set("doom", forKey: "keymap") | ||
| 18 | source.set(3, forKey: "optionAsMeta") | ||
| 19 | source.set(72, forKey: "fillColumn") | ||
| 20 | source.set(false, forKey: "showLineNumbers") | ||
| 21 | let text = UserConfig.template(from: source) | ||
| 22 | #expect(text.contains("keymap = \"doom\"")) | ||
| 23 | #expect(text.contains("option-as-meta = \"both\"")) | ||
| 24 | let target = defaults() | ||
| 25 | #expect(UserConfig.apply(text, to: target).isEmpty) | ||
| 26 | #expect(target.string(forKey: "keymap") == "doom") | ||
| 27 | #expect(target.integer(forKey: "optionAsMeta") == 3) | ||
| 28 | #expect(target.integer(forKey: "fillColumn") == 72) | ||
| 29 | #expect(target.bool(forKey: "showLineNumbers") == false) | ||
| 30 | } | ||
| 31 | |||
| 32 | @Test func problemsAndRemovedLines() { | ||
| 33 | let target = defaults() | ||
| 34 | 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 | #expect(target.integer(forKey: "fillColumn") == 60) | ||
| 38 | #expect(UserConfig.apply("[editor]\n", to: target).isEmpty) | ||
| 39 | #expect(target.object(forKey: "fillColumn") == nil) | ||
| 40 | } | ||
| 41 | |||
| 42 | @Test func editsKeepCommentsAndOrder() { | ||
| 43 | let text = "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n" | ||
| 44 | #expect(UserConfig.setting(text, section: "editor", key: "fill-column", to: .integer(72)) | ||
| 45 | == "# mine\n[editor]\nfill-column = 72 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n") | ||
| 46 | #expect(UserConfig.setting(text, section: "editor", key: "show-markup", to: .bool(true)) | ||
| 47 | == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\nshow-markup = true\n\n[agenda]\nspan = 10\n") | ||
| 48 | #expect(UserConfig.setting(text, section: "capture", key: "global-hotkey", to: .bool(false)) | ||
| 49 | == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n\n[capture]\nglobal-hotkey = false\n") | ||
| 50 | #expect(UserConfig.setting("[general]\nkeymap = \"emacs#1\" # c\n", section: "general", key: "keymap", to: .string("doom")) | ||
| 51 | == "[general]\nkeymap = \"doom\" # c\n") | ||
| 52 | } | ||
| 53 | |||
| 54 | @Test func syncsBothWays() throws { | ||
| 55 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") | ||
| 56 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 57 | let url = folder.appendingPathComponent("config.toml") | ||
| 58 | let store = defaults() | ||
| 59 | let sync = ConfigSync(url: url, defaults: store) | ||
| 60 | sync.start(watch: false) | ||
| 61 | #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 80")) | ||
| 62 | |||
| 63 | store.set(66, forKey: "fillColumn") | ||
| 64 | #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 66")) | ||
| 65 | |||
| 66 | var text = try String(contentsOf: url, encoding: .utf8) | ||
| 67 | text = text.replacingOccurrences(of: "span = 10", with: "span = 7") | ||
| 68 | try text.write(to: url, atomically: true, encoding: .utf8) | ||
| 69 | sync.reload() | ||
| 70 | #expect(store.integer(forKey: "agendaSpan") == 7) | ||
| 71 | #expect(sync.problems.isEmpty) | ||
| 72 | } | ||
| 73 | } | ||