Commit 1d5593eadf
Verified · cmc
Layout: unified · split
Sources/OrgApp/KeymapLoader.swift +4 −3
| @@ -7,12 +7,13 @@ public enum KeymapLoader { | |||
| 7 | public static var userFile: URL { UserConfig.file("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. With |
| 11 | public static func load(_ preset: KeymapPreset, userFile: URL = userFile) -> (keymap: Keymap, problems: [String]) { | 11 | /// `commands`, bindings of other command ids are reported and skipped. |
| 12 | public static func load(_ preset: KeymapPreset, userFile: URL = userFile, commands: Set<String>? = nil) -> (keymap: Keymap, problems: [String]) { | ||
| 12 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } | 13 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } |
| 13 | var problems: [String] = [] | 14 | var problems: [String] = [] |
| 14 | do { | 15 | do { |
| 15 | let user = try Keymap(toml: text, problems: &problems) | 16 | let user = try Keymap(toml: text, problems: &problems, commands: commands) |
| 16 | return (.layered([preset.keymap, user]), problems) | 17 | return (.layered([preset.keymap, user]), problems) |
| 17 | } catch { | 18 | } catch { |
| 18 | return (preset.keymap, ["\(error)"]) | 19 | return (preset.keymap, ["\(error)"]) |
Sources/OrgCore/Keymap/Keymap.swift +16 −3
| @@ -17,6 +17,9 @@ public struct KeyBinding: Sendable, Equatable { | |||
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | public var unbinds: Bool { command == "none" } | 19 | public var unbinds: Bool { command == "none" } |
| 20 | |||
| 21 | /// Modal states a binding's `mode` can name. | ||
| 22 | public static let modes: Set<String> = ["normal", "insert", "visual"] | ||
| 20 | } | 23 | } |
| 21 | 24 | ||
| 22 | /// Bindings in priority order: later bindings win over earlier ones for the same keys. | 25 | /// Bindings in priority order: later bindings win over earlier ones for the same keys. |
| @@ -29,8 +32,9 @@ public struct Keymap: Sendable, Equatable { | |||
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in | 34 | /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in |
| 32 | /// `problems`; a file that isn't valid TOML throws. | 35 | /// `problems`; a file that isn't valid TOML throws. With `commands`, a command id outside |
| 33 | public init(toml: String, problems: inout [String]) throws { | 36 | /// it (other than `none`) is such an entry. |
| 37 | public init(toml: String, problems: inout [String], commands: Set<String>? = nil) throws { | ||
| 34 | bindings = [] | 38 | bindings = [] |
| 35 | for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { | 39 | for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { |
| 36 | guard table.name == "bind", table.isArrayElement else { | 40 | guard table.name == "bind", table.isArrayElement else { |
| @@ -45,12 +49,21 @@ public struct Keymap: Sendable, Equatable { | |||
| 45 | problems.append("line \(table.line): missing command") | 49 | problems.append("line \(table.line): missing command") |
| 46 | continue | 50 | continue |
| 47 | } | 51 | } |
| 52 | if let commands, command != "none", !commands.contains(command) { | ||
| 53 | problems.append("line \(table.line): unknown command \(command)") | ||
| 54 | continue | ||
| 55 | } | ||
| 48 | let when = table.values["when"]?.string | 56 | let when = table.values["when"]?.string |
| 49 | if let when, !KeyContext.names.contains(when) { | 57 | if let when, !KeyContext.names.contains(when) { |
| 50 | problems.append("line \(table.line): unknown context \(when)") | 58 | problems.append("line \(table.line): unknown context \(when)") |
| 51 | continue | 59 | continue |
| 52 | } | 60 | } |
| 53 | bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: table.values["mode"]?.string)) | 61 | let mode = table.values["mode"]?.string |
| 62 | if let mode, !KeyBinding.modes.contains(mode) { | ||
| 63 | problems.append("line \(table.line): unknown mode \(mode)") | ||
| 64 | continue | ||
| 65 | } | ||
| 66 | bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: mode)) | ||
| 54 | } | 67 | } |
| 55 | } | 68 | } |
| 56 | 69 | ||
Sources/Orgstar/ContentView.swift +1 −1
| @@ -498,7 +498,7 @@ struct ContentView: View { | |||
| 498 | } | 498 | } |
| 499 | 499 | ||
| 500 | private func loadKeymap() { | 500 | private func loadKeymap() { |
| 501 | let loaded = KeymapLoader.load(preset) | 501 | let loaded = KeymapLoader.load(preset, commands: Set(EditorCommands.all.map(\.id))) |
| 502 | keymap = loaded.keymap | 502 | keymap = loaded.keymap |
| 503 | commands.keymap = loaded.keymap | 503 | commands.keymap = loaded.keymap |
| 504 | commands.modal = preset.isModal | 504 | commands.modal = preset.isModal |
Tests/OrgCoreTests/KeymapTests.swift +27
| @@ -71,6 +71,33 @@ struct KeymapTests { | |||
| 71 | #expect(problems.count == 3) | 71 | #expect(problems.count == 3) |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | @Test func reportsUnknownModesAndCommands() throws { | ||
| 75 | var problems: [String] = [] | ||
| 76 | let keymap = try Keymap(toml: """ | ||
| 77 | [[bind]] | ||
| 78 | keys = "g x" | ||
| 79 | command = "org.todo.cycle" | ||
| 80 | mode = "visual-line" | ||
| 81 | [[bind]] | ||
| 82 | keys = "g y" | ||
| 83 | command = "org.todo.cycel" | ||
| 84 | mode = "normal" | ||
| 85 | [[bind]] | ||
| 86 | keys = "g z" | ||
| 87 | command = "none" | ||
| 88 | mode = "visual" | ||
| 89 | [[bind]] | ||
| 90 | keys = "C-c x" | ||
| 91 | command = "org.todo.cycle" | ||
| 92 | """, problems: &problems, commands: ["org.todo.cycle"]) | ||
| 93 | #expect(problems == ["line 1: unknown mode visual-line", "line 5: unknown command org.todo.cycel"]) | ||
| 94 | #expect(keymap.bindings.map(\.command) == ["none", "org.todo.cycle"]) | ||
| 95 | // Without a command list, ids aren't checked. | ||
| 96 | problems = [] | ||
| 97 | _ = try Keymap(toml: "[[bind]]\nkeys = \"x\"\ncommand = \"anything\"\nmode = \"insert\"\n", problems: &problems) | ||
| 98 | #expect(problems.isEmpty) | ||
| 99 | } | ||
| 100 | |||
| 74 | @Test func laterLayersWinAndCanUnbind() throws { | 101 | @Test func laterLayersWinAndCanUnbind() throws { |
| 75 | let preset = KeymapPreset.emacs.keymap | 102 | let preset = KeymapPreset.emacs.keymap |
| 76 | let (user, _) = try map(""" | 103 | let (user, _) = try map(""" |
Tests/OrgEditorAppKitTests/ModalTests.swift +12
| @@ -160,3 +160,15 @@ struct ModalTests { | |||
| 160 | #expect(h.textView.selectedRange().location == 23) | 160 | #expect(h.textView.selectedRange().location == 23) |
| 161 | } | 161 | } |
| 162 | } | 162 | } |
| 163 | |||
| 164 | struct PresetCommandTests { | ||
| 165 | /// Every command a preset binds is one the editor runs, and every mode is a state. | ||
| 166 | @Test func presetsBindKnownCommands() throws { | ||
| 167 | let known = Set(EditorCommands.all.map(\.id)) | ||
| 168 | for preset in KeymapPreset.allCases { | ||
| 169 | var problems: [String] = [] | ||
| 170 | _ = try Keymap(toml: preset.toml, problems: &problems, commands: known) | ||
| 171 | #expect(problems.isEmpty, "\(preset): \(problems)") | ||
| 172 | } | ||
| 173 | } | ||
| 174 | } | ||