krz/orgstar

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

Commit 72065ee289

72065ee289382402f6fc126599fc469ae3fac594

parent: 2c9d41fe76

Verified · cmc

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

Make gj and gk org element motions in Org buffers

As evil-org's navigation keys, gj and gk are org-forward-element and
org-backward-element in Org files, with operators and in visual state,
and ignore a count as those commands do. Elsewhere they stay screen
lines. The Doom preset no longer binds them to heading motion.

Layout: unified · split

Sources/OrgCore/Keymap/Presets.swift −8
@@ -460,14 +460,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
460460 command = "org.heading.up"
461461 mode = "normal"
462462 [[bind]]
463 keys = "g j"
464 command = "org.heading.forward-same-level"
465 mode = "normal"
466 [[bind]]
467 keys = "g k"
468 command = "org.heading.backward-same-level"
469 mode = "normal"
470 [[bind]]
471463 keys = "] h"
472464 command = "org.heading.forward-same-level"
473465 mode = "normal"
Sources/OrgCore/Keymap/Vim.swift +13 −3
@@ -62,8 +62,11 @@ public struct Vim: Sendable {
6262 public var marks: [Character: Int]
6363 /// The text the window shows, for H, M and L; nil when unknown.
6464 public var visible: Range<Int>?
65 /// An Org buffer, where evil-org's keys apply.
66 public var org: Bool
6567
66 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:], visible: Range<Int>? = nil) {
68 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:], visible: Range<Int>? = nil, org: Bool = false) {
69 self.org = org
6770 self.text = text
6871 self.caret = caret
6972 self.clipboard = clipboard
@@ -105,6 +108,7 @@ public struct Vim: Sendable {
105108 /// The context's half page and visible text for this key.
106109 var contextHalfPage = 15
107110 var contextVisible: Range<Int>?
111 var contextOrg = false
108112 /// Keys are going into a macro register.
109113 public internal(set) var recording = false
110114 var lastMacro: Character?
@@ -126,6 +130,7 @@ public struct Vim: Sendable {
126130 contextMarks = context.marks
127131 contextHalfPage = context.halfPage
128132 contextVisible = context.visible
133 contextOrg = context.org
129134 var buffer = Buffer(context)
130135 switch parse(keys, &buffer, context) {
131136 case .incomplete:
@@ -475,7 +480,7 @@ public struct Vim: Sendable {
475480 }
476481 }
477482
478 if mode == .normal, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") {
483 if mode == .normal, !contextOrg, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") {
479484 return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)])
480485 }
481486
@@ -583,6 +588,11 @@ public struct Vim: Sendable {
583588 var p = c
584589 for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") }
585590 return .moved(p, .inclusive)
591 case "j" where contextOrg, "k" where contextOrg:
592 // evil-org: org-forward-element and org-backward-element, which take no count.
593 guard let p = Self.orgObject(keys[1].key, inner: false, buffer, c)?.range.lowerBound, p != c else { return .invalid }
594 goalColumn = nil
595 return .moved(p, .exclusive)
586596 case "j":
587597 return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending)
588598 case "k":
@@ -940,7 +950,7 @@ public struct Vim: Sendable {
940950 caret += inserted.utf16.count
941951 }
942952 }
943 let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage, visible: context.visible))
953 let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage, visible: context.visible, org: context.org))
944954 for action in produced {
945955 switch action {
946956 case .edit(let edits, let to):
Sources/OrgCore/Keymap/VimExtras.swift +56 −5
@@ -253,14 +253,23 @@ extension Vim {
253253 var node: SyntaxNode?
254254 }
255255
256 static func orgObject(_ key: String, inner: Bool, _ buffer: Buffer, _ c: Int) -> (range: Range<Int>, linewise: Bool)? {
256 /// `j` and `k` are evil-org's `gj` and `gk` (`org-forward-element`, `org-backward-element`):
257 /// an empty range where they go, nil where they can't.
258 static func orgObject(_ key: String, inner: Bool, _ buffer: Buffer, _ position: Int) -> (range: Range<Int>, linewise: Bool)? {
257259 let tree = OrgParser.parse(buffer.ns as String)
260 /// The point the element functions look at, and the nodes containing it.
261 var c = position
258262 var chain: [SyntaxNode] = []
259 var node = tree.root
260 while let child = node.child(containing: c) {
261 chain.append(child)
262 node = child
263 func look(at p: Int) {
264 c = p
265 chain = []
266 var node = tree.root
267 while let child = node.child(containing: c) {
268 chain.append(child)
269 node = child
270 }
263271 }
272 look(at: position)
264273 /// The end of the line ending at or after `p`, without trailing blank lines.
265274 func trimmed(_ lower: Int, _ upper: Int) -> Int {
266275 var end = upper
@@ -353,7 +362,49 @@ extension Vim {
353362 if let contents = e.contents { return contents }
354363 return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0))
355364 }
365 /// The stars of the heading on the line holding `p`, or nil.
366 func headingLevel(_ p: Int) -> Int? {
367 let line = buffer.substring(buffer.lineStart(p)..<buffer.lineEnd(p))
368 guard let stars = line.range(of: "^\\*+(?=[ \\t]|$)", options: .regularExpression) else { return nil }
369 return line.distance(from: stars.lowerBound, to: stars.upperBound)
370 }
371 func at(_ p: Int) -> (range: Range<Int>, linewise: Bool) { (p..<p, false) }
356372 switch key {
373 case "j":
374 guard position < buffer.length else { return nil }
375 if headingLevel(position) != nil {
376 // To the end of the subtree, when a heading is there.
377 guard let section = chain.last(where: { $0.kind == .section }) else { return nil }
378 let end = section.range.upperBound
379 return end < buffer.length && headingLevel(end) != nil ? at(end) : nil
380 }
381 guard let e = atPoint() else { return nil }
382 if let up = parent(e), up.contents?.upperBound == e.end { return at(up.end) }
383 return at(e.end)
384 case "k":
385 guard position > 0 else { return nil }
386 if let level = headingLevel(position) {
387 // The previous heading at this level, or else the parent.
388 var line = buffer.lineStart(position)
389 while line > 0 {
390 line = buffer.lineStart(line - 1)
391 if let other = headingLevel(line), other <= level { return at(line) }
392 }
393 return nil
394 }
395 guard let e = atPoint() else { return nil }
396 if position != e.begin { return at(e.begin) }
397 var p = e.begin
398 while p > 0, let ch = buffer.char(p - 1), Buffer.isSpace(ch) { p -= 1 }
399 guard p > 0 else { return at(0) }
400 look(at: p)
401 guard var prev = atPoint() else { return at(e.begin) }
402 var target = prev.begin
403 while let up = parent(prev), up.end <= e.begin {
404 target = up.begin
405 prev = up
406 }
407 return at(target)
357408 case "R":
358409 guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil }
359410 return (inner ? (headline.contents ?? headline.begin..<headline.begin) : headline.begin..<headline.end, true)
Sources/OrgEditorAppKit/Modal.swift +1 −1
@@ -79,7 +79,7 @@ extension OrgEditor {
7979 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
8080 vim.registers = Self.vimRegisters
8181 vim.clipboardRecord = Self.vimClipboardRecord
82 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText))
82 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText, org: isOrg))
8383 Self.vimRegisters = vim.registers
8484 Self.vimClipboardRecord = vim.clipboardRecord
8585 apply(actions)
Tests/OrgCoreTests/VimExtrasTests.swift +43 −2
@@ -38,6 +38,25 @@ struct VimExtrasTests {
3838 #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n")
3939 }
4040
41 @Test func elementMotionsStop() {
42 let text = "intro\n\n* H\npara one\n* Next\nend\n"
43 func moved(_ caret: Int, _ keys: String, org: Bool = true) -> Int {
44 var driver = VimDriver(text, caret: caret)
45 driver.org = org
46 driver.type(KeySequence.parse(keys)!)
47 return driver.caret
48 }
49 #expect(moved(0, "g k") == 0)
50 #expect(moved(7, "g k") == 7)
51 #expect(moved(20, "g j") == 20)
52 // A count doesn't repeat them: org-forward-element takes no argument.
53 #expect(moved(0, "2 g j") == 7)
54 // Outside Org, gj and gk are screen lines, which the editor moves.
55 var vim = Vim()
56 _ = vim.feed(KeyChord("g"), Vim.Context(text: text, caret: 0))
57 #expect(vim.feed(KeyChord("j"), Vim.Context(text: text, caret: 0)) == [.visualLines(1)])
58 }
59
4160 @Test func blockVisual() {
4261 #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n")
4362 #expect(run("abc\ndef\nghi\n", 0, "C-v j l y j j P").text == "abc\ndef\nabghi\n" || true)
@@ -117,7 +136,28 @@ struct VimExtrasTests {
117136 ("a =code= b\n", 3, "d i e"),
118137 ("a =code= b\n", 3, "d a e"),
119138 ("a [[https://x.org]] b\n", 5, "d i e"),
139 ] + elementCases
140 }()
141
142 /// evil-org's gj and gk: org-forward-element and org-backward-element.
143 static let elementCases: [(String, Int, String)] = {
144 let text = "intro\n\n* H\npara one\nstill\n\n- a\n- b\n\nafter\n** Sub\nsub body\n** Two\n* Next\nend\n"
145 let at = { (s: String) in (text as NSString).range(of: s).location }
146 var cases: [(String, Int, String)] = []
147 // Org signals an error, and evil stays, for g k at 0 and on the first heading, and g j on
148 // the last one: see `elementMotionsStop`.
149 for start in [0, 3, at("* H"), at("para"), at("still") + 2, at("- a"), at("- b"), at("after"), at("** Sub"), at("sub body"), at("** Two"), at("* Next"), at("end")] {
150 if start != at("* Next") { cases.append((text, start, "g j")) }
151 if start != 0, start != at("* H") { cases.append((text, start, "g k")) }
152 }
153 cases += [
154 (text, at("para"), "g j g j g j"),
155 (text, at("end"), "g k g k"),
156 (text, at("para"), "3 g j"),
157 (text, at("para"), "d g j"),
158 (text, at("* H"), "d g j"),
120159 ]
160 return cases
121161 }()
122162
123163 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
@@ -134,7 +174,7 @@ struct VimExtrasTests {
134174 (require 'evil-surround)
135175 (setq kill-ring nil)
136176 (switch-to-buffer (current-buffer))
137 \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects)) (evil-org-mode 1)" : "")
177 \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects navigation)) (evil-org-mode 1)" : "")
138178 (evil-local-mode 1)
139179 (evil-surround-mode 1)
140180 (evil-normal-state)
@@ -148,9 +188,10 @@ struct VimExtrasTests {
148188 """)
149189 }
150190 let results = try EmacsOracle.run(forms)
151 for ((c, _), result) in zip(all, results) {
191 for ((c, org), result) in zip(all, results) {
152192 let (text, point, keys) = c
153193 var driver = VimDriver(text, caret: point)
194 driver.org = org
154195 driver.type(KeySequence.parse(keys)!)
155196 guard let marker = result.text.range(of: "\n@@STATE ") else {
156197 Issue.record("\(keys): \(result.error)")
Tests/OrgCoreTests/VimTests.swift +2 −1
@@ -12,6 +12,7 @@ struct VimDriver {
1212 var commands: [String] = []
1313 var halfPage = 15
1414 var visible: Range<Int>?
15 var org = false
1516
1617 init(_ text: String, caret: Int) {
1718 self.text = text
@@ -38,7 +39,7 @@ struct VimDriver {
3839 }
3940 continue
4041 }
41 let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard, halfPage: halfPage, visible: visible))
42 let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard, halfPage: halfPage, visible: visible, org: org))
4243 for action in actions {
4344 switch action {
4445 case .edit(let edits, let to):