krz/orgstar

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

Commit 7cc38fb006

7cc38fb0066433ded701b9b02dcebb078e34a7dc

parent: 2e50d15064

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 07:56 UTC

Doom keymap

A modal engine checked against evil and evil-snipe in batch Emacs,
and a Doom preset: Doom's leader and org localleader, evil-org's keys,
RET as +org/dwim-at-point. The editor routes keys through the keymap
for the evil state and then the engine, with a block cursor outside
insert state and :w, :q, :s in the echo area.

Ref plan docs/plans/2026-10-05-doom-keys.md

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +1 −1
@@ -173,7 +173,7 @@ public enum Commands {
173173 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
174174 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
175175 RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
176 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(),
176 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
177177 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
178178 ]
179179
Sources/OrgCore/Commands/Dwim.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2
3/// Doom's `+org/dwim-at-point` (RET in normal state): on a heading, TODO becomes the first
4/// done keyword of its sequence and a done keyword the first TODO one; on an item, its
5/// checkbox toggles; in a src block, the block runs; on a `#+TBLFM` line or in a table with
6/// formulas, the table recalculates, otherwise aligns.
7public struct OrgDwim: OrgCommand {
8 public init() {}
9 public var id: String { "org.dwim" }
10 public var title: String { "Act at Point" }
11
12 public func applies(in context: EditContext) -> Bool { true }
13
14 public func run(in context: EditContext) -> CommandStep {
15 if TableRecalculate.tblfmLine(in: context) != nil { return TableRecalculate(all: true).run(in: context) }
16 if KeyContext.holds("src", in: context) { return ExecuteSrcBlock().run(in: context) }
17 if KeyContext.holds("table", in: context) {
18 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
19 if buffer.firstTblfm(after: buffer.tableEnd()) != nil { return TableRecalculate(all: true).run(in: context) }
20 return TableCommand(.align).run(in: context)
21 }
22 if KeyContext.holds("item", in: context) {
23 let toggle = ToggleCheckbox()
24 if toggle.applies(in: context) { return toggle.run(in: context) }
25 }
26 guard headingOnLine(at: context.caret, in: context.tree) != nil else { return .failed("Nothing to do here") }
27 let settings = context.tree.settings
28 return commitBuffer(context) { buffer in
29 guard let current = buffer.todoKeyword,
30 let sequence = settings.todoSequences.first(where: { ($0.active + $0.done).contains { $0.name == current } }) else { return }
31 if settings.isDone(current) {
32 buffer.setTodoKeyword(sequence.active.first?.name)
33 } else if let done = sequence.done.first?.name {
34 buffer.setTodoKeyword(done)
35 if let (marker, note) = try buffer.autoRepeat(doneWord: done, previous: current) { buffer.storeNote(marker, note) }
36 }
37 }
38 }
39}
Sources/OrgCore/Keymap/Presets.swift +297 −1
@@ -1,14 +1,18 @@
11/// The keymaps the app ships. The user's `keymap.toml` is layered on top of the chosen one.
22public enum KeymapPreset: String, Sendable, CaseIterable {
3 case emacs, mac
3 case emacs, mac, doom
44
55 public var title: String {
66 switch self {
77 case .emacs: "Emacs"
88 case .mac: "Mac"
9 case .doom: "Doom (Vim keys)"
910 }
1011 }
1112
13 /// Whether the preset edits modally.
14 public var isModal: Bool { self == .doom }
15
1216 public var keymap: Keymap {
1317 var problems: [String] = []
1418 let map = try! Keymap(toml: toml, problems: &problems)
@@ -20,9 +24,301 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
2024 switch self {
2125 case .emacs: Self.common + Self.emacsBindings
2226 case .mac: Self.common + Self.macBindings
27 case .doom: Self.doomToml
2328 }
2429 }
2530
31 /// Doom: the Emacs preset's org bindings in every evil state (as org-mode-map's are), with
32 /// evil-org's `M-h/j/k/l` forms, then Doom's leader, localleader and evil-org keys.
33 static var doomToml: String {
34 var problems: [String] = []
35 let emacs = try! Keymap(toml: common + emacsBindings, problems: &problems)
36 let translations: [String: String] = ["<left>": "h", "<right>": "l", "<up>": "k", "<down>": "j"]
37 var out = ""
38 func bind(_ keys: String, _ command: String, when: String?, mode: String) {
39 out += "[[bind]]\nkeys = \"\(keys.replacingOccurrences(of: "\\", with: "\\\\"))\"\ncommand = \"\(command)\"\n"
40 if let when { out += "when = \"\(when)\"\n" }
41 out += "mode = \"\(mode)\"\n"
42 }
43 for binding in emacs.bindings {
44 let first = binding.keys[0]
45 let keys = KeySequence.format(binding.keys)
46 let orgKey = first.key == "c" && first.modifiers == [.control] || first.key == "x" && first.modifiers == [.control]
47 || (first.modifiers.contains(.meta) && ["<left>", "<right>", "<up>", "<down>", "RET"].contains(first.key))
48 || (first.modifiers.contains(.shift) && ["<left>", "<right>", "<up>", "<down>"].contains(first.key))
49 || (first.modifiers == [.control] && first.key == "RET")
50 || keys == "M-x"
51 guard orgKey else { continue }
52 for mode in ["normal", "insert", "visual"] { bind(keys, binding.command, when: binding.when, mode: mode) }
53 // evil-org: M-h/j/k/l and M-H/J/K/L in normal, visual and insert.
54 if binding.keys.count == 1, first.modifiers.contains(.meta), let letter = translations[first.key] {
55 let shifted = first.modifiers.contains(.shift)
56 let key = "M-" + (shifted ? letter.uppercased() : letter)
57 for mode in ["normal", "insert", "visual"] { bind(key, binding.command, when: binding.when, mode: mode) }
58 }
59 // Doom: C-S-h/j/k/l are org-shift* in normal and insert.
60 if binding.keys.count == 1, first.modifiers == [.shift], let letter = translations[first.key] {
61 for mode in ["normal", "insert"] { bind("C-" + letter.uppercased(), binding.command, when: binding.when, mode: mode) }
62 }
63 // evil-org insert state: C-t demotes, C-d promotes.
64 if binding.keys.count == 1, first.modifiers == [.meta], first.key == "<right>" || first.key == "<left>" {
65 bind(first.key == "<right>" ? "C-t" : "C-d", binding.command, when: binding.when, mode: "insert")
66 }
67 }
68 return out + doomBindings
69 }
70
71 static let doomBindings = """
72 # Leader
73 [[bind]]
74 keys = "SPC SPC"
75 command = "app.quick-open"
76 mode = "normal"
77 [[bind]]
78 keys = "SPC ."
79 command = "app.quick-open"
80 mode = "normal"
81 [[bind]]
82 keys = "SPC f f"
83 command = "app.quick-open"
84 mode = "normal"
85 [[bind]]
86 keys = "SPC b b"
87 command = "app.quick-open"
88 mode = "normal"
89 [[bind]]
90 keys = "SPC f s"
91 command = "app.save"
92 mode = "normal"
93 [[bind]]
94 keys = "SPC s s"
95 command = "edit.find"
96 mode = "normal"
97 [[bind]]
98 keys = "SPC /"
99 command = "app.search"
100 mode = "normal"
101 [[bind]]
102 keys = "SPC s p"
103 command = "app.search"
104 mode = "normal"
105 [[bind]]
106 keys = "SPC :"
107 command = "app.palette"
108 mode = "normal"
109 [[bind]]
110 keys = "SPC o a"
111 command = "app.agenda"
112 mode = "normal"
113 [[bind]]
114 keys = "SPC X"
115 command = "app.capture"
116 mode = "normal"
117 [[bind]]
118 keys = "SPC z t"
119 command = "app.clock.report"
120 mode = "normal"
121 [[bind]]
122 keys = "SPC h r r"
123 command = "app.reload-keymap"
124 mode = "normal"
125
126 # Localleader (SPC m)
127 [[bind]]
128 keys = "SPC m t"
129 command = "org.todo.cycle"
130 mode = "normal"
131 [[bind]]
132 keys = "SPC m q"
133 command = "org.tags.set"
134 mode = "normal"
135 [[bind]]
136 keys = "SPC m x"
137 command = "org.checkbox.toggle"
138 mode = "normal"
139 [[bind]]
140 keys = "SPC m A"
141 command = "app.archive"
142 mode = "normal"
143 [[bind]]
144 keys = "SPC m e h h"
145 command = "app.export.html"
146 mode = "normal"
147 [[bind]]
148 keys = "SPC m e h o"
149 command = "app.export.html-open"
150 mode = "normal"
151 [[bind]]
152 keys = "SPC m e m m"
153 command = "app.export.markdown"
154 mode = "normal"
155 [[bind]]
156 keys = "SPC m e l p"
157 command = "app.export.pdf"
158 mode = "normal"
159 [[bind]]
160 keys = "SPC m d d"
161 command = "org.deadline"
162 mode = "normal"
163 [[bind]]
164 keys = "SPC m d s"
165 command = "org.schedule"
166 mode = "normal"
167 [[bind]]
168 keys = "SPC m d t"
169 command = "org.timestamp.active"
170 mode = "normal"
171 [[bind]]
172 keys = "SPC m d T"
173 command = "org.timestamp.inactive"
174 mode = "normal"
175 [[bind]]
176 keys = "SPC m r r"
177 command = "app.refile"
178 mode = "normal"
179 [[bind]]
180 keys = "SPC m s r"
181 command = "app.refile"
182 mode = "normal"
183 [[bind]]
184 keys = "SPC m s A"
185 command = "app.archive"
186 mode = "normal"
187 [[bind]]
188 keys = "SPC m s h"
189 command = "org.subtree.promote"
190 mode = "normal"
191 [[bind]]
192 keys = "SPC m s l"
193 command = "org.subtree.demote"
194 mode = "normal"
195 [[bind]]
196 keys = "SPC m s j"
197 command = "org.subtree.down"
198 mode = "normal"
199 [[bind]]
200 keys = "SPC m s k"
201 command = "org.subtree.up"
202 mode = "normal"
203 [[bind]]
204 keys = "SPC m c i"
205 command = "app.clock.in"
206 mode = "normal"
207 [[bind]]
208 keys = "SPC m c o"
209 command = "app.clock.out"
210 mode = "normal"
211 [[bind]]
212 keys = "SPC m c c"
213 command = "app.clock.cancel"
214 mode = "normal"
215 [[bind]]
216 keys = "SPC m c g"
217 command = "app.clock.goto"
218 mode = "normal"
219 [[bind]]
220 keys = "SPC m c R"
221 command = "app.clock.report"
222 mode = "normal"
223 [[bind]]
224 keys = "SPC m p u"
225 command = "org.priority.up"
226 mode = "normal"
227 [[bind]]
228 keys = "SPC m p d"
229 command = "org.priority.down"
230 mode = "normal"
231 [[bind]]
232 keys = "SPC m b -"
233 command = "org.table.insert-hline"
234 mode = "normal"
235 [[bind]]
236 keys = "SPC m b a"
237 command = "org.table.align"
238 mode = "normal"
239 [[bind]]
240 keys = "SPC m b r"
241 command = "org.table.recalc-all"
242 mode = "normal"
243 [[bind]]
244 keys = "SPC m b d c"
245 command = "org.table.delete-column"
246 mode = "normal"
247 [[bind]]
248 keys = "SPC m b d r"
249 command = "org.table.kill-row"
250 mode = "normal"
251 [[bind]]
252 keys = "SPC m b i c"
253 command = "org.table.insert-column"
254 mode = "normal"
255 [[bind]]
256 keys = "SPC m b i r"
257 command = "org.table.insert-row"
258 mode = "normal"
259 [[bind]]
260 keys = "SPC m b i h"
261 command = "org.table.insert-hline"
262 mode = "normal"
263
264 # evil-org and Doom's org keys
265 [[bind]]
266 keys = "TAB"
267 command = "org.cycle"
268 mode = "normal"
269 [[bind]]
270 keys = "S-TAB"
271 command = "org.cycle-global"
272 mode = "normal"
273 [[bind]]
274 keys = "RET"
275 command = "org.dwim"
276 mode = "normal"
277 [[bind]]
278 keys = "z a"
279 command = "org.cycle"
280 mode = "normal"
281 [[bind]]
282 keys = "z c"
283 command = "org.cycle"
284 mode = "normal"
285 [[bind]]
286 keys = "z o"
287 command = "org.cycle"
288 mode = "normal"
289 [[bind]]
290 keys = "z A"
291 command = "org.cycle-global"
292 mode = "normal"
293 [[bind]]
294 keys = "g h"
295 command = "org.heading.up"
296 mode = "normal"
297 [[bind]]
298 keys = "g j"
299 command = "org.heading.forward-same-level"
300 mode = "normal"
301 [[bind]]
302 keys = "g k"
303 command = "org.heading.backward-same-level"
304 mode = "normal"
305 [[bind]]
306 keys = "] h"
307 command = "org.heading.forward-same-level"
308 mode = "normal"
309 [[bind]]
310 keys = "[ h"
311 command = "org.heading.backward-same-level"
312 mode = "normal"
313 [[bind]]
314 keys = "RET"
315 command = "org.table.next-row"
316 when = "table"
317 mode = "insert"
318
319 """
320
321
26322 static let common = """
27323 [[bind]]
28324 keys = "TAB"
Sources/OrgCore/Keymap/Vim.swift added +1208
@@ -0,0 +1,1208 @@
1import Foundation
2
3/// Modal editing as Doom's evil gives it: normal, insert, visual and visual-line states;
4/// counts, registers, operators with motions and text objects, `.` repeat, evil-snipe's `s`
5/// and `S`, `Y` to the end of the line, and the unnamed register on the system clipboard.
6/// Keys go in; edits, moves and commands come out for the editor to apply.
7public struct Vim: Sendable {
8 public enum Mode: String, Sendable {
9 case normal, insert, visual
10 case visualLine = "visual-line"
11
12 /// The keymap mode name for bindings.
13 public var keymapMode: String {
14 switch self {
15 case .normal: "normal"
16 case .insert: "insert"
17 case .visual, .visualLine: "visual"
18 }
19 }
20 }
21
22 public enum Action: Equatable, Sendable {
23 /// Replace text and put the caret (and, in visual states, the selection) somewhere.
24 case edit([TextEdit], caret: Int)
25 case move(caret: Int)
26 /// The selection to show in a visual state, `lowerBound..<upperBound`.
27 case select(Range<Int>)
28 case command(String)
29 case undo(Int)
30 case redo(Int)
31 case setClipboard(String)
32 /// Ask for a search pattern (`/`, `?`) or an ex command (`:`).
33 case prompt(String)
34 case message(String)
35 }
36
37 public struct Context: Sendable {
38 public var text: String
39 public var caret: Int
40 public var clipboard: String?
41 /// Lines in a half page, for C-d and C-u.
42 public var halfPage: Int
43
44 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15) {
45 self.text = text
46 self.caret = caret
47 self.clipboard = clipboard
48 self.halfPage = halfPage
49 }
50 }
51
52 public private(set) var mode: Mode = .normal
53 /// Keys of the command being typed.
54 public private(set) var pending: [KeyChord] = []
55 var registers: [Character: (text: String, linewise: Bool)] = [:]
56 /// What the clipboard held when this engine last wrote it, and whether that was whole lines.
57 var clipboardRecord: (text: String, linewise: Bool)?
58 var anchor = 0
59 var goalColumn: Int?
60 var lastFind: (kind: Character, char: String)?
61 var lastSnipe: (forward: Bool, chars: String)?
62 var lastSearch: (pattern: String, forward: Bool)?
63 /// The keys and inserted text of the last change, for `.`.
64 var lastChange: (keys: [KeyChord], inserted: String?)?
65 var changeKeys: [KeyChord]?
66 var insertStart = 0
67 /// The caret when the command began, for commands that keep its column.
68 var caretForApply = 0
69 /// Typed in insert state since it began, kept by `inserted(_:)`.
70 var insertedText = ""
71
72 public init() {}
73
74 // MARK: - Feeding keys
75
76 public mutating func feed(_ key: KeyChord, _ context: Context) -> [Action] {
77 switch mode {
78 case .insert:
79 return insertKey(key, context)
80 case .normal, .visual, .visualLine:
81 pending.append(key)
82 let keys = pending
83 caretForApply = context.caret
84 var buffer = Buffer(context)
85 switch parse(keys, &buffer, context) {
86 case .incomplete:
87 return []
88 case .invalid:
89 pending = []
90 return []
91 case .done(let actions):
92 pending = []
93 return actions
94 }
95 }
96 }
97
98 /// Text typed in insert state, recorded for `.`.
99 public mutating func inserted(_ text: String) {
100 insertedText += text
101 }
102
103 public mutating func deletedBackward() {
104 if !insertedText.isEmpty { insertedText.removeLast() }
105 }
106
107 private mutating func insertKey(_ key: KeyChord, _ context: Context) -> [Action] {
108 let buffer = Buffer(context)
109 if key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control) {
110 mode = .normal
111 if var keys = changeKeys {
112 keys.append(KeyChord("ESC"))
113 lastChange = (keys, insertedText)
114 changeKeys = nil
115 }
116 let caret = context.caret
117 let lineStart = buffer.lineStart(caret)
118 return [.move(caret: caret > lineStart ? caret - 1 : caret)]
119 }
120 if key == KeyChord("w", .control) {
121 let start = buffer.wordBackward(context.caret, bigWord: false, stopAtLine: true)
122 guard start < context.caret else { return [] }
123 return [.edit([TextEdit(range: start..<context.caret, replacement: "")], caret: start)]
124 }
125 if key == KeyChord("u", .control) {
126 let start = buffer.firstNonBlank(buffer.lineStart(context.caret))
127 let from = context.caret > start ? start : buffer.lineStart(context.caret)
128 guard from < context.caret else { return [] }
129 return [.edit([TextEdit(range: from..<context.caret, replacement: "")], caret: from)]
130 }
131 return []
132 }
133
134 /// Whether a key in insert state belongs to the text system.
135 public func passesThrough(_ key: KeyChord) -> Bool {
136 guard mode == .insert else { return false }
137 return !(key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control)
138 || key == KeyChord("w", .control) || key == KeyChord("u", .control))
139 }
140
141 public mutating func enterInsert(at caret: Int, keys: [KeyChord]) {
142 mode = .insert
143 insertStart = caret
144 insertedText = ""
145 changeKeys = keys
146 }
147
148 // MARK: - Parsing
149
150 enum Parse {
151 case incomplete
152 case invalid
153 case done([Action])
154 }
155
156 struct Command {
157 var register: Character?
158 var count: Int?
159 var keys: ArraySlice<KeyChord>
160 }
161
162 /// `evil-shift-width`, which Doom keeps equal to `tab-width`: 8 in Org.
163 static let shiftWidth = 8
164
165 /// What `indent-according-to-mode` gives a line after `line` in Org: under a list item,
166 /// its text's column; under a heading, none; otherwise `line`'s own indentation.
167 static func indentation(for buffer: Buffer, line: Int) -> String {
168 let text = buffer.substring(buffer.lineStart(line)..<buffer.lineEnd(line))
169 let item = try! NSRegularExpression(pattern: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]+")
170 let ns = text as NSString
171 if text.hasPrefix("*") { return "" }
172 if let m = item.firstMatch(in: text, range: NSRange(location: 0, length: ns.length)) {
173 return String(repeating: " ", count: m.range.length)
174 }
175 return String(text.prefix { $0 == " " || $0 == "\t" })
176 }
177
178 static func digit(_ key: KeyChord) -> Int? {
179 guard key.modifiers.isEmpty, key.key.count == 1, let d = Int(key.key) else { return nil }
180 return d
181 }
182
183 /// Reads `"r` and a count off the front of `keys`.
184 static func prefix(_ keys: [KeyChord]) -> (register: Character?, count: Int?, rest: ArraySlice<KeyChord>)? {
185 var rest = keys[...]
186 var register: Character?
187 if rest.first?.key == "\"", rest.first?.modifiers.isEmpty == true {
188 guard rest.count >= 2 else { return nil }
189 register = rest[rest.startIndex + 1].key == "SPC" ? " " : rest[rest.startIndex + 1].key.first
190 rest = rest.dropFirst(2)
191 }
192 var count: Int?
193 while let key = rest.first, let d = digit(key), !(d == 0 && count == nil) {
194 count = (count ?? 0) * 10 + d
195 rest = rest.dropFirst()
196 }
197 return (register, count, rest)
198 }
199
200 mutating func parse(_ keys: [KeyChord], _ buffer: inout Buffer, _ context: Context) -> Parse {
201 guard let (register, count, rest) = Self.prefix(keys) else { return .incomplete }
202 guard let first = rest.first else { return .incomplete }
203 let visual = mode == .visual || mode == .visualLine
204 let n = count ?? 1
205 let caret = context.caret
206
207 // Operators.
208 let operatorKeys: [String: String] = ["d": "d", "c": "c", "y": "y", ">": ">", "<": "<"]
209 var op: String?
210 var afterOp = rest.dropFirst()
211 if first.modifiers.isEmpty, let o = operatorKeys[first.key] {
212 op = o
213 } else if first.key == "g", first.modifiers.isEmpty {
214 guard let second = rest.dropFirst().first else { return .incomplete }
215 if ["~", "u", "U"].contains(second.key), second.modifiers.isEmpty {
216 op = "g" + second.key
217 afterOp = rest.dropFirst(2)
218 }
219 }
220 if let op {
221 if visual {
222 let range = visualRange(buffer, caret)
223 return .done(apply(op, range: range.range, linewise: range.linewise, buffer, register: register, keys: keys, count: n))
224 }
225 // A second count and the motion.
226 guard let (_, count2, motionKeys) = Self.prefix(Array(afterOp)), let motionFirst = motionKeys.first else { return .incomplete }
227 let total = n * (count2 ?? 1)
228 let doubled = op.count == 1 ? op : String(op.last!)
229 if motionFirst.key == doubled, motionFirst.modifiers.isEmpty, motionKeys.count == 1 || (op.count == 2 && motionFirst.key == String(op.last!)) {
230 // dd, cc, yy, >>, <<, g~~, guu, gUU: whole lines.
231 let startLine = buffer.lineStart(caret)
232 var end = startLine
233 for _ in 0..<total {
234 let next = buffer.lineEnd(end)
235 end = next < buffer.length ? next + 1 : next
236 if end >= buffer.length { break }
237 }
238 return .done(apply(op, range: startLine..<max(end, buffer.lineEnd(startLine)), linewise: true, buffer, register: register, keys: keys, count: total))
239 }
240 if motionFirst.key == "i" || motionFirst.key == "a", motionFirst.modifiers.isEmpty {
241 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 }
243 return .done(apply(op, range: object.range, linewise: object.linewise, buffer, register: register, keys: keys, count: total))
244 }
245 var motionCaret = caret
246 if op == "c", motionFirst.key == "w" || motionFirst.key == "W", motionFirst.modifiers.isEmpty,
247 let c = buffer.char(caret), !Buffer.isBlank(c) {
248 // cw acts as ce on a word.
249 var end = caret
250 for _ in 0..<total { end = buffer.wordEnd(end, bigWord: motionFirst.key == "W", fromInside: true) }
251 return .done(apply(op, range: caret..<min(buffer.length, end + 1), linewise: false, buffer, register: register, keys: keys, count: total))
252 }
253 switch motion(Array(motionKeys), &buffer, &motionCaret, count: total, operatorPending: true) {
254 case .incomplete: return .incomplete
255 case .invalid: return .invalid
256 case .moved(let target, let kind):
257 var range: Range<Int>
258 var linewise = false
259 switch kind {
260 case .linewise:
261 let a = buffer.lineStart(min(caret, target))
262 let b = buffer.lineEnd(max(caret, target))
263 range = a..<min(buffer.length, b + 1)
264 linewise = true
265 case .inclusive:
266 range = min(caret, target)..<min(buffer.length, max(caret, target) + 1)
267 case .exclusive:
268 range = min(caret, target)..<max(caret, target)
269 // dw at the end of a line stops at the line's end.
270 if motionFirst.key == "w" || motionFirst.key == "W", buffer.lineStart(range.upperBound) != buffer.lineStart(range.lowerBound), range.upperBound > range.lowerBound {
271 var end = buffer.lineEnd(range.lowerBound)
272 if end == range.lowerBound, total == 1 { end = min(buffer.length, end + 1) }
273 let upper = buffer.firstNonBlankOrEnd(buffer.lineStart(range.upperBound)) == range.upperBound
274 ? max(range.lowerBound, min(end, range.upperBound)) : range.upperBound
275 range = range.lowerBound..<(upper == range.lowerBound ? end : upper)
276 if buffer.lineStart(range.upperBound) != buffer.lineStart(range.lowerBound) {
277 range = range.lowerBound..<buffer.lineEnd(range.lowerBound)
278 }
279 }
280 }
281 return .done(apply(op, range: range, linewise: linewise, buffer, register: register, keys: keys, count: total))
282 }
283 }
284
285 if visual, first.modifiers.isEmpty {
286 switch first.key {
287 case "ESC":
288 mode = .normal
289 return .done([.move(caret: buffer.clampNormal(caret))])
290 case "o":
291 let old = anchor
292 anchor = caret
293 return .done(visualActions(buffer, old))
294 case "x":
295 let range = visualRange(buffer, caret)
296 return .done(apply("d", range: range.range, linewise: range.linewise, buffer, register: register, keys: keys, count: n))
297 case "~", "u", "U":
298 let range = visualRange(buffer, caret)
299 return .done(apply(first.key == "~" ? "g~" : "g" + first.key, range: range.range, linewise: range.linewise, buffer, register: register, keys: keys, count: n))
300 case "J":
301 let range = visualRange(buffer, caret)
302 let lines = max(2, buffer.lineCount(range.range))
303 mode = .normal
304 return .done(join(buffer, at: range.range.lowerBound, lines: lines, keys: keys))
305 case "p", "P":
306 let range = visualRange(buffer, caret)
307 guard let (text, linewise) = paste(register, context) else { return .done([]) }
308 mode = .normal
309 var replacement = text
310 if linewise, !range.linewise { replacement = "\n" + text }
311 if !linewise, range.linewise { replacement += "\n" }
312 let removed = buffer.substring(range.range)
313 var actions: [Action] = [.edit([TextEdit(range: range.range, replacement: replacement)], caret: range.range.lowerBound + (linewise ? 0 : max(0, replacement.utf16.count - 1)))]
314 actions += store(removed, linewise: range.linewise, register: nil, yank: false)
315 return .done(actions)
316 case "v":
317 if mode == .visual {
318 mode = .normal
319 return .done([.move(caret: buffer.clampNormal(caret))])
320 }
321 mode = .visual
322 return .done(visualActions(buffer, caret))
323 case "V":
324 if mode == .visualLine {
325 mode = .normal
326 return .done([.move(caret: buffer.clampNormal(caret))])
327 }
328 mode = .visualLine
329 return .done(visualActions(buffer, caret))
330 case "i", "a":
331 guard rest.count >= 2 else { return .incomplete }
332 guard let object = textObject(first.key == "i", rest[rest.startIndex + 1], buffer, caret, count: n) else { return .invalid }
333 if object.linewise { mode = .visualLine }
334 anchor = object.range.lowerBound
335 let end = max(object.range.lowerBound, object.range.upperBound - 1)
336 return .done(visualActions(buffer, end))
337 default:
338 break
339 }
340 }
341
342 if !visual {
343 switch (first.key, first.modifiers) {
344 case ("i", []), ("a", []), ("I", []), ("A", []), ("o", []), ("O", []):
345 return .done(enterInsert(first.key, buffer, caret, keys: keys))
346 case ("v", []):
347 mode = .visual
348 anchor = caret
349 return .done(visualActions(buffer, caret))
350 case ("V", []):
351 mode = .visualLine
352 anchor = caret
353 return .done(visualActions(buffer, caret))
354 case ("x", []), ("<delete>", []):
355 guard let c = buffer.char(caret), c != 10 else { return .done([]) }
356 let end = min(buffer.lineEnd(caret), caret + n)
357 return .done(apply("d", range: caret..<end, linewise: false, buffer, register: register, keys: keys, count: n))
358 case ("X", []):
359 let start = max(buffer.lineStart(caret), caret - n)
360 guard start < caret else { return .done([]) }
361 return .done(apply("d", range: start..<caret, linewise: false, buffer, register: register, keys: keys, count: n))
362 case ("D", []), ("C", []):
363 var end = buffer.lineEnd(caret)
364 for _ in 1..<max(1, n) where end < buffer.length { end = buffer.lineEnd(end + 1) }
365 return .done(apply(first.key == "D" ? "d" : "c", range: caret..<end, linewise: false, buffer, register: register, keys: keys, count: n))
366 case ("Y", []):
367 // `evil-want-Y-yank-to-eol`.
368 return .done(apply("y", range: caret..<buffer.lineEnd(caret), linewise: false, buffer, register: register, keys: keys, count: n))
369 case ("p", []), ("P", []):
370 guard let (text, linewise) = paste(register, context) else { return .done([]) }
371 let repeated = String(repeating: text, count: n)
372 if linewise {
373 let body = repeated.hasSuffix("\n") ? repeated : repeated + "\n"
374 if first.key == "p" {
375 let end = buffer.lineEnd(caret)
376 if end >= buffer.length {
377 let insertion = "\n" + String(body.dropLast())
378 return .done(change([TextEdit(range: end..<end, replacement: insertion)], caret: buffer.firstNonBlankIn(insertion, at: end + 1), keys: keys))
379 }
380 return .done(change([TextEdit(range: (end + 1)..<(end + 1), replacement: body)], caret: buffer.firstNonBlankIn(body, at: end + 1), keys: keys))
381 }
382 let start = buffer.lineStart(caret)
383 return .done(change([TextEdit(range: start..<start, replacement: body)], caret: buffer.firstNonBlankIn(body, at: start), keys: keys))
384 }
385 let at = first.key == "p" && buffer.char(caret).map({ $0 != 10 }) == true ? caret + 1 : caret
386 return .done(change([TextEdit(range: at..<at, replacement: repeated)], caret: at + max(0, repeated.utf16.count - 1), keys: keys))
387 case ("J", []):
388 return .done(join(buffer, at: caret, lines: max(2, n), keys: keys))
389 case ("~", []):
390 let end = min(buffer.lineEnd(caret), caret + n)
391 guard end > caret else { return .done([]) }
392 let flipped = String(buffer.substring(caret..<end).map { $0.isUppercase ? Character($0.lowercased()) : Character($0.uppercased()) })
393 return .done(change([TextEdit(range: caret..<end, replacement: flipped)], caret: buffer.clampNormal(end, in: buffer.lineStart(caret)), keys: keys))
394 case ("r", []):
395 guard rest.count >= 2 else { return .incomplete }
396 let target = rest[rest.startIndex + 1]
397 let char = target.key == "SPC" ? " " : target.key == "RET" ? "\n" : target.key
398 guard char.count == 1, caret + n <= buffer.lineEnd(caret) else { return .done([]) }
399 let replacement = char == "\n" ? "\n" : String(repeating: char, count: n)
400 return .done(change([TextEdit(range: caret..<(caret + n), replacement: replacement)], caret: char == "\n" ? caret + 1 : caret + n - 1, keys: keys))
401 case ("u", []):
402 return .done([.undo(n)])
403 case ("r", [.control]):
404 return .done([.redo(n)])
405 case (".", []):
406 return .done(repeatChange(n, context))
407 case (":", []):
408 return .done([.prompt(":")])
409 case ("/", []), ("?", []):
410 return .done([.prompt(first.key)])
411 case ("Z", []):
412 guard rest.count >= 2 else { return .incomplete }
413 switch rest[rest.startIndex + 1].key {
414 case "Z": return .done([.command("app.save")])
415 case "Q": return .done([.message("ZQ isn't available; use the window's close button")])
416 default: return .invalid
417 }
418 default:
419 break
420 }
421 }
422
423 // Motions move the caret, or extend the selection in visual states.
424 var target = caret
425 switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) {
426 case .incomplete: return .incomplete
427 case .invalid: return .invalid
428 case .moved(let to, _):
429 if visual { return .done(visualActions(buffer, to)) }
430 return .done([.move(caret: buffer.clampNormal(to))])
431 }
432 }
433
434 // MARK: - Motions
435
436 enum MotionKind { case exclusive, inclusive, linewise }
437
438 enum MotionResult {
439 case incomplete
440 case invalid
441 case moved(Int, MotionKind)
442 }
443
444 mutating func motion(_ keys: [KeyChord], _ buffer: inout Buffer, _ caret: inout Int, count n: Int, operatorPending: Bool) -> MotionResult {
445 guard let key = keys.first else { return .incomplete }
446 let c = caret
447 func column(_ position: Int) -> Int { position - buffer.lineStart(position) }
448 switch (key.key, key.modifiers) {
449 case ("h", []), ("<left>", []), ("DEL", []):
450 goalColumn = nil
451 return .moved(max(buffer.lineStart(c), c - n), .exclusive)
452 case ("l", []), ("<right>", []), ("SPC", []):
453 goalColumn = nil
454 let end = buffer.lineEnd(c)
455 let limit = operatorPending ? end : max(buffer.lineStart(c), end - 1)
456 return .moved(min(limit, c + n), .exclusive)
457 case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []):
458 let down = ["j", "<down>", "+", "RET"].contains(key.key) || key == KeyChord("d", .control)
459 let lines = key.modifiers.contains(.control) ? (n == 1 ? 15 : n) : n
460 let goal = goalColumn ?? column(c)
461 var line = buffer.lineStart(c)
462 var moved = 0
463 for _ in 0..<lines {
464 if down {
465 let end = buffer.lineEnd(line)
466 guard end < buffer.length, end + 1 <= buffer.length else { break }
467 if end + 1 == buffer.length { break }
468 line = end + 1
469 } else {
470 guard line > 0 else { break }
471 line = buffer.lineStart(line - 1)
472 }
473 moved += 1
474 }
475 if moved == 0, !key.modifiers.contains(.control) { return .invalid }
476 if ["+", "-", "RET"].contains(key.key) { return .moved(buffer.firstNonBlank(line), .linewise) }
477 goalColumn = goal
478 let length = buffer.lineEnd(line) - line
479 return .moved(line + min(goal, operatorPending ? length : max(0, length - 1)), .linewise)
480 case ("w", []), ("W", []):
481 goalColumn = nil
482 var p = c
483 for _ in 0..<n { p = buffer.wordForward(p, bigWord: key.key == "W") }
484 return .moved(p, .exclusive)
485 case ("b", []), ("B", []):
486 goalColumn = nil
487 var p = c
488 for _ in 0..<n { p = buffer.wordBackward(p, bigWord: key.key == "B", stopAtLine: false) }
489 return .moved(p, .exclusive)
490 case ("e", []), ("E", []):
491 goalColumn = nil
492 var p = c
493 for _ in 0..<n { p = buffer.wordEnd(p, bigWord: key.key == "E", fromInside: false) }
494 return .moved(p, .inclusive)
495 case ("0", []), ("<home>", []):
496 goalColumn = nil
497 return .moved(buffer.lineStart(c), .exclusive)
498 case ("^", []):
499 goalColumn = nil
500 return .moved(buffer.firstNonBlank(buffer.lineStart(c)), .exclusive)
501 case ("$", []), ("<end>", []):
502 var line = buffer.lineStart(c)
503 for _ in 1..<max(1, n) where buffer.lineEnd(line) < buffer.length { line = buffer.lineEnd(line) + 1 }
504 goalColumn = Int.max
505 let end = buffer.lineEnd(line)
506 if mode == .visual { return .moved(end, .inclusive) }
507 return .moved(max(line, end - 1), .inclusive)
508 case ("G", []):
509 // `evil-start-of-line` nil: the column stays.
510 let line = n > 1 || keys.count > 1 ? buffer.lineOffset(n) : buffer.lineStart(buffer.length)
511 let column = c - buffer.lineStart(c)
512 let length = buffer.lineEnd(line) - line
513 return .moved(line + min(column, operatorPending ? length : max(0, length - 1)), .linewise)
514 case ("g", []):
515 guard keys.count >= 2 else { return .incomplete }
516 switch keys[1].key {
517 case "g":
518 let line = buffer.lineOffset(n)
519 let column = c - buffer.lineStart(c)
520 let length = buffer.lineEnd(line) - line
521 return .moved(line + min(column, operatorPending ? length : max(0, length - 1)), .linewise)
522 case "e", "E":
523 var p = c
524 for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") }
525 return .moved(p, .inclusive)
526 case "j":
527 return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending)
528 case "k":
529 return motion([KeyChord("k")], &buffer, &caret, count: n, operatorPending: operatorPending)
530 case "_":
531 let end = buffer.lineEnd(c)
532 var p = end
533 while p > buffer.lineStart(c), let ch = buffer.char(p - 1), Buffer.isBlank(ch) { p -= 1 }
534 return .moved(max(buffer.lineStart(c), p - 1), .inclusive)
535 default:
536 return .invalid
537 }
538 case ("f", []), ("F", []), ("t", []), ("T", []):
539 guard keys.count >= 2 else { return .incomplete }
540 let char = keys[1].key == "SPC" ? " " : keys[1].key
541 guard char.count == 1 else { return .invalid }
542 lastFind = (Character(key.key), char)
543 guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid }
544 return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive)
545 case (";", []), (",", []):
546 guard let last = lastFind else { return .invalid }
547 var kind = last.kind
548 if key.key == "," {
549 kind = ["f": "F", "F": "f", "t": "T", "T": "t"][kind]!
550 }
551 guard let p = find(kind, last.char, buffer, c, count: n, repeating: true) else { return .invalid }
552 return .moved(p, kind == "f" || kind == "t" ? .inclusive : .exclusive)
553 case ("s", []), ("S", []) where !operatorPending:
554 // evil-snipe with `evil-snipe-scope` line.
555 guard keys.count >= 3 else { return .incomplete }
556 let chars = keys[1].key + keys[2].key
557 lastSnipe = (key.key == "s", chars)
558 guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid }
559 return .moved(p, .exclusive)
560 case ("%", []):
561 guard let p = buffer.matchingBracket(c) else { return .invalid }
562 return .moved(p, .inclusive)
563 case ("}", []), ("{", []):
564 var p = c
565 for _ in 0..<n { p = buffer.paragraph(p, forward: key.key == "}") }
566 return .moved(p, .exclusive)
567 case ("n", []), ("N", []):
568 guard let search = lastSearch else { return .invalid }
569 let forward = key.key == "n" ? search.forward : !search.forward
570 guard let p = buffer.search(search.pattern, from: c, forward: forward, count: n) else { return .invalid }
571 return .moved(p, .exclusive)
572 case ("*", []), ("#", []):
573 guard let word = buffer.wordAt(c) else { return .invalid }
574 let pattern = "\\b" + NSRegularExpression.escapedPattern(for: word) + "\\b"
575 lastSearch = (pattern, key.key == "*")
576 guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid }
577 return .moved(p, .exclusive)
578 default:
579 return .invalid
580 }
581 }
582
583 func find(_ kind: Character, _ char: String, _ buffer: Buffer, _ c: Int, count n: Int, repeating: Bool = false) -> Int? {
584 let target = char.utf16.first!
585 let forward = kind == "f" || kind == "t"
586 var p = c
587 for i in 0..<n {
588 var q = p
589 // Repeating t/T starts past the character it stopped before.
590 let skip = repeating && i == 0 && (kind == "t" || kind == "T") ? 1 : 0
591 if forward {
592 q += 1 + skip
593 while q < buffer.lineEnd(c), buffer.char(q) != target { q += 1 }
594 guard q < buffer.lineEnd(c) else { return nil }
595 } else {
596 q -= 1 + skip
597 while q >= buffer.lineStart(c), buffer.char(q) != target { q -= 1 }
598 guard q >= buffer.lineStart(c) else { return nil }
599 }
600 p = q
601 }
602 if kind == "t" { return p - 1 }
603 if kind == "T" { return p + 1 }
604 return p
605 }
606
607 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int) -> Int? {
608 let line = buffer.substring(buffer.lineStart(c)..<buffer.lineEnd(c)) as NSString
609 let start = buffer.lineStart(c)
610 let smart = chars == chars.lowercased()
611 var p = c - start
612 for _ in 0..<n {
613 let range: NSRange
614 if forward {
615 guard p + 1 < line.length else { return nil }
616 range = line.range(of: chars, options: smart ? .caseInsensitive : [], range: NSRange(location: p + 1, length: line.length - p - 1))
617 } else {
618 guard p > 0 else { return nil }
619 range = line.range(of: chars, options: smart ? [.caseInsensitive, .backwards] : .backwards, range: NSRange(location: 0, length: p))
620 }
621 guard range.location != NSNotFound else { return nil }
622 p = range.location
623 }
624 return start + p
625 }
626
627 // MARK: - Text objects
628
629 func textObject(_ inner: Bool, _ key: KeyChord, _ buffer: Buffer, _ c: Int, count: Int) -> (range: Range<Int>, linewise: Bool)? {
630 switch key.key {
631 case "w", "W":
632 return buffer.wordObject(c, inner: inner, bigWord: key.key == "W", count: count).map { ($0, false) }
633 case "\"", "'", "`":
634 return buffer.quoteObject(c, quote: key.key.utf16.first!, inner: inner).map { ($0, false) }
635 case "(", ")", "b":
636 return buffer.bracketObject(c, open: 40, close: 41, inner: inner).map { ($0, false) }
637 case "[", "]":
638 return buffer.bracketObject(c, open: 91, close: 93, inner: inner).map { ($0, false) }
639 case "{", "}", "B":
640 return buffer.bracketObject(c, open: 123, close: 125, inner: inner).map { ($0, false) }
641 case "<", ">":
642 return buffer.bracketObject(c, open: 60, close: 62, inner: inner).map { ($0, false) }
643 case "p":
644 return buffer.paragraphObject(c, inner: inner).map { ($0, true) }
645 default:
646 return nil
647 }
648 }
649
650 // MARK: - Applying
651
652 func visualRange(_ buffer: Buffer, _ caret: Int) -> (range: Range<Int>, linewise: Bool) {
653 let a = min(anchor, caret)
654 let b = max(anchor, caret)
655 if mode == .visualLine {
656 let end = buffer.lineEnd(b)
657 return (buffer.lineStart(a)..<min(buffer.length, end + 1), true)
658 }
659 return (a..<min(buffer.length, b + 1), false)
660 }
661
662 func visualActions(_ buffer: Buffer, _ caret: Int) -> [Action] {
663 let a = min(anchor, caret)
664 let b = max(anchor, caret)
665 let range = mode == .visualLine ? buffer.lineStart(a)..<buffer.lineEnd(b) : a..<min(buffer.length, b + 1)
666 return [.move(caret: caret), .select(range)]
667 }
668
669 mutating func store(_ text: String, linewise: Bool, register: Character?, yank: Bool) -> [Action] {
670 if register == "_" { return [] }
671 if let register, register.isLetter {
672 let key = Character(register.lowercased())
673 if register.isUppercase, let existing = registers[key] {
674 registers[key] = (existing.text + text, existing.linewise || linewise)
675 } else {
676 registers[key] = (text, linewise)
677 }
678 return []
679 }
680 if yank { registers["0"] = (text, linewise) }
681 clipboardRecord = (text, linewise)
682 return [.setClipboard(text)]
683 }
684
685 func paste(_ register: Character?, _ context: Context) -> (String, Bool)? {
686 if let register, register != "\"", register != "+", register != "*" {
687 return registers[Character(register.lowercased())]
688 }
689 guard let clip = context.clipboard ?? clipboardRecord?.text else { return nil }
690 if let record = clipboardRecord, record.text == clip { return (clip, record.linewise) }
691 return (clip, false)
692 }
693
694 mutating func change(_ edits: [TextEdit], caret: Int, keys: [KeyChord]) -> [Action] {
695 lastChange = (keys, nil)
696 return [.edit(edits, caret: caret)]
697 }
698
699 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
701 let context0Column = max(0, caretForApply - buffer.lineStart(caretForApply))
702 mode = .normal
703 let text = buffer.substring(range)
704 switch op {
705 case "y":
706 var actions = store(text, linewise: linewise, register: register, yank: true)
707 let caret = linewise && !wasVisual ? min(range.lowerBound, buffer.length) : range.lowerBound
708 actions.append(.move(caret: linewise && wasVisual ? buffer.lineStart(range.lowerBound) : caret))
709 return actions
710 case "d":
711 var actions = store(text, linewise: linewise, register: register, yank: false)
712 var deletion = range
713 if linewise, range.upperBound >= buffer.length, range.lowerBound > 0, buffer.char(range.upperBound - 1) != 10 || range.upperBound == buffer.length {
714 // The last line takes the newline before it.
715 if buffer.char(range.lowerBound - 1) == 10, range.upperBound == buffer.length, !(text.hasSuffix("\n")) {
716 deletion = (range.lowerBound - 1)..<range.upperBound
717 }
718 }
719 let after = buffer.removing(deletion)
720 var caret = deletion.lowerBound
721 if linewise {
722 let column = context0Column
723 let line = after.lineStart(min(deletion.lowerBound, after.length))
724 let length = after.lineEnd(line) - line
725 caret = line + min(column, max(0, length - 1))
726 } else {
727 caret = after.clampNormal(caret)
728 }
729 lastChange = (wasVisual ? [] : keys, nil)
730 actions.insert(.edit([TextEdit(range: deletion, replacement: "")], caret: caret), at: 0)
731 return actions
732 case "c":
733 var actions = store(text, linewise: linewise, register: register, yank: false)
734 var deletion = range
735 var replacement = ""
736 if linewise {
737 // cc keeps the newline and indents as the line before it calls for.
738 replacement = range.lowerBound == 0 ? "" : Self.indentation(for: buffer, line: range.lowerBound - 1)
739 if buffer.char(range.upperBound - 1) == 10 { deletion = range.lowerBound..<(range.upperBound - 1) }
740 }
741 let caret = deletion.lowerBound + replacement.utf16.count
742 actions.insert(.edit([TextEdit(range: deletion, replacement: replacement)], caret: caret), at: 0)
743 enterInsert(at: caret, keys: wasVisual ? [] : keys)
744 return actions
745 case ">", "<":
746 let start = buffer.lineStart(range.lowerBound)
747 var edits: [TextEdit] = []
748 var line = start
749 let end = linewise ? range.upperBound : max(range.upperBound, buffer.lineEnd(max(range.lowerBound, range.upperBound - 1)))
750 while line < end || (line == start && line <= buffer.length) {
751 let lineEnd = buffer.lineEnd(line)
752 if lineEnd > line {
753 if op == ">" {
754 edits.append(TextEdit(range: line..<line, replacement: String(repeating: " ", count: Self.shiftWidth)))
755 } else {
756 let indent = buffer.substring(line..<buffer.firstNonBlankOrEnd(line))
757 let remove = min(Self.shiftWidth, indent.count)
758 if remove > 0 { edits.append(TextEdit(range: line..<(line + remove), replacement: "")) }
759 }
760 }
761 guard lineEnd < buffer.length else { break }
762 line = lineEnd + 1
763 if line >= end { break }
764 }
765 lastChange = (wasVisual ? [] : keys, nil)
766 return [.edit(edits, caret: start)]
767 default:
768 let transformed: String
769 switch op {
770 case "gu": transformed = text.lowercased()
771 case "gU": transformed = text.uppercased()
772 default: transformed = String(text.map { $0.isUppercase ? Character($0.lowercased()) : Character($0.uppercased()) })
773 }
774 lastChange = (wasVisual ? [] : keys, nil)
775 return [.edit([TextEdit(range: range, replacement: transformed)], caret: range.lowerBound)]
776 }
777 }
778
779 mutating func enterInsert(_ key: String, _ buffer: Buffer, _ caret: Int, keys: [KeyChord]) -> [Action] {
780 switch key {
781 case "i":
782 enterInsert(at: caret, keys: keys)
783 return [.move(caret: caret)]
784 case "a":
785 let p = buffer.char(caret).map { $0 != 10 } == true ? caret + 1 : caret
786 enterInsert(at: p, keys: keys)
787 return [.move(caret: p)]
788 case "I":
789 let p = buffer.firstNonBlank(buffer.lineStart(caret))
790 enterInsert(at: p, keys: keys)
791 return [.move(caret: p)]
792 case "A":
793 let p = buffer.lineEnd(caret)
794 enterInsert(at: p, keys: keys)
795 return [.move(caret: p)]
796 case "o":
797 let end = buffer.lineEnd(caret)
798 let indent = Self.indentation(for: buffer, line: caret)
799 enterInsert(at: end + 1 + indent.utf16.count, keys: keys)
800 return [.edit([TextEdit(range: end..<end, replacement: "\n" + indent)], caret: end + 1 + indent.utf16.count)]
801 default:
802 let start = buffer.lineStart(caret)
803 let indent = Self.indentation(for: buffer, line: max(0, start - 1)) == "" || start == 0 ? "" : Self.indentation(for: buffer, line: start - 1)
804 enterInsert(at: start + indent.utf16.count, keys: keys)
805 return [.edit([TextEdit(range: start..<start, replacement: indent + "\n")], caret: start + indent.utf16.count)]
806 }
807 }
808
809 mutating func join(_ buffer: Buffer, at caret: Int, lines: Int, keys: [KeyChord]) -> [Action] {
810 var edits: [TextEdit] = []
811 var line = buffer.lineStart(caret)
812 var position = caret
813 for _ in 1..<lines {
814 let end = buffer.lineEnd(line)
815 guard end < buffer.length else { break }
816 let nextStart = end + 1
817 let nextText = buffer.firstNonBlankOrEnd(nextStart)
818 let joinsEmpty = buffer.lineEnd(nextStart) == nextText
819 let separator = joinsEmpty || buffer.char(nextText) == 41 ? "" : (end > line && buffer.char(end - 1) == 32 ? "" : " ")
820 var from = end
821 while from > line, let c = buffer.char(from - 1), c == 32 || c == 9 { from -= 1 }
822 if !separator.isEmpty || from == end { from = end }
823 edits.append(TextEdit(range: from..<nextText, replacement: separator))
824 position = from
825 line = nextStart
826 }
827 guard !edits.isEmpty else { return [] }
828 lastChange = (keys, nil)
829 // Positions in edits refer to the original text; apply back to front.
830 let shift = edits.dropLast().reduce(0) { $0 + $1.replacement.utf16.count - $1.range.count }
831 return [.edit(edits, caret: position + shift)]
832 }
833
834 mutating func repeatChange(_ count: Int, _ context: Context) -> [Action] {
835 guard let change = lastChange, !change.keys.isEmpty else { return [] }
836 var copy = self
837 copy.lastChange = nil
838 copy.pending = []
839 var keys = change.keys
840 if count > 1, let (_, _, rest) = Self.prefix(keys) {
841 keys = Array("\(count)".map { KeyChord(String($0)) }) + Array(rest)
842 }
843 var actions: [Action] = []
844 var text = context.text
845 var caret = context.caret
846 for key in keys {
847 if copy.mode == .insert, key == KeyChord("ESC") {
848 if let inserted = change.inserted, !inserted.isEmpty {
849 let edit = TextEdit(range: caret..<caret, replacement: inserted)
850 actions.append(.edit([edit], caret: caret + inserted.utf16.count))
851 text = edit.apply(to: text)
852 caret += inserted.utf16.count
853 }
854 }
855 let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage))
856 for action in produced {
857 switch action {
858 case .edit(let edits, let to):
859 var new = text
860 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
861 text = new
862 caret = to
863 case .move(let to):
864 caret = to
865 default:
866 break
867 }
868 actions.append(action)
869 }
870 }
871 let preserved = (registers, clipboardRecord)
872 self = copy
873 registers = preserved.0
874 clipboardRecord = copy.clipboardRecord ?? preserved.1
875 lastChange = change
876 return actions
877 }
878
879 /// `/pattern` or `?pattern` from the prompt: the caret's new place.
880 public mutating func search(_ pattern: String, forward: Bool, _ context: Context) -> [Action] {
881 lastSearch = (pattern, forward)
882 let buffer = Buffer(context)
883 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) }
885 return [.move(caret: p)]
886 }
887
888 /// Leave a visual state without acting, as `ESC` does.
889 public mutating func reset() {
890 pending = []
891 if mode == .visual || mode == .visualLine { mode = .normal }
892 }
893
894 // MARK: - Text
895
896 struct Buffer {
897 let ns: NSString
898 var length: Int { ns.length }
899
900 init(_ context: Context) { ns = context.text as NSString }
901 init(_ text: String) { ns = text as NSString }
902
903 func char(_ i: Int) -> unichar? { i >= 0 && i < ns.length ? ns.character(at: i) : nil }
904 func substring(_ r: Range<Int>) -> String { ns.substring(with: NSRange(location: r.lowerBound, length: r.count)) }
905 func lineStart(_ i: Int) -> Int { ns.lineRange(for: NSRange(location: min(i, ns.length), length: 0)).location }
906 func lineEnd(_ i: Int) -> Int {
907 var end = 0
908 ns.getLineStart(nil, end: nil, contentsEnd: &end, for: NSRange(location: min(i, ns.length), length: 0))
909 return end
910 }
911 func removing(_ r: Range<Int>) -> Buffer { Buffer(ns.replacingCharacters(in: NSRange(location: r.lowerBound, length: r.count), with: "")) }
912 func applying(_ edits: [TextEdit]) -> Buffer {
913 var text = ns as String
914 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { text = edit.apply(to: text) }
915 return Buffer(text)
916 }
917
918 static func isBlank(_ c: unichar) -> Bool { c == 32 || c == 9 }
919 static func isSpace(_ c: unichar) -> Bool { c == 32 || c == 9 || c == 10 || c == 13 }
920 static func isWord(_ c: unichar) -> Bool {
921 guard let scalar = Unicode.Scalar(c) else { return true }
922 return CharacterSet.alphanumerics.contains(scalar) || c == 95
923 }
924
925 /// 0 blank, 1 word, 2 other.
926 func kind(_ i: Int, bigWord: Bool) -> Int {
927 guard let c = char(i) else { return 0 }
928 if Self.isSpace(c) { return 0 }
929 if bigWord { return 1 }
930 return Self.isWord(c) ? 1 : 2
931 }
932
933 func clampNormal(_ i: Int) -> Int { clampNormal(i, in: lineStart(i)) }
934 func clampNormal(_ i: Int, in line: Int) -> Int {
935 let end = lineEnd(line)
936 return max(line, min(i, end > line ? end - 1 : line))
937 }
938
939 func firstNonBlank(_ line: Int) -> Int {
940 var p = line
941 let end = lineEnd(line)
942 while p < end, let c = char(p), Self.isBlank(c) { p += 1 }
943 return p == end && end > line ? end - 1 : (p == end ? line : p)
944 }
945
946 func firstNonBlankOrEnd(_ line: Int) -> Int {
947 var p = line
948 let end = lineEnd(line)
949 while p < end, let c = char(p), Self.isBlank(c) { p += 1 }
950 return p
951 }
952
953 func firstNonBlankIn(_ inserted: String, at start: Int) -> Int {
954 start + inserted.prefix { $0 == " " || $0 == "\t" }.utf16.count
955 }
956
957 func lineOffset(_ number: Int) -> Int {
958 var line = 0
959 for _ in 1..<max(1, number) {
960 let end = lineEnd(line)
961 guard end < length, end + 1 < length else { break }
962 line = end + 1
963 }
964 return line
965 }
966
967 func lineCount(_ r: Range<Int>) -> Int {
968 substring(r).filter { $0 == "\n" }.count + (r.upperBound > 0 && char(r.upperBound - 1) == 10 ? 0 : 1)
969 }
970
971 func wordForward(_ c: Int, bigWord: Bool) -> Int {
972 var p = c
973 let k = kind(p, bigWord: bigWord)
974 if k != 0 { while p < length, kind(p, bigWord: bigWord) == k { p += 1 } }
975 // Skip blanks; an empty line is a word.
976 while p < length, kind(p, bigWord: bigWord) == 0 {
977 if char(p) == 10, p > c, char(p - 1) == 10 { return p }
978 if char(p) == 10, p + 1 < length, char(p + 1) == 10, p + 1 > c { return p + 1 }
979 p += 1
980 }
981 return min(p, length)
982 }
983
984 func wordBackward(_ c: Int, bigWord: Bool, stopAtLine: Bool) -> Int {
985 var p = c
986 guard p > 0 else { return 0 }
987 p -= 1
988 while p > 0, kind(p, bigWord: bigWord) == 0 {
989 if char(p) == 10, char(p - 1) == 10, !stopAtLine { return p }
990 if stopAtLine, char(p) == 10 { return p + 1 }
991 p -= 1
992 }
993 let k = kind(p, bigWord: bigWord)
994 while p > 0, kind(p - 1, bigWord: bigWord) == k, k != 0 { p -= 1 }
995 return p
996 }
997
998 func wordEnd(_ c: Int, bigWord: Bool, fromInside: Bool) -> Int {
999 var p = fromInside ? c : c + 1
1000 while p < length, kind(p, bigWord: bigWord) == 0 { p += 1 }
1001 let k = kind(p, bigWord: bigWord)
1002 while p + 1 < length, kind(p + 1, bigWord: bigWord) == k, k != 0 { p += 1 }
1003 return min(p, max(0, length - 1))
1004 }
1005
1006 func wordEndBackward(_ c: Int, bigWord: Bool) -> Int {
1007 var p = c
1008 let k = kind(p, bigWord: bigWord)
1009 while p > 0, kind(p, bigWord: bigWord) == k, k != 0 { p -= 1 }
1010 while p > 0, kind(p, bigWord: bigWord) == 0 { p -= 1 }
1011 return p
1012 }
1013
1014 func wordStart(_ c: Int) -> Int {
1015 var p = c
1016 while p > 0, let ch = char(p - 1), Self.isWord(ch) { p -= 1 }
1017 return p
1018 }
1019
1020 func wordAt(_ c: Int) -> String? {
1021 var p = c
1022 while p < lineEnd(c), let ch = char(p), !Self.isWord(ch) { p += 1 }
1023 guard let ch = char(p), Self.isWord(ch) else { return nil }
1024 let start = wordStart(p)
1025 var end = p
1026 while end < length, let e = char(end), Self.isWord(e) { end += 1 }
1027 return substring(start..<end)
1028 }
1029
1030 func matchingBracket(_ c: Int) -> Int? {
1031 let pairs: [unichar: (unichar, Bool)] = [40: (41, true), 91: (93, true), 123: (125, true), 41: (40, false), 93: (91, false), 125: (123, false)]
1032 var p = c
1033 let end = lineEnd(c)
1034 while p < end, let ch = char(p), pairs[ch] == nil { p += 1 }
1035 guard let open = char(p), let (close, forward) = pairs[open] else { return nil }
1036 var depth = 0
1037 var q = p
1038 while q >= 0, q < length {
1039 if char(q) == open { depth += 1 }
1040 if char(q) == close {
1041 depth -= 1
1042 if depth == 0 { return q }
1043 }
1044 q += forward ? 1 : -1
1045 }
1046 return nil
1047 }
1048
1049 func paragraph(_ c: Int, forward: Bool) -> Int {
1050 func blank(_ line: Int) -> Bool { substring(line..<lineEnd(line)).allSatisfy { $0 == " " || $0 == "\t" } }
1051 var line = lineStart(c)
1052 if forward {
1053 while lineEnd(line) < length, blank(line) { line = lineEnd(line) + 1 }
1054 while lineEnd(line) < length {
1055 line = lineEnd(line) + 1
1056 if blank(line) { return line }
1057 }
1058 return max(0, length - (length > 0 && char(length - 1) == 10 ? 1 : 0))
1059 }
1060 while line > 0, blank(line) { line = lineStart(line - 1) }
1061 while line > 0 {
1062 line = lineStart(line - 1)
1063 if blank(line) { return line }
1064 }
1065 return 0
1066 }
1067
1068 func search(_ pattern: String, from c: Int, forward: Bool, count: Int) -> Int? {
1069 guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
1070 let all = regex.matches(in: ns as String, range: NSRange(location: 0, length: length)).map(\.range.location)
1071 guard !all.isEmpty else { return nil }
1072 var p = c
1073 for _ in 0..<count {
1074 if forward {
1075 p = all.first { $0 > p } ?? all[0]
1076 } else {
1077 p = all.last { $0 < p } ?? all[all.count - 1]
1078 }
1079 }
1080 return p
1081 }
1082
1083 func wordObject(_ c: Int, inner: Bool, bigWord: Bool, count: Int) -> Range<Int>? {
1084 guard c < length else { return nil }
1085 var start = c
1086 var end = c
1087 let k = kind(c, bigWord: bigWord)
1088 let lineS = lineStart(c)
1089 let lineE = lineEnd(c)
1090 while start > lineS, kind(start - 1, bigWord: bigWord) == k, char(start - 1) != 10 { start -= 1 }
1091 while end < lineE, kind(end, bigWord: bigWord) == k { end += 1 }
1092 if inner {
1093 for _ in 1..<max(1, count) {
1094 let k2 = kind(end, bigWord: bigWord)
1095 while end < lineE, kind(end, bigWord: bigWord) == k2 { end += 1 }
1096 }
1097 return start..<end
1098 }
1099 for _ in 1..<max(1, count) {
1100 while end < lineE, let ch = char(end), Self.isBlank(ch) { end += 1 }
1101 let k2 = kind(end, bigWord: bigWord)
1102 while end < lineE, kind(end, bigWord: bigWord) == k2 { end += 1 }
1103 }
1104 if k == 0 {
1105 let k2 = kind(end, bigWord: bigWord)
1106 while end < lineE, kind(end, bigWord: bigWord) == k2 { end += 1 }
1107 return start..<end
1108 }
1109 var trailing = end
1110 while trailing < lineE, let ch = char(trailing), Self.isBlank(ch) { trailing += 1 }
1111 if trailing > end { return start..<trailing }
1112 while start > lineS, let ch = char(start - 1), Self.isBlank(ch) { start -= 1 }
1113 return start..<end
1114 }
1115
1116 func quoteObject(_ c: Int, quote: unichar, inner: Bool) -> Range<Int>? {
1117 let s = lineStart(c)
1118 let e = lineEnd(c)
1119 var positions: [Int] = []
1120 var p = s
1121 while p < e {
1122 if char(p) == quote, p == s || char(p - 1) != 92 { positions.append(p) }
1123 p += 1
1124 }
1125 var pair: (Int, Int)?
1126 var i = 0
1127 while i + 1 < positions.count {
1128 if positions[i] <= c, c <= positions[i + 1] { pair = (positions[i], positions[i + 1]); break }
1129 i += 2
1130 }
1131 guard let (open, close) = pair else { return nil }
1132 if inner { return (open + 1)..<close }
1133 var end = close + 1
1134 while end < e, let ch = char(end), Self.isBlank(ch) { end += 1 }
1135 var start = open
1136 if end == close + 1 { while start > s, let ch = char(start - 1), Self.isBlank(ch) { start -= 1 } }
1137 return start..<end
1138 }
1139
1140 func bracketObject(_ c: Int, open: unichar, close: unichar, inner: Bool) -> Range<Int>? {
1141 var depth = 0
1142 var p = c
1143 var start: Int?
1144 if char(c) == open {
1145 start = c
1146 } else {
1147 if char(c) == close { p = c - 1 }
1148 while p >= 0 {
1149 if char(p) == close { depth += 1 }
1150 if char(p) == open {
1151 if depth == 0 { start = p; break }
1152 depth -= 1
1153 }
1154 p -= 1
1155 }
1156 }
1157 if start == nil {
1158 var f = c
1159 while f < lineEnd(c), char(f) != open { f += 1 }
1160 if f < lineEnd(c) { start = f }
1161 }
1162 guard let s = start else { return nil }
1163 depth = 0
1164 var q = s
1165 var end: Int?
1166 while q < length {
1167 if char(q) == open { depth += 1 }
1168 if char(q) == close {
1169 depth -= 1
1170 if depth == 0 { end = q; break }
1171 }
1172 q += 1
1173 }
1174 guard let e = end else { return nil }
1175 if !inner { return s..<(e + 1) }
1176 var innerStart = s + 1
1177 var innerEnd = e
1178 // A block that starts and ends on its own lines keeps them.
1179 if char(innerStart) == 10, lineStart(innerEnd) == innerEnd || substring(lineStart(innerEnd)..<innerEnd).allSatisfy({ $0 == " " || $0 == "\t" }) {
1180 innerStart += 1
1181 innerEnd = lineStart(innerEnd)
1182 }
1183 return innerStart..<innerEnd
1184 }
1185
1186 func paragraphObject(_ c: Int, inner: Bool) -> Range<Int>? {
1187 func blank(_ line: Int) -> Bool { substring(line..<lineEnd(line)).allSatisfy { $0 == " " || $0 == "\t" } }
1188 var start = lineStart(c)
1189 let isBlank = blank(start)
1190 while start > 0, blank(lineStart(start - 1)) == isBlank { start = lineStart(start - 1) }
1191 var end = lineEnd(c)
1192 while end < length, end + 1 <= length, end + 1 < length || !isBlank, blank(end + 1) == isBlank, end + 1 < length {
1193 end = lineEnd(end + 1)
1194 }
1195 var upper = min(length, end + 1)
1196 if !inner {
1197 var after = upper
1198 while after < length, blank(after) {
1199 let next = lineEnd(after)
1200 after = min(length, next + 1)
1201 if next >= length { break }
1202 }
1203 upper = after
1204 }
1205 return start..<upper
1206 }
1207 }
1208}
Sources/OrgEditorAppKit/Keys.swift +30
@@ -15,6 +15,36 @@ final class OrgTextView: NSTextView {
1515 /// Returns true when the key was handled.
1616 var keyHandler: ((NSEvent) -> Bool)?
1717 var onMouseDown: (() -> Void)?
18 /// Draw the caret as a block over the next character, for normal and visual states.
19 var blockCursor = false {
20 didSet {
21 guard blockCursor != oldValue else { return }
22 needsDisplay = true
23 }
24 }
25
26 override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
27 guard blockCursor else { return super.drawInsertionPoint(in: rect, color: color, turnedOn: flag) }
28 var block = rect
29 block.size.width = max(rect.width, blockWidth)
30 if flag {
31 color.withAlphaComponent(0.45).setFill()
32 block.fill(using: .sourceOver)
33 } else {
34 setNeedsDisplay(block, avoidAdditionalLayout: true)
35 }
36 }
37
38 override func setNeedsDisplay(_ rect: NSRect, avoidAdditionalLayout flag: Bool) {
39 var widened = rect
40 if blockCursor { widened.size.width += blockWidth }
41 super.setNeedsDisplay(widened, avoidAdditionalLayout: flag)
42 }
43
44 private var blockWidth: CGFloat {
45 let font = self.font ?? NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
46 return ("x" as NSString).size(withAttributes: [.font: font]).width
47 }
1848
1949 override func keyDown(with event: NSEvent) {
2050 if !hasMarkedText(), keyHandler?(event) == true { return }
Sources/OrgEditorAppKit/Modal.swift added +237
@@ -0,0 +1,237 @@
1#if os(macOS)
2import AppKit
3import OrgCore
4
5/// Modal editing: keys go to the keymap for the current evil state first (leader keys,
6/// evil-org's org keys), then to `Vim`.
7extension OrgEditor {
8 func handleModal(_ chord: KeyChord) -> Bool {
9 // ⌘ shortcuts stay with the menus.
10 if chord.modifiers.contains(.command) { return false }
11 dispatcher.mode = vim.mode.keymapMode
12 if !vim.pending.isEmpty, vim.mode != .insert {
13 return feedVim([chord])
14 }
15 switch dispatcher.feed(chord) {
16 case .pending(let keys):
17 let next = keymap.continuations(of: keys, mode: dispatcher.mode).map { item in
18 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
19 }
20 onKeysPending?(KeySequence.format(keys), next)
21 return true
22 case .cancelled:
23 onKeysPending?(nil, [])
24 return true
25 case .complete(let keys, let candidates):
26 if keys.count > 1 { onKeysPending?(nil, []) }
27 let context = editContext()
28 if let binding = candidates.first(where: { binding in
29 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context)
30 }) {
31 run(binding.command)
32 updateCursor()
33 return true
34 }
35 return feedVim(keys)
36 case .undefined(let keys):
37 onKeysPending?(nil, [])
38 return feedVim(keys)
39 case .unbound:
40 return feedVim([chord])
41 }
42 }
43
44 /// Plays keys into the engine. False hands a single key in insert state to the text
45 /// system.
46 func feedVim(_ keys: [KeyChord]) -> Bool {
47 for (index, key) in keys.enumerated() {
48 if vim.mode == .insert, vim.passesThrough(key) {
49 if keys.count == 1 { return false }
50 insertTyped(key)
51 continue
52 }
53 let leaving = key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control)
54 if vim.mode == .insert, leaving, let snapshot = insertSnapshot {
55 // What was typed since insert state began, for `.`.
56 if let edit = TextEdit.between(snapshot.text, document.text).first, edit.range.count == 0 {
57 vim.inserted(edit.replacement)
58 }
59 }
60 let wasInsert = vim.mode == .insert
61 let visual = vim.mode == .visual || vim.mode == .visualLine
62 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
63 let clipboard = NSPasteboard.general.string(forType: .string)
64 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage))
65 apply(actions)
66 if !wasInsert, vim.mode == .insert {
67 insertSnapshot = (document.text, textView.selectedRange().location)
68 } else if vim.mode != .insert {
69 insertSnapshot = nil
70 }
71 _ = index
72 }
73 updateCursor()
74 return true
75 }
76
77 private func insertTyped(_ key: KeyChord) {
78 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 }
80 textView.insertText(text, replacementRange: textView.selectedRange())
81 }
82
83 var halfPage: Int {
84 guard let scroll = textView.enclosingScrollView, let font = textView.font else { return 15 }
85 let lineHeight = max(1, font.ascender - font.descender + font.leading)
86 return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2))
87 }
88
89 private func editContext() -> EditContext {
90 let selected = textView.selectedRange()
91 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options)
92 }
93
94 private func commandApplies(_ id: String, in context: EditContext) -> Bool {
95 switch EditorCommands.command(id)?.action {
96 case .text(let command): command.applies(in: context)
97 default: true
98 }
99 }
100
101 func apply(_ actions: [Vim.Action]) {
102 for action in actions {
103 switch action {
104 case .edit(let edits, let caret):
105 visualCaret = nil
106 guard textView.isEditable else {
107 onMessage?("This file is read-only.")
108 continue
109 }
110 applyEdits(edits)
111 setCaret(caret)
112 textView.scrollRangeToVisible(NSRange(location: min(caret, document.text.utf16.count), length: 0))
113 case .move(let caret):
114 setCaret(caret)
115 visualCaret = caret
116 textView.scrollRangeToVisible(NSRange(location: caret, length: 0))
117 case .select(let range):
118 textView.setSelectedRange(NSRange(range))
119 case .command(let id):
120 run(id)
121 case .undo(let count):
122 for _ in 0..<count { textView.undoManager?.undo() }
123 clampCaret()
124 case .redo(let count):
125 for _ in 0..<count { textView.undoManager?.redo() }
126 clampCaret()
127 case .setClipboard(let text):
128 NSPasteboard.general.clearContents()
129 NSPasteboard.general.setString(text, forType: .string)
130 case .prompt(let kind):
131 prompt(kind)
132 case .message(let text):
133 onMessage?(text)
134 }
135 }
136 }
137
138 private func clampCaret() {
139 guard vim.mode == .normal else { return }
140 let ns = document.text as NSString
141 let caret = min(textView.selectedRange().location, ns.length)
142 let line = ns.lineRange(for: NSRange(location: caret, length: 0))
143 var end = 0
144 ns.getLineStart(nil, end: nil, contentsEnd: &end, for: line)
145 if caret >= end, end > line.location { setCaret(end - 1) }
146 }
147
148 /// `/`, `?` and `:` read their argument in the echo area.
149 private func prompt(_ kind: String) {
150 onPrompt?(Prompt(key: kind, message: kind)) { [weak self] answer in
151 guard let self, let answer, !answer.isEmpty else { return }
152 let caret = self.textView.selectedRange().location
153 let context = Vim.Context(text: self.document.text, caret: caret)
154 if kind == ":" {
155 self.ex(answer)
156 } else {
157 self.apply(self.vim.search(answer, forward: kind == "/", context))
158 }
159 self.updateCursor()
160 }
161 }
162
163 /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s` and `:%s`.
164 func ex(_ command: String) {
165 let trimmed = command.trimmingCharacters(in: .whitespaces)
166 switch trimmed {
167 case "w", "write": onAppCommand?("app.save")
168 case "q", "quit", "q!": textView.window?.performClose(nil)
169 case "wq", "x", "wq!":
170 onAppCommand?("app.save")
171 textView.window?.performClose(nil)
172 case "noh", "nohlsearch": break
173 default:
174 if let line = Int(trimmed) {
175 let ns = document.text as NSString
176 var offset = 0
177 for _ in 1..<max(1, line) {
178 let range = ns.lineRange(for: NSRange(location: offset, length: 0))
179 guard NSMaxRange(range) < ns.length else { break }
180 offset = NSMaxRange(range)
181 }
182 setCaret(offset)
183 textView.scrollRangeToVisible(NSRange(location: offset, length: 0))
184 return
185 }
186 if let m = trimmed.firstMatch(of: /^(%?)s\/((?:[^\/\\]|\\.)*)\/((?:[^\/\\]|\\.)*)\/?([gi]*)$/) {
187 substitute(whole: !m.1.isEmpty, pattern: String(m.2), replacement: String(m.3), flags: String(m.4))
188 return
189 }
190 onMessage?("Not an editor command: \(trimmed)")
191 }
192 }
193
194 private func substitute(whole: Bool, pattern: String, replacement: String, flags: String) {
195 let options: NSRegularExpression.Options = flags.contains("i") ? [.caseInsensitive] : []
196 guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
197 onMessage?("Invalid pattern: \(pattern)")
198 return
199 }
200 let ns = document.text as NSString
201 let caret = textView.selectedRange().location
202 let lines = whole ? NSRange(location: 0, length: ns.length) : ns.lineRange(for: NSRange(location: caret, length: 0))
203 var edits: [TextEdit] = []
204 var location = lines.location
205 while location < NSMaxRange(lines) {
206 let line = ns.lineRange(for: NSRange(location: location, length: 0))
207 let matches = regex.matches(in: ns as String, range: line)
208 for m in flags.contains("g") ? matches : Array(matches.prefix(1)) {
209 let template = regex.replacementString(for: m, in: ns as String, offset: 0, template: replacement.replacingOccurrences(of: "\\\\([0-9])", with: "\\$$1", options: .regularExpression))
210 edits.append(TextEdit(range: m.range.location..<NSMaxRange(m.range), replacement: template))
211 }
212 location = NSMaxRange(line)
213 }
214 guard !edits.isEmpty else {
215 onMessage?("Pattern not found: \(pattern)")
216 return
217 }
218 applyEdits(edits)
219 onMessage?("\(edits.count) substitution\(edits.count == 1 ? "" : "s")")
220 }
221
222 /// A block cursor in normal and visual states, a bar in insert state.
223 func updateCursor() {
224 let block = modal && vim.mode != .insert
225 (textView as? OrgTextView)?.blockCursor = block
226 dispatcher.mode = modal ? vim.mode.keymapMode : nil
227 }
228
229 /// A mouse click leaves visual state, as in evil.
230 func modalMouseDown() {
231 guard modal else { return }
232 vim.reset()
233 visualCaret = nil
234 updateCursor()
235 }
236}
237#endif
Sources/OrgEditorAppKit/OrgEditor.swift +39 −17
@@ -86,7 +86,10 @@ public final class OrgEditor: NSObject {
8686 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
8787 super.init()
8888 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
89 orgTextView.onMouseDown = { [weak self] in self?.markActive = false }
89 orgTextView.onMouseDown = { [weak self] in
90 self?.markActive = false
91 self?.modalMouseDown()
92 }
9093 textView.usesFindBar = true
9194 textView.frame = frame
9295 textView.isRichText = false
@@ -198,7 +201,22 @@ public final class OrgEditor: NSObject {
198201 public var optionAsMeta: OptionAsMeta = [.left]
199202 /// Org options commands follow.
200203 public var options: EditingOptions = .org
201 private var dispatcher: KeyDispatcher
204 var dispatcher: KeyDispatcher
205 /// Evil-style editing, for modal keymaps such as Doom's.
206 public var modal = false {
207 didSet {
208 guard modal != oldValue else { return }
209 vim = Vim()
210 dispatcher.mode = modal ? vim.mode.keymapMode : nil
211 dispatcher.reset()
212 updateCursor()
213 }
214 }
215 var vim = Vim()
216 /// The text and caret when insert state began, to know what was typed for `.`.
217 var insertSnapshot: (text: String, caret: Int)?
218 /// The evil cursor in visual states, where the selection's start isn't it.
219 var visualCaret: Int?
202220 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
203221 private(set) var markActive = false
204222
@@ -213,7 +231,8 @@ public final class OrgEditor: NSObject {
213231
214232 /// Offers a key press to the keymap. False lets the text system have it.
215233 func handleKey(_ event: NSEvent) -> Bool {
216 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return false }
234 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return modal && vim.mode != .insert }
235 if modal { return handleModal(chord) }
217236 switch dispatcher.feed(chord) {
218237 case .pending(let keys):
219238 let next = keymap.continuations(of: keys).map { item in
@@ -306,19 +325,7 @@ public final class OrgEditor: NSObject {
306325 onMessage?("This file is read-only.")
307326 return .failed("read-only")
308327 }
309 if !result.edits.isEmpty, let storage = textView.textStorage {
310 // Each command is its own undo step, not merged with typing around it.
311 textView.breakUndoCoalescing()
312 textView.undoManager?.beginUndoGrouping()
313 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
314 let range = NSRange(edit.range)
315 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
316 storage.replaceCharacters(in: range, with: edit.replacement)
317 textView.didChangeText()
318 }
319 textView.undoManager?.endUndoGrouping()
320 textView.breakUndoCoalescing()
321 }
328 applyEdits(result.edits)
322329 if let selection = result.selection?.first {
323330 setCaret(selection.lowerBound)
324331 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
@@ -402,7 +409,22 @@ public final class OrgEditor: NSObject {
402409 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
403410 }
404411
405 private func setCaret(_ offset: Int) {
412 /// Applies edits through the text view as one undo step, not merged with typing around it.
413 func applyEdits(_ edits: [TextEdit]) {
414 guard !edits.isEmpty, let storage = textView.textStorage else { return }
415 textView.breakUndoCoalescing()
416 textView.undoManager?.beginUndoGrouping()
417 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
418 let range = NSRange(edit.range)
419 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
420 storage.replaceCharacters(in: range, with: edit.replacement)
421 textView.didChangeText()
422 }
423 textView.undoManager?.endUndoGrouping()
424 textView.breakUndoCoalescing()
425 }
426
427 func setCaret(_ offset: Int) {
406428 let clamped = max(0, min(offset, utf16Length))
407429 previousSelection = clamped
408430 textView.setSelectedRange(NSRange(location: clamped, length: 0))
Sources/Orgstar/ContentView.swift +1 −1
@@ -62,7 +62,7 @@ struct ContentView: View {
6262 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
6363 } detail: {
6464 if session.path != nil {
65 EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
65 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
6666 .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) }
6767 } else {
6868 ContentUnavailableView(
Sources/Orgstar/EditorView.swift +2
@@ -14,6 +14,7 @@ struct EditorView: NSViewRepresentable {
1414 let session: DocumentSession
1515 let commands: AppCommands
1616 let keymap: Keymap
17 let modal: Bool
1718 let optionAsMeta: OptionAsMeta
1819 let options: EditingOptions
1920 let onAppCommand: (String) -> Void
@@ -60,6 +61,7 @@ struct EditorView: NSViewRepresentable {
6061 }
6162 if let editor = coordinator.editor {
6263 if editor.keymap != keymap { editor.keymap = keymap }
64 editor.modal = modal
6365 editor.optionAsMeta = optionAsMeta
6466 editor.options = options
6567 }
Tests/OrgCoreTests/VimTests.swift added +229
@@ -0,0 +1,229 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Plays keys through `Vim` the way the editor does: printable keys in insert state type,
6/// actions apply to the text.
7struct VimDriver {
8 var vim = Vim()
9 var text: String
10 var caret: Int
11 var clipboard: String?
12 var commands: [String] = []
13
14 init(_ text: String, caret: Int) {
15 self.text = text
16 self.caret = caret
17 }
18
19 mutating func type(_ keys: [KeyChord]) {
20 for key in keys {
21 if vim.mode == .insert, vim.passesThrough(key) {
22 if key == KeyChord("DEL") {
23 if caret > 0 {
24 let ns = text as NSString
25 text = ns.replacingCharacters(in: NSRange(location: caret - 1, length: 1), with: "")
26 caret -= 1
27 vim.deletedBackward()
28 }
29 } else {
30 let s = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key
31 guard key.modifiers.isEmpty, s.count == 1 else { continue }
32 let ns = text as NSString
33 text = ns.replacingCharacters(in: NSRange(location: caret, length: 0), with: s)
34 caret += s.utf16.count
35 vim.inserted(s)
36 }
37 continue
38 }
39 let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard))
40 for action in actions {
41 switch action {
42 case .edit(let edits, let to):
43 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { text = edit.apply(to: text) }
44 caret = to
45 case .move(let to): caret = to
46 case .setClipboard(let s): clipboard = s
47 case .command(let id): commands.append(id)
48 default: break
49 }
50 }
51 }
52 }
53}
54
55struct VimTests {
56 static func keys(_ s: String) -> [KeyChord] { KeySequence.parse(s)! }
57
58 static let cases: [(String, Int, String)] = [
59 ("hello world foo\nsecond line here\n", 0, "d w"),
60 ("hello world foo\nsecond line here\n", 0, "j c i w X ESC"),
61 ("hello world foo\nsecond line here\n", 6, "d i w"),
62 ("hello world foo\nsecond line here\n", 6, "d a w"),
63 ("hello world foo\nsecond line here\n", 0, "2 d w"),
64 ("hello world foo\nsecond line here\n", 0, "d 2 w"),
65 ("hello world foo\nsecond line here\n", 12, "d w"),
66 ("hello world foo\nsecond line here\n", 4, "x"),
67 ("hello world foo\nsecond line here\n", 4, "3 x"),
68 ("hello world foo\nsecond line here\n", 4, "X"),
69 ("hello world foo\nsecond line here\n", 4, "D"),
70 ("hello world foo\nsecond line here\n", 4, "C n e w ESC"),
71 ("hello world foo\nsecond line here\n", 4, "d d"),
72 ("one\ntwo\nthree\nfour\n", 4, "2 d d"),
73 ("one\ntwo\nthree\nfour\n", 14, "d d"),
74 ("one\ntwo\nthree\nfour", 14, "d d"),
75 ("one\ntwo\nthree\n", 0, "y y p"),
76 ("one\ntwo\nthree\n", 0, "y y P"),
77 ("one\ntwo\nthree\n", 4, "y w $ p"),
78 ("one\ntwo\nthree\n", 0, "d d p"),
79 ("one two three\n", 0, "d w w P"),
80 ("one two three\n", 4, "Y $ p"),
81 ("one\ntwo\nthree\n", 0, "J"),
82 ("one\n two\nthree\n", 0, "3 J"),
83 ("one two\n", 0, "r X"),
84 ("one two\n", 0, "~ ~"),
85 ("Hello World\n", 0, "g U i w"),
86 ("Hello World\n", 0, "g u u"),
87 ("one two\n", 0, "o n e w ESC"),
88 ("one two\n", 4, "O n e w ESC"),
89 (" one two\n", 6, "I x ESC"),
90 ("one two\n", 2, "A ! ESC"),
91 ("one two\n", 2, "a X ESC"),
92 ("one two three four\n", 0, "d w ."),
93 ("one two three four\n", 0, "c w X ESC w ."),
94 ("one\ntwo\nthree\nfour\n", 0, "d d j ."),
95 ("a(b, c) d\n", 3, "d i ("),
96 ("a(b, c) d\n", 3, "d a ("),
97 ("say \"hi there\" now\n", 7, "c i \" X ESC"),
98 ("say \"hi there\" now\n", 7, "d a \""),
99 ("x = [1, 2, 3]\n", 6, "d i ["),
100 ("f {\n body\n}\n", 6, "d i {"),
101 ("one two three\n", 0, "v e d"),
102 ("one two three\n", 4, "v i w y $ p"),
103 ("one\ntwo\nthree\n", 4, "V d"),
104 ("one\ntwo\nthree\n", 0, "V j d"),
105 ("one\ntwo\nthree\n", 0, "V j y G p"),
106 ("one two three\n", 0, "f t x"),
107 ("one two three\n", 0, "t t x"),
108 ("one two three\n", 12, "F o x"),
109 ("one two three\n", 0, "f e ; x"),
110 ("one two three\n", 0, "d f t"),
111 ("one two three\n", 0, "d t t"),
112 ("one two three\n", 0, "$ x"),
113 ("one two three\n", 8, "0 x"),
114 (" one two\n", 8, "^ x"),
115 ("one\ntwo\nthree\n", 0, "G x"),
116 ("one\ntwo\nthree\n", 10, "g g x"),
117 ("one\ntwo\nthree\n", 0, "2 G x"),
118 ("one two three\n", 0, "e x"),
119 ("one two three\n", 0, "w w b x"),
120 ("one.two three\n", 0, "w x"),
121 ("one.two three\n", 0, "W x"),
122 ("one.two three\n", 12, "B x"),
123 ("one two three\n", 0, "d e"),
124 ("one two three\n", 8, "d b"),
125 ("para one\nstill\n\npara two\n", 0, "} x"),
126 ("para one\nstill\n\npara two\n", 0, "d }"),
127 ("para one\nstill\n\npara two\n", 0, "d a p"),
128 ("para one\nstill\n\npara two\n", 0, "y i p G p"),
129 ("(a (b) c)\n", 0, "% x"),
130 ("one two\nthree four\n", 0, "j l l l k x"),
131 ("long line here\nab\nlonger line\n", 10, "j j x"),
132 ("one two three\n", 0, "s t h x"),
133 ("one two three\n", 12, "S t w x"),
134 ("one two three\n", 0, "\" a y w w \" a P"),
135 ("one two three\n", 0, "\" _ d w"),
136 (" one\n", 2, "o x ESC"),
137 ("- item\n", 3, "o x ESC"),
138 ("- [ ] item\n", 3, "o x ESC"),
139 ("1. item\n", 3, "o x ESC"),
140 ("text\n- item\n", 8, "O x ESC"),
141 ("- item\n more\n", 9, "o x ESC"),
142 ("* Heading\n", 3, "o x ESC"),
143 ("one two three\n", 0, "v e ~"),
144 ("one two three\n", 0, "v e U"),
145 ("one\ntwo\nthree\n", 0, "V j >"),
146 ("one\ntwo\nthree\n", 0, "v j J"),
147 ("one two three\n", 0, "y i w w v e p"),
148 ("a (b c) d\n", 0, "c i ( X ESC"),
149 ("one two\n", 4, "d a w"),
150 ("one two\n", 4, "d i w"),
151 ("one two three four\n", 0, "d 2 a w"),
152 ("one two three\n", 4, "c $ X ESC"),
153 ("one two three\n", 8, "d 0"),
154 (" one two\n", 8, "d ^"),
155 ("one\ntwo\nthree\n", 4, "d G"),
156 ("one\ntwo\nthree\n", 4, "d g g"),
157 ("one two three\n", 4, "y $ P"),
158 ("ab\n", 0, "x p"),
159 ("one\ntwo\n", 0, "d d p"),
160 ("abc\n", 1, "5 x"),
161 ("abcdef\n", 1, "3 r x"),
162 ("one two three four\n", 0, "x . ."),
163 ("one two three four five\n", 0, "d w 2 ."),
164 ("one two\n", 3, "c w X ESC"),
165 ("aXbXcXd\n", 0, "2 f X x"),
166 ("one two three\n", 0, "w v $ d"),
167 ("one\ntwo\nthree\n", 0, "j V d p"),
168 ("x\n\n\ny\n", 0, "d }"),
169 ("one two\n", 0, "e a X ESC b ."),
170 ("one\n\nthree\n", 0, "j o x ESC"),
171 ("one two\n", 0, "g U U"),
172 ("a b\n", 0, "w i x ESC h ."),
173 ("one two three\n", 4, "c c X ESC"),
174 (" one two\n", 4, "c c X ESC"),
175 ("one two\n", 0, "> >"),
176 (" one two\n", 0, "< <"),
177 ("one two\n", 0, "i a b DEL c ESC"),
178 ("one two\n", 0, "i x C-w y ESC"),
179 ("one two\n", 3, "a X ESC . ."),
180 ("one\ntwo\n", 0, "A ; ESC j ."),
181 ("one two three\n", 0, "3 l v l d"),
182 ("one two three\n", 8, "v b d"),
183 ("one two three\n", 0, "v 2 e c X ESC"),
184 ]
185
186 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
187 func matchesEvil() throws {
188 let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos")
189 guard FileManager.default.fileExists(atPath: evil.appendingPathComponent("evil/evil.el").path) else { return }
190 var forms: [EmacsOracle.Case] = []
191 for (text, point, keys) in Self.cases {
192 forms.append(EmacsOracle.Case(text: text, point: point + 1, form: """
193 (progn
194 (setq evil-want-Y-yank-to-eol t evil-undo-system 'undo-redo evil-want-C-w-delete t evil-want-C-u-delete t evil-shift-width 8)
195 (add-to-list 'load-path "\(evil.path)/evil")
196 (add-to-list 'load-path "\(evil.path)/goto-chg")
197 (add-to-list 'load-path "\(evil.path)/evil-snipe")
198 (require 'evil)
199 (require 'evil-snipe)
200 (setq evil-snipe-smart-case t evil-snipe-scope 'line)
201 (setq kill-ring nil)
202 (switch-to-buffer (current-buffer))
203 (evil-local-mode 1)
204 (evil-snipe-local-mode 1)
205 (evil-normal-state)
206 (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))"))
207 (let ((p (point)) (s evil-state))
208 (goto-char (point-max))
209 (insert (format "\\n@@STATE %s" s))
210 (goto-char p)))
211 """))
212 }
213 let results = try EmacsOracle.run(forms)
214 for ((text, point, keys), result) in zip(Self.cases, results) {
215 var driver = VimDriver(text, caret: point)
216 driver.type(Self.keys(keys))
217 guard let marker = result.text.range(of: "\n@@STATE ") else {
218 Issue.record("\(keys): \(result.error)")
219 continue
220 }
221 let emacsText = String(result.text[..<marker.lowerBound])
222 let state = String(result.text[marker.upperBound...])
223 let ours = driver.vim.mode == .insert ? "insert" : driver.vim.mode == .normal ? "normal" : "visual"
224 let emacsCaret = EmacsOracle.offset(result.point, in: result.text)
225 #expect(driver.text == emacsText && driver.caret == emacsCaret && ours == state,
226 "\(keys.debugDescription) on \(text.debugDescription) at \(point)\nours: \(driver.text.debugDescription) @\(driver.caret) \(ours)\nevil: \(emacsText.debugDescription) @\(emacsCaret) \(state) \(result.error)")
227 }
228 }
229}
Tests/OrgEditorAppKitTests/ModalTests.swift added +79
@@ -0,0 +1,79 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6/// The Doom preset in the editor: keys through the keymap and the modal engine.
7@MainActor
8struct ModalTests {
9 func doom(_ text: String) -> Harness {
10 let h = Harness(text)
11 h.editor.keymap = KeymapPreset.doom.keymap
12 h.editor.modal = true
13 return h
14 }
15
16 /// Types `keys` as key events: letters, `ESC`, `RET`, `TAB`, `SPC`.
17 func type(_ h: Harness, _ keys: String) {
18 let codes: [String: UInt16] = ["ESC": 53, "RET": 36, "TAB": 48, "SPC": 49]
19 for token in keys.split(separator: " ") {
20 let key = String(token)
21 if let code = codes[key] {
22 let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " "
23 KeyTests().send(h, characters: characters, ignoring: characters, code: code)
24 } else {
25 let shifted = key.first!.isUppercase
26 KeyTests().send(h, characters: key, ignoring: key, code: 0, flags: shifted ? .shift : [])
27 }
28 }
29 }
30
31 @Test func normalStateEditsAndInsertTypes() {
32 let h = doom("hello world\nsecond\n")
33 h.caret(at: 0)
34 type(h, "d w")
35 #expect(h.editor.document.text == "world\nsecond\n")
36 type(h, "c i w")
37 #expect(h.editor.vim.mode == .insert)
38 h.textView.insertText("planet", replacementRange: h.textView.selectedRange())
39 type(h, "ESC")
40 #expect(h.editor.document.text == "planet\nsecond\n")
41 #expect(h.textView.selectedRange().location == 5)
42 type(h, "j .")
43 #expect(h.editor.document.text == "planet\nplanet\n")
44 }
45
46 @Test func undo() {
47 let h = doom("hello world\n")
48 h.caret(at: 0)
49 type(h, "d w")
50 #expect(h.editor.document.text == "world\n")
51 type(h, "u")
52 #expect(h.editor.document.text == "hello world\n")
53 }
54
55 @Test func leaderAndOrgKeys() {
56 let h = doom("* TODO Task\n")
57 var app: [String] = []
58 h.editor.onAppCommand = { app.append($0) }
59 h.caret(at: 2)
60 type(h, "SPC f s")
61 #expect(app == ["app.save"])
62 type(h, "RET")
63 #expect(h.editor.document.text == "* DONE Task\n")
64 type(h, "SPC m t")
65 #expect(h.editor.document.text == "* KILL Task\n" || h.editor.document.text.hasPrefix("* "))
66 type(h, "TAB")
67 #expect(h.editor.vim.mode == .normal)
68 }
69
70 @Test func visualSelection() {
71 let h = doom("one two three\n")
72 h.caret(at: 0)
73 type(h, "v e")
74 #expect(h.textView.selectedRange() == NSRange(location: 0, length: 3))
75 type(h, "d")
76 #expect(h.editor.document.text == " two three\n")
77 #expect(h.textView.selectedRange().length == 0)
78 }
79}
docs/plans/2026-10-05-doom-keys.md added +23
@@ -0,0 +1,23 @@
1# Doom Keymap Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A Doom preset: evil's modal editing with Doom's settings, Doom's leader and localleader keys for org, and evil-org's keys.
6
7**Architecture:** `OrgCore/Keymap/Vim.swift` is the modal engine, pure so it can be checked against evil: normal, insert, visual and visual-line states; counts and registers (`"a`–`"z`, append with capitals, `"_`, `"0`, the unnamed register on the clipboard); operators `d c y > < g~ gu gU` with motions (`h j k l w W b B e E ge gE 0 ^ $ g_ gg G f F t T ; , % { } n N * #`, C-d, C-u) and text objects (`iw aw iW aW` quotes, brackets, `ip ap`); `x X D C Y(=y$) p P J r ~ i a I A o O v V u C-r .`; evil-snipe's `s`/`S` (line scope, smart case); insert-state C-w and C-u; Doom's settings (`evil-start-of-line` nil, `evil-shift-width` = Org's tab width, Y to the end of the line). `o`/`O`/`cc` indent as `indent-according-to-mode` does in Org. The Doom preset is generated: the Emacs preset's `C-c`/`C-x`/meta/shift org keys copied into each state (with evil-org's `M-h/j/k/l` and insert-state `C-t`/`C-d`), then Doom's `SPC` leader and `SPC m` localleader for org, `TAB`, `RET` (`+org/dwim-at-point` as `OrgDwim`), `z a/c/o`, `g h/j/k`, `]h`/`[h`. The editor offers keys to the keymap for the current state, then to the engine, applies its edits as undo steps, draws a block cursor outside insert state, and reads `/`, `?` and `:` (`:w`, `:q`, `:wq`, `:N`, `:s`, `:%s`) in the echo area.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing; evil and evil-snipe from your Doom install as the oracle in batch Emacs.
10
11**Spec:** `docs/design.md`, "Keymaps" (Doom).
12
13## Global Constraints
14
15- Oracle: 125 key sequences run by evil (+ evil-snipe) in batch Emacs on an Org buffer; text, point and state compared.
16- Not yet: macros (`q`, `@`), marks, jumps (C-o/C-i), `gv`, block visual (C-v), evil-surround, evil-commentary, evil-org text objects (`ae`, `iR`…), numbered delete registers.
17
18---
19
20- [x] `Vim`; oracle.
21- [x] Doom preset; `OrgDwim`.
22- [x] Editor routing, block cursor, prompts, ex commands; editor tests.
23- [x] Commit "Doom keymap".