krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 4245fe5852

4245fe58527addd566ad2917d1636e382a4b8a1a

parent: 7ac0ac2c42

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:43 UTC

Use the window for C-d, C-u, H, M and L

C-d and C-u move the editor's half page (15 lines without one). H, M
and L go to the top, middle and bottom visible lines, H and L counting
from their end; the editor passes the visible text in the Vim context.

Layout: unified · split

Sources/OrgCore/Keymap/Vim.swift +26 −3
@@ -58,13 +58,16 @@ public struct Vim: Sendable {
5858 public var halfPage: Int
5959 /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start.
6060 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>?
6163
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) {
6365 self.text = text
6466 self.caret = caret
6567 self.clipboard = clipboard
6668 self.halfPage = halfPage
6769 self.marks = marks
70 self.visible = visible
6871 }
6972 }
7073
@@ -96,6 +99,9 @@ public struct Vim: Sendable {
9699 var blockInsert: (lines: [Int], column: Int, pad: Bool)?
97100 /// The editor's marks for this key.
98101 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>?
99105 /// Keys are going into a macro register.
100106 public internal(set) var recording = false
101107 var lastMacro: Character?
@@ -115,6 +121,8 @@ public struct Vim: Sendable {
115121 let keys = pending
116122 caretForApply = context.caret
117123 contextMarks = context.marks
124 contextHalfPage = context.halfPage
125 contextVisible = context.visible
118126 var buffer = Buffer(context)
119127 switch parse(keys, &buffer, context) {
120128 case .incomplete:
@@ -505,7 +513,7 @@ public struct Vim: Sendable {
505513 return .moved(min(limit, c + n), .exclusive)
506514 case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []):
507515 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
509517 let goal = goalColumn ?? column(c)
510518 var line = buffer.lineStart(c)
511519 var moved = 0
@@ -631,6 +639,21 @@ public struct Vim: Sendable {
631639 lastSearch = (pattern, key.key == "*")
632640 guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid }
633641 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)
634657 default:
635658 return .invalid
636659 }
@@ -914,7 +937,7 @@ public struct Vim: Sendable {
914937 caret += inserted.utf16.count
915938 }
916939 }
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))
918941 for action in produced {
919942 switch action {
920943 case .edit(let edits, let to):
Sources/OrgEditorAppKit/Modal.swift +14 −1
@@ -70,7 +70,7 @@ extension OrgEditor {
7070 let clipboard = NSPasteboard.general.string(forType: .string)
7171 var marks = vimMarks
7272 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))
7474 apply(actions)
7575 if !wasInsert, vim.mode == .insert {
7676 insertSnapshot = (document.text, textView.selectedRange().location)
@@ -104,6 +104,19 @@ extension OrgEditor {
104104 return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2))
105105 }
106106
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
107120 private func editContext() -> EditContext {
108121 let selected = textView.selectedRange()
109122 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 {
1010 var caret: Int
1111 var clipboard: String?
1212 var commands: [String] = []
13 var halfPage = 15
14 var visible: Range<Int>?
1315
1416 init(_ text: String, caret: Int) {
1517 self.text = text
@@ -36,7 +38,7 @@ struct VimDriver {
3638 }
3739 continue
3840 }
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))
4042 for action in actions {
4143 switch action {
4244 case .edit(let edits, let to):
@@ -183,6 +185,43 @@ struct VimTests {
183185 ("one two three\n", 0, "v 2 e c X ESC"),
184186 ]
185187
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
186225 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
187226 func matchesEvil() throws {
188227 let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos")
Tests/OrgEditorAppKitTests/ModalTests.swift +11
@@ -72,6 +72,17 @@ struct ModalTests {
7272 #expect(h.editor.vim.mode == .normal)
7373 }
7474
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
7586 @Test func undefinedLeaderSequencesDoNothing() {
7687 let h = doom("* Task\nbody text\n")
7788 var messages: [String] = []