krz/orgstar

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

Commit 056befa97f

056befa97fffa110959cb6010c1986461c09dc0b

parent: a31780675a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 03:52 UTC

Add keymaps

Layout: unified · split

Sources/OrgCore/Keymap/Keymap.swift added +173
@@ -0,0 +1,173 @@
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"]
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 default: return false
115 }
116 }
117
118 private static func enclosing(_ kind: SyntaxKind, _ context: EditContext) -> SyntaxNode? {
119 var node = context.tree.root
120 while let child = node.child(containing: context.caret) {
121 if child.kind == kind { return child }
122 node = child
123 }
124 return nil
125 }
126}
127
128/// Turns key presses into bindings, holding a pending prefix between presses.
129public struct KeyDispatcher: Sendable {
130 public enum Outcome: Sendable, Equatable {
131 /// A prefix; waiting for the next key.
132 case pending([KeyChord])
133 /// Bindings for the completed sequence, highest priority first.
134 case complete([KeyChord], [KeyBinding])
135 /// A sequence of two or more keys that nothing binds.
136 case undefined([KeyChord])
137 /// A single key nothing binds: let the text system have it.
138 case unbound
139 /// C-g during a prefix.
140 case cancelled
141 }
142
143 public var keymap: Keymap
144 public var mode: String?
145 public private(set) var pending: [KeyChord] = []
146
147 public init(keymap: Keymap, mode: String? = nil) {
148 self.keymap = keymap
149 self.mode = mode
150 }
151
152 public static let quit = KeyChord("g", .control)
153
154 public mutating func feed(_ chord: KeyChord) -> Outcome {
155 if !pending.isEmpty, chord == Self.quit {
156 pending = []
157 return .cancelled
158 }
159 let sequence = pending + [chord]
160 if keymap.isPrefix(sequence, mode: mode) {
161 pending = sequence
162 return .pending(sequence)
163 }
164 pending = []
165 let candidates = keymap.candidates(for: sequence, mode: mode)
166 if !candidates.isEmpty { return .complete(sequence, candidates) }
167 return sequence.count == 1 ? .unbound : .undefined(sequence)
168 }
169
170 public mutating func reset() {
171 pending = []
172 }
173}
Sources/OrgCore/Keymap/Keys.swift added +86
@@ -0,0 +1,86 @@
1/// One key press in Emacs notation: `C-x`, `M-<`, `S-<left>`, `s-s`, `TAB`.
2///
3/// `C` is Control, `M` Meta, `S` Shift, `s` Super (⌘). Shift on a letter is folded into the
4/// letter (`S-a` is `A`); on other keys it stays a modifier (`S-TAB`, `S-<up>`).
5public struct KeyChord: Hashable, Sendable, CustomStringConvertible {
6 public struct Modifiers: OptionSet, Hashable, Sendable {
7 public let rawValue: Int
8 public init(rawValue: Int) { self.rawValue = rawValue }
9 public static let control = Modifiers(rawValue: 1)
10 public static let meta = Modifiers(rawValue: 2)
11 public static let shift = Modifiers(rawValue: 4)
12 public static let command = Modifiers(rawValue: 8)
13 }
14
15 /// A single character, a named key (`TAB`, `RET`, `SPC`, `ESC`, `DEL`), or a bracketed
16 /// function key (`<left>`, `<f5>`, `<delete>`).
17 public let key: String
18 public let modifiers: Modifiers
19
20 public init(_ key: String, _ modifiers: Modifiers = []) {
21 var key = Self.aliases[key] ?? key
22 var modifiers = modifiers
23 if key == "<backtab>" {
24 key = "TAB"
25 modifiers.insert(.shift)
26 }
27 if modifiers.contains(.shift), key.count == 1, let letter = key.first, letter.isLetter, letter.isLowercase {
28 key = key.uppercased()
29 modifiers.remove(.shift)
30 }
31 self.key = key
32 self.modifiers = modifiers
33 }
34
35 static let named: Set<String> = ["TAB", "RET", "SPC", "ESC", "DEL"]
36 static let aliases = [
37 " ": "SPC", "<tab>": "TAB", "<return>": "RET", "<escape>": "ESC", "<backspace>": "DEL",
38 ]
39
40 /// Parses one token such as `C-M-x`; nil if it isn't a key.
41 public static func parse<S: StringProtocol>(_ token: S) -> KeyChord? {
42 var rest = Substring(token)
43 var modifiers: Modifiers = []
44 while rest.count >= 3, rest.dropFirst().first == "-" {
45 switch rest.first {
46 case "C": modifiers.insert(.control)
47 case "M": modifiers.insert(.meta)
48 case "S": modifiers.insert(.shift)
49 case "s": modifiers.insert(.command)
50 default: return nil
51 }
52 rest = rest.dropFirst(2)
53 }
54 let key = String(rest)
55 let isFunctionKey = key.count > 2 && key.hasPrefix("<") && key.hasSuffix(">")
56 guard key.count == 1 || named.contains(key) || isFunctionKey || aliases[key] != nil else { return nil }
57 return KeyChord(key, modifiers)
58 }
59
60 public var description: String {
61 var text = ""
62 if modifiers.contains(.control) { text += "C-" }
63 if modifiers.contains(.meta) { text += "M-" }
64 if modifiers.contains(.shift) { text += "S-" }
65 if modifiers.contains(.command) { text += "s-" }
66 return text + key
67 }
68}
69
70public enum KeySequence {
71 /// Parses space-separated chords: `C-c C-x C-i`. Nil if any token isn't a key.
72 public static func parse(_ text: String) -> [KeyChord]? {
73 let tokens = text.split(separator: " ")
74 guard !tokens.isEmpty else { return nil }
75 var chords: [KeyChord] = []
76 for token in tokens {
77 guard let chord = KeyChord.parse(token) else { return nil }
78 chords.append(chord)
79 }
80 return chords
81 }
82
83 public static func format(_ keys: [KeyChord]) -> String {
84 keys.map(\.description).joined(separator: " ")
85 }
86}
Sources/OrgCore/Keymap/Presets.swift added +192
@@ -0,0 +1,192 @@
1/// The keymaps the app ships. The user's `keymap.toml` is layered on top of the chosen one.
2public enum KeymapPreset: String, Sendable, CaseIterable {
3 case emacs, mac
4
5 public var title: String {
6 switch self {
7 case .emacs: "Emacs"
8 case .mac: "Mac"
9 }
10 }
11
12 public var keymap: Keymap {
13 var problems: [String] = []
14 let map = try! Keymap(toml: toml, problems: &problems)
15 precondition(problems.isEmpty, "preset \(rawValue): \(problems)")
16 return map
17 }
18
19 public var toml: String {
20 switch self {
21 case .emacs: Self.common + Self.emacsBindings
22 case .mac: Self.common + Self.macBindings
23 }
24 }
25
26 static let common = """
27 [[bind]]
28 keys = "TAB"
29 command = "org.cycle"
30 when = "heading"
31
32 [[bind]]
33 keys = "S-TAB"
34 command = "org.cycle-global"
35
36 """
37
38 static let emacsBindings = """
39 # Movement and editing. With a mark set (C-SPC), movement extends the region.
40 [[bind]]
41 keys = "C-f"
42 command = "edit.forward-char"
43 [[bind]]
44 keys = "C-b"
45 command = "edit.backward-char"
46 [[bind]]
47 keys = "C-n"
48 command = "edit.next-line"
49 [[bind]]
50 keys = "C-p"
51 command = "edit.previous-line"
52 [[bind]]
53 keys = "C-a"
54 command = "edit.beginning-of-line"
55 [[bind]]
56 keys = "C-e"
57 command = "edit.end-of-line"
58 [[bind]]
59 keys = "M-f"
60 command = "edit.forward-word"
61 [[bind]]
62 keys = "M-b"
63 command = "edit.backward-word"
64 [[bind]]
65 keys = "M-<"
66 command = "edit.beginning-of-buffer"
67 [[bind]]
68 keys = "M->"
69 command = "edit.end-of-buffer"
70 [[bind]]
71 keys = "C-v"
72 command = "edit.scroll-up"
73 [[bind]]
74 keys = "M-v"
75 command = "edit.scroll-down"
76 [[bind]]
77 keys = "C-l"
78 command = "edit.recenter"
79 [[bind]]
80 keys = "C-d"
81 command = "edit.delete-char"
82 [[bind]]
83 keys = "M-d"
84 command = "edit.kill-word"
85 [[bind]]
86 keys = "M-DEL"
87 command = "edit.backward-kill-word"
88 [[bind]]
89 keys = "C-k"
90 command = "edit.kill-line"
91 [[bind]]
92 keys = "C-y"
93 command = "edit.yank"
94 [[bind]]
95 keys = "C-SPC"
96 command = "edit.set-mark"
97 [[bind]]
98 keys = "C-x C-x"
99 command = "edit.exchange-point-and-mark"
100 [[bind]]
101 keys = "C-x h"
102 command = "edit.select-all"
103 [[bind]]
104 keys = "C-w"
105 command = "edit.kill-region"
106 [[bind]]
107 keys = "M-w"
108 command = "edit.copy-region"
109 [[bind]]
110 keys = "C-/"
111 command = "edit.undo"
112 [[bind]]
113 keys = "C-_"
114 command = "edit.undo"
115 [[bind]]
116 keys = "C-x u"
117 command = "edit.undo"
118 [[bind]]
119 keys = "C-g"
120 command = "edit.keyboard-quit"
121 [[bind]]
122 keys = "C-t"
123 command = "edit.transpose-chars"
124 [[bind]]
125 keys = "C-s"
126 command = "edit.find"
127 [[bind]]
128 keys = "C-r"
129 command = "edit.find"
130
131 # Files and commands
132 [[bind]]
133 keys = "C-x C-s"
134 command = "app.save"
135 [[bind]]
136 keys = "C-x C-f"
137 command = "app.quick-open"
138 [[bind]]
139 keys = "C-x b"
140 command = "app.quick-open"
141 [[bind]]
142 keys = "M-x"
143 command = "app.palette"
144
145 # Org
146 [[bind]]
147 keys = "C-c C-t"
148 command = "org.todo.cycle"
149 [[bind]]
150 keys = "S-<right>"
151 command = "org.todo.cycle"
152 when = "heading"
153 [[bind]]
154 keys = "S-<up>"
155 command = "org.priority.up"
156 when = "heading"
157 [[bind]]
158 keys = "S-<down>"
159 command = "org.priority.down"
160 when = "heading"
161 [[bind]]
162 keys = "M-<left>"
163 command = "org.heading.promote"
164 when = "heading"
165 [[bind]]
166 keys = "M-<right>"
167 command = "org.heading.demote"
168 when = "heading"
169
170 """
171
172 /// Standard Mac text keys stay as they are; org commands use Control-Command chords, which
173 /// macOS text editing leaves free.
174 static let macBindings = """
175 [[bind]]
176 keys = "C-s-t"
177 command = "org.todo.cycle"
178 [[bind]]
179 keys = "C-s-<up>"
180 command = "org.priority.up"
181 [[bind]]
182 keys = "C-s-<down>"
183 command = "org.priority.down"
184 [[bind]]
185 keys = "C-s-<left>"
186 command = "org.heading.promote"
187 [[bind]]
188 keys = "C-s-<right>"
189 command = "org.heading.demote"
190
191 """
192}
Sources/OrgCore/Keymap/TOML.swift added +118
@@ -0,0 +1,118 @@
1import Foundation
2
3/// The part of TOML keymaps use: `[table]` and `[[array-of-tables]]` headers, and
4/// `key = value` lines with basic or literal strings, booleans and integers. Comments start
5/// with `#` outside strings.
6public enum TOML {
7 public enum Value: Sendable, Equatable {
8 case string(String)
9 case bool(Bool)
10 case integer(Int)
11
12 public var string: String? {
13 if case .string(let value) = self { return value }
14 return nil
15 }
16
17 public var bool: Bool? {
18 if case .bool(let value) = self { return value }
19 return nil
20 }
21 }
22
23 public struct Table: Sendable, Equatable {
24 /// Empty for keys before the first header.
25 public var name: String
26 /// Declared with `[[name]]`.
27 public var isArrayElement: Bool
28 public var values: [String: Value]
29 /// 1-based line of the header.
30 public var line: Int
31 }
32
33 public struct ParseError: Error, Equatable, CustomStringConvertible {
34 public let line: Int
35 public let message: String
36 public var description: String { "line \(line): \(message)" }
37 }
38
39 /// Tables in file order.
40 public static func parse(_ text: String) throws -> [Table] {
41 var tables = [Table(name: "", isArrayElement: false, values: [:], line: 0)]
42 for (index, rawLine) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
43 let number = index + 1
44 let line = rawLine.trimmingCharacters(in: [" ", "\t", "\r"])
45 if line.isEmpty || line.hasPrefix("#") { continue }
46 if line.hasPrefix("[") {
47 let isArray = line.hasPrefix("[[")
48 let close = isArray ? "]]" : "]"
49 let body = line.dropFirst(isArray ? 2 : 1)
50 guard let end = body.range(of: close) else { throw ParseError(line: number, message: "unclosed table header") }
51 let rest = body[end.upperBound...].trimmingCharacters(in: [" ", "\t"])
52 guard rest.isEmpty || rest.hasPrefix("#") else { throw ParseError(line: number, message: "text after table header") }
53 let name = body[..<end.lowerBound].trimmingCharacters(in: [" ", "\t"])
54 guard !name.isEmpty else { throw ParseError(line: number, message: "empty table name") }
55 tables.append(Table(name: name, isArrayElement: isArray, values: [:], line: number))
56 continue
57 }
58 guard let equals = line.firstIndex(of: "=") else { throw ParseError(line: number, message: "expected key = value") }
59 let key = line[..<equals].trimmingCharacters(in: [" ", "\t"])
60 guard !key.isEmpty, key.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) else {
61 throw ParseError(line: number, message: "bad key")
62 }
63 var rest = Substring(line[line.index(after: equals)...]).drop { $0 == " " || $0 == "\t" }
64 let value = try parseValue(&rest, line: number)
65 rest = rest.drop { $0 == " " || $0 == "\t" }
66 guard rest.isEmpty || rest.first == "#" else { throw ParseError(line: number, message: "text after value") }
67 guard tables[tables.count - 1].values[key] == nil else { throw ParseError(line: number, message: "duplicate key \(key)") }
68 tables[tables.count - 1].values[key] = value
69 }
70 return tables
71 }
72
73 private static func parseValue(_ rest: inout Substring, line: Int) throws -> Value {
74 switch rest.first {
75 case "\"":
76 rest = rest.dropFirst()
77 var value = ""
78 while let c = rest.first {
79 rest = rest.dropFirst()
80 if c == "\"" { return .string(value) }
81 guard c == "\\" else {
82 value.append(c)
83 continue
84 }
85 guard let escaped = rest.first else { break }
86 rest = rest.dropFirst()
87 switch escaped {
88 case "\"": value.append("\"")
89 case "\\": value.append("\\")
90 case "n": value.append("\n")
91 case "t": value.append("\t")
92 case "u", "U":
93 let digits = escaped == "u" ? 4 : 8
94 guard let code = UInt32(rest.prefix(digits), radix: 16), let scalar = Unicode.Scalar(code), rest.count >= digits else {
95 throw ParseError(line: line, message: "bad unicode escape")
96 }
97 value.unicodeScalars.append(scalar)
98 rest = rest.dropFirst(digits)
99 default: throw ParseError(line: line, message: "bad escape \\\(escaped)")
100 }
101 }
102 throw ParseError(line: line, message: "unclosed string")
103 case "'":
104 rest = rest.dropFirst()
105 guard let end = rest.firstIndex(of: "'") else { throw ParseError(line: line, message: "unclosed string") }
106 let value = String(rest[..<end])
107 rest = rest[rest.index(after: end)...]
108 return .string(value)
109 default:
110 let word = rest.prefix { !$0.isWhitespace && $0 != "#" }
111 rest = rest.dropFirst(word.count)
112 if word == "true" { return .bool(true) }
113 if word == "false" { return .bool(false) }
114 if let number = Int(word.replacingOccurrences(of: "_", with: "")) { return .integer(number) }
115 throw ParseError(line: line, message: "unsupported value \(word)")
116 }
117 }
118}
Tests/OrgCoreTests/KeymapTests.swift added +128
@@ -0,0 +1,128 @@
1import Testing
2@testable import OrgCore
3
4struct KeyNotationTests {
5 @Test func parsesAndFormats() {
6 #expect(KeySequence.parse("C-c C-x C-i").map(KeySequence.format) == "C-c C-x C-i")
7 #expect(KeyChord.parse("C-M-x") == KeyChord("x", [.control, .meta]))
8 #expect(KeyChord.parse("S-a") == KeyChord("A"))
9 #expect(KeyChord.parse("S-TAB") == KeyChord("TAB", .shift))
10 #expect(KeyChord.parse("<backtab>") == KeyChord("TAB", .shift))
11 #expect(KeyChord.parse("C--") == KeyChord("-", .control))
12 #expect(KeyChord.parse("M-<") == KeyChord("<", .meta))
13 #expect(KeyChord.parse("s-<left>")?.description == "s-<left>")
14 #expect(KeyChord.parse("<tab>") == KeyChord("TAB"))
15 #expect(KeyChord.parse("X-a") == nil)
16 #expect(KeyChord.parse("abc") == nil)
17 #expect(KeySequence.parse("") == nil)
18 }
19}
20
21struct TOMLTests {
22 @Test func parsesTables() throws {
23 let tables = try TOML.parse("""
24 top = 1 # comment
25 [[bind]]
26 keys = "C-c \\"x\\""
27 when = 'heading'
28 on = true
29
30 [options]
31 name = "a\\u00e9"
32 """)
33 #expect(tables.count == 3)
34 #expect(tables[0].values["top"] == .integer(1))
35 #expect(tables[1].name == "bind" && tables[1].isArrayElement && tables[1].line == 2)
36 #expect(tables[1].values["keys"] == .string("C-c \"x\""))
37 #expect(tables[1].values["when"] == .string("heading"))
38 #expect(tables[1].values["on"] == .bool(true))
39 #expect(tables[2].values["name"] == .string("aé"))
40 }
41
42 @Test func reportsErrorsWithLines() {
43 #expect(throws: TOML.ParseError(line: 2, message: "unclosed string")) { try TOML.parse("[[bind]]\nkeys = \"C-c") }
44 #expect(throws: TOML.ParseError(line: 1, message: "expected key = value")) { try TOML.parse("keys") }
45 #expect(throws: TOML.ParseError(line: 2, message: "duplicate key a")) { try TOML.parse("a = 1\na = 2") }
46 }
47}
48
49struct KeymapTests {
50 func map(_ toml: String) throws -> (Keymap, [String]) {
51 var problems: [String] = []
52 let keymap = try Keymap(toml: toml, problems: &problems)
53 return (keymap, problems)
54 }
55
56 @Test func skipsBadEntries() throws {
57 let (keymap, problems) = try map("""
58 [[bind]]
59 keys = "C-c C-t"
60 command = "org.todo.cycle"
61 [[bind]]
62 keys = "Q-x"
63 command = "a"
64 [[bind]]
65 keys = "TAB"
66 command = "a"
67 when = "nowhere"
68 [other]
69 """)
70 #expect(keymap.bindings.count == 1)
71 #expect(problems.count == 3)
72 }
73
74 @Test func laterLayersWinAndCanUnbind() throws {
75 let preset = KeymapPreset.emacs.keymap
76 let (user, _) = try map("""
77 [[bind]]
78 keys = "C-c C-t"
79 command = "org.priority.up"
80 [[bind]]
81 keys = "C-k"
82 command = "none"
83 """)
84 let keymap = Keymap.layered([preset, user])
85 #expect(keymap.candidates(for: KeySequence.parse("C-c C-t")!).first?.command == "org.priority.up")
86 #expect(keymap.candidates(for: KeySequence.parse("C-k")!).isEmpty)
87 #expect(keymap.keys(for: "org.todo.cycle").map(KeySequence.format) == "S-<right>")
88 }
89
90 @Test func dispatchesPrefixes() {
91 var dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
92 let c = KeyChord("c", .control), t = KeyChord("t", .control), z = KeyChord("z", .control)
93 #expect(dispatcher.feed(c) == .pending([c]))
94 guard case .complete(let keys, let candidates) = dispatcher.feed(t) else {
95 Issue.record("expected a binding")
96 return
97 }
98 #expect(keys == [c, t] && candidates.first?.command == "org.todo.cycle")
99 #expect(dispatcher.feed(c) == .pending([c]))
100 #expect(dispatcher.feed(z) == .undefined([c, z]))
101 #expect(dispatcher.feed(c) == .pending([c]))
102 #expect(dispatcher.feed(KeyDispatcher.quit) == .cancelled)
103 #expect(dispatcher.feed(KeyChord("a")) == .unbound)
104 }
105
106 @Test func continuationsListNextKeys() {
107 let keymap = KeymapPreset.emacs.keymap
108 let next = keymap.continuations(of: [KeyChord("x", .control)])
109 #expect(next.contains { $0.key == KeyChord("s", .control) && $0.command == "app.save" })
110 #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil })
111 }
112
113 @Test func contexts() {
114 let text = "* a\n| x |\n- item\n"
115 let tree = OrgParser.parse(text)
116 func holds(_ name: String, _ caret: Int, _ selection: Range<Int>? = nil) -> Bool {
117 KeyContext.holds(name, in: EditContext(revision: 0, text: text, tree: tree, selection: [selection ?? caret..<caret]))
118 }
119 #expect(holds("heading", 1) && !holds("heading", 5))
120 #expect(holds("table", 5) && !holds("table", 12))
121 #expect(holds("item", 12))
122 #expect(holds("region", 0, 0..<2) && !holds("region", 0))
123 }
124
125 @Test func presetsParse() {
126 for preset in KeymapPreset.allCases { #expect(!preset.keymap.bindings.isEmpty) }
127 }
128}