Commit 2c9d41fe76
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Vim.swift +3 −2
| @@ -76,9 +76,10 @@ public struct Vim: Sendable { | ||
| 76 | 76 | public internal(set) var mode: Mode = .normal |
| 77 | 77 | /// Keys of the command being typed. |
| 78 | 78 | public private(set) var pending: [KeyChord] = [] |
| 79 | var registers: [Character: (text: String, linewise: Bool)] = [:] | |
| 79 | /// Named registers. Evil's are global: an editor with several engines shares them. | |
| 80 | public var registers: [Character: (text: String, linewise: Bool)] = [:] | |
| 80 | 81 | /// What the clipboard held when this engine last wrote it, and whether that was whole lines. |
| 81 | var clipboardRecord: (text: String, linewise: Bool)? | |
| 82 | public var clipboardRecord: (text: String, linewise: Bool)? | |
| 82 | 83 | var anchor = 0 |
| 83 | 84 | var goalColumn: Int? |
| 84 | 85 | var lastFind: (kind: Character, char: String)? |
Sources/OrgEditorAppKit/Modal.swift +39 −6
| @@ -3,6 +3,11 @@ import AppKit | ||
| 3 | 3 | import OrgCore |
| 4 | 4 | import OrgDocument |
| 5 | 5 | |
| 6 | /// An editor a file mark is in, gone when its buffer closes. | |
| 7 | struct WeakEditor { | |
| 8 | weak var editor: OrgEditor? | |
| 9 | } | |
| 10 | ||
| 6 | 11 | /// Modal editing: keys go to the keymap for the current evil state first (leader keys, |
| 7 | 12 | /// evil-org's org keys), then to `Vim`. |
| 8 | 13 | extension OrgEditor { |
| @@ -67,10 +72,16 @@ extension OrgEditor { | ||
| 67 | 72 | let wasInsert = vim.mode == .insert |
| 68 | 73 | let visual = vim.mode == .visual || vim.mode == .visualLine |
| 69 | 74 | let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location |
| 75 | if jumpToFileMark(key) { continue } | |
| 70 | 76 | let clipboard = NSPasteboard.general.string(forType: .string) |
| 71 | 77 | var marks = vimMarks |
| 78 | for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset } | |
| 72 | 79 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } |
| 80 | vim.registers = Self.vimRegisters | |
| 81 | vim.clipboardRecord = Self.vimClipboardRecord | |
| 73 | 82 | let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText)) |
| 83 | Self.vimRegisters = vim.registers | |
| 84 | Self.vimClipboardRecord = vim.clipboardRecord | |
| 74 | 85 | apply(actions) |
| 75 | 86 | if !wasInsert, vim.mode == .insert { |
| 76 | 87 | insertSnapshot = (document.text, textView.selectedRange().location) |
| @@ -83,13 +94,35 @@ extension OrgEditor { | ||
| 83 | 94 | return true |
| 84 | 95 | } |
| 85 | 96 | |
| 97 | /// `'A` or `` `A `` for a file mark in another editor: that editor's buffer shows at the | |
| 98 | /// mark. True when the key went there. | |
| 99 | private func jumpToFileMark(_ key: KeyChord) -> Bool { | |
| 100 | guard vim.mode == .normal, vim.pending.count == 1, let quote = vim.pending.first, quote == KeyChord("'") || quote == KeyChord("`"), | |
| 101 | key.modifiers.isEmpty, key.key.count == 1, let name = key.key.first, name.isUppercase, | |
| 102 | let mark = Self.fileMarks[name], let owner = mark.owner.editor, owner !== self else { return false } | |
| 103 | vim.reset() | |
| 104 | let ns = owner.document.text as NSString | |
| 105 | var target = min(mark.offset, ns.length) | |
| 106 | if quote == KeyChord("'") { | |
| 107 | // The line's first non-blank. | |
| 108 | target = ns.lineRange(for: NSRange(location: target, length: 0)).location | |
| 109 | while target < ns.length, ns.character(at: target) == 32 || ns.character(at: target) == 9 { target += 1 } | |
| 110 | } | |
| 111 | onJumpToEditor?(owner, target) | |
| 112 | return true | |
| 113 | } | |
| 114 | ||
| 86 | 115 | /// Moves marks and jump positions through an edit. |
| 87 | 116 | func mapVimPositions(through edit: TextEdit) { |
| 88 | guard !vimMarks.isEmpty || !jumpList.isEmpty else { return } | |
| 117 | let owned = Self.fileMarks.filter { $0.value.owner.editor === self } | |
| 118 | guard !vimMarks.isEmpty || !jumpList.isEmpty || !owned.isEmpty else { return } | |
| 89 | 119 | let names = Array(vimMarks.keys) |
| 90 | let mapped = ViewState(selection: names.map { vimMarks[$0]!..<vimMarks[$0]! } + jumpList.map { $0..<$0 }).mapped(through: [edit]).selection | |
| 120 | let fileNames = Array(owned.keys) | |
| 121 | let positions = names.map { vimMarks[$0]! } + fileNames.map { owned[$0]!.offset } + jumpList | |
| 122 | let mapped = ViewState(selection: positions.map { $0..<$0 }).mapped(through: [edit]).selection | |
| 91 | 123 | for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound } |
| 92 | jumpList = mapped.dropFirst(names.count).map { $0.lowerBound } | |
| 124 | for (i, name) in fileNames.enumerated() { Self.fileMarks[name]?.offset = mapped[names.count + i].lowerBound } | |
| 125 | jumpList = mapped.dropFirst(names.count + fileNames.count).map { $0.lowerBound } | |
| 93 | 126 | } |
| 94 | 127 | |
| 95 | 128 | func insertTyped(_ key: KeyChord) { |
| @@ -175,7 +208,7 @@ extension OrgEditor { | ||
| 175 | 208 | case .message(let text): |
| 176 | 209 | onMessage?(text) |
| 177 | 210 | case .setMark(let name, let offset): |
| 178 | vimMarks[name] = offset | |
| 211 | if name.isUppercase { Self.fileMarks[name] = (WeakEditor(editor: self), offset) } else { vimMarks[name] = offset } | |
| 179 | 212 | case .jumped(let from): |
| 180 | 213 | // A new jump drops the ones C-o went back past. |
| 181 | 214 | jumpList = Array(jumpList.prefix(jumpIndex)) |
| @@ -202,12 +235,12 @@ extension OrgEditor { | ||
| 202 | 235 | onMessage?("Defining keyboard macro…") |
| 203 | 236 | } else if let register = recordingRegister { |
| 204 | 237 | // Without the `q` that ended it. |
| 205 | macros[register] = Array(recordedKeys.dropLast()) | |
| 238 | Self.macros[register] = Array(recordedKeys.dropLast()) | |
| 206 | 239 | recordingRegister = nil |
| 207 | 240 | onMessage?("Keyboard macro defined") |
| 208 | 241 | } |
| 209 | 242 | case .playMacro(let name, let count): |
| 210 | guard let keys = macros[name], !keys.isEmpty else { | |
| 243 | guard let keys = Self.macros[name], !keys.isEmpty else { | |
| 211 | 244 | onMessage?("No macro in register \(name)") |
| 212 | 245 | continue |
| 213 | 246 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +10 −2
| @@ -365,15 +365,23 @@ public final class OrgEditor: NSObject { | ||
| 365 | 365 | var insertSnapshot: (text: String, caret: Int)? |
| 366 | 366 | /// The evil cursor in visual states, where the selection's start isn't it. |
| 367 | 367 | var visualCaret: Int? |
| 368 | /// Vim marks, mapped through edits. | |
| 368 | /// Vim's lowercase marks, mapped through edits. | |
| 369 | 369 | var vimMarks: [Character: Int] = [:] |
| 370 | /// Uppercase marks, with the editor each is in: global, as evil's file marks are. | |
| 371 | static var fileMarks: [Character: (owner: WeakEditor, offset: Int)] = [:] | |
| 372 | /// Registers and keyboard macros, shared by every editor as evil's are global. | |
| 373 | static var vimRegisters: [Character: (text: String, linewise: Bool)] = [:] | |
| 374 | static var vimClipboardRecord: (text: String, linewise: Bool)? | |
| 375 | static var macros: [Character: [KeyChord]] = [:] | |
| 376 | /// Shows `editor`'s buffer with the caret at the offset, for a jump to a file mark set | |
| 377 | /// there. | |
| 378 | public var onJumpToEditor: ((OrgEditor, Int) -> Void)? | |
| 370 | 379 | /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are. |
| 371 | 380 | var jumpList: [Int] = [] |
| 372 | 381 | var jumpIndex = 0 |
| 373 | 382 | /// The register keys go into while `q` records, and its keys so far. |
| 374 | 383 | var recordingRegister: Character? |
| 375 | 384 | var recordedKeys: [KeyChord] = [] |
| 376 | var macros: [Character: [KeyChord]] = [:] | |
| 377 | 385 | var replayingMacro = 0 |
| 378 | 386 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. |
| 379 | 387 | private(set) var markActive = false |
Sources/Orgstar/EditorView.swift +7 −2
| @@ -51,7 +51,7 @@ struct EditorView: NSViewRepresentable { | ||
| 51 | 51 | coordinator.path = session.path |
| 52 | 52 | container.subviews.forEach { $0.removeFromSuperview() } |
| 53 | 53 | coordinator.editor = nil |
| 54 | if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container) { | |
| 54 | if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container, coordinator) { | |
| 55 | 55 | coordinator.editors[path] = shown |
| 56 | 56 | shown.scroll.frame = container.bounds |
| 57 | 57 | container.addSubview(shown.scroll) |
| @@ -80,7 +80,7 @@ struct EditorView: NSViewRepresentable { | ||
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | /// An editor for the current buffer, which becomes the buffer's live state. |
| 83 | private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? { | |
| 83 | private func makeEditor(_ container: NSView, _ coordinator: Coordinator) -> (editor: OrgEditor, scroll: NSScrollView)? { | |
| 84 | 84 | guard let document = session.document else { return nil } |
| 85 | 85 | let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) |
| 86 | 86 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options) |
| @@ -105,6 +105,11 @@ struct EditorView: NSViewRepresentable { | ||
| 105 | 105 | } |
| 106 | 106 | session?.open(target) |
| 107 | 107 | } |
| 108 | editor.onJumpToEditor = { [weak session, weak coordinator] other, offset in | |
| 109 | guard let session, let path = coordinator?.editors.first(where: { $0.value.editor === other })?.key else { return } | |
| 110 | session.open(path) | |
| 111 | session.reveal(offset) | |
| 112 | } | |
| 108 | 113 | editor.onExternal = onExternal |
| 109 | 114 | editor.knownTags = knownTags |
| 110 | 115 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
Tests/OrgEditorAppKitTests/ModalTests.swift +35
| @@ -72,6 +72,41 @@ struct ModalTests { | ||
| 72 | 72 | #expect(h.editor.vim.mode == .normal) |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | @Test func registersAndMacrosAreShared() { | |
| 76 | let a = doom("alpha beta\n") | |
| 77 | let b = doom("one\ntwo\n") | |
| 78 | a.caret(at: 0) | |
| 79 | type(a, "\" r y w") | |
| 80 | b.caret(at: 0) | |
| 81 | type(b, "\" r P") | |
| 82 | #expect(b.editor.document.text == "alpha one\ntwo\n") | |
| 83 | type(a, "q m A ! ESC q") | |
| 84 | type(b, "j @ m") | |
| 85 | #expect(b.editor.document.text == "alpha one\ntwo!\n") | |
| 86 | } | |
| 87 | ||
| 88 | @Test func uppercaseMarksAreGlobal() { | |
| 89 | let a = doom("first\n second\n") | |
| 90 | let b = doom("other\ntext\n") | |
| 91 | var jumps: [(OrgEditor, Int)] = [] | |
| 92 | b.editor.onJumpToEditor = { jumps.append(($0, $1)) } | |
| 93 | a.caret(at: 10) | |
| 94 | type(a, "m Q m q") | |
| 95 | b.caret(at: 0) | |
| 96 | type(b, "' Q") | |
| 97 | #expect(jumps.count == 1 && jumps.first?.0 === a.editor && jumps.first?.1 == 8) | |
| 98 | type(b, "` Q") | |
| 99 | #expect(jumps.last?.1 == 10) | |
| 100 | // Lowercase marks stay in their buffer. | |
| 101 | type(b, "j ' q") | |
| 102 | #expect(b.textView.selectedRange().location == 6) | |
| 103 | // In its own buffer the mark is an ordinary jump, and follows edits. | |
| 104 | type(a, "g g O") | |
| 105 | a.textView.insertText("new", replacementRange: a.textView.selectedRange()) | |
| 106 | type(a, "ESC ` Q") | |
| 107 | #expect(a.textView.selectedRange().location == 14) | |
| 108 | } | |
| 109 | ||
| 75 | 110 | @Test func zzSavesAndClosesAsWq() { |
| 76 | 111 | let h = doom("text\n") |
| 77 | 112 | var app: [String] = [] |