Keymaps and command palette !12
17 files changed, +1391 −21
Layout: unified · split
Sources/OrgApp/KeymapLoader.swift added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | @MainActor | |
| 5 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | public 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. | |
| 24 | public 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. | |
| 105 | public 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. | |
| 129 | public 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>`). | |
| 5 | public 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 | ||
| 70 | public 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. | |
| 2 | public 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 @@ | ||
| 1 | import 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. | |
| 6 | public 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) | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | /// Every command a key, menu or the palette can run, by id. | |
| 5 | public 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 | ||
| 33 | public 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) | |
| 2 | import AppKit | |
| 3 | import OrgCore | |
| 4 | ||
| 5 | /// Which Option keys act as Meta. The others type characters, as usual on a Mac. | |
| 6 | public 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. | |
| 14 | final 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 | ||
| 37 | enum 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 | 75 | public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) { |
| 76 | 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 | 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 | 84 | textView.frame = frame |
| 80 | 85 | textView.isRichText = false |
| 81 | 86 | textView.isEditable = editable |
| @@ -166,16 +171,120 @@ public final class OrgEditor: NSObject { | ||
| 166 | 171 | |
| 167 | 172 | /// Called with messages commands report, such as why one couldn't run here. |
| 168 | 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 | |
| 171 | /// they form one undo step and reach the document through the same path. | |
| 172 | @discardableResult | |
| 173 | public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep { | |
| 187 | public var optionAsMeta: OptionAsMeta = [.left] | |
| 188 | private var dispatcher: KeyDispatcher | |
| 189 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. | |
| 190 | private(set) var markActive = false | |
| 191 | ||
| 192 | private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext { | |
| 174 | 193 | let selected = textView.selectedRange() |
| 175 | let context = EditContext( | |
| 194 | return EditContext( | |
| 176 | 195 | revision: document.revision, text: document.text, tree: document.tree, |
| 177 | 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 | 288 | let step = command.run(in: context) |
| 180 | 289 | switch step { |
| 181 | 290 | case .commit(let result): |
| @@ -370,6 +479,7 @@ public final class OrgEditor: NSObject { | ||
| 370 | 479 | |
| 371 | 480 | fileprivate func storageEdited(range: NSRange, delta: Int) { |
| 372 | 481 | guard !isLoading, let storage = textView.textStorage else { return } |
| 482 | markActive = false | |
| 373 | 483 | let clock = ContinuousClock() |
| 374 | 484 | let start = clock.now |
| 375 | 485 | let replacement = (storage.string as NSString).substring(with: range) |
| @@ -490,16 +600,6 @@ extension OrgEditor: DocumentBuffer { | ||
| 490 | 600 | } |
| 491 | 601 | |
| 492 | 602 | extension 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 | 603 | public func textViewDidChangeSelection(_ notification: Notification) { |
| 504 | 604 | selectionChanged() |
| 505 | 605 | } |
Sources/Orgstar/ContentView.swift +40 −1
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | import OrgApp |
| 2 | import OrgCore | |
| 3 | import OrgEditorAppKit | |
| 2 | 4 | import OrgIndex |
| 3 | 5 | import SwiftUI |
| 4 | 6 | |
| @@ -7,6 +9,9 @@ struct ContentView: View { | ||
| 7 | 9 | let session: DocumentSession |
| 8 | 10 | @Bindable var commands: AppCommands |
| 9 | 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 | 15 | @State private var selectedFile: String? |
| 11 | 16 | @State private var query = "" |
| 12 | 17 | @FocusState private var searchFocused: Bool |
| @@ -30,7 +35,8 @@ struct ContentView: View { | ||
| 30 | 35 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 31 | 36 | } detail: { |
| 32 | 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 | 40 | } else { |
| 35 | 41 | ContentUnavailableView( |
| 36 | 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 | 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 | 76 | .onChange(of: session.conflict) { _, conflict in |
| 63 | 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 | 148 | private var errorShown: Binding<Bool> { |
| 110 | 149 | Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) |
| 111 | 150 | } |
Sources/Orgstar/EditorView.swift +16
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgApp |
| 3 | import OrgCore | |
| 3 | 4 | import OrgEditorAppKit |
| 4 | 5 | import SwiftUI |
| 5 | 6 | |
| @@ -7,6 +8,10 @@ import SwiftUI | ||
| 7 | 8 | /// session's buffer; reveal requests go to the existing one. |
| 8 | 9 | struct EditorView: NSViewRepresentable { |
| 9 | 10 | let session: DocumentSession |
| 11 | let commands: AppCommands | |
| 12 | let keymap: Keymap | |
| 13 | let optionAsMeta: OptionAsMeta | |
| 14 | let onAppCommand: (String) -> Void | |
| 10 | 15 | |
| 11 | 16 | final class Coordinator { |
| 12 | 17 | var editor: OrgEditor? |
| @@ -29,6 +34,13 @@ struct EditorView: NSViewRepresentable { | ||
| 29 | 34 | if let document = session.document { |
| 30 | 35 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable) |
| 31 | 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 | 44 | let scroll = editor.makeScrollView() |
| 33 | 45 | scroll.frame = container.bounds |
| 34 | 46 | scroll.autoresizingMask = [.width, .height] |
| @@ -38,6 +50,10 @@ struct EditorView: NSViewRepresentable { | ||
| 38 | 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 | 57 | container.window?.isDocumentEdited = session.isDirty |
| 42 | 58 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 43 | 59 | coordinator.revealID = request.id |
Sources/Orgstar/Keys.swift added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | import OrgApp | |
| 2 | import OrgCore | |
| 3 | import OrgEditorAppKit | |
| 4 | import SwiftUI | |
| 5 | ||
| 6 | struct 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. | |
| 13 | struct 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. | |
| 52 | struct 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 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgApp |
| 3 | import OrgCore | |
| 4 | import OrgEditorAppKit | |
| 3 | 5 | import SwiftUI |
| 4 | 6 | |
| 5 | 7 | /// Window-level requests the menu bar makes. |
| @@ -10,6 +12,25 @@ final class AppCommands { | ||
| 10 | 12 | var showingFolderPicker = false |
| 11 | 13 | var focusSearch = false |
| 12 | 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 | 36 | @MainActor |
| @@ -70,6 +91,8 @@ struct OrgstarApp: App { | ||
| 70 | 91 | .keyboardShortcut("o", modifiers: [.command, .shift]) |
| 71 | 92 | Button("Quick Open…") { commands.showingQuickOpen = true } |
| 72 | 93 | .keyboardShortcut("p") |
| 94 | Button("Command Palette…") { commands.showingPalette = true } | |
| 95 | .keyboardShortcut("p", modifiers: [.command, .shift]) | |
| 73 | 96 | } |
| 74 | 97 | CommandGroup(replacing: .saveItem) { |
| 75 | 98 | Button("Save") { |
| @@ -83,6 +106,14 @@ struct OrgstarApp: App { | ||
| 83 | 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 | 117 | Settings { |
| 87 | 118 | SettingsView() |
| 88 | 119 | } |
| @@ -91,6 +122,8 @@ struct OrgstarApp: App { | ||
| 91 | 122 | |
| 92 | 123 | struct SettingsView: View { |
| 93 | 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 | 128 | var body: some View { |
| 96 | 129 | Form { |
| @@ -99,6 +132,19 @@ struct SettingsView: View { | ||
| 99 | 132 | Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) |
| 100 | 133 | } |
| 101 | 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 | 149 | .padding(20) |
| 104 | 150 | .frame(width: 420) |
Tests/OrgAppTests/AppTests.swift +17
| @@ -209,3 +209,20 @@ struct SessionSavingTests { | ||
| 209 | 209 | #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"]) |
| 210 | 210 | } |
| 211 | 211 | } |
| 212 | ||
| 213 | @MainActor | |
| 214 | struct 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 @@ | ||
| 1 | import Testing | |
| 2 | @testable import OrgCore | |
| 3 | ||
| 4 | struct 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 | ||
| 21 | struct 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 | ||
| 49 | struct 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 | 90 | @Test func globalCycleAndStartup() { |
| 91 | 91 | let h = Harness(outline) |
| 92 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | |
| 93 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | |
| 92 | h.editor.run("org.cycle-global") | |
| 93 | h.editor.run("org.cycle-global") | |
| 94 | 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 | 96 | #expect(h.visibleLines.count == 6) |
| 97 | 97 | #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"]) |
| 98 | 98 | } |
| @@ -118,7 +118,7 @@ struct FoldingTests { | ||
| 118 | 118 | let h = Harness(outline) |
| 119 | 119 | h.caret(at: 5) |
| 120 | 120 | #expect(!h.editor.cycleAtCaret()) |
| 121 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | |
| 121 | h.editor.run("org.cycle-global") | |
| 122 | 122 | #expect(h.visibleLines == ["* a\n", "* c\n"]) |
| 123 | 123 | } |
| 124 | 124 | |
Tests/OrgEditorAppKitTests/KeyTests.swift added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | import AppKit | |
| 2 | import OrgCore | |
| 3 | import Testing | |
| 4 | @testable import OrgEditorAppKit | |
| 5 | ||
| 6 | @MainActor | |
| 7 | struct 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". | |