Sources/OrgCore/Keymap/Keymap.swift
234 lines · 10810 bytes
16 symbols in this file
1import Foundation
2
3public struct KeyBinding: Sendable, Equatable {
4 public let keys: [KeyChord]
5 /// A command id, or `none` to unbind keys an earlier layer bound.
6 public let command: String
7 /// A context name (`KeyContext`); nil binds everywhere.
8 public let when: String?
9 /// A modal state; nil binds outside modal editing.
10 public let mode: String?
11
12 public init(keys: [KeyChord], command: String, when: String? = nil, mode: String? = nil) {
13 self.keys = keys
14 self.command = command
15 self.when = when
16 self.mode = mode
17 }
18
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"]
23}
24
25/// Bindings in priority order: later bindings win over earlier ones for the same keys.
26/// Layers are concatenated: preset, then the user's file.
27public struct Keymap: Sendable, Equatable {
28 public private(set) var bindings: [KeyBinding]
29
30 public init(_ bindings: [KeyBinding] = []) {
31 self.bindings = bindings
32 }
33
34 /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in
35 /// `problems`; a file that isn't valid TOML throws. With `commands`, a command id outside
36 /// it (other than `none`) is such an entry.
37 public init(toml: String, problems: inout [String], commands: Set<String>? = nil) throws {
38 bindings = []
39 for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) {
40 guard table.name == "bind", table.isArrayElement else {
41 problems.append("line \(table.line): unknown table [\(table.name)]")
42 continue
43 }
44 guard let keyText = table.values["keys"]?.string, let keys = KeySequence.parse(keyText) else {
45 problems.append("line \(table.line): bad or missing keys")
46 continue
47 }
48 guard let command = table.values["command"]?.string, !command.isEmpty else {
49 problems.append("line \(table.line): missing command")
50 continue
51 }
52 if let commands, command != "none", !commands.contains(command) {
53 problems.append("line \(table.line): unknown command \(command)")
54 continue
55 }
56 let when = table.values["when"]?.string
57 if let when, !KeyContext.names.contains(when) {
58 problems.append("line \(table.line): unknown context \(when)")
59 continue
60 }
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))
67 }
68 }
69
70 public static func layered(_ layers: [Keymap]) -> Keymap {
71 Keymap(layers.flatMap(\.bindings))
72 }
73
74 /// Bindings for exactly `keys` in `mode`, highest priority first. An unbinding with no
75 /// context hides everything below it.
76 public func candidates(for keys: [KeyChord], mode: String? = nil) -> [KeyBinding] {
77 var result: [KeyBinding] = []
78 for binding in bindings.reversed() where binding.mode == mode && binding.keys == keys {
79 if binding.unbinds {
80 if binding.when == nil { break }
81 continue
82 }
83 result.append(binding)
84 }
85 return result
86 }
87
88 /// Whether some live binding in `mode` starts with `keys` and is longer.
89 public func isPrefix(_ keys: [KeyChord], mode: String? = nil) -> Bool {
90 let longer = Set(bindings.filter { $0.mode == mode && $0.keys.count > keys.count && Array($0.keys.prefix(keys.count)) == keys }.map(\.keys))
91 return longer.contains { !candidates(for: $0, mode: mode).isEmpty }
92 }
93
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. 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?)] {
98 var seen: [KeyChord: String?] = [:]
99 for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix {
100 let next = binding.keys[prefix.count]
101 let sequence = prefix + [next]
102 guard seen[next] == nil else { continue }
103 if isPrefix(sequence, mode: mode) {
104 seen[next] = .some(nil)
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) }
108 }
109 }
110 return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description }
111 }
112
113 /// Live key sequences that run `command`, formatted, at most `limit`: what menus and the
114 /// palette show. Earlier `modes` first, then bindings that hold everywhere before those
115 /// with a `when`, then shorter ones.
116 public func keyLabels(for command: String, modes: [String?] = [nil], limit: Int = 2) -> [String] {
117 var found: [(keys: [KeyChord], rank: [Int])] = []
118 for (order, mode) in modes.enumerated() {
119 // Speed keys are labels for nothing: they act only at a heading's start.
120 for binding in bindings.reversed() where binding.mode == mode && binding.command == command && isLive(binding) && binding.when != "speed" {
121 found.append((binding.keys, [order, binding.when == nil ? 0 : 1, binding.keys.count, found.count]))
122 }
123 }
124 var seen: Set<String> = []
125 return found.sorted { $0.rank.lexicographicallyPrecedes($1.rank) }
126 .map { KeySequence.format($0.keys) }
127 .filter { seen.insert($0).inserted }
128 .prefix(limit)
129 .map { $0 }
130 }
131
132 /// Whether `binding` can run: no binding that always applies takes its keys first.
133 func isLive(_ binding: KeyBinding) -> Bool {
134 for candidate in candidates(for: binding.keys, mode: binding.mode) {
135 if candidate == binding { return true }
136 if candidate.when == nil { return false }
137 }
138 return false
139 }
140
141 /// The first live binding of `command`, for showing its keys.
142 public func keys(for command: String, mode: String? = nil) -> [KeyChord]? {
143 bindings.reversed().first { $0.mode == mode && $0.command == command && candidates(for: $0.keys, mode: mode).contains($0) }?.keys
144 }
145}
146
147/// Context names a binding's `when` can use.
148public enum KeyContext {
149 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line", "dblock", "speed", "property"]
150
151 public static func holds(_ name: String, in context: EditContext) -> Bool {
152 switch name {
153 case "heading": return headingOnLine(at: context.caret, in: context.tree) != nil
154 case "table": return enclosing(.table, context) != nil
155 case "item": return enclosing(.item, context) != nil
156 case "region": return context.selection.contains { !$0.isEmpty }
157 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
158 case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil
159 case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false
160 // The first or last line of a drawer or block, where TAB folds it.
161 case "fold-line": return Wrappers.toggleable(at: context.caret, in: context.tree) != nil
162 // The #+BEGIN line of a dynamic block, where C-c C-c updates it.
163 case "dblock":
164 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
165 return buffer.lookingAtLine(EmacsBuffer.dblockStart)
166 // A property line in a property drawer.
167 case "property": return EmacsBuffer(context.text, point: context.caret).propertyAtPoint() != nil
168 // `org-use-speed-commands`: at the very start of a heading line.
169 case "speed":
170 guard context.options.useSpeedCommands, context.selection.allSatisfy(\.isEmpty) else { return false }
171 let ns = context.text as NSString
172 let caret = context.caret
173 guard caret == 0 || (caret <= ns.length && ns.character(at: caret - 1) == 10) else { return false }
174 return ns.substring(from: caret).range(of: "^\\*+ ", options: .regularExpression) != nil
175 default: return false
176 }
177 }
178
179 private static func enclosing(_ kind: SyntaxKind, _ context: EditContext) -> SyntaxNode? {
180 var node = context.tree.root
181 while let child = node.child(containing: context.caret) {
182 if child.kind == kind { return child }
183 node = child
184 }
185 return nil
186 }
187}
188
189/// Turns key presses into bindings, holding a pending prefix between presses.
190public struct KeyDispatcher: Sendable {
191 public enum Outcome: Sendable, Equatable {
192 /// A prefix; waiting for the next key.
193 case pending([KeyChord])
194 /// Bindings for the completed sequence, highest priority first.
195 case complete([KeyChord], [KeyBinding])
196 /// A sequence of two or more keys that nothing binds.
197 case undefined([KeyChord])
198 /// A single key nothing binds: let the text system have it.
199 case unbound
200 /// C-g during a prefix.
201 case cancelled
202 }
203
204 public var keymap: Keymap
205 public var mode: String?
206 public private(set) var pending: [KeyChord] = []
207
208 public init(keymap: Keymap, mode: String? = nil) {
209 self.keymap = keymap
210 self.mode = mode
211 }
212
213 public static let quit = KeyChord("g", .control)
214
215 public mutating func feed(_ chord: KeyChord) -> Outcome {
216 if !pending.isEmpty, chord == Self.quit {
217 pending = []
218 return .cancelled
219 }
220 let sequence = pending + [chord]
221 if keymap.isPrefix(sequence, mode: mode) {
222 pending = sequence
223 return .pending(sequence)
224 }
225 pending = []
226 let candidates = keymap.candidates(for: sequence, mode: mode)
227 if !candidates.isEmpty { return .complete(sequence, candidates) }
228 return sequence.count == 1 ? .unbound : .undefined(sequence)
229 }
230
231 public mutating func reset() {
232 pending = []
233 }
234}