Keymaps and command palette !12

merged merged by cmc on 2026-10-05 03:57 UTC · krz/orgstar:phase2-keymaps into main

17 files changed, +1391 −21

Layout: unified · split

Sources/OrgApp/KeymapLoader.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2import OrgCore
3
4@MainActor
5public enum KeymapLoader {
6 /// `<data dir>/keymap.toml`.
7 public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("keymap.toml") }
8
9 /// The preset with the user's file on top, and the problems found in the file. A missing
10 /// file adds nothing; a file that isn't valid TOML adds nothing and is reported.
11 public static func load(_ preset: KeymapPreset, userFile: URL = userFile) -> (keymap: Keymap, problems: [String]) {
12 guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) }
13 var problems: [String] = []
14 do {
15 let user = try Keymap(toml: text, problems: &problems)
16 return (.layered([preset.keymap, user]), problems)
17 } catch {
18 return (preset.keymap, ["\(error)"])
19 }
20 }
21}
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 +189
@@ -0,0 +1,189 @@
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 h"
99 command = "edit.select-all"
100 [[bind]]
101 keys = "C-w"
102 command = "edit.kill-region"
103 [[bind]]
104 keys = "M-w"
105 command = "edit.copy-region"
106 [[bind]]
107 keys = "C-/"
108 command = "edit.undo"
109 [[bind]]
110 keys = "C-_"
111 command = "edit.undo"
112 [[bind]]
113 keys = "C-x u"
114 command = "edit.undo"
115 [[bind]]
116 keys = "C-g"
117 command = "edit.keyboard-quit"
118 [[bind]]
119 keys = "C-t"
120 command = "edit.transpose-chars"
121 [[bind]]
122 keys = "C-s"
123 command = "edit.find"
124 [[bind]]
125 keys = "C-r"
126 command = "edit.find"
127
128 # Files and commands
129 [[bind]]
130 keys = "C-x C-s"
131 command = "app.save"
132 [[bind]]
133 keys = "C-x C-f"
134 command = "app.quick-open"
135 [[bind]]
136 keys = "C-x b"
137 command = "app.quick-open"
138 [[bind]]
139 keys = "M-x"
140 command = "app.palette"
141
142 # Org
143 [[bind]]
144 keys = "C-c C-t"
145 command = "org.todo.cycle"
146 [[bind]]
147 keys = "S-<right>"
148 command = "org.todo.cycle"
149 when = "heading"
150 [[bind]]
151 keys = "S-<up>"
152 command = "org.priority.up"
153 when = "heading"
154 [[bind]]
155 keys = "S-<down>"
156 command = "org.priority.down"
157 when = "heading"
158 [[bind]]
159 keys = "M-<left>"
160 command = "org.heading.promote"
161 when = "heading"
162 [[bind]]
163 keys = "M-<right>"
164 command = "org.heading.demote"
165 when = "heading"
166
167 """
168
169 /// Standard Mac text keys stay as they are; org commands use Control-Command chords, which
170 /// macOS text editing leaves free.
171 static let macBindings = """
172 [[bind]]
173 keys = "C-s-t"
174 command = "org.todo.cycle"
175 [[bind]]
176 keys = "C-s-<up>"
177 command = "org.priority.up"
178 [[bind]]
179 keys = "C-s-<down>"
180 command = "org.priority.down"
181 [[bind]]
182 keys = "C-s-<left>"
183 command = "org.heading.promote"
184 [[bind]]
185 keys = "C-s-<right>"
186 command = "org.heading.demote"
187
188 """
189}
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}
Sources/OrgEditorAppKit/EditorCommands.swift added +85
@@ -0,0 +1,85 @@
1#if os(macOS)
2import OrgCore
3
4/// Every command a key, menu or the palette can run, by id.
5public struct EditorCommand: Sendable {
6 public enum Action: Sendable {
7 /// An `OrgCore` command, run through `OrgEditor.perform`.
8 case text(any OrgCommand)
9 /// Folding and other view changes the editor makes itself.
10 case view
11 /// A text-system action; `extending` is its variant that extends the selection, used
12 /// while a mark is active.
13 case edit(selector: String, extending: String? = nil)
14 /// Mark handling in the editor.
15 case mark
16 /// Handled by the app (save, quick open, palette).
17 case app
18 }
19
20 public let id: String
21 public let title: String
22 public let action: Action
23
24 /// Shown in the palette; text-system movement and editing are left to keys.
25 public var inPalette: Bool {
26 switch action {
27 case .edit, .mark: false
28 default: true
29 }
30 }
31}
32
33public enum EditorCommands {
34 public static let all: [EditorCommand] = Commands.all.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
35 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
36 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
37
38 EditorCommand(id: "app.save", title: "Save", action: .app),
39 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
40 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
41 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
43
44 movement("forward-char", "Forward Character", "moveForward:"),
45 movement("backward-char", "Backward Character", "moveBackward:"),
46 movement("next-line", "Next Line", "moveDown:"),
47 movement("previous-line", "Previous Line", "moveUp:"),
48 movement("beginning-of-line", "Beginning of Line", "moveToBeginningOfParagraph:"),
49 movement("end-of-line", "End of Line", "moveToEndOfParagraph:"),
50 movement("forward-word", "Forward Word", "moveWordForward:"),
51 movement("backward-word", "Backward Word", "moveWordBackward:"),
52 movement("beginning-of-buffer", "Beginning of Buffer", "moveToBeginningOfDocument:"),
53 movement("end-of-buffer", "End of Buffer", "moveToEndOfDocument:"),
54 movement("scroll-up", "Scroll Up", "pageDown:"),
55 movement("scroll-down", "Scroll Down", "pageUp:"),
56 edit("recenter", "Recenter", "centerSelectionInVisibleArea:"),
57 edit("delete-char", "Delete Character", "deleteForward:"),
58 edit("kill-word", "Kill Word", "deleteWordForward:"),
59 edit("backward-kill-word", "Kill Word Backward", "deleteWordBackward:"),
60 edit("kill-line", "Kill Line", "deleteToEndOfParagraph:"),
61 edit("yank", "Yank", "yank:"),
62 edit("select-all", "Select All", "selectAll:"),
63 edit("kill-region", "Kill Region", "cut:"),
64 edit("copy-region", "Copy Region", "copy:"),
65 edit("undo", "Undo", "undo:"),
66 edit("transpose-chars", "Transpose Characters", "transpose:"),
67 edit("find", "Find", "performTextFinderAction:"),
68 EditorCommand(id: "edit.set-mark", title: "Set Mark", action: .mark),
69 EditorCommand(id: "edit.keyboard-quit", title: "Quit", action: .mark),
70 ]
71
72 private static func movement(_ name: String, _ title: String, _ selector: String) -> EditorCommand {
73 let extending = String(selector.dropLast()) + "AndModifySelection:"
74 return EditorCommand(id: "edit.\(name)", title: title, action: .edit(selector: selector, extending: extending))
75 }
76
77 private static func edit(_ name: String, _ title: String, _ selector: String) -> EditorCommand {
78 EditorCommand(id: "edit.\(name)", title: title, action: .edit(selector: selector))
79 }
80
81 private static let byID = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
82
83 public static func command(_ id: String) -> EditorCommand? { byID[id] }
84}
85#endif
Sources/OrgEditorAppKit/Keys.swift added +75
@@ -0,0 +1,75 @@
1#if os(macOS)
2import AppKit
3import OrgCore
4
5/// Which Option keys act as Meta. The others type characters, as usual on a Mac.
6public struct OptionAsMeta: OptionSet, Sendable {
7 public let rawValue: Int
8 public init(rawValue: Int) { self.rawValue = rawValue }
9 public static let left = OptionAsMeta(rawValue: 1)
10 public static let right = OptionAsMeta(rawValue: 2)
11}
12
13/// A text view that offers key presses to the editor's keymap before the text system.
14final class OrgTextView: NSTextView {
15 /// Returns true when the key was handled.
16 var keyHandler: ((NSEvent) -> Bool)?
17 var onMouseDown: (() -> Void)?
18
19 override func keyDown(with event: NSEvent) {
20 if !hasMarkedText(), keyHandler?(event) == true { return }
21 super.keyDown(with: event)
22 }
23
24 override func performKeyEquivalent(with event: NSEvent) -> Bool {
25 if window?.firstResponder === self, !hasMarkedText(), event.modifierFlags.contains(.command), keyHandler?(event) == true {
26 return true
27 }
28 return super.performKeyEquivalent(with: event)
29 }
30
31 override func mouseDown(with event: NSEvent) {
32 onMouseDown?()
33 super.mouseDown(with: event)
34 }
35}
36
37enum KeyEvents {
38 /// Device-dependent bits for the left and right Option keys (`NX_DEVICELALTKEYMASK`,
39 /// `NX_DEVICERALTKEYMASK`).
40 static let leftOption: UInt = 0x20
41 static let rightOption: UInt = 0x40
42
43 static let named: [UInt16: String] = [
44 48: "TAB", 36: "RET", 76: "RET", 49: "SPC", 53: "ESC", 51: "DEL", 117: "<delete>",
45 123: "<left>", 124: "<right>", 125: "<down>", 126: "<up>",
46 115: "<home>", 119: "<end>", 116: "<prior>", 121: "<next>",
47 122: "<f1>", 120: "<f2>", 99: "<f3>", 118: "<f4>", 96: "<f5>", 97: "<f6>",
48 98: "<f7>", 100: "<f8>", 101: "<f9>", 109: "<f10>", 103: "<f11>", 111: "<f12>",
49 ]
50
51 /// The chord for a key press, or nil when it types a character with Option.
52 static func chord(for event: NSEvent, optionAsMeta: OptionAsMeta) -> KeyChord? {
53 guard event.type == .keyDown else { return nil }
54 let flags = event.modifierFlags
55 var modifiers: KeyChord.Modifiers = []
56 if flags.contains(.control) { modifiers.insert(.control) }
57 if flags.contains(.command) { modifiers.insert(.command) }
58 if flags.contains(.option) {
59 let raw = flags.rawValue
60 let meta = (raw & leftOption != 0 && optionAsMeta.contains(.left)) || (raw & rightOption != 0 && optionAsMeta.contains(.right))
61 if meta || !modifiers.isEmpty {
62 modifiers.insert(.meta)
63 } else {
64 return nil
65 }
66 }
67 if let name = named[event.keyCode] {
68 if flags.contains(.shift) { modifiers.insert(.shift) }
69 return KeyChord(name, modifiers)
70 }
71 guard let characters = event.charactersIgnoringModifiers, characters.count == 1 else { return nil }
72 return KeyChord(characters, modifiers)
73 }
74}
75#endif
Sources/OrgEditorAppKit/OrgEditor.swift +116 −16
@@ -74,8 +74,13 @@ public final class OrgEditor: NSObject {
74 74
75 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) { 75 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) {
76 self.document = document 76 self.document = document
77 textView = NSTextView(usingTextLayoutManager: true) 77 let orgTextView = OrgTextView(usingTextLayoutManager: true)
78 textView = orgTextView
79 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
78 super.init() 80 super.init()
81 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
82 orgTextView.onMouseDown = { [weak self] in self?.markActive = false }
83 textView.usesFindBar = true
79 textView.frame = frame 84 textView.frame = frame
80 textView.isRichText = false 85 textView.isRichText = false
81 textView.isEditable = editable 86 textView.isEditable = editable
@@ -166,16 +171,120 @@ public final class OrgEditor: NSObject {
166 171
167 /// Called with messages commands report, such as why one couldn't run here. 172 /// Called with messages commands report, such as why one couldn't run here.
168 public var onMessage: ((String) -> Void)? 173 public var onMessage: ((String) -> Void)?
174 /// Called with the pending prefix and what can follow it, and with nil when it ends.
175 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
176 /// Called with `app.*` commands, which the app carries out.
177 public var onAppCommand: ((String) -> Void)?
178
179 public var keymap: Keymap {
180 get { dispatcher.keymap }
181 set {
182 dispatcher.keymap = newValue
183 dispatcher.reset()
184 }
185 }
169 186
170 /// Runs `command` at the selection. Edits go through the text view, as typing does, so 187 public var optionAsMeta: OptionAsMeta = [.left]
171 /// they form one undo step and reach the document through the same path. 188 private var dispatcher: KeyDispatcher
172 @discardableResult 189 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
173 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep { 190 private(set) var markActive = false
191
192 private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
174 let selected = textView.selectedRange() 193 let selected = textView.selectedRange()
175 let context = EditContext( 194 return EditContext(
176 revision: document.revision, text: document.text, tree: document.tree, 195 revision: document.revision, text: document.text, tree: document.tree,
177 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers 196 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
178 ) 197 )
198 }
199
200 /// Offers a key press to the keymap. False lets the text system have it.
201 func handleKey(_ event: NSEvent) -> Bool {
202 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return false }
203 switch dispatcher.feed(chord) {
204 case .pending(let keys):
205 let next = keymap.continuations(of: keys).map { item in
206 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
207 }
208 onKeysPending?(KeySequence.format(keys), next)
209 return true
210 case .cancelled:
211 onKeysPending?(nil, [])
212 onMessage?("Quit")
213 return true
214 case .undefined(let keys):
215 onKeysPending?(nil, [])
216 onMessage?("\(KeySequence.format(keys)) is undefined")
217 return true
218 case .unbound:
219 return false
220 case .complete(let keys, let candidates):
221 if keys.count > 1 { onKeysPending?(nil, []) }
222 let context = context()
223 let runnable = candidates.first { binding in
224 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
225 }
226 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
227 run(binding.command)
228 return true
229 }
230 return false
231 }
232 }
233
234 private func applies(_ id: String, in context: EditContext) -> Bool {
235 switch EditorCommands.command(id)?.action {
236 case .text(let command): command.applies(in: context)
237 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
238 case .edit, .mark, .app: true
239 case nil: true
240 }
241 }
242
243 /// Runs a command by id, as a key, a menu item or the palette would.
244 public func run(_ id: String) {
245 guard let command = EditorCommands.command(id) else {
246 onMessage?("Unknown command \(id)")
247 return
248 }
249 var keepMark = false
250 switch command.action {
251 case .text(let text):
252 perform(text)
253 case .view:
254 if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() }
255 case .edit(let selector, let extending):
256 if markActive, let extending {
257 keepMark = true
258 textView.doCommand(by: NSSelectorFromString(extending))
259 } else if selector == "performTextFinderAction:" {
260 let item = NSMenuItem()
261 item.tag = NSTextFinder.Action.showFindInterface.rawValue
262 textView.performTextFinderAction(item)
263 } else {
264 textView.doCommand(by: NSSelectorFromString(selector))
265 }
266 case .mark:
267 if id == "edit.set-mark" {
268 let caret = textView.selectedRange().location
269 textView.setSelectedRange(NSRange(location: caret, length: 0))
270 keepMark = true
271 onMessage?("Mark set")
272 } else {
273 let selected = textView.selectedRange()
274 if markActive || selected.length > 0 { textView.setSelectedRange(NSRange(location: NSMaxRange(selected), length: 0)) }
275 onMessage?("Quit")
276 }
277 case .app:
278 onAppCommand?(id)
279 }
280 markActive = keepMark
281 }
282
283 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
284 /// they form one undo step and reach the document through the same path.
285 @discardableResult
286 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
287 let context = context(now: now, answers: answers)
179 let step = command.run(in: context) 288 let step = command.run(in: context)
180 switch step { 289 switch step {
181 case .commit(let result): 290 case .commit(let result):
@@ -370,6 +479,7 @@ public final class OrgEditor: NSObject {
370 479
371 fileprivate func storageEdited(range: NSRange, delta: Int) { 480 fileprivate func storageEdited(range: NSRange, delta: Int) {
372 guard !isLoading, let storage = textView.textStorage else { return } 481 guard !isLoading, let storage = textView.textStorage else { return }
482 markActive = false
373 let clock = ContinuousClock() 483 let clock = ContinuousClock()
374 let start = clock.now 484 let start = clock.now
375 let replacement = (storage.string as NSString).substring(with: range) 485 let replacement = (storage.string as NSString).substring(with: range)
@@ -490,16 +600,6 @@ extension OrgEditor: DocumentBuffer {
490} 600}
491 601
492extension OrgEditor: NSTextViewDelegate { 602extension OrgEditor: NSTextViewDelegate {
493 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
494 switch selector {
495 case #selector(NSResponder.insertTab(_:)): return cycleAtCaret()
496 case #selector(NSResponder.insertBacktab(_:)):
497 cycleGlobal()
498 return true
499 default: return false
500 }
501 }
502
503 public func textViewDidChangeSelection(_ notification: Notification) { 603 public func textViewDidChangeSelection(_ notification: Notification) {
504 selectionChanged() 604 selectionChanged()
505 } 605 }
Sources/Orgstar/ContentView.swift +40 −1
@@ -1,4 +1,6 @@
1import OrgApp 1import OrgApp
2import OrgCore
3import OrgEditorAppKit
2import OrgIndex 4import OrgIndex
3import SwiftUI 5import SwiftUI
4 6
@@ -7,6 +9,9 @@ struct ContentView: View {
7 let session: DocumentSession 9 let session: DocumentSession
8 @Bindable var commands: AppCommands 10 @Bindable var commands: AppCommands
9 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 11 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
12 @AppStorage("keymap") private var preset = KeymapPreset.emacs
13 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
14 @State private var keymap = KeymapPreset.emacs.keymap
10 @State private var selectedFile: String? 15 @State private var selectedFile: String?
11 @State private var query = "" 16 @State private var query = ""
12 @FocusState private var searchFocused: Bool 17 @FocusState private var searchFocused: Bool
@@ -30,7 +35,8 @@ struct ContentView: View {
30 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) 35 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
31 } detail: { 36 } detail: {
32 if session.path != nil { 37 if session.path != nil {
33 EditorView(session: session) 38 EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), onAppCommand: perform)
39 .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) }
34 } else { 40 } else {
35 ContentUnavailableView( 41 ContentUnavailableView(
36 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", 42 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
@@ -59,6 +65,14 @@ struct ContentView: View {
59 } 65 }
60 } 66 }
61 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } 67 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
68 .onChange(of: preset, initial: true) { loadKeymap() }
69 .sheet(isPresented: $commands.showingPalette) {
70 PaletteView(keymap: keymap) { id in
71 commands.showingPalette = false
72 // After the sheet closes, so app commands can present their own.
73 DispatchQueue.main.async { perform(id) }
74 }
75 }
62 .onChange(of: session.conflict) { _, conflict in 76 .onChange(of: session.conflict) { _, conflict in
63 if conflict != nil { commands.showingConflict = true } 77 if conflict != nil { commands.showingConflict = true }
64 } 78 }
@@ -106,6 +120,31 @@ struct ContentView: View {
106 } 120 }
107 } 121 }
108 122
123 private func loadKeymap() {
124 let loaded = KeymapLoader.load(preset)
125 keymap = loaded.keymap
126 if let first = loaded.problems.first {
127 let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : ""
128 commands.show("keymap.toml: \(first)\(more)")
129 }
130 }
131
132 /// Runs a command from a key, the palette or the Org menu.
133 private func perform(_ id: String) {
134 switch id {
135 case "app.save":
136 if !session.save(), session.conflict != nil { commands.showingConflict = true }
137 case "app.quick-open": commands.showingQuickOpen = true
138 case "app.palette": commands.showingPalette = true
139 case "app.search": commands.focusSearch = true
140 case "app.reload-keymap":
141 loadKeymap()
142 commands.show("Keymap reloaded")
143 default:
144 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
145 }
146 }
147
109 private var errorShown: Binding<Bool> { 148 private var errorShown: Binding<Bool> {
110 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) 149 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
111 } 150 }
Sources/Orgstar/EditorView.swift +16
@@ -1,5 +1,6 @@
1import AppKit 1import AppKit
2import OrgApp 2import OrgApp
3import OrgCore
3import OrgEditorAppKit 4import OrgEditorAppKit
4import SwiftUI 5import SwiftUI
5 6
@@ -7,6 +8,10 @@ import SwiftUI
7/// session's buffer; reveal requests go to the existing one. 8/// session's buffer; reveal requests go to the existing one.
8struct EditorView: NSViewRepresentable { 9struct EditorView: NSViewRepresentable {
9 let session: DocumentSession 10 let session: DocumentSession
11 let commands: AppCommands
12 let keymap: Keymap
13 let optionAsMeta: OptionAsMeta
14 let onAppCommand: (String) -> Void
10 15
11 final class Coordinator { 16 final class Coordinator {
12 var editor: OrgEditor? 17 var editor: OrgEditor?
@@ -29,6 +34,13 @@ struct EditorView: NSViewRepresentable {
29 if let document = session.document { 34 if let document = session.document {
30 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable) 35 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable)
31 editor.onChange = { [weak session] in session?.bufferChanged() } 36 editor.onChange = { [weak session] in session?.bufferChanged() }
37 editor.onMessage = { [weak commands] in commands?.show($0) }
38 editor.onKeysPending = { [weak commands] keys, next in
39 commands?.pendingKeys = keys
40 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
41 }
42 editor.onAppCommand = onAppCommand
43 commands.editor = editor
32 let scroll = editor.makeScrollView() 44 let scroll = editor.makeScrollView()
33 scroll.frame = container.bounds 45 scroll.frame = container.bounds
34 scroll.autoresizingMask = [.width, .height] 46 scroll.autoresizingMask = [.width, .height]
@@ -38,6 +50,10 @@ struct EditorView: NSViewRepresentable {
38 session.attach(editor) 50 session.attach(editor)
39 } 51 }
40 } 52 }
53 if let editor = coordinator.editor {
54 if editor.keymap != keymap { editor.keymap = keymap }
55 editor.optionAsMeta = optionAsMeta
56 }
41 container.window?.isDocumentEdited = session.isDirty 57 container.window?.isDocumentEdited = session.isDirty
42 if let request = session.revealRequest, request.id != coordinator.revealID { 58 if let request = session.revealRequest, request.id != coordinator.revealID {
43 coordinator.revealID = request.id 59 coordinator.revealID = request.id
Sources/Orgstar/Keys.swift added +93
@@ -0,0 +1,93 @@
1import OrgApp
2import OrgCore
3import OrgEditorAppKit
4import SwiftUI
5
6struct KeyHint: Hashable {
7 let key: String
8 let title: String
9}
10
11/// The minibuffer line: a pending prefix, or the last message for a few seconds. After a
12/// short pause on a prefix, the keys that can follow it.
13struct EchoArea: View {
14 let commands: AppCommands
15 @State private var showingHints = false
16
17 var body: some View {
18 VStack(alignment: .leading, spacing: 0) {
19 if showingHints, commands.pendingKeys != nil, !commands.keyHints.isEmpty {
20 Divider()
21 LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), alignment: .leading)], alignment: .leading, spacing: 4) {
22 ForEach(commands.keyHints, id: \.self) { hint in
23 HStack(spacing: 8) {
24 Text(hint.key).font(.system(.body, design: .monospaced)).foregroundStyle(.tint)
25 Text(hint.title).foregroundStyle(hint.title == "+prefix" ? .secondary : .primary).lineLimit(1)
26 }
27 }
28 }
29 .padding(10)
30 }
31 if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message {
32 Divider()
33 Text(text)
34 .font(.system(.callout, design: .monospaced))
35 .lineLimit(1)
36 .padding(.horizontal, 10)
37 .padding(.vertical, 4)
38 .frame(maxWidth: .infinity, alignment: .leading)
39 }
40 }
41 .background(.bar)
42 .task(id: commands.pendingKeys) {
43 showingHints = false
44 guard commands.pendingKeys != nil else { return }
45 try? await Task.sleep(for: .milliseconds(600))
46 if !Task.isCancelled { showingHints = true }
47 }
48 }
49}
50
51/// M-x and ⇧⌘P: every command by title, with its keys.
52struct PaletteView: View {
53 let keymap: Keymap
54 let run: (String) -> Void
55 @State private var query = ""
56 @Environment(\.dismiss) private var dismiss
57
58 private var matches: [EditorCommand] {
59 let scored: [(command: EditorCommand, score: Int)] = EditorCommands.all.filter(\.inPalette).compactMap { command in
60 guard let score = FuzzyMatch.score(query, command.title) else { return nil }
61 return (command, score)
62 }
63 return scored.sorted { $0.score != $1.score ? $0.score > $1.score : $0.command.title < $1.command.title }.map(\.command)
64 }
65
66 var body: some View {
67 let matches = matches
68 VStack(spacing: 0) {
69 TextField("Run command", text: $query)
70 .textFieldStyle(.plain)
71 .font(.title3)
72 .padding(12)
73 .onSubmit { if let first = matches.first { run(first.id) } }
74 Divider()
75 List(matches, id: \.id) { command in
76 Button {
77 run(command.id)
78 } label: {
79 HStack {
80 Text(command.title)
81 Spacer()
82 if let keys = keymap.keys(for: command.id) {
83 Text(KeySequence.format(keys)).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
84 }
85 }
86 }
87 .buttonStyle(.plain)
88 }
89 }
90 .frame(width: 520, height: 360)
91 .onExitCommand { dismiss() }
92 }
93}
Sources/Orgstar/OrgstarApp.swift +46
@@ -1,5 +1,7 @@
1import AppKit 1import AppKit
2import OrgApp 2import OrgApp
3import OrgCore
4import OrgEditorAppKit
3import SwiftUI 5import SwiftUI
4 6
5/// Window-level requests the menu bar makes. 7/// Window-level requests the menu bar makes.
@@ -10,6 +12,25 @@ final class AppCommands {
10 var showingFolderPicker = false 12 var showingFolderPicker = false
11 var focusSearch = false 13 var focusSearch = false
12 var showingConflict = false 14 var showingConflict = false
15 var showingPalette = false
16 /// The editor showing the open file, for menus and the palette.
17 var editor: OrgEditor?
18 /// The pending key prefix, and what can follow it.
19 var pendingKeys: String?
20 var keyHints: [KeyHint] = []
21 /// The last message from a command or the keymap, cleared after a few seconds.
22 private(set) var message: String?
23 private var messageID = 0
24
25 func show(_ text: String) {
26 messageID += 1
27 let id = messageID
28 message = text
29 Task {
30 try? await Task.sleep(for: .seconds(4))
31 if messageID == id { message = nil }
32 }
33 }
13} 34}
14 35
15@MainActor 36@MainActor
@@ -70,6 +91,8 @@ struct OrgstarApp: App {
70 .keyboardShortcut("o", modifiers: [.command, .shift]) 91 .keyboardShortcut("o", modifiers: [.command, .shift])
71 Button("Quick Open…") { commands.showingQuickOpen = true } 92 Button("Quick Open…") { commands.showingQuickOpen = true }
72 .keyboardShortcut("p") 93 .keyboardShortcut("p")
94 Button("Command Palette…") { commands.showingPalette = true }
95 .keyboardShortcut("p", modifiers: [.command, .shift])
73 } 96 }
74 CommandGroup(replacing: .saveItem) { 97 CommandGroup(replacing: .saveItem) {
75 Button("Save") { 98 Button("Save") {
@@ -83,6 +106,14 @@ struct OrgstarApp: App {
83 .keyboardShortcut("f", modifiers: [.command, .shift]) 106 .keyboardShortcut("f", modifiers: [.command, .shift])
84 } 107 }
85 } 108 }
109 .commands {
110 CommandMenu("Org") {
111 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
112 Button(command.title) { commands.editor?.run(command.id) }
113 .disabled(commands.editor == nil)
114 }
115 }
116 }
86 Settings { 117 Settings {
87 SettingsView() 118 SettingsView()
88 } 119 }
@@ -91,6 +122,8 @@ struct OrgstarApp: App {
91 122
92struct SettingsView: View { 123struct SettingsView: View {
93 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 124 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
125 @AppStorage("keymap") private var preset = KeymapPreset.emacs
126 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
94 127
95 var body: some View { 128 var body: some View {
96 Form { 129 Form {
@@ -99,6 +132,19 @@ struct SettingsView: View {
99 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) 132 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
100 } 133 }
101 .pickerStyle(.radioGroup) 134 .pickerStyle(.radioGroup)
135 Picker("Keys:", selection: $preset) {
136 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
137 }
138 Picker("Option as Meta:", selection: $optionAsMeta) {
139 Text("Left Option").tag(OptionAsMeta.left.rawValue)
140 Text("Right Option").tag(OptionAsMeta.right.rawValue)
141 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
142 Text("Neither").tag(0)
143 }
144 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
145 .font(.caption)
146 .foregroundStyle(.secondary)
147 .textSelection(.enabled)
102 } 148 }
103 .padding(20) 149 .padding(20)
104 .frame(width: 420) 150 .frame(width: 420)
Tests/OrgAppTests/AppTests.swift +17
@@ -209,3 +209,20 @@ struct SessionSavingTests {
209 #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"]) 209 #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"])
210 } 210 }
211} 211}
212
213@MainActor
214struct KeymapLoaderTests {
215 @Test func layersTheUserFile() throws {
216 let folder = try Folder()
217 let missing = KeymapLoader.load(.emacs, userFile: folder.url.appendingPathComponent("none.toml"))
218 #expect(missing.keymap == KeymapPreset.emacs.keymap && missing.problems.isEmpty)
219
220 let file = try folder.write("keymap.toml", "[[bind]]\nkeys = \"C-c a\"\ncommand = \"app.palette\"\n[[bind]]\nkeys = \"Q-q\"\ncommand = \"x\"\n")
221 let loaded = KeymapLoader.load(.emacs, userFile: file)
222 #expect(loaded.keymap.candidates(for: KeySequence.parse("C-c a")!).first?.command == "app.palette")
223 #expect(loaded.problems == ["line 4: bad or missing keys"])
224
225 let broken = try folder.write("broken.toml", "keys = \"x")
226 #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"])
227 }
228}
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}
Tests/OrgEditorAppKitTests/EditorTests.swift +4 −4
@@ -89,10 +89,10 @@ struct FoldingTests {
89 89
90 @Test func globalCycleAndStartup() { 90 @Test func globalCycleAndStartup() {
91 let h = Harness(outline) 91 let h = Harness(outline)
92 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) 92 h.editor.run("org.cycle-global")
93 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) 93 h.editor.run("org.cycle-global")
94 #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"]) 94 #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"])
95 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) 95 h.editor.run("org.cycle-global")
96 #expect(h.visibleLines.count == 6) 96 #expect(h.visibleLines.count == 6)
97 #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"]) 97 #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"])
98 } 98 }
@@ -118,7 +118,7 @@ struct FoldingTests {
118 let h = Harness(outline) 118 let h = Harness(outline)
119 h.caret(at: 5) 119 h.caret(at: 5)
120 #expect(!h.editor.cycleAtCaret()) 120 #expect(!h.editor.cycleAtCaret())
121 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) 121 h.editor.run("org.cycle-global")
122 #expect(h.visibleLines == ["* a\n", "* c\n"]) 122 #expect(h.visibleLines == ["* a\n", "* c\n"])
123 } 123 }
124 124
Tests/OrgEditorAppKitTests/KeyTests.swift added +119
@@ -0,0 +1,119 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6@MainActor
7struct KeyTests {
8 static let codes: [Character: UInt16] = ["a": 0, "s": 1, "f": 3, "g": 5, "z": 6, "x": 7, "c": 8, "w": 13, "t": 17]
9
10 /// Presses a key. `control` sends the control character, as macOS does.
11 func press(_ h: Harness, _ key: Character, control: Bool = false, option: UInt = 0, typed: String? = nil) {
12 var flags: NSEvent.ModifierFlags = []
13 if control { flags.insert(.control) }
14 if option != 0 { flags = NSEvent.ModifierFlags(rawValue: flags.rawValue | NSEvent.ModifierFlags.option.rawValue | option) }
15 let ignoring = String(key)
16 let characters = typed ?? (control ? String(UnicodeScalar(UInt8(key.asciiValue! & 0x1f))) : ignoring)
17 send(h, characters: characters, ignoring: ignoring, code: Self.codes[key] ?? 0, flags: flags)
18 }
19
20 func send(_ h: Harness, characters: String, ignoring: String, code: UInt16, flags: NSEvent.ModifierFlags = []) {
21 let event = NSEvent.keyEvent(
22 with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, windowNumber: h.window.windowNumber,
23 context: nil, characters: characters, charactersIgnoringModifiers: ignoring, isARepeat: false, keyCode: code
24 )!
25 h.textView.keyDown(with: event)
26 }
27
28 @Test func prefixSequenceRunsAnOrgCommand() {
29 let h = Harness("* a\n")
30 var pending: [String?] = []
31 var next: [(key: String, title: String)] = []
32 h.editor.onKeysPending = { keys, continuations in
33 pending.append(keys)
34 if keys != nil { next = continuations }
35 }
36 h.caret(at: 1)
37 press(h, "c", control: true)
38 #expect(pending == ["C-c"])
39 #expect(next.contains { $0.key == "C-t" && $0.title == "Cycle TODO State" })
40 press(h, "t", control: true)
41 #expect(h.string == "* TODO a\n")
42 #expect(pending == ["C-c", nil])
43 h.checkInSync()
44 }
45
46 @Test func undefinedSequenceReportsAMessage() {
47 let h = Harness("text\n")
48 var messages: [String] = []
49 h.editor.onMessage = { messages.append($0) }
50 press(h, "c", control: true)
51 press(h, "z", control: true)
52 #expect(messages == ["C-c C-z is undefined"])
53 press(h, "x", control: true)
54 press(h, "g", control: true)
55 #expect(messages.last == "Quit")
56 #expect(h.string == "text\n")
57 }
58
59 @Test func tabFoldsOnHeadingsAndIndentsElsewhere() {
60 let h = Harness(outline)
61 h.caret(at: 1)
62 send(h, characters: "\t", ignoring: "\t", code: 48)
63 #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
64 h.caret(at: h.offset(of: "text"))
65 send(h, characters: "\t", ignoring: "\t", code: 48)
66 #expect(h.string.hasSuffix("\ttext\n"))
67 }
68
69 @Test func markExtendsMovementAndKillTakesTheRegion() {
70 let h = Harness("abc\n")
71 h.caret(at: 0)
72 send(h, characters: "\0", ignoring: " ", code: 49, flags: .control)
73 #expect(h.editor.markActive)
74 press(h, "f", control: true)
75 press(h, "f", control: true)
76 #expect(h.textView.selectedRange() == NSRange(location: 0, length: 2))
77 press(h, "w", control: true)
78 #expect(h.string == "c\n")
79 #expect(!h.editor.markActive)
80 h.checkInSync()
81 }
82
83 @Test func leftOptionIsMetaRightOptionTypes() {
84 let h = Harness("one two\n")
85 h.caret(at: 0)
86 press(h, "f", option: KeyEvents.leftOption, typed: "ƒ")
87 #expect(h.caret == 3)
88 press(h, "f", option: KeyEvents.rightOption, typed: "ƒ")
89 #expect(h.string == "oneƒ two\n")
90 }
91
92 @Test func appCommandsGoToTheApp() {
93 let h = Harness("text\n")
94 var commands: [String] = []
95 h.editor.onAppCommand = { commands.append($0) }
96 press(h, "x", control: true)
97 press(h, "s", control: true)
98 #expect(commands == ["app.save"])
99 }
100
101 @Test func userKeymapOverridesThePreset() throws {
102 let h = Harness("* a\n")
103 var problems: [String] = []
104 let user = try Keymap(toml: "[[bind]]\nkeys = \"C-c C-t\"\ncommand = \"org.priority.up\"\n", problems: &problems)
105 h.editor.keymap = .layered([KeymapPreset.emacs.keymap, user])
106 h.caret(at: 1)
107 press(h, "c", control: true)
108 press(h, "t", control: true)
109 #expect(h.string == "* [#B] a\n")
110 }
111
112 @Test func everyPresetCommandExists() {
113 for preset in KeymapPreset.allCases {
114 for binding in preset.keymap.bindings {
115 #expect(EditorCommands.command(binding.command) != nil, "\(preset): \(binding.command)")
116 }
117 }
118 }
119}
docs/plans/2026-10-05-keymaps.md added +65
@@ -0,0 +1,65 @@
1# Keymaps and Command Palette Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Keys run commands: TOML keymaps in layers (preset, then `keymap.toml`), key sequences of any length with an echo area and a which-key popup, context dispatch through `when`, Emacs and Mac presets, Option as Meta per side, and a command palette (M-x, ⇧⌘P) over every command.
6
7**Architecture:** Platform-free in `OrgCore/Keymap`: Emacs key notation (`KeyChord`, `KeySequence`), the TOML subset keymaps use, `Keymap` (layered bindings, unbinding with `none`, prefixes, continuations), `KeyContext` (`heading`, `table`, `item`, `region`) and `KeyDispatcher` (the pending-prefix state machine). In `OrgEditorAppKit`, `OrgTextView` turns key events into chords before the text system sees them and the editor runs the winning binding: `org.*` text commands through `perform`, view commands (`org.cycle`, `org.cycle-global`), `edit.*` commands as text-system selectors with an Emacs-style active mark, and `app.*` commands handed to the app. The app loads the keymap, shows the echo area and which-key, and hosts the palette and an Org menu.
8
9**Tech Stack:** Swift 6.2 tools, AppKit, SwiftUI, Swift Testing.
10
11**Spec:** `docs/design.md`, "Commands and keymaps" (keymap format, presets).
12
13## Global Constraints
14
15- `⌘` shortcuts from the menus (save, undo, find, quick open) work in every preset.
16- While the text view has marked text (IME composition), keys go to the input method untouched.
17- `mode` is parsed and kept; bindings with a `mode` are inactive until the modal engine (Doom preset).
18- The user's keymap lives at `<data dir>/keymap.toml`; problems in it are reported, and the entries that parse still apply.
19
20## Defaults chosen (user may change)
21
22- Option as Meta: left Option is Meta, right Option types characters.
23- Mac preset org keys: ⌃⌘T TODO, ⌃⌘↑/↓ priority, ⌃⌘←/→ promote/demote.
24- An active region is replaced by typing (Cocoa), as `delete-selection-mode`.
25- which-key appears 0.6 s after a prefix.
26
27## File structure
28
29| File | Responsibility |
30| --- | --- |
31| `Sources/OrgCore/Keymap/Keys.swift` | `KeyChord`, `KeySequence` |
32| `Sources/OrgCore/Keymap/TOML.swift` | TOML subset parser |
33| `Sources/OrgCore/Keymap/Keymap.swift` | `KeyBinding`, `Keymap`, `KeyContext`, `KeyDispatcher` |
34| `Sources/OrgCore/Keymap/Presets.swift` | `KeymapPreset` (Emacs, Mac) |
35| `Sources/OrgEditorAppKit/Keys.swift` | `OrgTextView`, event-to-chord, `OptionAsMeta` |
36| `Sources/OrgEditorAppKit/EditorCommands.swift` | Command registry: ids, titles, how each runs |
37| `Sources/OrgEditorAppKit/OrgEditor.swift` | Dispatch, mark, callbacks for pending keys and app commands |
38| `Sources/Orgstar/*.swift` | Keymap loading, echo area, which-key, palette, Org menu, Settings |
39
40---
41
42### Task 1: Keymap core
43
44`KeyChord.parse`, `KeySequence.parse/format`, `TOML.parse`, `Keymap(toml:problems:)`, `Keymap.layered`, `candidates(for:mode:)`, `isPrefix`, `continuations(of:)`, `keys(for:)`, `KeyContext.holds`, `KeyDispatcher.feed` returning `.pending`, `.complete`, `.undefined`, `.unbound` or `.cancelled`.
45
46- [ ] Tests: notation round trips and shift folding; TOML tables, escapes and error lines; bad entries skipped with problems; later layers win and `none` unbinds; prefix dispatch, undefined sequences and C-g; continuations; contexts; presets parse.
47- [ ] Commit "Add keymaps".
48
49### Task 2: Keys in the editor
50
51- `OrgTextView.keyDown` and `performKeyEquivalent` feed the dispatcher (except during IME composition); `.unbound` goes to the text system.
52- Context dispatch: the first candidate whose `when` holds and whose command applies runs; for a single key with none, the text system gets the key; for a longer sequence the first candidate runs and reports why it can't.
53- `edit.set-mark` starts a region that movement extends; C-g, editing, kill and copy end it.
54- Option as Meta per side, from the device-dependent modifier bits.
55- `onKeysPending(String?, [(key, title)])`, `onAppCommand(String)`, and `run(_ id:)` for menus and the palette.
56- [ ] Tests with synthesized key events: C-c C-t cycles TODO; TAB folds on a heading and inserts a tab in a paragraph; C-SPC C-f C-w kills one character; M-f moves a word with left Option and types with right Option; C-x C-s reaches `onAppCommand`; an undefined sequence reports a message; pending keys are reported.
57- [ ] Commit "Run commands from keys".
58
59### Task 3: App
60
61- Settings: preset, Option as Meta; `keymap.toml` loaded at launch and with "Reload Keymap".
62- Echo area at the bottom of the editor: pending keys and command messages. which-key popup lists the continuations with command titles.
63- Command palette (M-x, ⇧⌘P): fuzzy search over command titles, showing each command's keys; runs on the editor.
64- Org menu with the org commands.
65- [ ] Verify with the snapshot hook; commit "Keymaps, echo area and palette in the app".