#if os(macOS) import AppKit import OrgCore /// Modal editing: keys go to the keymap for the current evil state first (leader keys, /// evil-org's org keys), then to `Vim`. extension OrgEditor { func handleModal(_ chord: KeyChord) -> Bool { // ⌘ shortcuts stay with the menus. if chord.modifiers.contains(.command) { return false } dispatcher.mode = vim.mode.keymapMode if !vim.pending.isEmpty, vim.mode != .insert { return feedVim([chord]) } switch dispatcher.feed(chord) { case .pending(let keys): let next = keymap.continuations(of: keys, mode: dispatcher.mode).map { item in (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") } onKeysPending?(KeySequence.format(keys), next) return true case .cancelled: onKeysPending?(nil, []) return true case .complete(let keys, let candidates): if keys.count > 1 { onKeysPending?(nil, []) } let context = editContext() if let binding = candidates.first(where: { binding in (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context) }) { run(binding.command) updateCursor() return true } return feedVim(keys) case .undefined(let keys): onKeysPending?(nil, []) return feedVim(keys) case .unbound: return feedVim([chord]) } } /// Plays keys into the engine. False hands a single key in insert state to the text /// system. func feedVim(_ keys: [KeyChord]) -> Bool { for (index, key) in keys.enumerated() { if vim.mode == .insert, vim.passesThrough(key) { if keys.count == 1 { return false } insertTyped(key) continue } let leaving = key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control) if vim.mode == .insert, leaving, let snapshot = insertSnapshot { // What was typed since insert state began, for `.`. if let edit = TextEdit.between(snapshot.text, document.text).first, edit.range.count == 0 { vim.inserted(edit.replacement) } } let wasInsert = vim.mode == .insert let visual = vim.mode == .visual || vim.mode == .visualLine let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location let clipboard = NSPasteboard.general.string(forType: .string) let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage)) apply(actions) if !wasInsert, vim.mode == .insert { insertSnapshot = (document.text, textView.selectedRange().location) } else if vim.mode != .insert { insertSnapshot = nil } _ = index } updateCursor() return true } private func insertTyped(_ key: KeyChord) { let text = key.key == "SPC" ? " " : key.key == "RET" ? "\n" : key.key == "TAB" ? "\t" : key.key guard key.modifiers.isEmpty, text.count == 1 else { return } textView.insertText(text, replacementRange: textView.selectedRange()) } var halfPage: Int { guard let scroll = textView.enclosingScrollView, let font = textView.font else { return 15 } let lineHeight = max(1, font.ascender - font.descender + font.leading) return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2)) } private func editContext() -> EditContext { let selected = textView.selectedRange() return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location.. Bool { switch EditorCommands.command(id)?.action { case .text(let command): command.applies(in: context) default: true } } func apply(_ actions: [Vim.Action]) { for action in actions { switch action { case .edit(let edits, let caret): visualCaret = nil guard textView.isEditable else { onMessage?("This file is read-only.") continue } applyEdits(edits) setCaret(caret) textView.scrollRangeToVisible(NSRange(location: min(caret, document.text.utf16.count), length: 0)) case .move(let caret): setCaret(caret) visualCaret = caret textView.scrollRangeToVisible(NSRange(location: caret, length: 0)) case .select(let range): textView.setSelectedRange(NSRange(range)) case .command(let id): run(id) case .undo(let count): for _ in 0..= end, end > line.location { setCaret(end - 1) } } /// `/`, `?` and `:` read their argument in the echo area. private func prompt(_ kind: String) { onPrompt?(Prompt(key: kind, message: kind)) { [weak self] answer in guard let self, let answer, !answer.isEmpty else { return } let caret = self.textView.selectedRange().location let context = Vim.Context(text: self.document.text, caret: caret) if kind == ":" { self.ex(answer) } else { self.apply(self.vim.search(answer, forward: kind == "/", context)) } self.updateCursor() } } /// The ex commands people reach for: `:w`, `:q`, `:wq`, `:x`, `:N`, `:s` and `:%s`. func ex(_ command: String) { let trimmed = command.trimmingCharacters(in: .whitespaces) switch trimmed { case "w", "write": onAppCommand?("app.save") case "q", "quit", "q!": textView.window?.performClose(nil) case "wq", "x", "wq!": onAppCommand?("app.save") textView.window?.performClose(nil) case "noh", "nohlsearch": break default: if let line = Int(trimmed) { let ns = document.text as NSString var offset = 0 for _ in 1..