krz/orgstar

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

Sources/OrgEditorAppKit/Modal.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgEditorAppKit/Modal.swift history · blame · raw

408 lines · 19540 bytes

  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