Commit 10c3366127
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Presets.swift −3
| @@ -95,9 +95,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 95 | keys = "C-SPC" | 95 | keys = "C-SPC" |
| 96 | command = "edit.set-mark" | 96 | command = "edit.set-mark" |
| 97 | [[bind]] | 97 | [[bind]] |
| 98 | keys = "C-x C-x" | ||
| 99 | command = "edit.exchange-point-and-mark" | ||
| 100 | [[bind]] | ||
| 101 | keys = "C-x h" | 98 | keys = "C-x h" |
| 102 | command = "edit.select-all" | 99 | command = "edit.select-all" |
| 103 | [[bind]] | 100 | [[bind]] |
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 | 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 | ||
| 492 | extension OrgEditor: NSTextViewDelegate { | 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 | public func textViewDidChangeSelection(_ notification: Notification) { | 603 | public func textViewDidChangeSelection(_ notification: Notification) { |
| 504 | selectionChanged() | 604 | selectionChanged() |
| 505 | } | 605 | } |
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 @@ | |||
| 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 | } | ||