krz/orgstar

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

Sources/OrgEditorAppKit/Modal.swift

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

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