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 {
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] = []