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 {
460 command = "org.heading.up" 460 command = "org.heading.up"
461 mode = "normal" 461 mode = "normal"
462 [[bind]] 462 [[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]]
471 keys = "] h" 463 keys = "] h"
472 command = "org.heading.forward-same-level" 464 command = "org.heading.forward-same-level"
473 mode = "normal" 465 mode = "normal"
Sources/OrgCore/Keymap/Vim.swift +13 −3
@@ -62,8 +62,11 @@ public struct Vim: Sendable {
62 public var marks: [Character: Int] 62 public var marks: [Character: Int]
63 /// The text the window shows, for H, M and L; nil when unknown. 63 /// The text the window shows, for H, M and L; nil when unknown.
64 public var visible: Range<Int>? 64 public var visible: Range<Int>?
65 /// An Org buffer, where evil-org's keys apply.
66 public var org: Bool
65 67
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
67 self.text = text 70 self.text = text
68 self.caret = caret 71 self.caret = caret
69 self.clipboard = clipboard 72 self.clipboard = clipboard
@@ -105,6 +108,7 @@ public struct Vim: Sendable {
105 /// The context's half page and visible text for this key. 108 /// The context's half page and visible text for this key.
106 var contextHalfPage = 15 109 var contextHalfPage = 15
107 var contextVisible: Range<Int>? 110 var contextVisible: Range<Int>?
111 var contextOrg = false
108 /// Keys are going into a macro register. 112 /// Keys are going into a macro register.
109 public internal(set) var recording = false 113 public internal(set) var recording = false
110 var lastMacro: Character? 114 var lastMacro: Character?
@@ -126,6 +130,7 @@ public struct Vim: Sendable {
126 contextMarks = context.marks 130 contextMarks = context.marks
127 contextHalfPage = context.halfPage 131 contextHalfPage = context.halfPage
128 contextVisible = context.visible 132 contextVisible = context.visible
133 contextOrg = context.org
129 var buffer = Buffer(context) 134 var buffer = Buffer(context)
130 switch parse(keys, &buffer, context) { 135 switch parse(keys, &buffer, context) {
131 case .incomplete: 136 case .incomplete:
@@ -475,7 +480,7 @@ public struct Vim: Sendable {
475 } 480 }
476 } 481 }
477 482
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") {
479 return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) 484 return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)])
480 } 485 }
481 486
@@ -583,6 +588,11 @@ public struct Vim: Sendable {
583 var p = c 588 var p = c
584 for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") } 589 for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") }
585 return .moved(p, .inclusive) 590 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)
586 case "j": 596 case "j":
587 return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending) 597 return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending)
588 case "k": 598 case "k":
@@ -940,7 +950,7 @@ public struct Vim: Sendable {
940 caret += inserted.utf16.count 950 caret += inserted.utf16.count
941 } 951 }
942 } 952 }
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))
944 for action in produced { 954 for action in produced {
945 switch action { 955 switch action {
946 case .edit(let edits, let to): 956 case .edit(let edits, let to):
Sources/OrgCore/Keymap/VimExtras.swift +56 −5
@@ -253,14 +253,23 @@ extension Vim {
253 var node: SyntaxNode? 253 var node: SyntaxNode?
254 } 254 }
255 255
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)? {
257 let tree = OrgParser.parse(buffer.ns as String) 259 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
258 var chain: [SyntaxNode] = [] 262 var chain: [SyntaxNode] = []
259 var node = tree.root 263 func look(at p: Int) {
260 while let child = node.child(containing: c) { 264 c = p
261 chain.append(child) 265 chain = []
262 node = child 266 var node = tree.root
267 while let child = node.child(containing: c) {
268 chain.append(child)
269 node = child
270 }
263 } 271 }
272 look(at: position)
264 /// The end of the line ending at or after `p`, without trailing blank lines. 273 /// The end of the line ending at or after `p`, without trailing blank lines.
265 func trimmed(_ lower: Int, _ upper: Int) -> Int { 274 func trimmed(_ lower: Int, _ upper: Int) -> Int {
266 var end = upper 275 var end = upper
@@ -353,7 +362,49 @@ extension Vim {
353 if let contents = e.contents { return contents } 362 if let contents = e.contents { return contents }
354 return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0)) 363 return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0))
355 } 364 }
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) }
356 switch key { 372 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)
357 case "R": 408 case "R":
358 guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil } 409 guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil }
359 return (inner ? (headline.contents ?? headline.begin..<headline.begin) : headline.begin..<headline.end, true) 410 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 {
79 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } 79 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
80 vim.registers = Self.vimRegisters 80 vim.registers = Self.vimRegisters
81 vim.clipboardRecord = Self.vimClipboardRecord 81 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))
83 Self.vimRegisters = vim.registers 83 Self.vimRegisters = vim.registers
84 Self.vimClipboardRecord = vim.clipboardRecord 84 Self.vimClipboardRecord = vim.clipboardRecord
85 apply(actions) 85 apply(actions)
Tests/OrgCoreTests/VimExtrasTests.swift +43 −2
@@ -38,6 +38,25 @@ struct VimExtrasTests {
38 #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n") 38 #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n")
39 } 39 }
40 40
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
41 @Test func blockVisual() { 60 @Test func blockVisual() {
42 #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n") 61 #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n")
43 #expect(run("abc\ndef\nghi\n", 0, "C-v j l y j j P").text == "abc\ndef\nabghi\n" || true) 62 #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 {
117 ("a =code= b\n", 3, "d i e"), 136 ("a =code= b\n", 3, "d i e"),
118 ("a =code= b\n", 3, "d a e"), 137 ("a =code= b\n", 3, "d a e"),
119 ("a [[https://x.org]] b\n", 5, "d i e"), 138 ("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"),
120 ] 159 ]
160 return cases
121 }() 161 }()
122 162
123 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 163 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
@@ -134,7 +174,7 @@ struct VimExtrasTests {
134 (require 'evil-surround) 174 (require 'evil-surround)
135 (setq kill-ring nil) 175 (setq kill-ring nil)
136 (switch-to-buffer (current-buffer)) 176 (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)" : "")
138 (evil-local-mode 1) 178 (evil-local-mode 1)
139 (evil-surround-mode 1) 179 (evil-surround-mode 1)
140 (evil-normal-state) 180 (evil-normal-state)
@@ -148,9 +188,10 @@ struct VimExtrasTests {
148 """) 188 """)
149 } 189 }
150 let results = try EmacsOracle.run(forms) 190 let results = try EmacsOracle.run(forms)
151 for ((c, _), result) in zip(all, results) { 191 for ((c, org), result) in zip(all, results) {
152 let (text, point, keys) = c 192 let (text, point, keys) = c
153 var driver = VimDriver(text, caret: point) 193 var driver = VimDriver(text, caret: point)
194 driver.org = org
154 driver.type(KeySequence.parse(keys)!) 195 driver.type(KeySequence.parse(keys)!)
155 guard let marker = result.text.range(of: "\n@@STATE ") else { 196 guard let marker = result.text.range(of: "\n@@STATE ") else {
156 Issue.record("\(keys): \(result.error)") 197 Issue.record("\(keys): \(result.error)")
Tests/OrgCoreTests/VimTests.swift +2 −1
@@ -12,6 +12,7 @@ struct VimDriver {
12 var commands: [String] = [] 12 var commands: [String] = []
13 var halfPage = 15 13 var halfPage = 15
14 var visible: Range<Int>? 14 var visible: Range<Int>?
15 var org = false
15 16
16 init(_ text: String, caret: Int) { 17 init(_ text: String, caret: Int) {
17 self.text = text 18 self.text = text
@@ -38,7 +39,7 @@ struct VimDriver {
38 } 39 }
39 continue 40 continue
40 } 41 }
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))
42 for action in actions { 43 for action in actions {
43 switch action { 44 switch action {
44 case .edit(let edits, let to): 45 case .edit(let edits, let to):