Sources/OrgEditorAppKit/Modal.swift
242 lines · 10464 bytes
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 let state = modal ? vim.mode.rawValue : nil
228 if state != reportedMode {
229 reportedMode = state
230 onModeChange?(state)
231 }
232 }
233
234 /// A mouse click leaves visual state, as in evil.
235 func modalMouseDown() {
236 guard modal else { return }
237 vim.reset()
238 visualCaret = nil
239 updateCursor()
240 }
241}
242#endif