krz/orgstar

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

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 /// What can follow `prefix`: each next key with the command it runs, or nil when it 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. 95 /// leads to a longer sequence. Sorted by key. The command is the first candidate that
96 public func continuations(of prefix: [KeyChord], mode: String? = nil) -> [(key: KeyChord, command: String?)] { 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 var seen: [KeyChord: String?] = [:] 98 var seen: [KeyChord: String?] = [:]
98 for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix { 99 for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix {
99 let next = binding.keys[prefix.count] 100 let next = binding.keys[prefix.count]
@@ -101,8 +102,9 @@ public struct Keymap: Sendable, Equatable {
101 guard seen[next] == nil else { continue } 102 guard seen[next] == nil else { continue }
102 if isPrefix(sequence, mode: mode) { 103 if isPrefix(sequence, mode: mode) {
103 seen[next] = .some(nil) 104 seen[next] = .some(nil)
104 } else if let command = candidates(for: sequence, mode: mode).first?.command { 105 } else {
105 seen[next] = .some(command) 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 return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description } 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 switch dispatcher.feed(chord) { 17 switch dispatcher.feed(chord) {
18 case .pending(let keys): 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 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") 21 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
21 } 22 }
22 onKeysPending?(KeySequence.format(keys), next) 23 onKeysPending?(KeySequence.format(keys), next)
@@ -27,9 +28,7 @@ extension OrgEditor {
27 case .complete(let keys, let candidates): 28 case .complete(let keys, let candidates):
28 if keys.count > 1 { onKeysPending?(nil, []) } 29 if keys.count > 1 { onKeysPending?(nil, []) }
29 let context = editContext() 30 let context = editContext()
30 if let binding = candidates.first(where: { binding in 31 if let binding = candidates.first(where: { modalRuns($0, in: context) }) {
31 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context)
32 }) {
33 run(binding.command) 32 run(binding.command)
34 updateCursor() 33 updateCursor()
35 return true 34 return true
@@ -104,6 +103,10 @@ extension OrgEditor {
104 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options) 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 private func commandApplies(_ id: String, in context: EditContext) -> Bool { 110 private func commandApplies(_ id: String, in context: EditContext) -> Bool {
108 if !isOrg, id.hasPrefix("org.") { return false } 111 if !isOrg, id.hasPrefix("org.") { return false }
109 return switch EditorCommands.command(id)?.action { 112 return switch EditorCommands.command(id)?.action {
Sources/OrgEditorAppKit/OrgEditor.swift +8 −4
@@ -395,7 +395,8 @@ public final class OrgEditor: NSObject {
395 if modal { return handleModal(chord) } 395 if modal { return handleModal(chord) }
396 switch dispatcher.feed(chord) { 396 switch dispatcher.feed(chord) {
397 case .pending(let keys): 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 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") 400 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
400 } 401 }
401 onKeysPending?(KeySequence.format(keys), next) 402 onKeysPending?(KeySequence.format(keys), next)
@@ -413,9 +414,7 @@ public final class OrgEditor: NSObject {
413 case .complete(let keys, let candidates): 414 case .complete(let keys, let candidates):
414 if keys.count > 1 { onKeysPending?(nil, []) } 415 if keys.count > 1 { onKeysPending?(nil, []) }
415 let context = context() 416 let context = context()
416 let runnable = candidates.first { binding in 417 let runnable = candidates.first { runs($0, in: context) }
417 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
418 }
419 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) { 418 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
420 run(binding.command) 419 run(binding.command)
421 return true 420 return true
@@ -435,6 +434,11 @@ public final class OrgEditor: NSObject {
435 return true 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 private func applies(_ id: String, in context: EditContext) -> Bool { 442 private func applies(_ id: String, in context: EditContext) -> Bool {
439 if !isOrg, id.hasPrefix("org.") { return false } 443 if !isOrg, id.hasPrefix("org.") { return false }
440 return switch EditorCommands.command(id)?.action { 444 return switch EditorCommands.command(id)?.action {
Tests/OrgCoreTests/KeymapTests.swift +13
@@ -194,6 +194,19 @@ struct KeymapTests {
194 #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil }) 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 @Test func contexts() { 210 @Test func contexts() {
198 let text = "* a\n| x |\n- item\n" 211 let text = "* a\n| x |\n- item\n"
199 let tree = OrgParser.parse(text) 212 let tree = OrgParser.parse(text)
Tests/OrgEditorAppKitTests/ModalTests.swift +15
@@ -72,6 +72,21 @@ struct ModalTests {
72 #expect(h.editor.vim.mode == .normal) 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 @Test func visualSelection() { 90 @Test func visualSelection() {
76 let h = doom("one two three\n") 91 let h = doom("one two three\n")
77 h.caret(at: 0) 92 h.caret(at: 0)