Commit 4245fe5852
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Vim.swift +26 −3
| @@ -58,13 +58,16 @@ public struct Vim: Sendable { | ||
| 58 | 58 | public var halfPage: Int |
| 59 | 59 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. |
| 60 | 60 | public var marks: [Character: Int] |
| 61 | /// The text the window shows, for H, M and L; nil when unknown. | |
| 62 | public var visible: Range<Int>? | |
| 61 | 63 | |
| 62 | public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:]) { | |
| 64 | public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:], visible: Range<Int>? = nil) { | |
| 63 | 65 | self.text = text |
| 64 | 66 | self.caret = caret |
| 65 | 67 | self.clipboard = clipboard |
| 66 | 68 | self.halfPage = halfPage |
| 67 | 69 | self.marks = marks |
| 70 | self.visible = visible | |
| 68 | 71 | } |
| 69 | 72 | } |
| 70 | 73 | |
| @@ -96,6 +99,9 @@ public struct Vim: Sendable { | ||
| 96 | 99 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? |
| 97 | 100 | /// The editor's marks for this key. |
| 98 | 101 | var contextMarks: [Character: Int] = [:] |
| 102 | /// The context's half page and visible text for this key. | |
| 103 | var contextHalfPage = 15 | |
| 104 | var contextVisible: Range<Int>? | |
| 99 | 105 | /// Keys are going into a macro register. |
| 100 | 106 | public internal(set) var recording = false |
| 101 | 107 | var lastMacro: Character? |
| @@ -115,6 +121,8 @@ public struct Vim: Sendable { | ||
| 115 | 121 | let keys = pending |
| 116 | 122 | caretForApply = context.caret |
| 117 | 123 | contextMarks = context.marks |
| 124 | contextHalfPage = context.halfPage | |
| 125 | contextVisible = context.visible | |
| 118 | 126 | var buffer = Buffer(context) |
| 119 | 127 | switch parse(keys, &buffer, context) { |
| 120 | 128 | case .incomplete: |
| @@ -505,7 +513,7 @@ public struct Vim: Sendable { | ||
| 505 | 513 | return .moved(min(limit, c + n), .exclusive) |
| 506 | 514 | case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []): |
| 507 | 515 | let down = ["j", "<down>", "+", "RET"].contains(key.key) || key == KeyChord("d", .control) |
| 508 | let lines = key.modifiers.contains(.control) ? (n == 1 ? 15 : n) : n | |
| 516 | let lines = key.modifiers.contains(.control) ? (n == 1 ? contextHalfPage : n) : n | |
| 509 | 517 | let goal = goalColumn ?? column(c) |
| 510 | 518 | var line = buffer.lineStart(c) |
| 511 | 519 | var moved = 0 |
| @@ -631,6 +639,21 @@ public struct Vim: Sendable { | ||
| 631 | 639 | lastSearch = (pattern, key.key == "*") |
| 632 | 640 | guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid } |
| 633 | 641 | return .moved(p, .exclusive) |
| 642 | case ("H", []), ("M", []), ("L", []): | |
| 643 | // The visible lines: H the count-th from the top, L from the bottom, M the middle. | |
| 644 | guard let visible = contextVisible else { return .invalid } | |
| 645 | goalColumn = nil | |
| 646 | let lower = min(visible.lowerBound, buffer.length) | |
| 647 | let last = buffer.lineStart(max(lower, min(visible.upperBound, buffer.length) - 1)) | |
| 648 | var lines: [Int] = [] | |
| 649 | var line = buffer.lineStart(lower) | |
| 650 | while true { | |
| 651 | lines.append(line) | |
| 652 | guard line < last, buffer.lineEnd(line) < buffer.length else { break } | |
| 653 | line = buffer.lineEnd(line) + 1 | |
| 654 | } | |
| 655 | let index = key.key == "H" ? min(n, lines.count) - 1 : key.key == "L" ? max(0, lines.count - n) : (lines.count - 1) / 2 | |
| 656 | return .moved(buffer.firstNonBlank(lines[index]), .linewise) | |
| 634 | 657 | default: |
| 635 | 658 | return .invalid |
| 636 | 659 | } |
| @@ -914,7 +937,7 @@ public struct Vim: Sendable { | ||
| 914 | 937 | caret += inserted.utf16.count |
| 915 | 938 | } |
| 916 | 939 | } |
| 917 | let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage)) | |
| 940 | let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage, visible: context.visible)) | |
| 918 | 941 | for action in produced { |
| 919 | 942 | switch action { |
| 920 | 943 | case .edit(let edits, let to): |
Sources/OrgEditorAppKit/Modal.swift +14 −1
| @@ -70,7 +70,7 @@ extension OrgEditor { | ||
| 70 | 70 | let clipboard = NSPasteboard.general.string(forType: .string) |
| 71 | 71 | var marks = vimMarks |
| 72 | 72 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } |
| 73 | let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks)) | |
| 73 | let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText)) | |
| 74 | 74 | apply(actions) |
| 75 | 75 | if !wasInsert, vim.mode == .insert { |
| 76 | 76 | insertSnapshot = (document.text, textView.selectedRange().location) |
| @@ -104,6 +104,19 @@ extension OrgEditor { | ||
| 104 | 104 | return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2)) |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | /// The text the window shows, from the line at its top to the line at its bottom. | |
| 108 | var visibleText: Range<Int>? { | |
| 109 | guard let layout = textView.textLayoutManager, let content = layout.textContentManager, let scroll = textView.enclosingScrollView else { return nil } | |
| 110 | let bounds = scroll.contentView.bounds | |
| 111 | let origin = textView.textContainerOrigin | |
| 112 | let start = content.documentRange.location | |
| 113 | guard let top = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, bounds.minY - origin.y))) else { return nil } | |
| 114 | let lower = content.offset(from: start, to: top.rangeInElement.location) | |
| 115 | let bottom = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, bounds.maxY - origin.y - 1))) | |
| 116 | let upper = bottom.map { content.offset(from: start, to: $0.rangeInElement.endLocation) } ?? utf16Length | |
| 117 | return lower..<max(lower, upper) | |
| 118 | } | |
| 119 | ||
| 107 | 120 | private func editContext() -> EditContext { |
| 108 | 121 | let selected = textView.selectedRange() |
| 109 | 122 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options) |
Tests/OrgCoreTests/VimTests.swift +40 −1
| @@ -10,6 +10,8 @@ struct VimDriver { | ||
| 10 | 10 | var caret: Int |
| 11 | 11 | var clipboard: String? |
| 12 | 12 | var commands: [String] = [] |
| 13 | var halfPage = 15 | |
| 14 | var visible: Range<Int>? | |
| 13 | 15 | |
| 14 | 16 | init(_ text: String, caret: Int) { |
| 15 | 17 | self.text = text |
| @@ -36,7 +38,7 @@ struct VimDriver { | ||
| 36 | 38 | } |
| 37 | 39 | continue |
| 38 | 40 | } |
| 39 | let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard)) | |
| 41 | let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard, halfPage: halfPage, visible: visible)) | |
| 40 | 42 | for action in actions { |
| 41 | 43 | switch action { |
| 42 | 44 | case .edit(let edits, let to): |
| @@ -183,6 +185,43 @@ struct VimTests { | ||
| 183 | 185 | ("one two three\n", 0, "v 2 e c X ESC"), |
| 184 | 186 | ] |
| 185 | 187 | |
| 188 | /// Thirty lines of seven characters: line `i` starts at `7 * i`. | |
| 189 | static let lines = (0..<30).map { String(format: "line%02d\n", $0) }.joined() | |
| 190 | ||
| 191 | func driven(_ caret: Int, _ keys: String, halfPage: Int? = nil, visible: Range<Int>? = nil) -> Int { | |
| 192 | var driver = VimDriver(Self.lines, caret: caret) | |
| 193 | if let halfPage { driver.halfPage = halfPage } | |
| 194 | driver.visible = visible | |
| 195 | driver.type(Self.keys(keys)) | |
| 196 | return driver.caret | |
| 197 | } | |
| 198 | ||
| 199 | @Test func halfPageScrollsFollowTheWindow() { | |
| 200 | #expect(driven(0, "C-d", halfPage: 5) == 35) | |
| 201 | #expect(driven(70, "C-u", halfPage: 5) == 35) | |
| 202 | #expect(driven(0, "C-d") == 105) | |
| 203 | #expect(driven(0, "3 C-d", halfPage: 5) == 21) | |
| 204 | } | |
| 205 | ||
| 206 | @Test func windowLines() { | |
| 207 | let shown = 70..<140 | |
| 208 | #expect(driven(0, "H", visible: shown) == 70) | |
| 209 | #expect(driven(0, "3 H", visible: shown) == 84) | |
| 210 | #expect(driven(0, "2 0 H", visible: shown) == 133) | |
| 211 | #expect(driven(0, "L", visible: shown) == 133) | |
| 212 | #expect(driven(0, "2 L", visible: shown) == 126) | |
| 213 | #expect(driven(0, "M", visible: shown) == 98) | |
| 214 | #expect(driven(3, "H") == 3) | |
| 215 | // Linewise with an operator, and a jump. | |
| 216 | var driver = VimDriver(Self.lines, caret: 70) | |
| 217 | driver.visible = shown | |
| 218 | driver.type(Self.keys("d L")) | |
| 219 | #expect(driver.text == Self.lines.replacingOccurrences(of: (10..<20).map { String(format: "line%02d\n", $0) }.joined(), with: "")) | |
| 220 | var vim = Vim() | |
| 221 | let actions = vim.feed(KeyChord("L"), Vim.Context(text: Self.lines, caret: 70, visible: shown)) | |
| 222 | #expect(actions == [.jumped(from: 70), .move(caret: 133)]) | |
| 223 | } | |
| 224 | ||
| 186 | 225 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 187 | 226 | func matchesEvil() throws { |
| 188 | 227 | let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") |
Tests/OrgEditorAppKitTests/ModalTests.swift +11
| @@ -72,6 +72,17 @@ struct ModalTests { | ||
| 72 | 72 | #expect(h.editor.vim.mode == .normal) |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | @Test func windowLinesInTheEditor() { | |
| 76 | let h = doom("one\ntwo\nthree\nfour\nfive\n") | |
| 77 | h.caret(at: 0) | |
| 78 | type(h, "L") | |
| 79 | #expect(h.textView.selectedRange().location == 19) | |
| 80 | type(h, "M") | |
| 81 | #expect(h.textView.selectedRange().location == 8) | |
| 82 | type(h, "H") | |
| 83 | #expect(h.textView.selectedRange().location == 0) | |
| 84 | } | |
| 85 | ||
| 75 | 86 | @Test func undefinedLeaderSequencesDoNothing() { |
| 76 | 87 | let h = doom("* Task\nbody text\n") |
| 77 | 88 | var messages: [String] = [] |