krz/orgstar

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

Commit d97c1b6580

d97c1b65806245f0532b2e44b4bcec45da6ecd70

parent: 515b208bfa

Verified · cmc

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

Repeat snipes with ; and ,

After s or S, ; repeats the snipe and , reverses it, with the snipe's
count, over the visible text (Doom's evil-snipe-repeat-scope). After
f, F, t or T they repeat the find as before.

Layout: unified · split

Sources/OrgCore/Keymap/Vim.swift +19 −5
@@ -86,7 +86,9 @@ public struct Vim: Sendable {
86 var anchor = 0 86 var anchor = 0
87 var goalColumn: Int? 87 var goalColumn: Int?
88 var lastFind: (kind: Character, char: String)? 88 var lastFind: (kind: Character, char: String)?
89 var lastSnipe: (forward: Bool, chars: String)? 89 var lastSnipe: (forward: Bool, chars: String, count: Int)?
90 /// `;` and `,` repeat a snipe when one came after the last f, F, t or T.
91 var lastWasSnipe = false
90 var lastSearch: (pattern: String, forward: Bool)? 92 var lastSearch: (pattern: String, forward: Bool)?
91 /// The keys and inserted text of the last change, for `.`. 93 /// The keys and inserted text of the last change, for `.`.
92 var lastChange: (keys: [KeyChord], inserted: String?)? 94 var lastChange: (keys: [KeyChord], inserted: String?)?
@@ -610,8 +612,16 @@ public struct Vim: Sendable {
610 let char = keys[1].key == "SPC" ? " " : keys[1].key 612 let char = keys[1].key == "SPC" ? " " : keys[1].key
611 guard char.count == 1 else { return .invalid } 613 guard char.count == 1 else { return .invalid }
612 lastFind = (Character(key.key), char) 614 lastFind = (Character(key.key), char)
615 lastWasSnipe = false
613 guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } 616 guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid }
614 return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive) 617 return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive)
618 case (";", []) where lastWasSnipe, (",", []) where lastWasSnipe:
619 // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible.
620 guard let last = lastSnipe else { return .invalid }
621 let forward = key.key == ";" ? last.forward : !last.forward
622 let scope = contextVisible.map { min($0.lowerBound, buffer.length)..<min($0.upperBound, buffer.length) }
623 guard let p = snipe(forward: forward, last.chars, buffer, c, count: n * last.count, scope: scope) else { return .invalid }
624 return .moved(p, .exclusive)
615 case (";", []), (",", []): 625 case (";", []), (",", []):
616 guard let last = lastFind else { return .invalid } 626 guard let last = lastFind else { return .invalid }
617 var kind = last.kind 627 var kind = last.kind
@@ -624,7 +634,8 @@ public struct Vim: Sendable {
624 // evil-snipe with `evil-snipe-scope` line. 634 // evil-snipe with `evil-snipe-scope` line.
625 guard keys.count >= 3 else { return .incomplete } 635 guard keys.count >= 3 else { return .incomplete }
626 let chars = keys[1].key + keys[2].key 636 let chars = keys[1].key + keys[2].key
627 lastSnipe = (key.key == "s", chars) 637 lastSnipe = (key.key == "s", chars, n)
638 lastWasSnipe = true
628 guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid } 639 guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid }
629 return .moved(p, .exclusive) 640 return .moved(p, .exclusive)
630 case ("%", []): 641 case ("%", []):
@@ -696,9 +707,12 @@ public struct Vim: Sendable {
696 return p 707 return p
697 } 708 }
698 709
699 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int) -> Int? { 710 /// Searches the caret's line, or `scope` when given.
700 let line = buffer.substring(buffer.lineStart(c)..<buffer.lineEnd(c)) as NSString 711 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? {
701 let start = buffer.lineStart(c) 712 let range = scope ?? buffer.lineStart(c)..<buffer.lineEnd(c)
713 let line = buffer.substring(range) as NSString
714 let start = range.lowerBound
715 guard range.lowerBound <= c, c <= range.upperBound else { return nil }
702 let smart = chars == chars.lowercased() 716 let smart = chars == chars.lowercased()
703 var p = c - start 717 var p = c - start
704 for _ in 0..<n { 718 for _ in 0..<n {
Tests/OrgCoreTests/VimTests.swift +22
@@ -134,6 +134,11 @@ struct VimTests {
134 ("long line here\nab\nlonger line\n", 10, "j j x"), 134 ("long line here\nab\nlonger line\n", 10, "j j x"),
135 ("one two three\n", 0, "s t h x"), 135 ("one two three\n", 0, "s t h x"),
136 ("one two three\n", 12, "S t w x"), 136 ("one two three\n", 12, "S t w x"),
137 ("ab ab ab ab\n", 0, "s a b ; ; x"),
138 ("ab ab ab ab\n", 0, "s a b ; ; , x"),
139 ("ab ab ab ab\n", 10, "S a b ; x"),
140 ("ab ab ab ab ab ab\n", 0, "2 s a b ; x"),
141 ("one tw tw tw\n", 0, "f w s t w ; x"),
137 ("one two three\n", 0, "\" a y w w \" a P"), 142 ("one two three\n", 0, "\" a y w w \" a P"),
138 ("one two three\n", 0, "\" _ d w"), 143 ("one two three\n", 0, "\" _ d w"),
139 (" one\n", 2, "o x ESC"), 144 (" one\n", 2, "o x ESC"),
@@ -206,6 +211,23 @@ struct VimTests {
206 #expect(vim.feed(KeyChord("Q"), context) == [.ex("q!")]) 211 #expect(vim.feed(KeyChord("Q"), context) == [.ex("q!")])
207 } 212 }
208 213
214 @Test func snipeRepeatsOverTheVisibleText() {
215 let text = "ab x\ncd ab\nab\n"
216 func caret(_ keys: String, visible: Range<Int>?) -> Int {
217 var driver = VimDriver(text, caret: 0)
218 driver.visible = visible
219 driver.type(Self.keys(keys))
220 return driver.caret
221 }
222 // The snipe itself keeps to the line; repeats search what the window shows.
223 #expect(caret("s a b ;", visible: nil) == 0)
224 #expect(caret("s a b ;", visible: 0..<14) == 8)
225 #expect(caret("s a b ; ;", visible: 0..<14) == 11)
226 #expect(caret("s a b ; ; ,", visible: 0..<14) == 8)
227 // After f, ; repeats the f.
228 #expect(caret("s a b f x ;", visible: 0..<14) == 3)
229 }
230
209 @Test func halfPageScrollsFollowTheWindow() { 231 @Test func halfPageScrollsFollowTheWindow() {
210 #expect(driven(0, "C-d", halfPage: 5) == 35) 232 #expect(driven(0, "C-d", halfPage: 5) == 35)
211 #expect(driven(70, "C-u", halfPage: 5) == 35) 233 #expect(driven(70, "C-u", halfPage: 5) == 35)