krz/orgstar

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

Sources/OrgCore/Keymap/Keymap.swift

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

176 lines · 7341 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
 22/// Bindings in priority order: later bindings win over earlier ones for the same keys.
 23/// Layers are concatenated: preset, then the user's file.
 24public struct Keymap: Sendable, Equatable {
 25    public private(set) var bindings: [KeyBinding]
 26
 27    public init(_ bindings: [KeyBinding] = []) {
 28        self.bindings = bindings
 29    }
 30
 31    /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in
 32    /// `problems`; a file that isn't valid TOML throws.
 33    public init(toml: String, problems: inout [String]) throws {
 34        bindings = []
 35        for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) {
 36            guard table.name == "bind", table.isArrayElement else {
 37                problems.append("line \(table.line): unknown table [\(table.name)]")
 38                continue
 39            }
 40            guard let keyText = table.values["keys"]?.string, let keys = KeySequence.parse(keyText) else {
 41                problems.append("line \(table.line): bad or missing keys")
 42                continue
 43            }
 44            guard let command = table.values["command"]?.string, !command.isEmpty else {
 45                problems.append("line \(table.line): missing command")
 46                continue
 47            }
 48            let when = table.values["when"]?.string
 49            if let when, !KeyContext.names.contains(when) {
 50                problems.append("line \(table.line): unknown context \(when)")
 51                continue
 52            }
 53            bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: table.values["mode"]?.string))
 54        }
 55    }
 56
 57    public static func layered(_ layers: [Keymap]) -> Keymap {
 58        Keymap(layers.flatMap(\.bindings))
 59    }
 60
 61    /// Bindings for exactly `keys` in `mode`, highest priority first. An unbinding with no
 62    /// context hides everything below it.
 63    public func candidates(for keys: [KeyChord], mode: String? = nil) -> [KeyBinding] {
 64        var result: [KeyBinding] = []
 65        for binding in bindings.reversed() where binding.mode == mode && binding.keys == keys {
 66            if binding.unbinds {
 67                if binding.when == nil { break }
 68                continue
 69            }
 70            result.append(binding)
 71        }
 72        return result
 73    }
 74
 75    /// Whether some live binding in `mode` starts with `keys` and is longer.
 76    public func isPrefix(_ keys: [KeyChord], mode: String? = nil) -> Bool {
 77        let longer = Set(bindings.filter { $0.mode == mode && $0.keys.count > keys.count && Array($0.keys.prefix(keys.count)) == keys }.map(\.keys))
 78        return longer.contains { !candidates(for: $0, mode: mode).isEmpty }
 79    }
 80
 81    /// What can follow `prefix`: each next key with the command it runs, or nil when it
 82    /// leads to a longer sequence. Sorted by key.
 83    public func continuations(of prefix: [KeyChord], mode: String? = nil) -> [(key: KeyChord, command: String?)] {
 84        var seen: [KeyChord: String?] = [:]
 85        for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix {
 86            let next = binding.keys[prefix.count]
 87            let sequence = prefix + [next]
 88            guard seen[next] == nil else { continue }
 89            if isPrefix(sequence, mode: mode) {
 90                seen[next] = .some(nil)
 91            } else if let command = candidates(for: sequence, mode: mode).first?.command {
 92                seen[next] = .some(command)
 93            }
 94        }
 95        return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description }
 96    }
 97
 98    /// The first live binding of `command`, for showing its keys.
 99    public func keys(for command: String, mode: String? = nil) -> [KeyChord]? {
100        bindings.reversed().first { $0.mode == mode && $0.command == command && candidates(for: $0.keys, mode: mode).contains($0) }?.keys
101    }
102}
103
104/// Context names a binding's `when` can use.
105public enum KeyContext {
106    public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src"]
107
108    public static func holds(_ name: String, in context: EditContext) -> Bool {
109        switch name {
110        case "heading": return headingOnLine(at: context.caret, in: context.tree) != nil
111        case "table": return enclosing(.table, context) != nil
112        case "item": return enclosing(.item, context) != nil
113        case "region": return context.selection.contains { !$0.isEmpty }
114        case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
115        case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil
116        case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false
117        default: return false
118        }
119    }
120
121    private static func enclosing(_ kind: SyntaxKind, _ context: EditContext) -> SyntaxNode? {
122        var node = context.tree.root
123        while let child = node.child(containing: context.caret) {
124            if child.kind == kind { return child }
125            node = child
126        }
127        return nil
128    }
129}
130
131/// Turns key presses into bindings, holding a pending prefix between presses.
132public struct KeyDispatcher: Sendable {
133    public enum Outcome: Sendable, Equatable {
134        /// A prefix; waiting for the next key.
135        case pending([KeyChord])
136        /// Bindings for the completed sequence, highest priority first.
137        case complete([KeyChord], [KeyBinding])
138        /// A sequence of two or more keys that nothing binds.
139        case undefined([KeyChord])
140        /// A single key nothing binds: let the text system have it.
141        case unbound
142        /// C-g during a prefix.
143        case cancelled
144    }
145
146    public var keymap: Keymap
147    public var mode: String?
148    public private(set) var pending: [KeyChord] = []
149
150    public init(keymap: Keymap, mode: String? = nil) {
151        self.keymap = keymap
152        self.mode = mode
153    }
154
155    public static let quit = KeyChord("g", .control)
156
157    public mutating func feed(_ chord: KeyChord) -> Outcome {
158        if !pending.isEmpty, chord == Self.quit {
159            pending = []
160            return .cancelled
161        }
162        let sequence = pending + [chord]
163        if keymap.isPrefix(sequence, mode: mode) {
164            pending = sequence
165            return .pending(sequence)
166        }
167        pending = []
168        let candidates = keymap.candidates(for: sequence, mode: mode)
169        if !candidates.isEmpty { return .complete(sequence, candidates) }
170        return sequence.count == 1 ? .unbound : .undefined(sequence)
171    }
172
173    public mutating func reset() {
174        pending = []
175    }
176}