Sources/OrgEditorAppKit/Modal.swift
402 lines · 19180 bytes
17 symbols in this file
1#if os(macOS)
2import AppKit
3import OrgCore
4import OrgDocument
5
6/// An editor a file mark is in, gone when its buffer closes.
7struct WeakEditor {
8 weak var editor: OrgEditor?
9}
10
11/// Modal editing: keys go to the keymap for the current evil state first (leader keys,
12/// evil-org's org keys), then to `Vim`.
13extension OrgEditor {
14 func handleModal(_ chord: KeyChord) -> Bool {
15 // ⌘ shortcuts stay with the menus.
16 if chord.modifiers.contains(.command) { return false }
17 if recordingRegister != nil, replayingMacro == 0 { recordedKeys.append(chord) }
18 dispatcher.mode = vim.mode.keymapMode
19 if !vim.pending.isEmpty, vim.mode != .insert {
20 return feedVim([chord])
21 }
22 switch dispatcher.feed(chord) {
23 case .pending(let keys):
24 let context = editContext()
25 let next = keymap.continuations(of: keys, mode: dispatcher.mode) { modalRuns($0, in: context) }.map { item in
26 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
27 }
28 onKeysPending?(KeySequence.format(keys), next)
29 return true
30 case .cancelled:
31 onKeysPending?(nil, [])
32 return true
33 case .complete(let keys, let candidates):
34 if keys.count > 1 { onKeysPending?(nil, []) }
35 let context = editContext()
36 if let binding = candidates.first(where: { modalRuns($0, in: context) }) {
37 if binding.command == "org.fill-paragraph", vim.mode == .visual || vim.mode == .visualLine {
38 fillVisual()
39 } else {
40 run(binding.command)
41 }
42 updateCursor()
43 return true
44 }
45 return feedVim(keys)
46 case .undefined(let keys):
47 onKeysPending?(nil, [])
48 // The leader and Emacs prefixes (C-c, C-x) mean nothing to evil: the sequence is
49 // undefined, as in Doom. Other prefixes (g, z, [, ]) are evil's too.
50 if let first = keys.first, first == KeyChord("SPC") || !first.modifiers.isDisjoint(with: [.control, .meta]) {
51 onMessage?("\(KeySequence.format(keys)) is undefined")
52 return true
53 }
54 return feedVim(keys)
55 case .unbound:
56 return feedVim([chord])
57 }
58 }
59
60 /// M-q in visual states: `org-fill-paragraph` on the region, the cursor kept. A change
61 /// deactivates the region, which leaves visual state.
62 private func fillVisual() {
63 let selected = textView.selectedRange()
64 let cursor = visualCaret ?? selected.location
65 let text = document.text
66 perform(FillParagraph(point: cursor))
67 if document.text == text {
68 textView.setSelectedRange(selected)
69 visualCaret = cursor
70 } else {
71 vim.reset()
72 visualCaret = nil
73 clampCaret()
74 }
75 }
76
77 /// Plays keys into the engine. False hands a single key in insert state to the text
78 /// system.
79 func feedVim(_ keys: [KeyChord]) -> Bool {
80 for (index, key) in keys.enumerated() {
81 if vim.mode == .insert, vim.passesThrough(key) {
82 if keys.count == 1 { return electricPair(key) }
83 if !electricPair(key) { insertTyped(key) }
84 continue
85 }
86 let leaving = key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control)
87 if vim.mode == .insert, leaving, let snapshot = insertSnapshot {
88 // What was typed since insert state began, for `.`.
89 if let edit = TextEdit.between(snapshot.text, document.text).first, edit.range.count == 0 {
90 vim.inserted(edit.replacement)
91 }
92 }
93 let wasInsert = vim.mode == .insert
94 let visual = vim.mode == .visual || vim.mode == .visualLine
95 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
96 if jumpToFileMark(key) { continue }
97 let clipboard = NSPasteboard.general.string(forType: .string)
98 var marks = vimMarks
99 for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset }
100 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
101 vim.registers = Self.vimRegisters
102 vim.clipboardRecord = Self.vimClipboardRecord
103 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText, org: isOrg))
104 Self.vimRegisters = vim.registers
105 Self.vimClipboardRecord = vim.clipboardRecord
106 apply(actions)
107 if !wasInsert, vim.mode == .insert {
108 insertSnapshot = (document.text, textView.selectedRange().location)
109 } else if vim.mode != .insert {
110 insertSnapshot = nil
111 }
112 _ = index
113 }
114 updateCursor()
115 return true
116 }
117
118 /// `'A` or `` `A `` for a file mark in another editor: that editor's buffer shows at the
119 /// mark. True when the key went there.
120 private func jumpToFileMark(_ key: KeyChord) -> Bool {
121 guard vim.mode == .normal, vim.pending.count == 1, let quote = vim.pending.first, quote == KeyChord("'") || quote == KeyChord("`"),
122 key.modifiers.isEmpty, key.key.count == 1, let name = key.key.first, name.isUppercase,
123 let mark = Self.fileMarks[name], let owner = mark.owner.editor, owner !== self else { return false }
124 vim.reset()
125 let ns = owner.document.text as NSString
126 var target = min(mark.offset, ns.length)
127 if quote == KeyChord("'") {
128 // The line's first non-blank.
129 target = ns.lineRange(for: NSRange(location: target, length: 0)).location
130 while target < ns.length, ns.character(at: target) == 32 || ns.character(at: target) == 9 { target += 1 }
131 }
132 onJumpToEditor?(owner, target)
133 return true
134 }
135
136 /// Moves marks and jump positions through an edit.
137 func mapVimPositions(through edit: TextEdit) {
138 let owned = Self.fileMarks.filter { $0.value.owner.editor === self }
139 guard !vimMarks.isEmpty || !jumpList.isEmpty || !owned.isEmpty else { return }
140 let names = Array(vimMarks.keys)
141 let fileNames = Array(owned.keys)
142 let positions = names.map { vimMarks[$0]! } + fileNames.map { owned[$0]!.offset } + jumpList
143 let mapped = ViewState(selection: positions.map { $0..<$0 }).mapped(through: [edit]).selection
144 for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound }
145 for (i, name) in fileNames.enumerated() { Self.fileMarks[name]?.offset = mapped[names.count + i].lowerBound }
146 jumpList = mapped.dropFirst(names.count + fileNames.count).map { $0.lowerBound }
147 }
148
149 func insertTyped(_ key: KeyChord) {
150 let text = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key
151 guard key.modifiers.isEmpty, text.count == 1 else { return }
152 textView.insertText(text, replacementRange: textView.selectedRange())
153 }
154
155 var halfPage: Int {
156 guard let scroll = textView.enclosingScrollView, let font = textView.font else { return 15 }
157 let lineHeight = max(1, font.ascender - font.descender + font.leading)
158 return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2))
159 }
160
161 /// The text the window shows, from the line at its top to the line at its bottom.
162 var visibleText: Range<Int>? {
163 guard let layout = textView.textLayoutManager, let content = layout.textContentManager, let scroll = textView.enclosingScrollView else { return nil }
164 let bounds = scroll.contentView.bounds
165 let origin = textView.textContainerOrigin
166 let start = content.documentRange.location
167 guard let top = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, bounds.minY - origin.y))) else { return nil }
168 let lower = content.offset(from: start, to: top.rangeInElement.location)
169 let bottom = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, bounds.maxY - origin.y - 1)))
170 let upper = bottom.map { content.offset(from: start, to: $0.rangeInElement.endLocation) } ?? utf16Length
171 return lower..<max(lower, upper)
172 }
173
174 private func editContext() -> EditContext {
175 let selected = textView.selectedRange()
176 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options)
177 }
178
179 private func modalRuns(_ binding: KeyBinding, in context: EditContext) -> Bool {
180 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context)
181 }
182
183 private func commandApplies(_ id: String, in context: EditContext) -> Bool {
184 if !isOrg, id.hasPrefix("org.") { return false }
185 return switch EditorCommands.command(id)?.action {
186 case .text(let command): command.applies(in: context)
187 default: true
188 }
189 }
190
191 func apply(_ actions: [Vim.Action]) {
192 for action in actions {
193 switch action {
194 case .edit(let edits, let caret):
195 visualCaret = nil
196 guard textView.isEditable else {
197 onMessage?("This file is read-only.")
198 continue
199 }
200 applyEdits(edits)
201 setCaret(caret)
202 textView.scrollRangeToVisible(NSRange(location: min(caret, document.text.utf16.count), length: 0))
203 case .move(let caret):
204 setCaret(caret)
205 visualCaret = caret
206 textView.scrollRangeToVisible(NSRange(location: caret, length: 0))
207 case .select(let range):
208 textView.setSelectedRange(NSRange(range))
209 case .command(let id):
210 run(id)
211 case .visualLines(let count):
212 for _ in 0..<abs(count) {
213 if count > 0 { textView.moveDown(nil) } else { textView.moveUp(nil) }
214 }
215 clampCaret()
216 case .undo(let count):
217 for _ in 0..<count { textView.undoManager?.undo() }
218 clampCaret()
219 case .redo(let count):
220 for _ in 0..<count { textView.undoManager?.redo() }
221 clampCaret()
222 case .setClipboard(let text):
223 NSPasteboard.general.clearContents()
224 NSPasteboard.general.setString(text, forType: .string)
225 case .prompt(let kind):
226 prompt(kind)
227 case .ex(let command):
228 ex(command)
229 case .fill(let range, let keep):
230 visualCaret = nil
231 textView.setSelectedRange(NSRange(location: textView.selectedRange().location, length: 0))
232 _ = perform(FillRegion(range: range, keep: keep))
233 clampCaret()
234 case .message(let text):
235 onMessage?(text)
236 case .setMark(let name, let offset):
237 if name.isUppercase { Self.fileMarks[name] = (WeakEditor(editor: self), offset) } else { vimMarks[name] = offset }
238 case .jumped(let from):
239 // A new jump drops the ones C-o went back past.
240 jumpList = Array(jumpList.prefix(jumpIndex))
241 if jumpList.last != from { jumpList.append(from) }
242 if jumpList.count > 100 { jumpList.removeFirst(jumpList.count - 100) }
243 jumpIndex = jumpList.count
244 case .jumpBack(let count):
245 // Leaving the newest place keeps it, so C-i comes back to it.
246 if jumpIndex == jumpList.count {
247 jumpList.append(textView.selectedRange().location)
248 jumpIndex = jumpList.count - 1
249 }
250 guard jumpIndex - count >= 0 else { continue }
251 jumpIndex -= count
252 reveal(jumpList[jumpIndex])
253 case .jumpForward(let count):
254 guard jumpIndex + count < jumpList.count else { continue }
255 jumpIndex += count
256 reveal(jumpList[jumpIndex])
257 case .recordMacro(let name):
258 if let name {
259 recordingRegister = name
260 recordedKeys = []
261 onMessage?("Defining keyboard macro…")
262 } else if let register = recordingRegister {
263 // Without the `q` that ended it.
264 Self.macros[register] = Array(recordedKeys.dropLast())
265 recordingRegister = nil
266 onMessage?("Keyboard macro defined")
267 }
268 case .playMacro(let name, let count):
269 guard let keys = Self.macros[name], !keys.isEmpty else {
270 onMessage?("No macro in register \(name)")
271 continue
272 }
273 guard replayingMacro < 20 else { continue }
274 replayingMacro += 1
275 defer { replayingMacro -= 1 }
276 for _ in 0..<count {
277 for key in keys where !handleModal(key) {
278 // Printable keys in insert state type, as the text system would.
279 insertTyped(key)
280 }
281 }
282 case .selectBlock(let ranges):
283 textView.setSelectedRanges(ranges.map { NSValue(range: NSRange($0)) }, affinity: .downstream, stillSelecting: false)
284 }
285 }
286 }
287
288 private func clampCaret() {
289 guard vim.mode == .normal else { return }
290 let ns = document.text as NSString
291 let caret = min(textView.selectedRange().location, ns.length)
292 let line = ns.lineRange(for: NSRange(location: caret, length: 0))
293 var end = 0
294 ns.getLineStart(nil, end: nil, contentsEnd: &end, for: line)
295 if caret >= end, end > line.location { setCaret(end - 1) }
296 }
297
298 /// `/`, `?` and `:` read their argument in the echo area.
299 private func prompt(_ kind: String) {
300 onPrompt?(Prompt(key: kind, message: kind)) { [weak self] answer in
301 guard let self, let answer, !answer.isEmpty else { return }
302 let caret = self.textView.selectedRange().location
303 let context = Vim.Context(text: self.document.text, caret: caret)
304 if kind == ":" {
305 self.ex(answer)
306 } else {
307 self.apply(self.vim.search(answer, forward: kind == "/", context))
308 }
309 self.updateCursor()
310 }
311 }
312
313 /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s`, `:%s`, `:e FILE`,
314 /// `:bn`, `:bp`, `:bd`, `:b` and `:ls`.
315 func ex(_ command: String) {
316 let trimmed = command.trimmingCharacters(in: .whitespaces)
317 if let m = trimmed.firstMatch(of: #/^e(?:dit)?!?\s+(.+)$/#) {
318 onOpenFile?(String(m.1).trimmingCharacters(in: .whitespaces))
319 return
320 }
321 switch trimmed {
322 case "w", "write": onAppCommand?("app.save")
323 case "bn", "bnext": onAppCommand?("app.buffer.next")
324 case "bp", "bprevious", "bN", "bNext": onAppCommand?("app.buffer.previous")
325 case "bd", "bdelete", "bd!", "bw", "bwipeout": onAppCommand?("app.buffer.kill")
326 case "b", "buffer", "ls", "buffers", "files": onAppCommand?("app.buffer.switch")
327 case "e", "edit", "e!", "edit!": onAppCommand?("app.revert")
328 case "q", "quit", "q!": textView.window?.performClose(nil)
329 case "wq", "x", "wq!":
330 onAppCommand?("app.save")
331 textView.window?.performClose(nil)
332 case "noh", "nohlsearch": break
333 default:
334 if let line = Int(trimmed) {
335 let ns = document.text as NSString
336 var offset = 0
337 for _ in 1..<max(1, line) {
338 let range = ns.lineRange(for: NSRange(location: offset, length: 0))
339 guard NSMaxRange(range) < ns.length else { break }
340 offset = NSMaxRange(range)
341 }
342 setCaret(offset)
343 textView.scrollRangeToVisible(NSRange(location: offset, length: 0))
344 return
345 }
346 if let m = trimmed.firstMatch(of: /^(%?)s\/((?:[^\/\\]|\\.)*)\/((?:[^\/\\]|\\.)*)\/?([gi]*)$/) {
347 substitute(whole: !m.1.isEmpty, pattern: String(m.2), replacement: String(m.3), flags: String(m.4))
348 return
349 }
350 onMessage?("Not an editor command: \(trimmed)")
351 }
352 }
353
354 private func substitute(whole: Bool, pattern: String, replacement: String, flags: String) {
355 let options: NSRegularExpression.Options = flags.contains("i") ? [.caseInsensitive] : []
356 guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else {
357 onMessage?("Invalid pattern: \(pattern)")
358 return
359 }
360 let ns = document.text as NSString
361 let caret = textView.selectedRange().location
362 let lines = whole ? NSRange(location: 0, length: ns.length) : ns.lineRange(for: NSRange(location: caret, length: 0))
363 var edits: [TextEdit] = []
364 var location = lines.location
365 while location < NSMaxRange(lines) {
366 let line = ns.lineRange(for: NSRange(location: location, length: 0))
367 let matches = regex.matches(in: ns as String, range: line)
368 for m in flags.contains("g") ? matches : Array(matches.prefix(1)) {
369 let template = regex.replacementString(for: m, in: ns as String, offset: 0, template: replacement.replacingOccurrences(of: "\\\\([0-9])", with: "\\$$1", options: .regularExpression))
370 edits.append(TextEdit(range: m.range.location..<NSMaxRange(m.range), replacement: template))
371 }
372 location = NSMaxRange(line)
373 }
374 guard !edits.isEmpty else {
375 onMessage?("Pattern not found: \(pattern)")
376 return
377 }
378 applyEdits(edits)
379 onMessage?("\(edits.count) substitution\(edits.count == 1 ? "" : "s")")
380 }
381
382 /// A block cursor in normal and visual states, a bar in insert state.
383 func updateCursor() {
384 let block = modal && vim.mode != .insert
385 (textView as? OrgTextView)?.blockCursor = block
386 dispatcher.mode = modal ? vim.mode.keymapMode : nil
387 let state = modal ? vim.mode.rawValue : nil
388 if state != reportedMode {
389 reportedMode = state
390 onModeChange?(state)
391 }
392 }
393
394 /// A mouse click leaves visual state, as in evil.
395 func modalMouseDown() {
396 guard modal else { return }
397 vim.reset()
398 visualCaret = nil
399 updateCursor()
400 }
401}
402#endif