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 {
8888 public var clipboardRecord: (text: String, linewise: Bool)?
8989 var anchor = 0
9090 var goalColumn: Int?
91 var lastFind: (kind: Character, char: String)?
92 var lastSnipe: (forward: Bool, chars: String, count: Int)?
93 /// `;` and `,` repeat a snipe when one came after the last f, F, t or T.
94 var lastWasSnipe = false
91 /// The last snipe: s, S, f, F, t or T. `consume` lands on the match (s, f, F) rather
92 /// than next to it (t, T); `keys` repeat it right after it, forward and back.
93 var lastSnipe: (forward: Bool, chars: String, count: Int, consume: Bool, keys: (forward: KeyChord, backward: KeyChord))?
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)?
9596 var lastSearch: (pattern: String, forward: Bool)?
9697 /// The keys and inserted text of the last change, for `.`.
9798 var lastChange: (keys: [KeyChord], inserted: String?)?
@@ -129,6 +130,11 @@ public struct Vim: Sendable {
129130 case .insert:
130131 return insertKey(key, context)
131132 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 }
132138 pending.append(key)
133139 let keys = pending
134140 caretForApply = context.caret
@@ -611,36 +617,23 @@ public struct Vim: Sendable {
611617 return .invalid
612618 }
613619 case ("f", []), ("F", []), ("t", []), ("T", []):
620 // evil-snipe-override-mode: one-character snipes.
614621 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
616623 guard char.count == 1 else { return .invalid }
617 lastFind = (Character(key.key), char)
618 lastWasSnipe = false
619 guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid }
620 return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive)
621 case (";", []) where lastWasSnipe, (",", []) where lastWasSnipe:
624 let forward = key.key == "f" || key.key == "t"
625 lastSnipe = (forward, char, n, key.key == "f" || key.key == "F", (KeyChord(key.key.lowercased()), KeyChord(key.key.uppercased())))
626 return snipe(forward: forward, count: n, buffer, c, operatorPending: operatorPending, repeating: false)
627 case (";", []), (",", []):
622628 // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible.
623629 guard let last = lastSnipe else { return .invalid }
624630 let forward = key.key == ";" ? last.forward : !last.forward
625 let scope = contextVisible.map { min($0.lowerBound, buffer.length)..<min($0.upperBound, buffer.length) }
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)
631 return snipe(forward: forward, count: n * last.count, buffer, c, operatorPending: operatorPending, repeating: true)
636632 case ("s", []) where !operatorPending, ("S", []) where !operatorPending:
637 // evil-snipe with `evil-snipe-scope` line.
638633 guard keys.count >= 3 else { return .incomplete }
639634 let chars = keys[1].key + keys[2].key
640 lastSnipe = (key.key == "s", chars, n)
641 lastWasSnipe = true
642 guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid }
643 return .moved(p, .exclusive)
635 lastSnipe = (key.key == "s", chars, n, true, (KeyChord("s"), KeyChord("S")))
636 return snipe(forward: key.key == "s", count: n, buffer, c, operatorPending: operatorPending, repeating: false)
644637 case ("%", []):
645638 guard let p = buffer.matchingBracket(c) else { return .invalid }
646639 return .moved(p, .inclusive)
@@ -686,51 +679,68 @@ public struct Vim: Sendable {
686679 }
687680 }
688681
689 func find(_ kind: Character, _ char: String, _ buffer: Buffer, _ c: Int, count n: Int, repeating: Bool = false) -> Int? {
690 let target = char.utf16.first!
691 let forward = kind == "f" || kind == "t"
692 var p = c
693 for i in 0..<n {
694 var q = p
695 // Repeating t/T starts past the character it stopped before.
696 let skip = repeating && i == 0 && (kind == "t" || kind == "T") ? 1 : 0
697 if forward {
698 q += 1 + skip
699 while q < buffer.lineEnd(c), buffer.char(q) != target { q += 1 }
700 guard q < buffer.lineEnd(c) else { return nil }
701 } else {
702 q -= 1 + skip
703 while q >= buffer.lineStart(c), buffer.char(q) != target { q -= 1 }
704 guard q >= buffer.lineStart(c) else { return nil }
682 /// `evil-snipe--seek` for the last snipe: the `n`th match from `c` in the line after (or
683 /// before) the search start, or for a repeat in the visible text, with smart case and
684 /// `evil-snipe-skip-leading-whitespace`.
685 mutating func snipe(forward: Bool, count n: Int, _ buffer: Buffer, _ c: Int, operatorPending: Bool, repeating: Bool) -> MotionResult {
686 guard let last = lastSnipe else { return .invalid }
687 // f, s and F search from the next or this character, t and T one further.
688 let start = forward ? c + (last.consume ? 1 : 2) : c - (last.consume ? 0 : 1)
689 guard start >= 0, start <= buffer.length else { return .invalid }
690 var bound = forward ? buffer.lineEnd(start) : buffer.lineStart(start)
691 if repeating, let visible = contextVisible {
692 bound = forward ? min(visible.upperBound, buffer.length) - 1 : min(visible.lowerBound, start)
693 }
694 let blankKeys = last.chars.first == " " || last.chars.first == "\t"
695 var pattern = NSRegularExpression.escapedPattern(for: last.chars)
696 if blankKeys, let ch = buffer.char(start), Buffer.isBlank(ch) { pattern += "[^ \t]" }
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
705717 }
706 p = q
707718 }
708 if kind == "t" { return p - 1 }
709 if kind == "T" { return p + 1 }
710 return p
711 }
712
713 /// Searches the caret's line, or `scope` when given.
714 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? {
715 let range = scope ?? buffer.lineStart(c)..<buffer.lineEnd(c)
716 let line = buffer.substring(range) as NSString
717 let start = range.lowerBound
718 guard range.lowerBound <= c, c <= range.upperBound else { return nil }
719 let smart = chars == chars.lowercased()
720 var p = c - start
721 for _ in 0..<n {
722 let range: NSRange
723 if forward {
724 guard p + 1 < line.length else { return nil }
725 range = line.range(of: chars, options: smart ? .caseInsensitive : [], range: NSRange(location: p + 1, length: line.length - p - 1))
719 guard let found = match else {
720 if repeating { snipeRepeatKeys = last.keys }
721 return .invalid
722 }
723 if !operatorPending { snipeRepeatKeys = last.keys }
724 var end = NSMaxRange(found)
725 var length = found.length
726 if blankKeys {
727 end -= 1
728 length -= 1
729 }
730 let visual = mode == .visual || mode == .visualLine || mode == .visualBlock
731 var p: Int
732 if !forward {
733 p = last.consume ? found.location : end
734 } else {
735 p = visual ? (last.consume ? end : found.location) : operatorPending ? end : found.location
736 if last.consume {
737 if visual { p -= 1 }
726738 } else {
727 guard p > 0 else { return nil }
728 range = line.range(of: chars, options: smart ? [.caseInsensitive, .backwards] : .backwards, range: NSRange(location: 0, length: p))
739 p -= length
740 if length > 1, !operatorPending { p += 1 }
729741 }
730 guard range.location != NSNotFound else { return nil }
731 p = range.location
732742 }
733 return start + p
743 return .moved(p, .exclusive)
734744 }
735745
736746 // MARK: - Text objects
Sources/OrgCore/Keymap/VimExtras.swift +1 −1
@@ -11,7 +11,7 @@ extension Vim {
1111 /// Motions evil records in the jump list.
1212 static func isJump(_ keys: [KeyChord]) -> Bool {
1313 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 }
1515 return key.key == "g" && keys.count > 1 && keys[1].key == "g"
1616 }
1717
Tests/OrgCoreTests/VimTests.swift +34 −1
@@ -193,6 +193,36 @@ struct VimTests {
193193 ("one two three\n", 0, "3 l v l d"),
194194 ("one two three\n", 8, "v b d"),
195195 ("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"),
196226 ]
197227
198228 /// Thirty lines of seven characters: line `i` starts at `7 * i`.
@@ -272,11 +302,12 @@ struct VimTests {
272302 (add-to-list 'load-path "\(evil.path)/evil-snipe")
273303 (require 'evil)
274304 (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)
276306 (setq kill-ring nil)
277307 (switch-to-buffer (current-buffer))
278308 (evil-local-mode 1)
279309 (evil-snipe-local-mode 1)
310 (evil-snipe-override-local-mode 1)
280311 (evil-normal-state)
281312 (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))"))
282313 (let ((p (point)) (s evil-state))
@@ -288,6 +319,8 @@ struct VimTests {
288319 let results = try EmacsOracle.run(forms)
289320 for ((text, point, keys), result) in zip(Self.cases, results) {
290321 var driver = VimDriver(text, caret: point)
322 // A batch Emacs window shows the whole buffer.
323 driver.visible = 0..<text.utf16.count
291324 driver.type(Self.keys(keys))
292325 guard let marker = result.text.range(of: "\n@@STATE ") else {
293326 Issue.record("\(keys): \(result.error)")