krz/orgstar

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

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