Commit 0b9419ea7c
0b9419ea7c722f5d4fc50f85334716b50545ac2b
parent: 1d5593eadf
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-08 03:39 UTC
Show the key hint for the command that would run
Hints after a prefix pick the binding whose context holds and whose
command applies, as dispatch does, instead of the highest-priority one.
Layout: unified · split
Sources/OrgCore/Keymap/Keymap.swift
+6 −4
| @@ -92,8 +92,9 @@ public struct Keymap: Sendable, Equatable { |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | /// What can follow `prefix`: each next key with the command it runs, or nil when it |
| 95 | | /// leads to a longer sequence. Sorted by key. |
| 96 | | public func continuations(of prefix: [KeyChord], mode: String? = nil) -> [(key: KeyChord, command: String?)] { |
| 95 | /// leads to a longer sequence. Sorted by key. The command is the first candidate that |
| 96 | /// `runs` accepts, as dispatch picks it, or the first candidate when none does. |
| 97 | public func continuations(of prefix: [KeyChord], mode: String? = nil, runs: (KeyBinding) -> Bool = { _ in true }) -> [(key: KeyChord, command: String?)] { |
| 97 | 98 | var seen: [KeyChord: String?] = [:] |
| 98 | 99 | for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix { |
| 99 | 100 | let next = binding.keys[prefix.count] |
| @@ -101,8 +102,9 @@ public struct Keymap: Sendable, Equatable { |
| 101 | 102 | guard seen[next] == nil else { continue } |
| 102 | 103 | if isPrefix(sequence, mode: mode) { |
| 103 | 104 | seen[next] = .some(nil) |
| 104 | | } else if let command = candidates(for: sequence, mode: mode).first?.command { |
| 105 | | seen[next] = .some(command) |
| 105 | } else { |
| 106 | let found = candidates(for: sequence, mode: mode) |
| 107 | if let command = (found.first(where: runs) ?? found.first)?.command { seen[next] = .some(command) } |
| 106 | 108 | } |
| 107 | 109 | } |
| 108 | 110 | return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description } |
Sources/OrgEditorAppKit/Modal.swift
+7 −4
| @@ -16,7 +16,8 @@ extension OrgEditor { |
| 16 | 16 | } |
| 17 | 17 | switch dispatcher.feed(chord) { |
| 18 | 18 | case .pending(let keys): |
| 19 | | let next = keymap.continuations(of: keys, mode: dispatcher.mode).map { item in |
| 19 | let context = editContext() |
| 20 | let next = keymap.continuations(of: keys, mode: dispatcher.mode) { modalRuns($0, in: context) }.map { item in |
| 20 | 21 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") |
| 21 | 22 | } |
| 22 | 23 | onKeysPending?(KeySequence.format(keys), next) |
| @@ -27,9 +28,7 @@ extension OrgEditor { |
| 27 | 28 | case .complete(let keys, let candidates): |
| 28 | 29 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 29 | 30 | let context = editContext() |
| 30 | | if let binding = candidates.first(where: { binding in |
| 31 | | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context) |
| 32 | | }) { |
| 31 | if let binding = candidates.first(where: { modalRuns($0, in: context) }) { |
| 33 | 32 | run(binding.command) |
| 34 | 33 | updateCursor() |
| 35 | 34 | return true |
| @@ -104,6 +103,10 @@ extension OrgEditor { |
| 104 | 103 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options) |
| 105 | 104 | } |
| 106 | 105 | |
| 106 | private func modalRuns(_ binding: KeyBinding, in context: EditContext) -> Bool { |
| 107 | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context) |
| 108 | } |
| 109 | |
| 107 | 110 | private func commandApplies(_ id: String, in context: EditContext) -> Bool { |
| 108 | 111 | if !isOrg, id.hasPrefix("org.") { return false } |
| 109 | 112 | return switch EditorCommands.command(id)?.action { |
Sources/OrgEditorAppKit/OrgEditor.swift
+8 −4
| @@ -395,7 +395,8 @@ public final class OrgEditor: NSObject { |
| 395 | 395 | if modal { return handleModal(chord) } |
| 396 | 396 | switch dispatcher.feed(chord) { |
| 397 | 397 | case .pending(let keys): |
| 398 | | let next = keymap.continuations(of: keys).map { item in |
| 398 | let context = context() |
| 399 | let next = keymap.continuations(of: keys) { runs($0, in: context) }.map { item in |
| 399 | 400 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") |
| 400 | 401 | } |
| 401 | 402 | onKeysPending?(KeySequence.format(keys), next) |
| @@ -413,9 +414,7 @@ public final class OrgEditor: NSObject { |
| 413 | 414 | case .complete(let keys, let candidates): |
| 414 | 415 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 415 | 416 | let context = context() |
| 416 | | let runnable = candidates.first { binding in |
| 417 | | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context) |
| 418 | | } |
| 417 | let runnable = candidates.first { runs($0, in: context) } |
| 419 | 418 | if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) { |
| 420 | 419 | run(binding.command) |
| 421 | 420 | return true |
| @@ -435,6 +434,11 @@ public final class OrgEditor: NSObject { |
| 435 | 434 | return true |
| 436 | 435 | } |
| 437 | 436 | |
| 437 | /// Whether a key's `binding` runs in `context`: its context holds and its command applies. |
| 438 | private func runs(_ binding: KeyBinding, in context: EditContext) -> Bool { |
| 439 | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context) |
| 440 | } |
| 441 | |
| 438 | 442 | private func applies(_ id: String, in context: EditContext) -> Bool { |
| 439 | 443 | if !isOrg, id.hasPrefix("org.") { return false } |
| 440 | 444 | return switch EditorCommands.command(id)?.action { |
Tests/OrgCoreTests/KeymapTests.swift
+13
| @@ -194,6 +194,19 @@ struct KeymapTests { |
| 194 | 194 | #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil }) |
| 195 | 195 | } |
| 196 | 196 | |
| 197 | @Test func continuationsFollowContext() { |
| 198 | let keymap = KeymapPreset.emacs.keymap |
| 199 | let text = "* a\n#+begin_src sh\necho\n#+end_src\n" |
| 200 | func hint(at caret: Int) -> String?? { |
| 201 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) |
| 202 | return keymap.continuations(of: [KeyChord("c", .control)]) { binding in |
| 203 | binding.when.map { KeyContext.holds($0, in: context) } ?? true |
| 204 | }.first { $0.key == KeyChord("c", .control) }?.command |
| 205 | } |
| 206 | #expect(hint(at: 1) == "org.ctrl-c-ctrl-c") |
| 207 | #expect(hint(at: 20) == "org.babel.execute") |
| 208 | } |
| 209 | |
| 197 | 210 | @Test func contexts() { |
| 198 | 211 | let text = "* a\n| x |\n- item\n" |
| 199 | 212 | let tree = OrgParser.parse(text) |
Tests/OrgEditorAppKitTests/ModalTests.swift
+15
| @@ -72,6 +72,21 @@ struct ModalTests { |
| 72 | 72 | #expect(h.editor.vim.mode == .normal) |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | @Test func keyHintsFollowContext() { |
| 76 | let h = doom("* a\n#+begin_src sh\necho\n#+end_src\n") |
| 77 | var hints: [(key: String, title: String)] = [] |
| 78 | h.editor.onKeysPending = { _, next in if !next.isEmpty { hints = next } } |
| 79 | func hint() -> String? { hints.first { $0.key == "C-c" }?.title } |
| 80 | h.caret(at: 1) |
| 81 | type(h, "C-c") |
| 82 | #expect(hint() != nil && hint() != "Run Source Block") |
| 83 | type(h, "C-g") |
| 84 | h.caret(at: 20) |
| 85 | type(h, "C-c") |
| 86 | #expect(hint() == "Run Source Block") |
| 87 | type(h, "C-g") |
| 88 | } |
| 89 | |
| 75 | 90 | @Test func visualSelection() { |
| 76 | 91 | let h = doom("one two three\n") |
| 77 | 92 | h.caret(at: 0) |