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