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