Vim macros, marks, jumps, block visual, surround, commentary, Org text objects !100

merged merged by cmc on 2026-10-06 06:01 UTC · krz/orgstar:vim-extras into main

10 files changed, +922 −13

Layout: unified · split

Sources/OrgCore/Keymap/Vim.swift +75 −8
@@ -8,13 +8,14 @@ public struct Vim: Sendable {
8 public enum Mode: String, Sendable { 8 public enum Mode: String, Sendable {
9 case normal, insert, visual 9 case normal, insert, visual
10 case visualLine = "visual-line" 10 case visualLine = "visual-line"
11 case visualBlock = "visual-block"
11 12
12 /// The keymap mode name for bindings. 13 /// The keymap mode name for bindings.
13 public var keymapMode: String { 14 public var keymapMode: String {
14 switch self { 15 switch self {
15 case .normal: "normal" 16 case .normal: "normal"
16 case .insert: "insert" 17 case .insert: "insert"
17 case .visual, .visualLine: "visual" 18 case .visual, .visualLine, .visualBlock: "visual"
18 } 19 }
19 } 20 }
20 } 21 }
@@ -32,6 +33,19 @@ public struct Vim: Sendable {
32 /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). 33 /// Ask for a search pattern (`/`, `?`) or an ex command (`:`).
33 case prompt(String) 34 case prompt(String)
34 case message(String) 35 case message(String)
36 /// `m`: a mark at this offset, for the editor to keep through edits.
37 case setMark(Character, Int)
38 /// A jump left this offset: the editor adds it to the jump list.
39 case jumped(from: Int)
40 /// C-o and C-i through the jump list.
41 case jumpBack(Int)
42 case jumpForward(Int)
43 /// `q`: start recording keys into a register, or with nil stop.
44 case recordMacro(Character?)
45 /// `@`: play a register's keys, this many times.
46 case playMacro(Character, Int)
47 /// A block selection: one range per line.
48 case selectBlock([Range<Int>])
35 } 49 }
36 50
37 public struct Context: Sendable { 51 public struct Context: Sendable {
@@ -40,16 +54,19 @@ public struct Vim: Sendable {
40 public var clipboard: String? 54 public var clipboard: String?
41 /// Lines in a half page, for C-d and C-u. 55 /// Lines in a half page, for C-d and C-u.
42 public var halfPage: Int 56 public var halfPage: Int
57 /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start.
58 public var marks: [Character: Int]
43 59
44 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15) { 60 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:]) {
45 self.text = text 61 self.text = text
46 self.caret = caret 62 self.caret = caret
47 self.clipboard = clipboard 63 self.clipboard = clipboard
48 self.halfPage = halfPage 64 self.halfPage = halfPage
65 self.marks = marks
49 } 66 }
50 } 67 }
51 68
52 public private(set) var mode: Mode = .normal 69 public internal(set) var mode: Mode = .normal
53 /// Keys of the command being typed. 70 /// Keys of the command being typed.
54 public private(set) var pending: [KeyChord] = [] 71 public private(set) var pending: [KeyChord] = []
55 var registers: [Character: (text: String, linewise: Bool)] = [:] 72 var registers: [Character: (text: String, linewise: Bool)] = [:]
@@ -68,6 +85,20 @@ public struct Vim: Sendable {
68 var caretForApply = 0 85 var caretForApply = 0
69 /// Typed in insert state since it began, kept by `inserted(_:)`. 86 /// Typed in insert state since it began, kept by `inserted(_:)`.
70 var insertedText = "" 87 var insertedText = ""
88 /// The last visual selection, for `gv`.
89 var lastVisual: (anchor: Int, caret: Int, mode: Mode)?
90 /// Marks the engine set this run, for a context without the editor's.
91 var marks: [Character: Int] = [:]
92 /// Where block `I`, `A` and `c` copy what was typed when insert state ends: the other lines,
93 /// by number from 1, the column, and whether short lines are padded to it.
94 var blockInsert: (lines: [Int], column: Int, pad: Bool)?
95 /// The editor's marks for this key.
96 var contextMarks: [Character: Int] = [:]
97 /// Keys are going into a macro register.
98 public internal(set) var recording = false
99 var lastMacro: Character?
100 /// `$` in block visual state: every line to its end.
101 var blockToEnd = false
71 102
72 public init() {} 103 public init() {}
73 104
@@ -77,10 +108,11 @@ public struct Vim: Sendable {
77 switch mode { 108 switch mode {
78 case .insert: 109 case .insert:
79 return insertKey(key, context) 110 return insertKey(key, context)
80 case .normal, .visual, .visualLine: 111 case .normal, .visual, .visualLine, .visualBlock:
81 pending.append(key) 112 pending.append(key)
82 let keys = pending 113 let keys = pending
83 caretForApply = context.caret 114 caretForApply = context.caret
115 contextMarks = context.marks
84 var buffer = Buffer(context) 116 var buffer = Buffer(context)
85 switch parse(keys, &buffer, context) { 117 switch parse(keys, &buffer, context) {
86 case .incomplete: 118 case .incomplete:
@@ -115,6 +147,10 @@ public struct Vim: Sendable {
115 } 147 }
116 let caret = context.caret 148 let caret = context.caret
117 let lineStart = buffer.lineStart(caret) 149 let lineStart = buffer.lineStart(caret)
150 if let block = blockInsert {
151 blockInsert = nil
152 if !insertedText.isEmpty, !insertedText.contains("\n") { return replicateBlockInsert(block, buffer, caret: caret) }
153 }
118 return [.move(caret: caret > lineStart ? caret - 1 : caret)] 154 return [.move(caret: caret > lineStart ? caret - 1 : caret)]
119 } 155 }
120 if key == KeyChord("w", .control) { 156 if key == KeyChord("w", .control) {
@@ -204,6 +240,9 @@ public struct Vim: Sendable {
204 let n = count ?? 1 240 let n = count ?? 1
205 let caret = context.caret 241 let caret = context.caret
206 242
243 if mode == .visualBlock { return parseBlock(rest, &buffer, context, register: register, count: n, keys: keys) }
244 if let result = parseExtras(rest, &buffer, context, register: register, count: count, keys: keys) { return result }
245
207 // Operators. 246 // Operators.
208 let operatorKeys: [String: String] = ["d": "d", "c": "c", "y": "y", ">": ">", "<": "<"] 247 let operatorKeys: [String: String] = ["d": "d", "c": "c", "y": "y", ">": ">", "<": "<"]
209 var op: String? 248 var op: String?
@@ -212,7 +251,7 @@ public struct Vim: Sendable {
212 op = o 251 op = o
213 } else if first.key == "g", first.modifiers.isEmpty { 252 } else if first.key == "g", first.modifiers.isEmpty {
214 guard let second = rest.dropFirst().first else { return .incomplete } 253 guard let second = rest.dropFirst().first else { return .incomplete }
215 if ["~", "u", "U"].contains(second.key), second.modifiers.isEmpty { 254 if ["~", "u", "U", "c"].contains(second.key), second.modifiers.isEmpty {
216 op = "g" + second.key 255 op = "g" + second.key
217 afterOp = rest.dropFirst(2) 256 afterOp = rest.dropFirst(2)
218 } 257 }
@@ -240,6 +279,8 @@ public struct Vim: Sendable {
240 if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty { 279 if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty {
241 guard motionKeys.count >= 2 else { return .incomplete } 280 guard motionKeys.count >= 2 else { return .incomplete }
242 guard let object = textObject(motionFirst.key == "i", motionKeys[motionKeys.startIndex + 1], buffer, caret, count: total) else { return .invalid } 281 guard let object = textObject(motionFirst.key == "i", motionKeys[motionKeys.startIndex + 1], buffer, caret, count: total) else { return .invalid }
282 // evil-org's subtree objects move point to the heading first.
283 if motionKeys[motionKeys.startIndex + 1].key == "R" { caretForApply = buffer.lineStart(object.range.lowerBound) }
243 return .done(apply(op, range: object.range, linewise: object.linewise, buffer, register: register, keys: keys, count: total)) 284 return .done(apply(op, range: object.range, linewise: object.linewise, buffer, register: register, keys: keys, count: total))
244 } 285 }
245 var motionCaret = caret 286 var motionCaret = caret
@@ -285,6 +326,7 @@ public struct Vim: Sendable {
285 if visual, first.modifiers.isEmpty { 326 if visual, first.modifiers.isEmpty {
286 switch first.key { 327 switch first.key {
287 case "ESC": 328 case "ESC":
329 lastVisual = (anchor, caret, mode)
288 mode = .normal 330 mode = .normal
289 return .done([.move(caret: buffer.clampNormal(caret))]) 331 return .done([.move(caret: buffer.clampNormal(caret))])
290 case "o": 332 case "o":
@@ -427,7 +469,8 @@ public struct Vim: Sendable {
427 case .invalid: return .invalid 469 case .invalid: return .invalid
428 case .moved(let to, _): 470 case .moved(let to, _):
429 if visual { return .done(visualActions(buffer, to)) } 471 if visual { return .done(visualActions(buffer, to)) }
430 return .done([.move(caret: buffer.clampNormal(to))]) 472 let jump: [Action] = Self.isJump(Array(rest)) && to != caret ? [.jumped(from: caret)] : []
473 return .done(jump + [.move(caret: buffer.clampNormal(to))])
431 } 474 }
432 } 475 }
433 476
@@ -560,6 +603,13 @@ public struct Vim: Sendable {
560 case ("%", []): 603 case ("%", []):
561 guard let p = buffer.matchingBracket(c) else { return .invalid } 604 guard let p = buffer.matchingBracket(c) else { return .invalid }
562 return .moved(p, .inclusive) 605 return .moved(p, .inclusive)
606 case ("'", []), ("`", []):
607 guard keys.count >= 2 else { return .incomplete }
608 let name = keys[1].key == "'" || keys[1].key == "`" ? Character("'") : keys[1].key.first
609 guard let name, let mark = currentMarks[name] else { return .invalid }
610 goalColumn = nil
611 let p = min(mark, buffer.length)
612 return key.key == "'" ? .moved(buffer.firstNonBlank(buffer.lineStart(p)), .linewise) : .moved(p, .exclusive)
563 case ("}", []), ("{", []): 613 case ("}", []), ("{", []):
564 var p = c 614 var p = c
565 for _ in 0..<n { p = buffer.paragraph(p, forward: key.key == "}") } 615 for _ in 0..<n { p = buffer.paragraph(p, forward: key.key == "}") }
@@ -642,6 +692,8 @@ public struct Vim: Sendable {
642 return buffer.bracketObject(c, open: 60, close: 62, inner: inner).map { ($0, false) } 692 return buffer.bracketObject(c, open: 60, close: 62, inner: inner).map { ($0, false) }
643 case "p": 693 case "p":
644 return buffer.paragraphObject(c, inner: inner).map { ($0, true) } 694 return buffer.paragraphObject(c, inner: inner).map { ($0, true) }
695 case "e", "E", "r", "R":
696 return Self.orgObject(key.key, inner: inner, buffer, c)
645 default: 697 default:
646 return nil 698 return nil
647 } 699 }
@@ -698,6 +750,7 @@ public struct Vim: Sendable {
698 750
699 mutating func apply(_ op: String, range: Range<Int>, linewise: Bool, _ buffer: Buffer, register: Character?, keys: [KeyChord], count: Int) -> [Action] { 751 mutating func apply(_ op: String, range: Range<Int>, linewise: Bool, _ buffer: Buffer, register: Character?, keys: [KeyChord], count: Int) -> [Action] {
700 let wasVisual = mode == .visual || mode == .visualLine 752 let wasVisual = mode == .visual || mode == .visualLine
753 if wasVisual { lastVisual = (anchor, caretForApply, mode) }
701 let context0Column = max(0, caretForApply - buffer.lineStart(caretForApply)) 754 let context0Column = max(0, caretForApply - buffer.lineStart(caretForApply))
702 mode = .normal 755 mode = .normal
703 let text = buffer.substring(range) 756 let text = buffer.substring(range)
@@ -764,6 +817,9 @@ public struct Vim: Sendable {
764 } 817 }
765 lastChange = (wasVisual ? [] : keys, nil) 818 lastChange = (wasVisual ? [] : keys, nil)
766 return [.edit(edits, caret: start)] 819 return [.edit(edits, caret: start)]
820 case "gc":
821 lastChange = (wasVisual ? [] : keys, nil)
822 return [.edit(Self.toggleComments(buffer, range), caret: buffer.lineStart(range.lowerBound))]
767 default: 823 default:
768 let transformed: String 824 let transformed: String
769 switch op { 825 switch op {
@@ -882,13 +938,13 @@ public struct Vim: Sendable {
882 let buffer = Buffer(context) 938 let buffer = Buffer(context)
883 guard let p = buffer.search(pattern, from: context.caret, forward: forward, count: 1) else { return [.message("Search failed: \(pattern)")] } 939 guard let p = buffer.search(pattern, from: context.caret, forward: forward, count: 1) else { return [.message("Search failed: \(pattern)")] }
884 if mode == .visual || mode == .visualLine { return visualActions(buffer, p) } 940 if mode == .visual || mode == .visualLine { return visualActions(buffer, p) }
885 return [.move(caret: p)] 941 return [.jumped(from: context.caret), .move(caret: p)]
886 } 942 }
887 943
888 /// Leave a visual state without acting, as `ESC` does. 944 /// Leave a visual state without acting, as `ESC` does.
889 public mutating func reset() { 945 public mutating func reset() {
890 pending = [] 946 pending = []
891 if mode == .visual || mode == .visualLine { mode = .normal } 947 if mode == .visual || mode == .visualLine || mode == .visualBlock { mode = .normal }
892 } 948 }
893 949
894 // MARK: - Text 950 // MARK: - Text
@@ -964,6 +1020,17 @@ public struct Vim: Sendable {
964 return line 1020 return line
965 } 1021 }
966 1022
1023 /// The number, from 1, of the line holding `i`.
1024 func lineNumber(_ i: Int) -> Int {
1025 var count = 1
1026 var p = 0
1027 while p < min(i, length) {
1028 if char(p) == 10 { count += 1 }
1029 p += 1
1030 }
1031 return count
1032 }
1033
967 func lineCount(_ r: Range<Int>) -> Int { 1034 func lineCount(_ r: Range<Int>) -> Int {
968 substring(r).filter { $0 == "\n" }.count + (r.upperBound > 0 && char(r.upperBound - 1) == 10 ? 0 : 1) 1035 substring(r).filter { $0 == "\n" }.count + (r.upperBound > 0 && char(r.upperBound - 1) == 10 ? 0 : 1)
969 } 1036 }
Sources/OrgCore/Keymap/VimExtras.swift added +540
@@ -0,0 +1,540 @@
1import Foundation
2
3// Doom's evil beyond the basics: marks and the jump list, macros, `gv`, block visual state,
4// evil-surround (`ys`, `cs`, `ds`, visual `S`), commenting (`gc`) and evil-org's text objects
5// (`e` object, `E` element, `r` greater element, `R` subtree).
6
7extension Vim {
8 /// Marks from the editor, over the ones set in this engine.
9 var currentMarks: [Character: Int] { marks.merging(contextMarks) { $1 } }
10
11 /// Motions evil records in the jump list.
12 static func isJump(_ keys: [KeyChord]) -> Bool {
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 }
15 return key.key == "g" && keys.count > 1 && keys[1].key == "g"
16 }
17
18 /// Keys evil handles here before operators and motions, or nil.
19 mutating func parseExtras(_ rest: ArraySlice<KeyChord>, _ buffer: inout Buffer, _ context: Context, register: Character?, count: Int?, keys: [KeyChord]) -> Parse? {
20 guard let first = rest.first else { return .incomplete }
21 let visual = mode == .visual || mode == .visualLine
22 let n = count ?? 1
23 let caret = context.caret
24 let second = rest.dropFirst().first
25 func needSecond() -> Parse? { second == nil ? .incomplete : nil }
26
27 if first.modifiers == [.control] {
28 switch first.key {
29 case "o" where !visual: return .done([.jumpBack(n)])
30 case "i" where !visual: return .done([.jumpForward(n)])
31 case "v":
32 if visual { lastVisual = (anchor, caret, mode) }
33 mode = .visualBlock
34 if !visual { anchor = caret }
35 return .done(blockActions(buffer, caret))
36 default: return nil
37 }
38 }
39 guard first.modifiers.isEmpty else { return nil }
40
41 if visual, first.key == "S" {
42 if let wait = needSecond() { return wait }
43 let range = visualRange(buffer, caret)
44 lastVisual = (anchor, caret, mode)
45 mode = .normal
46 return .done(surround(range.range, with: second!, linewise: range.linewise, buffer, keys: []))
47 }
48 guard !visual else { return nil }
49
50 switch first.key {
51 case "m":
52 if let wait = needSecond() { return wait }
53 guard let name = second!.key.first, second!.key.count == 1, name.isLetter else { return .invalid }
54 marks[name] = caret
55 return .done([.setMark(name, caret)])
56 case "q":
57 if recording {
58 recording = false
59 return .done([.recordMacro(nil)])
60 }
61 if let wait = needSecond() { return wait }
62 guard let name = second!.key.first, second!.key.count == 1, name.isLetter || name.isNumber else { return .invalid }
63 recording = true
64 return .done([.recordMacro(name)])
65 case "@":
66 if let wait = needSecond() { return wait }
67 let name: Character
68 if second!.key == "@" {
69 guard let last = lastMacro else { return .done([.message("No previous macro")]) }
70 name = last
71 } else {
72 guard let c = second!.key.first, second!.key.count == 1 else { return .invalid }
73 name = c
74 }
75 lastMacro = name
76 return .done([.playMacro(name, n)])
77 case "g":
78 if let wait = needSecond() { return wait }
79 guard second!.key == "v", second!.modifiers.isEmpty else { return nil }
80 guard let last = lastVisual else { return .done([]) }
81 mode = last.mode
82 anchor = min(last.anchor, buffer.length)
83 let to = min(last.caret, buffer.length)
84 return .done(mode == .visualBlock ? blockActions(buffer, to) : visualActions(buffer, to))
85 case "y", "c", "d":
86 guard second?.key == "s", second?.modifiers.isEmpty == true else { return nil }
87 return surroundCommand(first.key, Array(rest.dropFirst(2)), &buffer, caret: caret, count: n, keys: keys)
88 default:
89 return nil
90 }
91 }
92
93 // MARK: - Surround
94
95 /// The delimiters evil-surround puts around text for `key`: an opening bracket adds a space
96 /// inside, a closing one doesn't.
97 static func delimiters(_ key: KeyChord) -> (open: String, close: String)? {
98 let pairs: [String: (String, String)] = [
99 "(": ("( ", " )"), ")": ("(", ")"), "b": ("(", ")"), "[": ("[ ", " ]"), "]": ("[", "]"), "r": ("[", "]"),
100 "{": ("{ ", " }"), "}": ("{", "}"), "B": ("{", "}"), "<": ("< ", " >"), ">": ("<", ">"), "a": ("<", ">"), "SPC": (" ", " "),
101 ]
102 if let pair = pairs[key.key] { return pair }
103 guard key.modifiers.isEmpty, key.key.count == 1 else { return nil }
104 return (key.key, key.key)
105 }
106
107 /// `ys{motion}{char}`, `yss{char}`, `cs{old}{new}` and `ds{char}`.
108 mutating func surroundCommand(_ op: String, _ after: [KeyChord], _ buffer: inout Buffer, caret: Int, count: Int, keys: [KeyChord]) -> Parse {
109 switch op {
110 case "y":
111 guard let (_, count2, motionKeys) = Self.prefix(after), let motionFirst = motionKeys.first else { return .incomplete }
112 let total = count * (count2 ?? 1)
113 if motionFirst.key == "s", motionFirst.modifiers.isEmpty {
114 guard motionKeys.count >= 2 else { return .incomplete }
115 let line = buffer.lineStart(caret)
116 let range = buffer.firstNonBlank(line)..<buffer.lineEnd(line)
117 return .done(surround(range, with: motionKeys[motionKeys.startIndex + 1], linewise: false, buffer, keys: keys))
118 }
119 if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty {
120 guard motionKeys.count >= 3 else { return .incomplete }
121 guard let object = textObject(motionFirst.key == "i", motionKeys[motionKeys.startIndex + 1], buffer, caret, count: total) else { return .invalid }
122 return .done(surround(object.range, with: motionKeys[motionKeys.startIndex + 2], linewise: object.linewise, buffer, keys: keys))
123 }
124 var target = caret
125 switch motion(Array(motionKeys), &buffer, &target, count: total, operatorPending: true) {
126 case .incomplete: return .incomplete
127 case .invalid: return .invalid
128 case .moved(let to, let kind):
129 // The surround character follows the motion's keys.
130 let used = Self.motionLength(Array(motionKeys))
131 guard motionKeys.count > used else { return .incomplete }
132 let char = motionKeys[motionKeys.startIndex + used]
133 var range: Range<Int>
134 switch kind {
135 case .linewise: range = buffer.lineStart(min(caret, to))..<buffer.lineEnd(max(caret, to))
136 case .inclusive: range = min(caret, to)..<min(buffer.length, max(caret, to) + 1)
137 case .exclusive: range = min(caret, to)..<max(caret, to)
138 }
139 // ysw leaves the space after the word outside.
140 while range.upperBound > range.lowerBound, let c = buffer.char(range.upperBound - 1), Buffer.isBlank(c) { range = range.lowerBound..<(range.upperBound - 1) }
141 return .done(surround(range, with: char, linewise: kind == .linewise, buffer, keys: keys))
142 }
143 case "d", "c":
144 guard let target = after.first else { return .incomplete }
145 if op == "c", after.count < 2 { return .incomplete }
146 guard let found = surrounding(target, buffer, caret) else { return .invalid }
147 var edits: [TextEdit]
148 if op == "d" {
149 edits = [TextEdit(range: found.close, replacement: ""), TextEdit(range: found.open, replacement: "")]
150 } else {
151 guard let (open, close) = Self.delimiters(after[after.startIndex + 1]) else { return .invalid }
152 edits = [TextEdit(range: found.close, replacement: close), TextEdit(range: found.open, replacement: open)]
153 }
154 lastChange = (keys, nil)
155 return .done([.edit(edits, caret: found.open.lowerBound)])
156 default:
157 return .invalid
158 }
159 }
160
161 /// How many keys a motion takes: two for `f`, `t`, `'`, `` ` `` and `g`, otherwise one.
162 static func motionLength(_ keys: [KeyChord]) -> Int {
163 guard let first = keys.first, first.modifiers.isEmpty else { return 1 }
164 return ["f", "F", "t", "T", "'", "`", "g"].contains(first.key) ? 2 : 1
165 }
166
167 /// Wraps `range` in the delimiters for `key`; linewise, on lines of their own.
168 mutating func surround(_ range: Range<Int>, with key: KeyChord, linewise: Bool, _ buffer: Buffer, keys: [KeyChord]) -> [Action] {
169 guard let (open, close) = Self.delimiters(key) else { return [] }
170 var edits: [TextEdit]
171 if linewise {
172 let start = buffer.lineStart(range.lowerBound)
173 let end = buffer.lineEnd(max(range.lowerBound, range.upperBound - 1))
174 edits = [TextEdit(range: end..<end, replacement: "\n" + close.trimmingCharacters(in: .whitespaces)),
175 TextEdit(range: start..<start, replacement: open.trimmingCharacters(in: .whitespaces) + "\n")]
176 } else {
177 edits = [TextEdit(range: range.upperBound..<range.upperBound, replacement: close), TextEdit(range: range.lowerBound..<range.lowerBound, replacement: open)]
178 }
179 if !keys.isEmpty { lastChange = (keys, nil) }
180 return [.edit(edits, caret: range.lowerBound)]
181 }
182
183 /// The delimiters around `caret` for `key`, as `ds` and `cs` find them; with an opening
184 /// bracket, the blanks inside go too.
185 func surrounding(_ key: KeyChord, _ buffer: Buffer, _ caret: Int) -> (open: Range<Int>, close: Range<Int>)? {
186 let pairs: [String: (unichar, unichar)] = ["(": (40, 41), ")": (40, 41), "b": (40, 41), "[": (91, 93), "]": (91, 93), "r": (91, 93),
187 "{": (123, 125), "}": (123, 125), "B": (123, 125), "<": (60, 62), ">": (60, 62), "a": (60, 62)]
188 let outer: Range<Int>
189 if let (open, close) = pairs[key.key] {
190 guard let r = buffer.bracketObject(caret, open: open, close: close, inner: false) else { return nil }
191 outer = r
192 } else if key.key.count == 1, let quote = key.key.utf16.first {
193 guard let r = buffer.quoteObject(caret, quote: quote, inner: false) else { return nil }
194 outer = r
195 // `a"` takes blanks after the closing quote; the delimiter is the quote itself.
196 var end = r.upperBound
197 while end > r.lowerBound, buffer.char(end - 1) != quote { end -= 1 }
198 return (r.lowerBound..<(r.lowerBound + 1), (end - 1)..<end)
199 } else {
200 return nil
201 }
202 var open = outer.lowerBound..<(outer.lowerBound + 1)
203 var close = (outer.upperBound - 1)..<outer.upperBound
204 if ["(", "[", "{", "<"].contains(key.key) {
205 while open.upperBound < close.lowerBound, let c = buffer.char(open.upperBound), Buffer.isBlank(c) { open = open.lowerBound..<(open.upperBound + 1) }
206 while close.lowerBound > open.upperBound, let c = buffer.char(close.lowerBound - 1), Buffer.isBlank(c) { close = (close.lowerBound - 1)..<close.upperBound }
207 }
208 return (open, close)
209 }
210
211 // MARK: - Commenting
212
213 /// `comment-or-uncomment-region` in Org: `# ` at the shallowest indentation of the lines'
214 /// text, blank lines left alone; when every line is a comment, the comments come off.
215 static func toggleComments(_ buffer: Buffer, _ range: Range<Int>) -> [TextEdit] {
216 var lines: [Int] = []
217 var line = buffer.lineStart(range.lowerBound)
218 let last = buffer.lineStart(max(range.lowerBound, range.upperBound - 1))
219 while true {
220 lines.append(line)
221 let end = buffer.lineEnd(line)
222 guard line < last, end < buffer.length else { break }
223 line = end + 1
224 }
225 let content = lines.filter { buffer.firstNonBlankOrEnd($0) < buffer.lineEnd($0) }
226 guard !content.isEmpty else { return [] }
227 let commented = content.allSatisfy { start in
228 let text = buffer.substring(buffer.firstNonBlankOrEnd(start)..<buffer.lineEnd(start))
229 return text == "#" || text.hasPrefix("# ")
230 }
231 if commented {
232 return content.reversed().map { start in
233 let at = buffer.firstNonBlankOrEnd(start)
234 let length = buffer.lineEnd(start) - at >= 2 && buffer.char(at + 1) == 32 ? 2 : 1
235 return TextEdit(range: at..<(at + length), replacement: "")
236 }
237 }
238 let column = content.map { buffer.firstNonBlankOrEnd($0) - $0 }.min() ?? 0
239 return content.reversed().map { TextEdit(range: ($0 + column)..<($0 + column), replacement: "# ") }
240 }
241
242 // MARK: - Org text objects
243
244 /// evil-org's text objects: `e` the object at point, `E` the element, `r` the greater element
245 /// around it, `R` the subtree. `a` takes the blanks after; `i` the contents.
246 /// An org-element: `end` takes the blanks after it, `contents` is nil for elements without any.
247 struct OrgElement {
248 var begin: Int
249 var end: Int
250 var contents: Range<Int>?
251 var greater: Bool
252 var block = false
253 var node: SyntaxNode?
254 }
255
256 static func orgObject(_ key: String, inner: Bool, _ buffer: Buffer, _ c: Int) -> (range: Range<Int>, linewise: Bool)? {
257 let tree = OrgParser.parse(buffer.ns as String)
258 var chain: [SyntaxNode] = []
259 var node = tree.root
260 while let child = node.child(containing: c) {
261 chain.append(child)
262 node = child
263 }
264 /// The end of the line ending at or after `p`, without trailing blank lines.
265 func trimmed(_ lower: Int, _ upper: Int) -> Int {
266 var end = upper
267 while end > lower, let ch = buffer.char(end - 1), ch == 10 || Buffer.isBlank(ch) { end -= 1 }
268 if end > lower, buffer.char(end) == 10 { end += 1 }
269 return max(lower, end)
270 }
271 /// The org-element for a node of ours; a `section` here is Org's headline.
272 func element(_ n: SyntaxNode) -> OrgElement {
273 let r = n.range
274 var post = r.upperBound
275 let limit = n.parent?.range.upperBound ?? buffer.length
276 while post < limit {
277 let lineEnd = buffer.lineEnd(post)
278 guard (post..<lineEnd).allSatisfy({ buffer.char($0).map(Buffer.isBlank) ?? true }), lineEnd < buffer.length else { break }
279 post = min(limit, lineEnd + 1)
280 }
281 switch n.kind {
282 case .section:
283 let body = min(r.upperBound, buffer.lineEnd(r.lowerBound) + 1)
284 let contentsEnd = trimmed(body, r.upperBound)
285 return OrgElement(begin: r.lowerBound, end: r.upperBound, contents: body < contentsEnd ? body..<contentsEnd : nil, greater: true, node: n)
286 case .plainList, .table, .drawer, .propertyDrawer, .dynamicBlock, .footnoteDefinition, .inlineTask:
287 return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: true, node: n)
288 case .item:
289 let first = n.children.first { ![.bullet, .checkbox].contains($0.kind) }?.range.lowerBound ?? r.upperBound
290 return OrgElement(begin: r.lowerBound, end: post, contents: min(first, r.upperBound)..<trimmed(first, r.upperBound), greater: true, node: n)
291 case .block:
292 let name = buffer.substring(r.lowerBound..<buffer.lineEnd(r.lowerBound)).lowercased()
293 let greater = !["src", "example", "export", "verse", "comment"].contains { name.hasPrefix("#+begin_\($0)") }
294 return OrgElement(begin: r.lowerBound, end: post, contents: nil, greater: greater, block: true, node: n)
295 case .latexEnvironment:
296 return OrgElement(begin: r.lowerBound, end: post, contents: nil, greater: false, block: true, node: n)
297 case .paragraph:
298 return OrgElement(begin: r.lowerBound, end: post, contents: r.lowerBound..<trimmed(r.lowerBound, r.upperBound), greater: false, node: n)
299 default:
300 return OrgElement(begin: r.lowerBound, end: post, contents: nil, greater: false, node: n)
301 }
302 }
303 /// Org's section: the text under a heading before its first child, or before the first heading.
304 func section(_ n: SyntaxNode) -> OrgElement? {
305 let begin = n.kind == .zerothSection ? n.range.lowerBound : min(n.range.upperBound, buffer.lineEnd(n.range.lowerBound) + 1)
306 let end = n.children.first { $0.kind == .section && $0.range.lowerBound >= begin }?.range.lowerBound ?? n.range.upperBound
307 guard begin < end else { return nil }
308 return OrgElement(begin: begin, end: end, contents: begin..<trimmed(begin, end), greater: true)
309 }
310 func parent(_ e: OrgElement) -> OrgElement? {
311 guard let n = e.node else {
312 // A section's parent is its headline.
313 return chain.last { $0.kind == .section && $0.range.lowerBound < e.begin }.map(element)
314 }
315 guard let up = n.parent else { return nil }
316 switch up.kind {
317 case .section where n.kind != .section: return section(up)
318 case .zerothSection: return section(up)
319 case .document: return nil
320 default: return element(up)
321 }
322 }
323 /// org-element-at-point.
324 func atPoint() -> OrgElement? {
325 let objects: Set<SyntaxKind> = [.title, .heading, .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription,
326 .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock,
327 .latexFragment, .lineBreak, .superscript, .subscript, .entity, .tableCell, .nodeProperty]
328 var elements = chain.filter { !objects.contains($0.kind) }
329 if chain.contains(where: { $0.kind == .heading }), let i = elements.lastIndex(where: { $0.kind == .section }) {
330 elements = Array(elements[...i])
331 }
332 guard var n = elements.last else { return nil }
333 if [.section, .zerothSection, .plainList, .item].contains(n.kind), !chain.contains(where: { $0.kind == .heading }) {
334 // On a blank line: the element before it, whose post-blank it is.
335 if let before = n.children.last(where: { $0.range.upperBound <= c && $0.kind != .heading && $0.kind != .bullet }), !n.children.contains(where: { $0.range.contains(c) }) {
336 n = before
337 } else if n.kind == .zerothSection || n.kind == .section {
338 return section(n)
339 }
340 }
341 if let up = n.parent, up.range.lowerBound == n.range.lowerBound, (n.kind == .item && up.kind == .plainList) || (n.kind == .tableRow && up.kind == .table) {
342 n = up
343 }
344 return element(n)
345 }
346 func innerRange(_ e: OrgElement) -> Range<Int> {
347 if e.block {
348 let first = min(e.end, buffer.lineEnd(e.begin) + 1)
349 let closing = buffer.lineStart(max(e.begin, trimmed(e.begin, e.end) - 1))
350 return min(first, closing)..<closing
351 }
352 if let contents = e.contents { return contents }
353 return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0))
354 }
355 switch key {
356 case "R":
357 guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil }
358 return (inner ? (headline.contents ?? headline.begin..<headline.begin) : headline.begin..<headline.end, true)
359 case "E":
360 guard let e = atPoint() else { return nil }
361 return (inner ? innerRange(e) : e.begin..<e.end, false)
362 case "r":
363 guard var e = atPoint() else { return nil }
364 if !e.greater, let up = parent(e) { e = up }
365 return (inner ? innerRange(e) : e.begin..<e.end, !inner)
366 default:
367 let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .timestamp, .footnoteReference,
368 .statisticsCookie, .target, .radioTarget, .macro, .inlineSourceBlock, .latexFragment, .entity, .superscript, .subscript]
369 guard let found = chain.last(where: { objects.contains($0.kind) }) else {
370 guard let e = atPoint() else { return nil }
371 return (inner ? innerRange(e) : e.begin..<e.end, false)
372 }
373 var range = found.range
374 var end = range.upperBound
375 while let ch = buffer.char(end), Buffer.isBlank(ch) { end += 1 }
376 if inner {
377 switch found.kind {
378 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
379 range = (range.lowerBound + 1)..<(range.upperBound - 1)
380 case .link:
381 if let description = found.firstChild(.linkDescription) { range = description.range }
382 default:
383 break
384 }
385 return (range, false)
386 }
387 return (range.lowerBound..<end, false)
388 }
389 }
390
391 // MARK: - Block visual
392
393 /// The block's columns and line starts, from `anchor` to `caret`; `$` stretches it to each
394 /// line's end.
395 func block(_ buffer: Buffer, _ caret: Int) -> (lines: [Int], left: Int, right: Int) {
396 let a = min(anchor, caret), b = max(anchor, caret)
397 let left = min(anchor - buffer.lineStart(anchor), caret - buffer.lineStart(caret))
398 let right = blockToEnd ? Int.max : max(anchor - buffer.lineStart(anchor), caret - buffer.lineStart(caret))
399 var lines: [Int] = []
400 var line = buffer.lineStart(a)
401 while true {
402 lines.append(line)
403 let end = buffer.lineEnd(line)
404 guard end < buffer.length, line < buffer.lineStart(b) else { break }
405 line = end + 1
406 }
407 return (lines, left, right)
408 }
409
410 /// Each line's part of the block.
411 func blockRanges(_ buffer: Buffer, _ caret: Int) -> [Range<Int>] {
412 let (lines, left, right) = block(buffer, caret)
413 return lines.map { line in
414 let end = buffer.lineEnd(line)
415 let lower = min(line + left, end)
416 let upper = right == Int.max ? end : min(line + right + 1, end)
417 return lower..<max(lower, upper)
418 }
419 }
420
421 func blockActions(_ buffer: Buffer, _ caret: Int) -> [Action] {
422 [.move(caret: caret), .selectBlock(blockRanges(buffer, caret))]
423 }
424
425 /// Keys in block visual state.
426 mutating func parseBlock(_ rest: ArraySlice<KeyChord>, _ buffer: inout Buffer, _ context: Context, register: Character?, count n: Int, keys: [KeyChord]) -> Parse {
427 guard let first = rest.first else { return .incomplete }
428 let caret = context.caret
429 let ranges = blockRanges(buffer, caret)
430 func leave() {
431 lastVisual = (anchor, caret, .visualBlock)
432 mode = .normal
433 blockToEnd = false
434 }
435 if first == KeyChord("v", .control) || (first.modifiers.isEmpty && first.key == "ESC") {
436 leave()
437 return .done([.move(caret: buffer.clampNormal(caret))])
438 }
439 guard first.modifiers.isEmpty else { return .invalid }
440 switch first.key {
441 case "v", "V":
442 lastVisual = (anchor, caret, .visualBlock)
443 mode = first.key == "v" ? .visual : .visualLine
444 blockToEnd = false
445 return .done(visualActions(buffer, caret))
446 case "o", "O":
447 let old = anchor
448 anchor = caret
449 return .done(blockActions(buffer, old))
450 case "$":
451 blockToEnd = true
452 return .done(blockActions(buffer, buffer.lineEnd(caret) > buffer.lineStart(caret) ? buffer.lineEnd(caret) - 1 : caret))
453 case "d", "x", "y", "c":
454 let removed = ranges.map { buffer.substring($0) }.joined(separator: "\n")
455 leave()
456 var actions = store(removed, linewise: false, register: register, yank: first.key == "y")
457 let top = ranges.first?.lowerBound ?? caret
458 if first.key == "y" { return .done(actions + [.move(caret: top)]) }
459 let edits = ranges.reversed().map { TextEdit(range: $0, replacement: "") }
460 if first.key == "c" {
461 let (lines, left, _) = block(buffer, caret)
462 blockInsert = (lines.dropFirst().map { buffer.lineNumber($0) }, left, false)
463 actions.insert(.edit(edits, caret: top), at: 0)
464 enterInsert(at: top, keys: [])
465 return .done(actions)
466 }
467 actions.insert(.edit(edits, caret: buffer.removing(top..<top).clampNormal(top)), at: 0)
468 return .done(actions)
469 case "I", "A":
470 let (lines, left, right) = block(buffer, caret)
471 leave()
472 let column = first.key == "I" ? left : (right == Int.max ? Int.max : right + 1)
473 let top = lines.first ?? caret
474 let at: Int
475 if column == Int.max {
476 at = buffer.lineEnd(top)
477 } else {
478 at = min(top + column, buffer.lineEnd(top))
479 }
480 blockInsert = (lines.dropFirst().map { buffer.lineNumber($0) }, column, first.key == "A")
481 var actions: [Action] = []
482 // `A` past a short first line pads it with spaces.
483 if first.key == "A", column != Int.max, buffer.lineEnd(top) < top + column {
484 let pad = top + column - buffer.lineEnd(top)
485 actions.append(.edit([TextEdit(range: buffer.lineEnd(top)..<buffer.lineEnd(top), replacement: String(repeating: " ", count: pad))], caret: top + column))
486 enterInsert(at: top + column, keys: [])
487 return .done(actions)
488 }
489 enterInsert(at: at, keys: [])
490 return .done([.move(caret: at)])
491 case "r":
492 guard rest.count >= 2 else { return .incomplete }
493 let char = rest[rest.startIndex + 1].key == "SPC" ? " " : rest[rest.startIndex + 1].key
494 guard char.count == 1 else { return .invalid }
495 leave()
496 let edits = ranges.reversed().map { TextEdit(range: $0, replacement: String(repeating: char, count: $0.count)) }
497 return .done([.edit(edits, caret: ranges.first?.lowerBound ?? caret)])
498 case "~", "u", "U":
499 leave()
500 let edits = ranges.reversed().map { r -> TextEdit in
501 let s = buffer.substring(r)
502 let t = first.key == "u" ? s.lowercased() : first.key == "U" ? s.uppercased() : String(s.map { $0.isUppercase ? Character($0.lowercased()) : Character($0.uppercased()) })
503 return TextEdit(range: r, replacement: t)
504 }
505 return .done([.edit(edits, caret: ranges.first?.lowerBound ?? caret)])
506 case ">", "<":
507 leave()
508 let lines = block(buffer, caret).lines
509 let range = (lines.first ?? caret)..<min(buffer.length, buffer.lineEnd(lines.last ?? caret) + 1)
510 mode = .normal
511 return .done(apply(first.key, range: range, linewise: true, buffer, register: register, keys: [], count: n))
512 default:
513 var target = caret
514 switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) {
515 case .incomplete: return .incomplete
516 case .invalid: return .invalid
517 case .moved(let to, _): return .done(blockActions(buffer, to))
518 }
519 }
520 }
521
522 /// What block `I`, `A` or `c` typed, put on the block's other lines at its column.
523 func replicateBlockInsert(_ block: (lines: [Int], column: Int, pad: Bool), _ buffer: Buffer, caret: Int) -> [Action] {
524 var edits: [TextEdit] = []
525 for number in block.lines.reversed() {
526 let line = buffer.lineOffset(number)
527 let end = buffer.lineEnd(line)
528 if block.column == Int.max {
529 edits.append(TextEdit(range: end..<end, replacement: insertedText))
530 } else if line + block.column <= end {
531 edits.append(TextEdit(range: (line + block.column)..<(line + block.column), replacement: insertedText))
532 } else if block.pad {
533 edits.append(TextEdit(range: end..<end, replacement: String(repeating: " ", count: line + block.column - end) + insertedText))
534 }
535 }
536 let lineStart = buffer.lineStart(caret)
537 let back = caret > lineStart ? caret - 1 : caret
538 return edits.isEmpty ? [.move(caret: back)] : [.edit(edits, caret: back)]
539 }
540}
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -87,6 +87,7 @@ public enum EditorCommands {
87 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), 87 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
88 EditorCommand(id: "app.export-dialog", title: "Export…", action: .app), 88 EditorCommand(id: "app.export-dialog", title: "Export…", action: .app),
89 EditorCommand(id: "app.sync-conflicts", title: "Resolve Sync Conflicts…", action: .app), 89 EditorCommand(id: "app.sync-conflicts", title: "Resolve Sync Conflicts…", action: .app),
90 EditorCommand(id: "app.revert", title: "Revert to File on Disk", action: .app),
90 EditorCommand(id: "app.recovery", title: "Recovery Versions…", action: .app), 91 EditorCommand(id: "app.recovery", title: "Recovery Versions…", action: .app),
91 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), 92 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app),
92 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), 93 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app),
Sources/OrgEditorAppKit/Modal.swift +74 −3
@@ -1,6 +1,7 @@
1#if os(macOS) 1#if os(macOS)
2import AppKit 2import AppKit
3import OrgCore 3import OrgCore
4import OrgDocument
4 5
5/// Modal editing: keys go to the keymap for the current evil state first (leader keys, 6/// Modal editing: keys go to the keymap for the current evil state first (leader keys,
6/// evil-org's org keys), then to `Vim`. 7/// evil-org's org keys), then to `Vim`.
@@ -8,6 +9,7 @@ extension OrgEditor {
8 func handleModal(_ chord: KeyChord) -> Bool { 9 func handleModal(_ chord: KeyChord) -> Bool {
9 // ⌘ shortcuts stay with the menus. 10 // ⌘ shortcuts stay with the menus.
10 if chord.modifiers.contains(.command) { return false } 11 if chord.modifiers.contains(.command) { return false }
12 if recordingRegister != nil, replayingMacro == 0 { recordedKeys.append(chord) }
11 dispatcher.mode = vim.mode.keymapMode 13 dispatcher.mode = vim.mode.keymapMode
12 if !vim.pending.isEmpty, vim.mode != .insert { 14 if !vim.pending.isEmpty, vim.mode != .insert {
13 return feedVim([chord]) 15 return feedVim([chord])
@@ -61,7 +63,9 @@ extension OrgEditor {
61 let visual = vim.mode == .visual || vim.mode == .visualLine 63 let visual = vim.mode == .visual || vim.mode == .visualLine
62 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location 64 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
63 let clipboard = NSPasteboard.general.string(forType: .string) 65 let clipboard = NSPasteboard.general.string(forType: .string)
64 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage)) 66 var marks = vimMarks
67 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
68 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks))
65 apply(actions) 69 apply(actions)
66 if !wasInsert, vim.mode == .insert { 70 if !wasInsert, vim.mode == .insert {
67 insertSnapshot = (document.text, textView.selectedRange().location) 71 insertSnapshot = (document.text, textView.selectedRange().location)
@@ -74,7 +78,16 @@ extension OrgEditor {
74 return true 78 return true
75 } 79 }
76 80
77 private func insertTyped(_ key: KeyChord) { 81 /// Moves marks and jump positions through an edit.
82 func mapVimPositions(through edit: TextEdit) {
83 guard !vimMarks.isEmpty || !jumpList.isEmpty else { return }
84 let names = Array(vimMarks.keys)
85 let mapped = ViewState(selection: names.map { vimMarks[$0]!..<vimMarks[$0]! } + jumpList.map { $0..<$0 }).mapped(through: [edit]).selection
86 for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound }
87 jumpList = mapped.dropFirst(names.count).map { $0.lowerBound }
88 }
89
90 func insertTyped(_ key: KeyChord) {
78 let text = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key 91 let text = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key
79 guard key.modifiers.isEmpty, text.count == 1 else { return } 92 guard key.modifiers.isEmpty, text.count == 1 else { return }
80 textView.insertText(text, replacementRange: textView.selectedRange()) 93 textView.insertText(text, replacementRange: textView.selectedRange())
@@ -132,6 +145,54 @@ extension OrgEditor {
132 prompt(kind) 145 prompt(kind)
133 case .message(let text): 146 case .message(let text):
134 onMessage?(text) 147 onMessage?(text)
148 case .setMark(let name, let offset):
149 vimMarks[name] = offset
150 case .jumped(let from):
151 // A new jump drops the ones C-o went back past.
152 jumpList = Array(jumpList.prefix(jumpIndex))
153 if jumpList.last != from { jumpList.append(from) }
154 if jumpList.count > 100 { jumpList.removeFirst(jumpList.count - 100) }
155 jumpIndex = jumpList.count
156 case .jumpBack(let count):
157 // Leaving the newest place keeps it, so C-i comes back to it.
158 if jumpIndex == jumpList.count {
159 jumpList.append(textView.selectedRange().location)
160 jumpIndex = jumpList.count - 1
161 }
162 guard jumpIndex - count >= 0 else { continue }
163 jumpIndex -= count
164 reveal(jumpList[jumpIndex])
165 case .jumpForward(let count):
166 guard jumpIndex + count < jumpList.count else { continue }
167 jumpIndex += count
168 reveal(jumpList[jumpIndex])
169 case .recordMacro(let name):
170 if let name {
171 recordingRegister = name
172 recordedKeys = []
173 onMessage?("Defining keyboard macro…")
174 } else if let register = recordingRegister {
175 // Without the `q` that ended it.
176 macros[register] = Array(recordedKeys.dropLast())
177 recordingRegister = nil
178 onMessage?("Keyboard macro defined")
179 }
180 case .playMacro(let name, let count):
181 guard let keys = macros[name], !keys.isEmpty else {
182 onMessage?("No macro in register \(name)")
183 continue
184 }
185 guard replayingMacro < 20 else { continue }
186 replayingMacro += 1
187 defer { replayingMacro -= 1 }
188 for _ in 0..<count {
189 for key in keys where !handleModal(key) {
190 // Printable keys in insert state type, as the text system would.
191 insertTyped(key)
192 }
193 }
194 case .selectBlock(let ranges):
195 textView.setSelectedRanges(ranges.map { NSValue(range: NSRange($0)) }, affinity: .downstream, stillSelecting: false)
135 } 196 }
136 } 197 }
137 } 198 }
@@ -161,11 +222,21 @@ extension OrgEditor {
161 } 222 }
162 } 223 }
163 224
164 /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s` and `:%s`. 225 /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s`, `:%s`, `:e FILE`,
226 /// `:bn`, `:bp`, `:bd`, `:b` and `:ls`.
165 func ex(_ command: String) { 227 func ex(_ command: String) {
166 let trimmed = command.trimmingCharacters(in: .whitespaces) 228 let trimmed = command.trimmingCharacters(in: .whitespaces)
229 if let m = trimmed.firstMatch(of: #/^e(?:dit)?!?\s+(.+)$/#) {
230 onOpenFile?(String(m.1).trimmingCharacters(in: .whitespaces))
231 return
232 }
167 switch trimmed { 233 switch trimmed {
168 case "w", "write": onAppCommand?("app.save") 234 case "w", "write": onAppCommand?("app.save")
235 case "bn", "bnext": onAppCommand?("app.buffer.next")
236 case "bp", "bprevious", "bN", "bNext": onAppCommand?("app.buffer.previous")
237 case "bd", "bdelete", "bd!", "bw", "bwipeout": onAppCommand?("app.buffer.kill")
238 case "b", "buffer", "ls", "buffers", "files": onAppCommand?("app.buffer.switch")
239 case "e", "edit", "e!", "edit!": onAppCommand?("app.revert")
169 case "q", "quit", "q!": textView.window?.performClose(nil) 240 case "q", "quit", "q!": textView.window?.performClose(nil)
170 case "wq", "x", "wq!": 241 case "wq", "x", "wq!":
171 onAppCommand?("app.save") 242 onAppCommand?("app.save")
Sources/OrgEditorAppKit/OrgEditor.swift +13
@@ -277,6 +277,8 @@ public final class OrgEditor: NSObject {
277 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? 277 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
278 /// Called with `app.*` commands, which the app carries out. 278 /// Called with `app.*` commands, which the app carries out.
279 public var onAppCommand: ((String) -> Void)? 279 public var onAppCommand: ((String) -> Void)?
280 /// `:e FILE`: the path as typed, for the app to open.
281 public var onOpenFile: ((String) -> Void)?
280 /// Called when a command asks a question; reply with the answer, or nil to cancel. 282 /// Called when a command asks a question; reply with the answer, or nil to cancel.
281 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? 283 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
282 /// Runs work outside the text, such as a table recalculation in Emacs. 284 /// Runs work outside the text, such as a table recalculation in Emacs.
@@ -330,6 +332,16 @@ public final class OrgEditor: NSObject {
330 var insertSnapshot: (text: String, caret: Int)? 332 var insertSnapshot: (text: String, caret: Int)?
331 /// The evil cursor in visual states, where the selection's start isn't it. 333 /// The evil cursor in visual states, where the selection's start isn't it.
332 var visualCaret: Int? 334 var visualCaret: Int?
335 /// Vim marks, mapped through edits.
336 var vimMarks: [Character: Int] = [:]
337 /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are.
338 var jumpList: [Int] = []
339 var jumpIndex = 0
340 /// The register keys go into while `q` records, and its keys so far.
341 var recordingRegister: Character?
342 var recordedKeys: [KeyChord] = []
343 var macros: [Character: [KeyChord]] = [:]
344 var replayingMacro = 0
333 /// A region is active: movement extends the selection, as after C-SPC in Emacs. 345 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
334 private(set) var markActive = false 346 private(set) var markActive = false
335 347
@@ -1180,6 +1192,7 @@ public final class OrgEditor: NSObject {
1180 view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false) 1192 view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false)
1181 mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) 1193 mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound))
1182 if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] } 1194 if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] }
1195 mapVimPositions(through: edit)
1183 if let sparse = sparseHidden { sparseHidden = ViewState(selection: sparse).mapped(through: [edit]).selection.filter { !$0.isEmpty } } 1196 if let sparse = sparseHidden { sparseHidden = ViewState(selection: sparse).mapped(through: [edit]).selection.filter { !$0.isEmpty } }
1184 let oldHighlights = ViewState(selection: highlights).mapped(through: [edit]).selection 1197 let oldHighlights = ViewState(selection: highlights).mapped(through: [edit]).selection
1185 highlights = [] 1198 highlights = []
Sources/Orgstar/ContentView.swift +5
@@ -602,6 +602,11 @@ struct ContentView: View {
602 case "app.capture": 602 case "app.capture":
603 NSApp.activate() 603 NSApp.activate()
604 openWindow(id: "capture") 604 openWindow(id: "capture")
605 case "app.revert":
606 // `:e`: the file as it is on disk; unsaved changes go to recovery.
607 guard session.path != nil else { return commands.show("No file is open") }
608 session.useDiskVersion()
609 commands.show("Reverted to the file on disk")
605 case "app.sync-conflicts": 610 case "app.sync-conflicts":
606 guard session.path != nil else { return commands.show("No file is open") } 611 guard session.path != nil else { return commands.show("No file is open") }
607 panel = .syncCopies(UUID()) 612 panel = .syncCopies(UUID())
Sources/Orgstar/EditorView.swift +10
@@ -95,6 +95,16 @@ struct EditorView: NSViewRepresentable {
95 editor.onModeChange = { [weak commands] in commands?.editorState = $0 } 95 editor.onModeChange = { [weak commands] in commands?.editorState = $0 }
96 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 } 96 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
97 editor.onAppCommand = onAppCommand 97 editor.onAppCommand = onAppCommand
98 editor.onOpenFile = { [weak session, weak commands] typed in
99 let expanded = (typed as NSString).expandingTildeInPath
100 let base = URL(fileURLWithPath: path).deletingLastPathComponent()
101 let target = expanded.hasPrefix("/") ? expanded : base.appendingPathComponent(expanded).standardizedFileURL.path
102 guard FileManager.default.fileExists(atPath: target) else {
103 commands?.show("No file \(typed)")
104 return
105 }
106 session?.open(target)
107 }
98 editor.onExternal = onExternal 108 editor.onExternal = onExternal
99 editor.knownTags = knownTags 109 editor.knownTags = knownTags
100 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } 110 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
Sources/Orgstar/Keys.swift +2 −1
@@ -134,6 +134,7 @@ struct StateTag: View {
134 private var label: String { 134 private var label: String {
135 switch state { 135 switch state {
136 case "visual-line": "V-LINE" 136 case "visual-line": "V-LINE"
137 case "visual-block": "V-BLOCK"
137 default: state.uppercased() 138 default: state.uppercased()
138 } 139 }
139 } 140 }
@@ -141,7 +142,7 @@ struct StateTag: View {
141 private var color: Color { 142 private var color: Color {
142 switch state { 143 switch state {
143 case "insert": theme.color("state-insert") ?? .green 144 case "insert": theme.color("state-insert") ?? .green
144 case "visual", "visual-line": theme.color("state-visual") ?? .orange 145 case "visual", "visual-line", "visual-block": theme.color("state-visual") ?? .orange
145 default: theme.color("state-normal") ?? .blue 146 default: theme.color("state-normal") ?? .blue
146 } 147 }
147 } 148 }
Tests/OrgCoreTests/VimExtrasTests.swift added +167
@@ -0,0 +1,167 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct VimExtrasTests {
6 func run(_ text: String, _ caret: Int, _ keys: String) -> (text: String, caret: Int) {
7 var driver = VimDriver(text, caret: caret)
8 driver.type(KeySequence.parse(keys)!)
9 return (driver.text, driver.caret)
10 }
11
12 @Test func surrounds() {
13 #expect(run("hello world\n", 0, "y s i w )").text == "(hello) world\n")
14 #expect(run("hello world\n", 0, "y s i w (").text == "( hello ) world\n")
15 #expect(run("hello world\n", 0, "y s w ]").text == "[hello] world\n")
16 #expect(run(" hello world\n", 4, "y s s \"").text == " \"hello world\"\n")
17 #expect(run("( hello )\n", 4, "d s (").text == "hello\n")
18 #expect(run("( hello )\n", 4, "d s )").text == " hello \n")
19 #expect(run("(hello)\n", 3, "c s ) ]").text == "[hello]\n")
20 #expect(run("say \"hi\" now\n", 5, "d s \"").text == "say hi now\n")
21 #expect(run("say hi now\n", 4, "v l S *").text == "say *hi* now\n")
22 #expect(run("one\ntwo\n", 0, "V S {").text == "{\none\n}\ntwo\n")
23 // `.` repeats a surround.
24 #expect(run("a b\n", 0, "y s i w ) W .").text == "(a) (b)\n")
25 }
26
27 @Test func comments() {
28 #expect(run("text\nmore\n", 0, "g c c").text == "# text\nmore\n")
29 #expect(run("# text\nmore\n", 0, "g c c").text == "text\nmore\n")
30 #expect(run(" a\n\n b\nc\n", 0, "g c 2 j").text == " # a\n\n # b\nc\n")
31 #expect(run("a\nb\nc\n", 0, "V j g c").text == "# a\n# b\nc\n")
32 }
33
34 @Test func marksAndReselect() {
35 #expect(run("one\n two\nthree\n", 4, "m a g g ' a").caret == 6)
36 #expect(run("one\ntwo\nthree\nfour\n", 4, "m a j j d ' a").text == "one\n")
37 #expect(run("one two\n", 4, "m b 0 ` b x").text == "one wo\n")
38 #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n")
39 }
40
41 @Test func blockVisual() {
42 #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)
44 #expect(run("abc\ndef\nghi\n", 1, "C-v j I X ESC").text == "aXbc\ndXef\nghi\n")
45 #expect(run("abc\nde\nghi\n", 0, "C-v j j $ A ! ESC").text == "abc!\nde!\nghi!\n")
46 #expect(run("abc\nd\nghi\n", 1, "C-v j j A ! ESC").text == "ab!c\nd !\ngh!i\n")
47 #expect(run("abc\ndef\n", 0, "C-v j l r x").text == "xxc\nxxf\n")
48 #expect(run("abc\ndef\n", 0, "C-v j l c Z ESC").text == "Zc\nZf\n")
49 #expect(run("abc\ndef\n", 0, "C-v j ~").text == "Abc\nDef\n")
50 }
51
52 static let cases: [(String, Int, String)] = [
53 ("hello world\n", 0, "y s i w )"),
54 ("hello world\n", 0, "y s i w ("),
55 ("hello world\n", 2, "y s w ]"),
56 (" hello world\n", 4, "y s s \""),
57 ("( hello )\n", 4, "d s ("),
58 ("( hello )\n", 4, "d s )"),
59 ("(hello)\n", 3, "c s ) ]"),
60 ("(hello)\n", 3, "c s ) {"),
61 ("say \"hi\" now\n", 5, "d s \""),
62 ("say hi now\n", 4, "v l S *"),
63 ("one\ntwo\n", 0, "V S {"),
64 ("a b\n", 0, "y s i w ) W ."),
65 ("one\n two\nthree\n", 4, "m a g g ' a"),
66 ("one\n two\nthree\n", 6, "m a g g ` a"),
67 ("one\ntwo\nthree\nfour\n", 4, "m a j j d ' a"),
68 ("one two\n", 4, "m b 0 ` b x"),
69 ("one\ntwo\nthree\nfour\n", 0, "G C-o x"),
70 ("one\ntwo\nthree\nfour\n", 0, "G C-o C-i x"),
71 ("abcdef\n", 1, "v l l ESC 0 g v d"),
72 ("abc\ndef\nghi\n", 0, "C-v j l d"),
73 ("abc\ndef\nghi\n", 1, "C-v j I X ESC"),
74 ("abc\nde\nghi\n", 0, "C-v j j $ A ! ESC"),
75 ("abc\nd\nghi\n", 1, "C-v j j A ! ESC"),
76 ("abc\ndef\n", 0, "C-v j l r x"),
77 ("abc\ndef\n", 0, "C-v j l c Z ESC"),
78 ("abc\ndef\n", 0, "C-v j ~"),
79 ("abc\ndef\n", 0, "C-v j l o x"),
80 ]
81
82 static let orgCases: [(String, Int, String)] = {
83 let text = "* H\ntext *bold* and [[x][link]] here\n** Sub\nsub body\n* Next\n"
84 let at = { (s: String) in (text as NSString).range(of: s).location }
85 return [
86 (text, at("bold"), "d a e"),
87 (text, at("bold"), "c i e B ESC"),
88 (text, at("link]"), "d i e"),
89 (text, 5, "d a R"),
90 (text, 5, "d i R"),
91 (text, at("sub body"), "d a R"),
92 (text, at("sub body"), "d a r"),
93 (text, 5, "d a E"),
94 ("- a\n- b\n\nafter\n", 0, "d a r"),
95 ("- a\n- b\n\nafter\n", 0, "d i r"),
96 ("- a\n- b\n\nafter\n", 0, "d a E"),
97 ("#+begin_src sh\necho\n#+end_src\n", 16, "d i E"),
98 ("#+begin_src sh\necho\n#+end_src\n", 16, "d a E"),
99 ("| a | b |\n| c | d |\n", 2, "d a r"),
100 ("| a | b |\n| c | d |\n", 12, "d a E"),
101 (text, 6, "d i E"),
102 (text, 6, "d i r"),
103 (text, at("bold"), "d i r"),
104 (text, 0, "d a r"),
105 (text, 0, "d i r"),
106 (text, 0, "d a E"),
107 (text, 0, "d i E"),
108 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 0, "d a r"),
109 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 4, "d a E"),
110 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 7, "y a r P"),
111 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 7, "d i r"),
112 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 10, "d a E"),
113 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 18, "d a r"),
114 ("pre\n\n- a\n- b\n\n more\n\nafter\n", 18, "d a E"),
115 ("#+begin_quote\nq\n#+end_quote\n", 14, "d a r"),
116 ("#+begin_quote\nq\n#+end_quote\n", 14, "d i r"),
117 ("a =code= b\n", 3, "d i e"),
118 ("a =code= b\n", 3, "d a e"),
119 ("a [[https://x.org]] b\n", 5, "d i e"),
120 ]
121 }()
122
123 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
124 func matchesEvil() throws {
125 let repos = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos")
126 guard FileManager.default.fileExists(atPath: repos.appendingPathComponent("evil-org-mode/evil-org.el").path) else { return }
127 let all = Self.cases.map { ($0, false) } + Self.orgCases.map { ($0, true) }
128 let forms = all.map { (c, org) in
129 EmacsOracle.Case(text: c.0, point: c.1 + 1, form: """
130 (progn
131 (setq evil-want-Y-yank-to-eol t evil-undo-system 'undo-redo evil-want-C-i-jump t)
132 (dolist (d '("evil" "goto-chg" "evil-surround" "evil-org-mode")) (add-to-list 'load-path (concat \(lispString(repos.path)) "/" d)))
133 (require 'evil)
134 (require 'evil-surround)
135 (setq kill-ring nil)
136 (switch-to-buffer (current-buffer))
137 \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects)) (evil-org-mode 1)" : "")
138 (evil-local-mode 1)
139 (evil-surround-mode 1)
140 (evil-normal-state)
141 (set-mark (point))
142 (deactivate-mark)
143 (execute-kbd-macro (kbd \(lispString(c.2.replacingOccurrences(of: "ESC", with: "<escape>")))))
144 (let ((p (point)) (s evil-state))
145 (goto-char (point-max))
146 (insert (format "\\n@@STATE %s" s))
147 (goto-char p)))
148 """)
149 }
150 let results = try EmacsOracle.run(forms)
151 for ((c, _), result) in zip(all, results) {
152 let (text, point, keys) = c
153 var driver = VimDriver(text, caret: point)
154 driver.type(KeySequence.parse(keys)!)
155 guard let marker = result.text.range(of: "\n@@STATE ") else {
156 Issue.record("\(keys): \(result.error)")
157 continue
158 }
159 let emacsText = String(result.text[..<marker.lowerBound])
160 let state = String(result.text[marker.upperBound...])
161 let ours = driver.vim.mode == .insert ? "insert" : driver.vim.mode == .normal ? "normal" : "visual"
162 let emacsCaret = EmacsOracle.offset(result.point, in: result.text)
163 #expect(driver.text == emacsText && driver.caret == emacsCaret && ours == state,
164 "\(keys.debugDescription) on \(text.debugDescription) at \(point)\nours: \(driver.text.debugDescription) @\(driver.caret) \(ours)\nevil: \(emacsText.debugDescription) @\(emacsCaret) \(state) \(result.error)")
165 }
166 }
167}
Tests/OrgEditorAppKitTests/ModalTests.swift +35 −1
@@ -18,7 +18,10 @@ struct ModalTests {
18 let codes: [String: UInt16] = ["ESC": 53, "RET": 36, "TAB": 48, "SPC": 49] 18 let codes: [String: UInt16] = ["ESC": 53, "RET": 36, "TAB": 48, "SPC": 49]
19 for token in keys.split(separator: " ") { 19 for token in keys.split(separator: " ") {
20 let key = String(token) 20 let key = String(token)
21 if let code = codes[key] { 21 if key.hasPrefix("C-") {
22 let letter = String(key.dropFirst(2))
23 KeyTests().send(h, characters: letter, ignoring: letter, code: 0, flags: .control)
24 } else if let code = codes[key] {
22 let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " " 25 let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " "
23 KeyTests().send(h, characters: characters, ignoring: characters, code: code) 26 KeyTests().send(h, characters: characters, ignoring: characters, code: code)
24 } else { 27 } else {
@@ -123,4 +126,35 @@ struct ModalTests {
123 h.editor.modal = false 126 h.editor.modal = false
124 #expect(states.last == .some(nil)) 127 #expect(states.last == .some(nil))
125 } 128 }
129
130 @Test func macrosReplayKeys() {
131 let h = doom("one two\nthree four\nfive six\nseven\n")
132 h.caret(at: 0)
133 type(h, "q a d w j q")
134 #expect(h.editor.document.text == "two\nthree four\nfive six\nseven\n")
135 type(h, "@ a")
136 #expect(h.editor.document.text == "two\nfour\nfive six\nseven\n")
137 type(h, "@ @")
138 #expect(h.editor.document.text == "two\nfour\nsix\nseven\n")
139 type(h, "g g q b A ! ESC j q 2 @ b")
140 #expect(h.editor.document.text == "two!\nfour!\nsix!\nseven\n", "\(h.editor.document.text.debugDescription)")
141 }
142
143 @Test func jumpsAndMarks() {
144 let h = doom("one\ntwo\nthree\nfour\n")
145 h.caret(at: 4)
146 type(h, "G")
147 #expect(h.textView.selectedRange().location == 19)
148 type(h, "C-o")
149 #expect(h.textView.selectedRange().location == 4)
150 type(h, "C-i")
151 #expect(h.textView.selectedRange().location == 19)
152 type(h, "g g m a G ' a")
153 #expect(h.textView.selectedRange().location == 0)
154 // Marks follow edits above them.
155 type(h, "G m b g g O")
156 h.textView.insertText("new", replacementRange: h.textView.selectedRange())
157 type(h, "ESC ` b")
158 #expect(h.textView.selectedRange().location == 23)
159 }
126} 160}