Commit 4245fe5852
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Vim.swift +26 −3
| @@ -58,13 +58,16 @@ public struct Vim: Sendable { | |||
| 58 | public var halfPage: Int | 58 | public var halfPage: Int |
| 59 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. | 59 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. |
| 60 | public var marks: [Character: Int] | 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 | self.text = text | 65 | self.text = text |
| 64 | self.caret = caret | 66 | self.caret = caret |
| 65 | self.clipboard = clipboard | 67 | self.clipboard = clipboard |
| 66 | self.halfPage = halfPage | 68 | self.halfPage = halfPage |
| 67 | self.marks = marks | 69 | self.marks = marks |
| 70 | self.visible = visible | ||
| 68 | } | 71 | } |
| 69 | } | 72 | } |
| 70 | 73 | ||
| @@ -96,6 +99,9 @@ public struct Vim: Sendable { | |||
| 96 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? | 99 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? |
| 97 | /// The editor's marks for this key. | 100 | /// The editor's marks for this key. |
| 98 | var contextMarks: [Character: Int] = [:] | 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 | /// Keys are going into a macro register. | 105 | /// Keys are going into a macro register. |
| 100 | public internal(set) var recording = false | 106 | public internal(set) var recording = false |
| 101 | var lastMacro: Character? | 107 | var lastMacro: Character? |
| @@ -115,6 +121,8 @@ public struct Vim: Sendable { | |||
| 115 | let keys = pending | 121 | let keys = pending |
| 116 | caretForApply = context.caret | 122 | caretForApply = context.caret |
| 117 | contextMarks = context.marks | 123 | contextMarks = context.marks |
| 124 | contextHalfPage = context.halfPage | ||
| 125 | contextVisible = context.visible | ||
| 118 | var buffer = Buffer(context) | 126 | var buffer = Buffer(context) |
| 119 | switch parse(keys, &buffer, context) { | 127 | switch parse(keys, &buffer, context) { |
| 120 | case .incomplete: | 128 | case .incomplete: |
| @@ -505,7 +513,7 @@ public struct Vim: Sendable { | |||
| 505 | return .moved(min(limit, c + n), .exclusive) | 513 | return .moved(min(limit, c + n), .exclusive) |
| 506 | case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []): | 514 | case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []): |
| 507 | let down = ["j", "<down>", "+", "RET"].contains(key.key) || key == KeyChord("d", .control) | 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 | let goal = goalColumn ?? column(c) | 517 | let goal = goalColumn ?? column(c) |
| 510 | var line = buffer.lineStart(c) | 518 | var line = buffer.lineStart(c) |
| 511 | var moved = 0 | 519 | var moved = 0 |
| @@ -631,6 +639,21 @@ public struct Vim: Sendable { | |||
| 631 | lastSearch = (pattern, key.key == "*") | 639 | lastSearch = (pattern, key.key == "*") |
| 632 | guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid } | 640 | guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid } |
| 633 | return .moved(p, .exclusive) | 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 | default: | 657 | default: |
| 635 | return .invalid | 658 | return .invalid |
| 636 | } | 659 | } |
| @@ -914,7 +937,7 @@ public struct Vim: Sendable { | |||
| 914 | caret += inserted.utf16.count | 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 | for action in produced { | 941 | for action in produced { |
| 919 | switch action { | 942 | switch action { |
| 920 | case .edit(let edits, let to): | 943 | case .edit(let edits, let to): |
Sources/OrgEditorAppKit/Modal.swift +14 −1
| @@ -70,7 +70,7 @@ extension OrgEditor { | |||
| 70 | let clipboard = NSPasteboard.general.string(forType: .string) | 70 | let clipboard = NSPasteboard.general.string(forType: .string) |
| 71 | var marks = vimMarks | 71 | var marks = vimMarks |
| 72 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } | 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 | apply(actions) | 74 | apply(actions) |
| 75 | if !wasInsert, vim.mode == .insert { | 75 | if !wasInsert, vim.mode == .insert { |
| 76 | insertSnapshot = (document.text, textView.selectedRange().location) | 76 | insertSnapshot = (document.text, textView.selectedRange().location) |
| @@ -104,6 +104,19 @@ extension OrgEditor { | |||
| 104 | return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2)) | 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 | private func editContext() -> EditContext { | 120 | private func editContext() -> EditContext { |
| 108 | let selected = textView.selectedRange() | 121 | let selected = textView.selectedRange() |
| 109 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options) | 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 | var caret: Int | 10 | var caret: Int |
| 11 | var clipboard: String? | 11 | var clipboard: String? |
| 12 | var commands: [String] = [] | 12 | var commands: [String] = [] |
| 13 | var halfPage = 15 | ||
| 14 | var visible: Range<Int>? | ||
| 13 | 15 | ||
| 14 | init(_ text: String, caret: Int) { | 16 | init(_ text: String, caret: Int) { |
| 15 | self.text = text | 17 | self.text = text |
| @@ -36,7 +38,7 @@ struct VimDriver { | |||
| 36 | } | 38 | } |
| 37 | continue | 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 | for action in actions { | 42 | for action in actions { |
| 41 | switch action { | 43 | switch action { |
| 42 | case .edit(let edits, let to): | 44 | case .edit(let edits, let to): |
| @@ -183,6 +185,43 @@ struct VimTests { | |||
| 183 | ("one two three\n", 0, "v 2 e c X ESC"), | 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 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 225 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 187 | func matchesEvil() throws { | 226 | func matchesEvil() throws { |
| 188 | let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") | 227 | let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") |
Tests/OrgEditorAppKitTests/ModalTests.swift +11
| @@ -72,6 +72,17 @@ struct ModalTests { | |||
| 72 | #expect(h.editor.vim.mode == .normal) | 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 | @Test func undefinedLeaderSequencesDoNothing() { | 86 | @Test func undefinedLeaderSequencesDoNothing() { |
| 76 | let h = doom("* Task\nbody text\n") | 87 | let h = doom("* Task\nbody text\n") |
| 77 | var messages: [String] = [] | 88 | var messages: [String] = [] |