krz/orgstar

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

Sources/OrgCore/Keymap/Keymap.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgCore/Keymap/Keymap.swift history · blame · raw

234 lines · 10810 bytes

  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}