krz/orgstar

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

Commit 1a30f921e8

1a30f921e8fc5fd4544ac16b1bbf4121494b7bde

parent: 37493404c7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 04:54 UTC

Make f, F, t and T snipe as in Doom

Doom turns on evil-snipe-override-mode: f, F, t and T are one-character snipes with smart case and leading-whitespace skipping, t and T skip an adjacent match, ; and , always repeat the last snipe over the visible text, and a snipe's own keys repeat it as the next key. Snipes are jumps.

Layout: unified · split

Sources/OrgCore/Keymap/Vim.swift +76 −66
@@ -88,10 +88,11 @@ public struct Vim: Sendable {
88 public var clipboardRecord: (text: String, linewise: Bool)? 88 public var clipboardRecord: (text: String, linewise: Bool)?
89 var anchor = 0 89 var anchor = 0
90 var goalColumn: Int? 90 var goalColumn: Int?
91 var lastFind: (kind: Character, char: String)? 91 /// The last snipe: s, S, f, F, t or T. `consume` lands on the match (s, f, F) rather
92 var lastSnipe: (forward: Bool, chars: String, count: Int)? 92 /// than next to it (t, T); `keys` repeat it right after it, forward and back.
93 /// `;` and `,` repeat a snipe when one came after the last f, F, t or T. 93 var lastSnipe: (forward: Bool, chars: String, count: Int, consume: Bool, keys: (forward: KeyChord, backward: KeyChord))?
94 var lastWasSnipe = false 94 /// Set after a snipe: its keys repeat it as the next key, as evil-snipe's transient map has it.
95 var snipeRepeatKeys: (forward: KeyChord, backward: KeyChord)?
95 var lastSearch: (pattern: String, forward: Bool)? 96 var lastSearch: (pattern: String, forward: Bool)?
96 /// The keys and inserted text of the last change, for `.`. 97 /// The keys and inserted text of the last change, for `.`.
97 var lastChange: (keys: [KeyChord], inserted: String?)? 98 var lastChange: (keys: [KeyChord], inserted: String?)?
@@ -129,6 +130,11 @@ public struct Vim: Sendable {
129 case .insert: 130 case .insert:
130 return insertKey(key, context) 131 return insertKey(key, context)
131 case .normal, .visual, .visualLine, .visualBlock: 132 case .normal, .visual, .visualLine, .visualBlock:
133 var key = key
134 if pending.isEmpty, let repeatKeys = snipeRepeatKeys {
135 if key == repeatKeys.forward { key = KeyChord(";") } else if key == repeatKeys.backward { key = KeyChord(",") }
136 }
137 if pending.isEmpty { snipeRepeatKeys = nil }
132 pending.append(key) 138 pending.append(key)
133 let keys = pending 139 let keys = pending
134 caretForApply = context.caret 140 caretForApply = context.caret
@@ -611,36 +617,23 @@ public struct Vim: Sendable {
611 return .invalid 617 return .invalid
612 } 618 }
613 case ("f", []), ("F", []), ("t", []), ("T", []): 619 case ("f", []), ("F", []), ("t", []), ("T", []):
620 // evil-snipe-override-mode: one-character snipes.
614 guard keys.count >= 2 else { return .incomplete } 621 guard keys.count >= 2 else { return .incomplete }
615 let char = keys[1].key == "SPC" ? " " : keys[1].key 622 let char = keys[1].key == "SPC" ? " " : keys[1].key == "TAB" ? "\t" : keys[1].key
616 guard char.count == 1 else { return .invalid } 623 guard char.count == 1 else { return .invalid }
617 lastFind = (Character(key.key), char) 624 let forward = key.key == "f" || key.key == "t"
618 lastWasSnipe = false 625 lastSnipe = (forward, char, n, key.key == "f" || key.key == "F", (KeyChord(key.key.lowercased()), KeyChord(key.key.uppercased())))
619 guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } 626 return snipe(forward: forward, count: n, buffer, c, operatorPending: operatorPending, repeating: false)
620 return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive) 627 case (";", []), (",", []):
621 case (";", []) where lastWasSnipe, (",", []) where lastWasSnipe:
622 // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible. 628 // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible.
623 guard let last = lastSnipe else { return .invalid } 629 guard let last = lastSnipe else { return .invalid }
624 let forward = key.key == ";" ? last.forward : !last.forward 630 let forward = key.key == ";" ? last.forward : !last.forward
625 let scope = contextVisible.map { min($0.lowerBound, buffer.length)..<min($0.upperBound, buffer.length) } 631 return snipe(forward: forward, count: n * last.count, buffer, c, operatorPending: operatorPending, repeating: true)
626 guard let p = snipe(forward: forward, last.chars, buffer, c, count: n * last.count, scope: scope) else { return .invalid }
627 return .moved(p, .exclusive)
628 case (";", []), (",", []):
629 guard let last = lastFind else { return .invalid }
630 var kind = last.kind
631 if key.key == "," {
632 kind = ["f": "F", "F": "f", "t": "T", "T": "t"][kind]!
633 }
634 guard let p = find(kind, last.char, buffer, c, count: n, repeating: true) else { return .invalid }
635 return .moved(p, kind == "f" || kind == "t" ? .inclusive : .exclusive)
636 case ("s", []) where !operatorPending, ("S", []) where !operatorPending: 632 case ("s", []) where !operatorPending, ("S", []) where !operatorPending:
637 // evil-snipe with `evil-snipe-scope` line.
638 guard keys.count >= 3 else { return .incomplete } 633 guard keys.count >= 3 else { return .incomplete }
639 let chars = keys[1].key + keys[2].key 634 let chars = keys[1].key + keys[2].key
640 lastSnipe = (key.key == "s", chars, n) 635 lastSnipe = (key.key == "s", chars, n, true, (KeyChord("s"), KeyChord("S")))
641 lastWasSnipe = true 636 return snipe(forward: key.key == "s", count: n, buffer, c, operatorPending: operatorPending, repeating: false)
642 guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid }
643 return .moved(p, .exclusive)
644 case ("%", []): 637 case ("%", []):
645 guard let p = buffer.matchingBracket(c) else { return .invalid } 638 guard let p = buffer.matchingBracket(c) else { return .invalid }
646 return .moved(p, .inclusive) 639 return .moved(p, .inclusive)
@@ -686,51 +679,68 @@ public struct Vim: Sendable {
686 } 679 }
687 } 680 }
688 681
689 func find(_ kind: Character, _ char: String, _ buffer: Buffer, _ c: Int, count n: Int, repeating: Bool = false) -> Int? { 682 /// `evil-snipe--seek` for the last snipe: the `n`th match from `c` in the line after (or
690 let target = char.utf16.first! 683 /// before) the search start, or for a repeat in the visible text, with smart case and
691 let forward = kind == "f" || kind == "t" 684 /// `evil-snipe-skip-leading-whitespace`.
692 var p = c 685 mutating func snipe(forward: Bool, count n: Int, _ buffer: Buffer, _ c: Int, operatorPending: Bool, repeating: Bool) -> MotionResult {
693 for i in 0..<n { 686 guard let last = lastSnipe else { return .invalid }
694 var q = p 687 // f, s and F search from the next or this character, t and T one further.
695 // Repeating t/T starts past the character it stopped before. 688 let start = forward ? c + (last.consume ? 1 : 2) : c - (last.consume ? 0 : 1)
696 let skip = repeating && i == 0 && (kind == "t" || kind == "T") ? 1 : 0 689 guard start >= 0, start <= buffer.length else { return .invalid }
697 if forward { 690 var bound = forward ? buffer.lineEnd(start) : buffer.lineStart(start)
698 q += 1 + skip 691 if repeating, let visible = contextVisible {
699 while q < buffer.lineEnd(c), buffer.char(q) != target { q += 1 } 692 bound = forward ? min(visible.upperBound, buffer.length) - 1 : min(visible.lowerBound, start)
700 guard q < buffer.lineEnd(c) else { return nil } 693 }
701 } else { 694 let blankKeys = last.chars.first == " " || last.chars.first == "\t"
702 q -= 1 + skip 695 var pattern = NSRegularExpression.escapedPattern(for: last.chars)
703 while q >= buffer.lineStart(c), buffer.char(q) != target { q -= 1 } 696 if blankKeys, let ch = buffer.char(start), Buffer.isBlank(ch) { pattern += "[^ \t]" }
704 guard q >= buffer.lineStart(c) else { return nil } 697 let regex = try! NSRegularExpression(pattern: pattern, options: last.chars == last.chars.lowercased() ? .caseInsensitive : [])
698 var match: NSRange?
699 if forward, start <= bound {
700 var from = start
701 for _ in 0..<n {
702 match = regex.firstMatch(in: buffer.ns as String, range: NSRange(location: from, length: bound - from))?.range
703 guard let found = match else { break }
704 from = NSMaxRange(found)
705 }
706 } else if !forward {
707 var from = start
708 for _ in 0..<n {
709 match = nil
710 var p = from - 1
711 while p >= bound, match == nil {
712 match = regex.firstMatch(in: buffer.ns as String, options: .anchored, range: NSRange(location: p, length: from - p))?.range
713 p -= 1
714 }
715 guard let found = match else { break }
716 from = found.location
705 } 717 }
706 p = q
707 } 718 }
708 if kind == "t" { return p - 1 } 719 guard let found = match else {
709 if kind == "T" { return p + 1 } 720 if repeating { snipeRepeatKeys = last.keys }
710 return p 721 return .invalid
711 } 722 }
712 723 if !operatorPending { snipeRepeatKeys = last.keys }
713 /// Searches the caret's line, or `scope` when given. 724 var end = NSMaxRange(found)
714 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? { 725 var length = found.length
715 let range = scope ?? buffer.lineStart(c)..<buffer.lineEnd(c) 726 if blankKeys {
716 let line = buffer.substring(range) as NSString 727 end -= 1
717 let start = range.lowerBound 728 length -= 1
718 guard range.lowerBound <= c, c <= range.upperBound else { return nil } 729 }
719 let smart = chars == chars.lowercased() 730 let visual = mode == .visual || mode == .visualLine || mode == .visualBlock
720 var p = c - start 731 var p: Int
721 for _ in 0..<n { 732 if !forward {
722 let range: NSRange 733 p = last.consume ? found.location : end
723 if forward { 734 } else {
724 guard p + 1 < line.length else { return nil } 735 p = visual ? (last.consume ? end : found.location) : operatorPending ? end : found.location
725 range = line.range(of: chars, options: smart ? .caseInsensitive : [], range: NSRange(location: p + 1, length: line.length - p - 1)) 736 if last.consume {
737 if visual { p -= 1 }
726 } else { 738 } else {
727 guard p > 0 else { return nil } 739 p -= length
728 range = line.range(of: chars, options: smart ? [.caseInsensitive, .backwards] : .backwards, range: NSRange(location: 0, length: p)) 740 if length > 1, !operatorPending { p += 1 }
729 } 741 }
730 guard range.location != NSNotFound else { return nil }
731 p = range.location
732 } 742 }
733 return start + p 743 return .moved(p, .exclusive)
734 } 744 }
735 745
736 // MARK: - Text objects 746 // MARK: - Text objects
Sources/OrgCore/Keymap/VimExtras.swift +1 −1
@@ -11,7 +11,7 @@ extension Vim {
11 /// Motions evil records in the jump list. 11 /// Motions evil records in the jump list.
12 static func isJump(_ keys: [KeyChord]) -> Bool { 12 static func isJump(_ keys: [KeyChord]) -> Bool {
13 guard let key = keys.first, key.modifiers.isEmpty else { return false } 13 guard let key = keys.first, key.modifiers.isEmpty else { return false }
14 if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L"].contains(key.key) { return true } 14 if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L", "s", "S", "f", "F", "t", "T"].contains(key.key) { return true }
15 return key.key == "g" && keys.count > 1 && keys[1].key == "g" 15 return key.key == "g" && keys.count > 1 && keys[1].key == "g"
16 } 16 }
17 17
Tests/OrgCoreTests/VimTests.swift +34 −1
@@ -193,6 +193,36 @@ struct VimTests {
193 ("one two three\n", 0, "3 l v l d"), 193 ("one two three\n", 0, "3 l v l d"),
194 ("one two three\n", 8, "v b d"), 194 ("one two three\n", 8, "v b d"),
195 ("one two three\n", 0, "v 2 e c X ESC"), 195 ("one two three\n", 0, "v 2 e c X ESC"),
196 // evil-snipe-override-mode: f, F, t and T snipe one character, ; and , repeat over
197 // the visible text, and the snipe's own keys repeat it right after.
198 ("abc abc\nxbz abc\n", 0, "f c ; ; x"),
199 ("abc abc\nxbz abc\n", 0, "f c f f x"),
200 ("abc abc\nxbz abc\n", 0, "f c f F x"),
201 ("abc abc abc\n", 10, "F b f x"),
202 ("abc abc abc abc\n", 10, "F b F x"),
203 ("abc abc\nxbz abc\n", 14, "T a ; x"),
204 ("abc abc\nxbz abc\n", 0, "t c t x"),
205 ("abc abc\nxbz abc\n", 0, "f c l f c x"),
206 ("aAa\n", 0, "f a x"),
207 ("aAa\n", 0, "f A x"),
208 ("abab\n", 0, "t b x"),
209 ("ab ab\n", 4, "T a x"),
210 ("abc\nxbz\n", 2, "t z x"),
211 ("a b c\n", 0, "f SPC x"),
212 ("a b\n", 0, "d f SPC"),
213 ("a b c\n", 0, "t SPC x"),
214 ("one two three\n", 0, "d t e"),
215 ("one two three\n", 0, "c f o X ESC"),
216 ("one two three\n", 12, "d F o"),
217 ("one two three\n", 12, "d T o"),
218 ("one two three\n", 0, "v f e d"),
219 ("one two three\n", 0, "v t e d"),
220 ("one two three\n", 0, "d f e ."),
221 ("a1a2a3a4a5a6\n", 0, "2 f a ; x"),
222 ("ab ab ab\n", 0, "s a b s S x"),
223 ("ab ab ab\n", 0, "s a b d ;"),
224 ("ab ab ab\n", 0, "v s a b d"),
225 ("ab x\ncd ab\nab\n", 5, "s a b ; , x"),
196 ] 226 ]
197 227
198 /// Thirty lines of seven characters: line `i` starts at `7 * i`. 228 /// Thirty lines of seven characters: line `i` starts at `7 * i`.
@@ -272,11 +302,12 @@ struct VimTests {
272 (add-to-list 'load-path "\(evil.path)/evil-snipe") 302 (add-to-list 'load-path "\(evil.path)/evil-snipe")
273 (require 'evil) 303 (require 'evil)
274 (require 'evil-snipe) 304 (require 'evil-snipe)
275 (setq evil-snipe-smart-case t evil-snipe-scope 'line) 305 (setq evil-snipe-smart-case t evil-snipe-scope 'line evil-snipe-repeat-scope 'visible evil-snipe-char-fold t)
276 (setq kill-ring nil) 306 (setq kill-ring nil)
277 (switch-to-buffer (current-buffer)) 307 (switch-to-buffer (current-buffer))
278 (evil-local-mode 1) 308 (evil-local-mode 1)
279 (evil-snipe-local-mode 1) 309 (evil-snipe-local-mode 1)
310 (evil-snipe-override-local-mode 1)
280 (evil-normal-state) 311 (evil-normal-state)
281 (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))")) 312 (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))"))
282 (let ((p (point)) (s evil-state)) 313 (let ((p (point)) (s evil-state))
@@ -288,6 +319,8 @@ struct VimTests {
288 let results = try EmacsOracle.run(forms) 319 let results = try EmacsOracle.run(forms)
289 for ((text, point, keys), result) in zip(Self.cases, results) { 320 for ((text, point, keys), result) in zip(Self.cases, results) {
290 var driver = VimDriver(text, caret: point) 321 var driver = VimDriver(text, caret: point)
322 // A batch Emacs window shows the whole buffer.
323 driver.visible = 0..<text.utf16.count
291 driver.type(Self.keys(keys)) 324 driver.type(Self.keys(keys))
292 guard let marker = result.text.range(of: "\n@@STATE ") else { 325 guard let marker = result.text.range(of: "\n@@STATE ") else {
293 Issue.record("\(keys): \(result.error)") 326 Issue.record("\(keys): \(result.error)")