bench518756c54
Merge pull request #4 from krolyxon/vim
Vim keybindings in the GUI editor (default), Settings menu to switch
+3378 −44
13 files changed
- M CHANGELOG.md +21 −0
- M README.md +33 −5
- M crates/bench51-gui/src/app.rs +56 −8
- M crates/bench51-gui/src/chrome.rs +26 −12
- M crates/bench51-gui/src/editor.rs +301 −16
- M crates/bench51-gui/src/main.rs +10 −1
- M crates/bench51-gui/src/modals.rs +28 −1
- M crates/bench51-gui/src/state.rs +27 −1
- A crates/bench51-gui/src/vim/ex.rs +84 −0
- A crates/bench51-gui/src/vim/keys.rs +128 −0
- A crates/bench51-gui/src/vim/mod.rs +1818 −0
- A crates/bench51-gui/src/vim/tests.rs +358 −0
- A crates/bench51-gui/src/vim/text.rs +488 −0
| @@ -4,6 +4,27 @@ All notable changes to bench51. The format follows | |||
| 4 | 4 | [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow | |
| 5 | 5 | [Semantic Versioning](https://semver.org/). | |
| 6 | 6 | ||
| 7 | + | ## [Unreleased] | |
| 8 | + | ||
| 9 | + | ### Added | |
| 10 | + | ||
| 11 | + | - Vim keybindings in the GUI editor, on by default: Normal, Insert, Visual | |
| 12 | + | and Visual Line modes, motions with counts, operators with text objects, | |
| 13 | + | registers, undo/redo, `.` repeat, macros, search with highlighting, and | |
| 14 | + | `:w :q :wq :e :make`. A block cursor and a mode line show the state, and | |
| 15 | + | `gd` jumps to a label's definition. | |
| 16 | + | - A Settings menu with Keybindings (Vim / Standard) and Theme, and a | |
| 17 | + | `--keymap vim|standard` flag. | |
| 18 | + | ||
| 19 | + | ### Changed | |
| 20 | + | ||
| 21 | + | - Theme moved from the View menu to Settings. | |
| 22 | + | ||
| 23 | + | ### Fixed | |
| 24 | + | ||
| 25 | + | - `--theme`, `--keymap` and `--clock` no longer overwrite the saved | |
| 26 | + | settings; they only apply to that run. | |
| 27 | + | ||
| 7 | 28 | ## [0.2.0] - 2026-11-01 | |
| 8 | 29 | ||
| 9 | 30 | ### Added | |
| @@ -65,8 +65,8 @@ Linux, in a window or right in the terminal. | |||
| 65 | 65 | UART #1, Ports, Timers and Interrupts windows. Every window docks and tabs | |
| 66 | 66 | wherever you drag it; the edit and debug layouts are remembered separately, | |
| 67 | 67 | as in µVision. | |
| 68 | − | - **Editor** with syntax highlighting, undo/redo, find, and breakpoint, error | |
| 69 | − | and PC markers in the gutter. | |
| 68 | + | - **Editor** with Vim keybindings (or standard ones, in Settings), syntax | |
| 69 | + | highlighting, find, and breakpoint, error and PC markers in the gutter. | |
| 70 | 70 | - **Two-pass assembler** for the full 8051 instruction set (all 255 opcodes): | |
| 71 | 71 | - labels, expressions (`+ - * / MOD AND OR XOR NOT SHL SHR HIGH LOW`, `$`) | |
| 72 | 72 | - number formats `42`, `2Ah`, `0x2A`, `101010b`, `52o`, `'*'` | |
| @@ -132,9 +132,10 @@ bench51-gui firmware.hex # disassemble & debug a HEX file | |||
| 132 | 132 | bench51-gui --theme light | |
| 133 | 133 | ``` | |
| 134 | 134 | ||
| 135 | − | `--theme system|dark|light` overrides the theme chosen in View → Theme, and | |
| 136 | − | `--clock <MHz>` the Xtal set in Project → Options for Target (default | |
| 137 | − | 11.0592), which is used for the elapsed-time display. | |
| 135 | + | `--theme system|dark|light` and `--keymap vim|standard` override the choices | |
| 136 | + | in the Settings menu for one run. `--clock <MHz>` overrides the Xtal set in | |
| 137 | + | Project → Options for Target (default 11.0592), which is used for the | |
| 138 | + | elapsed-time display. | |
| 138 | 139 | ||
| 139 | 140 | On Linux it runs on Wayland and X11 and uses the XDG desktop portal for file | |
| 140 | 141 | dialogs. Building it from source on Debian or Ubuntu needs the packages egui | |
| @@ -164,6 +165,33 @@ change it, click a pin in Ports to hold it low from outside, and click the | |||
| 164 | 165 | bit chips in Registers, Timers and Interrupts to flip SFR bits. Files can be | |
| 165 | 166 | dropped onto the window. | |
| 166 | 167 | ||
| 168 | + | ### Editing with Vim keys | |
| 169 | + | ||
| 170 | + | The editor starts in Vim's Normal mode, with a block cursor and a mode line | |
| 171 | + | along its bottom edge: | |
| 172 | + | ||
| 173 | + | - **Modes:** `i a I A o O` to insert, `v V` for Visual and Visual Line, Esc | |
| 174 | + | to go back. | |
| 175 | + | - **Motions:** `h j k l w b e W B E 0 ^ $ gg G f t F T ; , % { } H M L`, | |
| 176 | + | Ctrl-D/U/F/B. All take counts (`5j`, `3w`). | |
| 177 | + | - **Operators:** `d c y > <` with any motion or text object (`iw aw i" a" | |
| 178 | + | i( a( ip`), doubled for whole lines (`dd`, `yy`, `>>`). Also | |
| 179 | + | `x X D C s S r J ~ p P`. | |
| 180 | + | - **Repeat and undo:** `u` undoes, Ctrl-R redoes, `.` repeats the last | |
| 181 | + | change, and `qa`…`q` / `@a` record and replay a macro. | |
| 182 | + | - **Search:** `/ ? n N * #`; `:noh` clears the highlight. | |
| 183 | + | - **Assembly helpers:** `gd` jumps to a label's definition, `gt`/`gT` | |
| 184 | + | switch files, and new lines line up with the instruction column after a | |
| 185 | + | label. | |
| 186 | + | - **Commands:** `:w`, `:q`, `:wq`, `:e file`, `:42`, and `:make` to build. | |
| 187 | + | - **Clipboard:** yanks and deletes also go to the system clipboard, and | |
| 188 | + | Ctrl+V pastes from it. | |
| 189 | + | - **Debug sessions:** the editor is read-only, but moving, searching and | |
| 190 | + | yanking still work. | |
| 191 | + | ||
| 192 | + | Settings → Keybindings → Standard switches to ordinary editing: type to | |
| 193 | + | insert, with Ctrl+Z to undo. F1 lists the keys for whichever is active. | |
| 194 | + | ||
| 167 | 195 | ## Terminal version | |
| 168 | 196 | ||
| 169 | 197 | `bench51` is the same IDE in a terminal, the way `emacs -nw` is Emacs | |
| @@ -10,9 +10,11 @@ use eframe::egui::{ | |||
| 10 | 10 | use egui_tiles::Tree; | |
| 11 | 11 | ||
| 12 | 12 | use crate::layout::{self, Pane, PaneBehavior}; | |
| 13 | − | use crate::state::{Action, BuildTab, LogKind, LogLine, Modal, Mode, Prefs, State, ThemePref}; | |
| 13 | + | use crate::state::{ | |
| 14 | + | Action, BuildTab, Keymap, LogKind, LogLine, Modal, Mode, Prefs, State, ThemePref, | |
| 15 | + | }; | |
| 14 | 16 | use crate::theme::{self, palette}; | |
| 15 | − | use crate::{chrome, modals}; | |
| 17 | + | use crate::{chrome, editor, modals}; | |
| 16 | 18 | ||
| 17 | 19 | /// One simulation chunk, as in the TUI's frame loop. | |
| 18 | 20 | const TICK: f64 = 1.0 / 30.0; | |
| @@ -43,6 +45,10 @@ pub struct GuiApp { | |||
| 43 | 45 | /// The user confirmed quitting with unsaved changes. | |
| 44 | 46 | allow_close: bool, | |
| 45 | 47 | title: String, | |
| 48 | + | /// Prefs as saved before the command line overrode them; those | |
| 49 | + | /// overrides last only for this run. | |
| 50 | + | saved_prefs: Prefs, | |
| 51 | + | cli: Prefs, | |
| 46 | 52 | } | |
| 47 | 53 | ||
| 48 | 54 | fn theme_preference(t: ThemePref) -> ThemePreference { | |
| @@ -76,6 +82,7 @@ impl GuiApp { | |||
| 76 | 82 | files: Vec<PathBuf>, | |
| 77 | 83 | clock: Option<f64>, | |
| 78 | 84 | theme: Option<ThemePref>, | |
| 85 | + | keymap: Option<Keymap>, | |
| 79 | 86 | ) -> Self { | |
| 80 | 87 | let ctx = &cc.egui_ctx; | |
| 81 | 88 | theme::install(ctx); | |
| @@ -83,16 +90,23 @@ impl GuiApp { | |||
| 83 | 90 | .storage | |
| 84 | 91 | .and_then(|s| eframe::get_value(s, "prefs")) | |
| 85 | 92 | .unwrap_or_default(); | |
| 93 | + | let saved_prefs = prefs.clone(); | |
| 86 | 94 | if let Some(c) = clock { | |
| 87 | 95 | prefs.clock_mhz = c; | |
| 88 | 96 | } | |
| 89 | 97 | if let Some(t) = theme { | |
| 90 | 98 | prefs.theme = t; | |
| 91 | 99 | } | |
| 100 | + | if let Some(k) = keymap { | |
| 101 | + | prefs.keymap = k; | |
| 102 | + | } | |
| 92 | 103 | prefs.speed = prefs.speed.min(bench51_core::session::SPEEDS.len() - 1); | |
| 93 | 104 | ctx.set_theme(theme_preference(prefs.theme)); | |
| 94 | 105 | ctx.set_zoom_factor(prefs.zoom.clamp(0.5, 3.0)); | |
| 106 | + | let cli = prefs.clone(); | |
| 95 | 107 | let mut app = Self { | |
| 108 | + | saved_prefs, | |
| 109 | + | cli, | |
| 96 | 110 | st: State::new(prefs), | |
| 97 | 111 | edit_tree: load_tree(cc, "edit_layout", layout::edit_tree), | |
| 98 | 112 | debug_tree: load_tree(cc, "debug_layout", layout::debug_tree), | |
| @@ -125,6 +139,12 @@ impl GuiApp { | |||
| 125 | 139 | let none = Modifiers::NONE; | |
| 126 | 140 | let running = self.st.running(); | |
| 127 | 141 | let active = self.st.active; | |
| 142 | + | // In Vim mode a focused editor keeps Ctrl+F (page down) for itself. | |
| 143 | + | let vim_editor = self.st.prefs.keymap == Keymap::Vim | |
| 144 | + | && self | |
| 145 | + | .st | |
| 146 | + | .doc() | |
| 147 | + | .is_some_and(|d| ctx.memory(|m| m.has_focus(editor::edit_id(d.id)))); | |
| 128 | 148 | // Most specific first: `consume_key` ignores extra Shift. | |
| 129 | 149 | let table: [(Modifiers, Key, Action); 25] = [ | |
| 130 | 150 | (cmd_shift, Key::S, Action::SaveAs), | |
| @@ -155,6 +175,9 @@ impl GuiApp { | |||
| 155 | 175 | ]; | |
| 156 | 176 | ctx.input_mut(|i| { | |
| 157 | 177 | for (mods, key, action) in table { | |
| 178 | + | if vim_editor && action == Action::Find { | |
| 179 | + | continue; | |
| 180 | + | } | |
| 158 | 181 | if i.consume_key(mods, key) { | |
| 159 | 182 | self.st.actions.push(action); | |
| 160 | 183 | } | |
| @@ -299,13 +322,16 @@ impl GuiApp { | |||
| 299 | 322 | } | |
| 300 | 323 | } | |
| 301 | 324 | ||
| 302 | − | fn save(&mut self, ask: bool) { | |
| 325 | + | /// Saves the active document: to `to`, to its own path, or wherever the | |
| 326 | + | /// user picks (always when `ask`). | |
| 327 | + | fn save(&mut self, ask: bool, to: Option<PathBuf>) { | |
| 303 | 328 | let dir = self.start_dir(); | |
| 304 | 329 | let Some(doc) = self.st.doc_mut() else { | |
| 305 | 330 | return; | |
| 306 | 331 | }; | |
| 307 | − | let path = match (&doc.path, ask) { | |
| 308 | − | (Some(p), false) => Some(p.clone()), | |
| 332 | + | let path = match (to, &doc.path, ask) { | |
| 333 | + | (Some(p), _, _) => Some(p), | |
| 334 | + | (None, Some(p), false) => Some(p.clone()), | |
| 309 | 335 | _ => { | |
| 310 | 336 | let name = doc.name(); | |
| 311 | 337 | let stem = name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s); | |
| @@ -350,6 +376,8 @@ impl GuiApp { | |||
| 350 | 376 | if self.st.active >= i && self.st.active > 0 { | |
| 351 | 377 | self.st.active -= 1; | |
| 352 | 378 | } | |
| 379 | + | self.st.vim.abandon(); | |
| 380 | + | self.st.focus_editor = true; | |
| 353 | 381 | if self.st.docs.is_empty() { | |
| 354 | 382 | self.st.mode = Mode::Edit; | |
| 355 | 383 | self.st.active = 0; | |
| @@ -368,7 +396,9 @@ impl GuiApp { | |||
| 368 | 396 | if i < self.st.docs.len() && i != self.st.active { | |
| 369 | 397 | self.pause_active(); | |
| 370 | 398 | self.st.active = i; | |
| 399 | + | self.st.vim.abandon(); | |
| 371 | 400 | } | |
| 401 | + | self.st.focus_editor = true; | |
| 372 | 402 | } | |
| 373 | 403 | ||
| 374 | 404 | // ---- Build & debug ---- | |
| @@ -461,8 +491,9 @@ impl GuiApp { | |||
| 461 | 491 | } | |
| 462 | 492 | Action::Open => self.open_dialog(), | |
| 463 | 493 | Action::OpenPath(p) => self.open_path(&p), | |
| 464 | − | Action::Save => self.save(false), | |
| 465 | − | Action::SaveAs => self.save(true), | |
| 494 | + | Action::Save => self.save(false, None), | |
| 495 | + | Action::SaveAs => self.save(true, None), | |
| 496 | + | Action::SaveTo(p) => self.save(false, Some(p)), | |
| 466 | 497 | Action::Close(i) => { | |
| 467 | 498 | if self.st.docs.get(i).is_some_and(|d| d.dirty()) { | |
| 468 | 499 | self.st.modal = Some(Modal::ConfirmClose(i)); | |
| @@ -554,6 +585,11 @@ impl GuiApp { | |||
| 554 | 585 | self.st.prefs.theme = t; | |
| 555 | 586 | ctx.set_theme(theme_preference(t)); | |
| 556 | 587 | } | |
| 588 | + | Action::SetKeymap(k) => { | |
| 589 | + | self.st.prefs.keymap = k; | |
| 590 | + | self.st.vim.abandon(); | |
| 591 | + | self.st.focus_editor = true; | |
| 592 | + | } | |
| 557 | 593 | Action::Zoom(d) => ctx.set_zoom_factor((ctx.zoom_factor() + d).clamp(0.5, 3.0)), | |
| 558 | 594 | Action::ZoomReset => ctx.set_zoom_factor(1.0), | |
| 559 | 595 | Action::Find => { | |
| @@ -643,7 +679,19 @@ impl eframe::App for GuiApp { | |||
| 643 | 679 | } | |
| 644 | 680 | ||
| 645 | 681 | fn save(&mut self, storage: &mut dyn eframe::Storage) { | |
| 646 | − | eframe::set_value(storage, "prefs", &self.st.prefs); | |
| 682 | + | // A setting still at its command-line value is saved as it was | |
| 683 | + | // before; one changed in the app since is saved as changed. | |
| 684 | + | let mut prefs = self.st.prefs.clone(); | |
| 685 | + | if prefs.theme == self.cli.theme { | |
| 686 | + | prefs.theme = self.saved_prefs.theme; | |
| 687 | + | } | |
| 688 | + | if prefs.keymap == self.cli.keymap { | |
| 689 | + | prefs.keymap = self.saved_prefs.keymap; | |
| 690 | + | } | |
| 691 | + | if prefs.clock_mhz == self.cli.clock_mhz { | |
| 692 | + | prefs.clock_mhz = self.saved_prefs.clock_mhz; | |
| 693 | + | } | |
| 694 | + | eframe::set_value(storage, "prefs", &prefs); | |
| 647 | 695 | eframe::set_value(storage, "edit_layout", &self.edit_tree); | |
| 648 | 696 | eframe::set_value(storage, "debug_layout", &self.debug_tree); | |
| 649 | 697 | } | |
| @@ -6,7 +6,7 @@ use eframe::egui::{self, Align, Button, FontId, Layout, RichText, Stroke, Ui}; | |||
| 6 | 6 | ||
| 7 | 7 | use crate::icons::Icon; | |
| 8 | 8 | use crate::layout::Pane; | |
| 9 | − | use crate::state::{Action, Modal, Mode, State, ThemePref}; | |
| 9 | + | use crate::state::{Action, Keymap, Modal, Mode, State, ThemePref}; | |
| 10 | 10 | use crate::theme::{ACCENT, LABEL, LABEL_TRACKING, palette}; | |
| 11 | 11 | use crate::widgets; | |
| 12 | 12 | ||
| @@ -99,17 +99,6 @@ pub fn menu_bar(ui: &mut Ui, st: &mut State) { | |||
| 99 | 99 | } | |
| 100 | 100 | } | |
| 101 | 101 | ui.separator(); | |
| 102 | − | ui.menu_button(title(ui, "Theme"), |ui| { | |
| 103 | − | for (t, name) in [ | |
| 104 | − | (ThemePref::System, "System"), | |
| 105 | − | (ThemePref::Dark, "Dark"), | |
| 106 | − | (ThemePref::Light, "Light"), | |
| 107 | − | ] { | |
| 108 | − | if check(ui, name, st.prefs.theme == t) { | |
| 109 | − | acts.push(Action::SetTheme(t)); | |
| 110 | − | } | |
| 111 | − | } | |
| 112 | − | }); | |
| 113 | 102 | if item(ui, "Zoom in", "Ctrl++") { | |
| 114 | 103 | acts.push(Action::Zoom(0.1)); | |
| 115 | 104 | } | |
| @@ -185,6 +174,26 @@ pub fn menu_bar(ui: &mut Ui, st: &mut State) { | |||
| 185 | 174 | } | |
| 186 | 175 | } | |
| 187 | 176 | }); | |
| 177 | + | ui.menu_button(title(ui, "Settings"), |ui| { | |
| 178 | + | ui.menu_button(title(ui, "Keybindings"), |ui| { | |
| 179 | + | for (k, name) in [(Keymap::Vim, "Vim"), (Keymap::Standard, "Standard")] { | |
| 180 | + | if check(ui, name, st.prefs.keymap == k) { | |
| 181 | + | acts.push(Action::SetKeymap(k)); | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | }); | |
| 185 | + | ui.menu_button(title(ui, "Theme"), |ui| { | |
| 186 | + | for (t, name) in [ | |
| 187 | + | (ThemePref::System, "System"), | |
| 188 | + | (ThemePref::Dark, "Dark"), | |
| 189 | + | (ThemePref::Light, "Light"), | |
| 190 | + | ] { | |
| 191 | + | if check(ui, name, st.prefs.theme == t) { | |
| 192 | + | acts.push(Action::SetTheme(t)); | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | }); | |
| 196 | + | }); | |
| 188 | 197 | ui.menu_button(title(ui, "Help"), |ui| { | |
| 189 | 198 | if item(ui, "Keyboard shortcuts", "F1") { | |
| 190 | 199 | acts.push(Action::Modal(Modal::Shortcuts)); | |
| @@ -374,6 +383,11 @@ pub fn status_bar(ui: &mut Ui, st: &State) { | |||
| 374 | 383 | "edit" | |
| 375 | 384 | }; | |
| 376 | 385 | ui.label(text(mode.into()).color(p.text_disabled)); | |
| 386 | + | let keys = match st.prefs.keymap { | |
| 387 | + | Keymap::Vim => "vim", | |
| 388 | + | Keymap::Standard => "std", | |
| 389 | + | }; | |
| 390 | + | ui.label(text(keys.into()).color(p.text_disabled)); | |
| 377 | 391 | // Running: a dot that blinks in hard 2 Hz steps. | |
| 378 | 392 | let (r, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); | |
| 379 | 393 | let on = !live || (ui.input(|i| i.time) * 4.0) as i64 % 2 == 0; | |
| @@ -4,9 +4,12 @@ | |||
| 4 | 4 | use std::collections::{HashMap, HashSet}; | |
| 5 | 5 | ||
| 6 | 6 | use bench51_core::syntax::tokens; | |
| 7 | + | use std::path::PathBuf; | |
| 8 | + | ||
| 7 | 9 | use eframe::egui::{ | |
| 8 | − | self, Align, Align2, Color32, CursorIcon, FontId, Id, Key, Layout, Modifiers, Rect, RichText, | |
| 9 | − | Sense, Shape, Stroke, TextBuffer, TextFormat, Ui, Vec2, | |
| 10 | + | self, Align, Align2, Color32, Context, CursorIcon, Event, EventFilter, FontId, Id, Key, Layout, | |
| 11 | + | Modifiers, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextBuffer, TextFormat, Ui, | |
| 12 | + | UiBuilder, Vec2, WidgetText, | |
| 10 | 13 | cache::{ComputerMut, FrameCache}, | |
| 11 | 14 | pos2, | |
| 12 | 15 | text::{CCursor, CCursorRange, LayoutJob}, | |
| @@ -14,8 +17,9 @@ use eframe::egui::{ | |||
| 14 | 17 | vec2, | |
| 15 | 18 | }; | |
| 16 | 19 | ||
| 17 | − | use crate::state::{Action, Document, Mode, State}; | |
| 20 | + | use crate::state::{Action, Document, Keymap, Mode, State}; | |
| 18 | 21 | use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette}; | |
| 22 | + | use crate::vim::{self, VimKey}; | |
| 19 | 23 | use crate::widgets::{self, PillKind}; | |
| 20 | 24 | ||
| 21 | 25 | const MARGIN: Vec2 = vec2(10.0, 8.0); | |
| @@ -29,7 +33,173 @@ pub fn show(ui: &mut Ui, st: &mut State) { | |||
| 29 | 33 | if st.find.open { | |
| 30 | 34 | find_bar(ui, st); | |
| 31 | 35 | } | |
| 32 | − | code(ui, st); | |
| 36 | + | if st.prefs.keymap == Keymap::Vim { | |
| 37 | + | let full = ui.available_rect_before_wrap(); | |
| 38 | + | let h = 26.0; | |
| 39 | + | let code_rect = Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - h)); | |
| 40 | + | let line_rect = Rect::from_min_max(pos2(full.min.x, full.max.y - h), full.max); | |
| 41 | + | ui.scope_builder(UiBuilder::new().max_rect(code_rect), |ui| code(ui, st)); | |
| 42 | + | mode_line(ui, line_rect, st); | |
| 43 | + | ui.advance_cursor_after_rect(full); | |
| 44 | + | } else { | |
| 45 | + | code(ui, st); | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Id of a document's text editor widget. | |
| 50 | + | pub fn edit_id(doc_id: u64) -> Id { | |
| 51 | + | Id::unique(("editor", doc_id)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Vim's mode, recording state, command line or message, and pending keys. | |
| 55 | + | fn mode_line(ui: &Ui, rect: Rect, st: &State) { | |
| 56 | + | let p = palette(ui.ctx()); | |
| 57 | + | let v = &st.vim; | |
| 58 | + | let painter = ui.painter(); | |
| 59 | + | painter.hline(rect.x_range(), rect.top() + 0.5, Stroke::new(1.0, p.border)); | |
| 60 | + | let label = |text: &str, color: Color32| { | |
| 61 | + | WidgetText::from(p.label(text).color(color)).into_galley( | |
| 62 | + | ui, | |
| 63 | + | Some(egui::TextWrapMode::Extend), | |
| 64 | + | f32::INFINITY, | |
| 65 | + | egui::TextStyle::Small, | |
| 66 | + | ) | |
| 67 | + | }; | |
| 68 | + | let cy = rect.center().y; | |
| 69 | + | let mut x = rect.left() + 12.0; | |
| 70 | + | let put = |g: std::sync::Arc<egui::Galley>, x: &mut f32, color: Color32| { | |
| 71 | + | let w = g.size().x; | |
| 72 | + | painter.galley(pos2(*x, cy - g.size().y / 2.0), g, color); | |
| 73 | + | *x += w; | |
| 74 | + | }; | |
| 75 | + | let color = if v.mode == vim::Mode::Normal { | |
| 76 | + | p.text_secondary | |
| 77 | + | } else { | |
| 78 | + | p.text_display | |
| 79 | + | }; | |
| 80 | + | put(label(v.mode_label(), color), &mut x, color); | |
| 81 | + | x += 18.0; | |
| 82 | + | if let Some(r) = v.recording() { | |
| 83 | + | painter.circle_filled(pos2(x + 3.5, cy), 3.5, ACCENT); | |
| 84 | + | x += 12.0; | |
| 85 | + | put(label(&format!("rec @{r}"), ACCENT), &mut x, ACCENT); | |
| 86 | + | x += 18.0; | |
| 87 | + | } | |
| 88 | + | let font = FontId::monospace(13.0); | |
| 89 | + | if let Some((prompt, input)) = &v.cmdline { | |
| 90 | + | let r = painter.text( | |
| 91 | + | pos2(x, cy), | |
| 92 | + | Align2::LEFT_CENTER, | |
| 93 | + | format!("{prompt}{input}"), | |
| 94 | + | font.clone(), | |
| 95 | + | p.text_display, | |
| 96 | + | ); | |
| 97 | + | let cw = ui.fonts_mut(|f| f.glyph_width(&font, '0')); | |
| 98 | + | painter.rect_filled( | |
| 99 | + | Rect::from_center_size(pos2(r.right() + cw / 2.0, cy), vec2(cw, r.height())), | |
| 100 | + | 0.0, | |
| 101 | + | p.text_display, | |
| 102 | + | ); | |
| 103 | + | } else if let Some((msg, error)) = &v.message { | |
| 104 | + | let color = if *error { ACCENT } else { p.text_secondary }; | |
| 105 | + | painter.text(pos2(x, cy), Align2::LEFT_CENTER, msg, font.clone(), color); | |
| 106 | + | } | |
| 107 | + | painter.text( | |
| 108 | + | pos2(rect.right() - 12.0, cy), | |
| 109 | + | Align2::RIGHT_CENTER, | |
| 110 | + | v.pending_label(), | |
| 111 | + | font, | |
| 112 | + | p.text_display, | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Where the view goes after Vim handled this frame's keys. | |
| 117 | + | enum VimScroll { | |
| 118 | + | /// Just keep the cursor visible. | |
| 119 | + | Cursor, | |
| 120 | + | Align(vim::Align), | |
| 121 | + | Lines(isize), | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Keyboard input for the focused editor (Ctrl+Tab stays with the panes). | |
| 125 | + | fn is_keyboard(e: &Event) -> bool { | |
| 126 | + | match e { | |
| 127 | + | Event::Key { key, modifiers, .. } => !(modifiers.ctrl && *key == Key::Tab), | |
| 128 | + | Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_) => true, | |
| 129 | + | _ => false, | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// A path typed in `:w` or `:e`, relative to the document's folder. | |
| 134 | + | fn resolve(doc: &Document, path: &str) -> PathBuf { | |
| 135 | + | let p = PathBuf::from(path); | |
| 136 | + | match doc.path.as_ref().and_then(|d| d.parent()) { | |
| 137 | + | Some(dir) if p.is_relative() => dir.join(p), | |
| 138 | + | _ => p, | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Takes this frame's keys away from the TextEdit, feeds them to Vim and | |
| 143 | + | /// turns its effects into app actions. | |
| 144 | + | fn drive_vim(ctx: &Context, st: &mut State, debug: bool) -> Option<VimScroll> { | |
| 145 | + | let events: Vec<Event> = ctx.input_mut(|i| { | |
| 146 | + | let (keys, rest) = std::mem::take(&mut i.events) | |
| 147 | + | .into_iter() | |
| 148 | + | .partition(is_keyboard); | |
| 149 | + | i.events = rest; | |
| 150 | + | keys | |
| 151 | + | }); | |
| 152 | + | let mut keys: Vec<VimKey> = events.iter().flat_map(vim::from_egui).collect(); | |
| 153 | + | if debug && st.vim.mode == vim::Mode::Insert { | |
| 154 | + | keys.insert(0, VimKey::Esc); | |
| 155 | + | } | |
| 156 | + | if keys.is_empty() { | |
| 157 | + | return None; | |
| 158 | + | } | |
| 159 | + | let n_docs = st.docs.len(); | |
| 160 | + | let State { | |
| 161 | + | docs, | |
| 162 | + | active, | |
| 163 | + | vim, | |
| 164 | + | actions, | |
| 165 | + | vim_view, | |
| 166 | + | .. | |
| 167 | + | } = st; | |
| 168 | + | let active = *active; | |
| 169 | + | let doc = docs.get_mut(active)?; | |
| 170 | + | let mut scroll = VimScroll::Cursor; | |
| 171 | + | for k in keys { | |
| 172 | + | let dirty = doc.dirty(); | |
| 173 | + | let mut cx = vim::Cx { | |
| 174 | + | text: &mut doc.text, | |
| 175 | + | cursor: &mut doc.cursor_char, | |
| 176 | + | history: &mut doc.history, | |
| 177 | + | readonly: debug, | |
| 178 | + | dirty, | |
| 179 | + | view: *vim_view, | |
| 180 | + | }; | |
| 181 | + | for fx in vim.key(k, &mut cx) { | |
| 182 | + | use vim::Effect; | |
| 183 | + | match fx { | |
| 184 | + | Effect::Clipboard(t) => ctx.copy_text(t), | |
| 185 | + | Effect::Save => actions.push(Action::Save), | |
| 186 | + | Effect::SaveAs(p) => actions.push(Action::SaveTo(resolve(doc, &p))), | |
| 187 | + | Effect::Close { force: true } => actions.push(Action::ForceClose(active)), | |
| 188 | + | Effect::Close { force: false } => actions.push(Action::Close(active)), | |
| 189 | + | Effect::Quit { force: true } => actions.push(Action::ForceQuit), | |
| 190 | + | Effect::Quit { force: false } => actions.push(Action::Quit), | |
| 191 | + | Effect::Edit(p) => actions.push(Action::OpenPath(resolve(doc, &p))), | |
| 192 | + | Effect::Make => actions.push(Action::Build), | |
| 193 | + | Effect::NextDoc => actions.push(Action::Activate((active + 1) % n_docs)), | |
| 194 | + | Effect::PrevDoc => actions.push(Action::Activate((active + n_docs - 1) % n_docs)), | |
| 195 | + | Effect::Scroll(a) => scroll = VimScroll::Align(a), | |
| 196 | + | Effect::ScrollLines(n) => scroll = VimScroll::Lines(n), | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | doc.cursor_char = doc.cursor_char.min(doc.text.chars().count()); | |
| 201 | + | doc.cursor = line_col(&doc.text, doc.cursor_char); | |
| 202 | + | Some(scroll) | |
| 33 | 203 | } | |
| 34 | 204 | ||
| 35 | 205 | // ------------------------------------------------------------- tabs, find | |
| @@ -169,6 +339,7 @@ fn find_bar(ui: &mut Ui, st: &mut State) { | |||
| 169 | 339 | } | |
| 170 | 340 | if close { | |
| 171 | 341 | st.find.open = false; | |
| 342 | + | st.focus_editor = true; | |
| 172 | 343 | } | |
| 173 | 344 | } | |
| 174 | 345 | ||
| @@ -373,12 +544,34 @@ struct Marks { | |||
| 373 | 544 | ||
| 374 | 545 | fn code(ui: &mut Ui, st: &mut State) { | |
| 375 | 546 | let debug = st.mode == Mode::Debug; | |
| 547 | + | let vim_on = st.prefs.keymap == Keymap::Vim; | |
| 548 | + | let ctx = ui.ctx().clone(); | |
| 549 | + | let Some(doc_id) = st.doc().map(|d| d.id) else { | |
| 550 | + | return; | |
| 551 | + | }; | |
| 552 | + | let edit_id = edit_id(doc_id); | |
| 553 | + | if std::mem::take(&mut st.focus_editor) && !st.find.open { | |
| 554 | + | ctx.memory_mut(|m| m.request_focus(edit_id)); | |
| 555 | + | } | |
| 556 | + | let focused = ctx.memory(|m| m.has_focus(edit_id)); | |
| 557 | + | let vim_scroll = if vim_on && focused { | |
| 558 | + | drive_vim(&ctx, st, debug) | |
| 559 | + | } else { | |
| 560 | + | None | |
| 561 | + | }; | |
| 376 | 562 | let query = if st.find.open { | |
| 377 | 563 | st.find.query.clone() | |
| 378 | 564 | } else { | |
| 379 | 565 | String::new() | |
| 380 | 566 | }; | |
| 381 | − | let Some(doc) = st.docs.get_mut(st.active) else { | |
| 567 | + | let State { | |
| 568 | + | docs, | |
| 569 | + | active, | |
| 570 | + | vim, | |
| 571 | + | vim_view, | |
| 572 | + | .. | |
| 573 | + | } = st; | |
| 574 | + | let Some(doc) = docs.get_mut(*active) else { | |
| 382 | 575 | return; | |
| 383 | 576 | }; | |
| 384 | 577 | let p = palette(ui.ctx()); | |
| @@ -421,13 +614,8 @@ fn code(ui: &mut Ui, st: &mut State) { | |||
| 421 | 614 | 0.0 | |
| 422 | 615 | }; | |
| 423 | 616 | let gutter_w = bp_w + num_w + addr_w + 16.0; | |
| 424 | − | let edit_id = Id::unique(("editor", doc.id)); | |
| 425 | − | let ctx = ui.ctx().clone(); | |
| 426 | 617 | ||
| 427 | − | if !debug | |
| 428 | − | && ctx.memory(|m| m.has_focus(edit_id)) | |
| 429 | − | && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) | |
| 430 | − | { | |
| 618 | + | if !vim_on && !debug && focused && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) { | |
| 431 | 619 | insert_tab(&ctx, edit_id, &mut doc.text); | |
| 432 | 620 | } | |
| 433 | 621 | ||
| @@ -459,9 +647,30 @@ fn code(ui: &mut Ui, st: &mut State) { | |||
| 459 | 647 | } | |
| 460 | 648 | let hit = doc.find_hit; | |
| 461 | 649 | ||
| 462 | − | let matches = find_all(&doc.text, &query); | |
| 650 | + | // Vim owns the cursor: show it, or the Visual selection, in the | |
| 651 | + | // TextEdit, except while the mouse is dragging out a selection. | |
| 652 | + | let block = vim_on && vim.block_cursor(); | |
| 653 | + | let dragging = ctx.dragged_id() == Some(edit_id); | |
| 654 | + | if vim_on && !dragging { | |
| 655 | + | let mut state = TextEditState::load(&ctx, edit_id).unwrap_or_default(); | |
| 656 | + | let range = match vim.selection(&doc.text, doc.cursor_char) { | |
| 657 | + | Some((a, b)) => CCursorRange::two(CCursor::new(a), CCursor::new(b)), | |
| 658 | + | None => CCursorRange::one(CCursor::new(doc.cursor_char)), | |
| 659 | + | }; | |
| 660 | + | state.cursor.set_char_range(Some(range)); | |
| 661 | + | state.store(&ctx, edit_id); | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | let matches = if !query.is_empty() { | |
| 665 | + | find_all(&doc.text, &query) | |
| 666 | + | } else if vim_on { | |
| 667 | + | vim.search_ranges(&doc.text) | |
| 668 | + | } else { | |
| 669 | + | Vec::new() | |
| 670 | + | }; | |
| 463 | 671 | let mut clicked_line = None; | |
| 464 | 672 | let mut cursor = None; | |
| 673 | + | let mut mouse = None; | |
| 465 | 674 | ||
| 466 | 675 | egui::ScrollArea::both() | |
| 467 | 676 | .id_salt(("code", doc.id)) | |
| @@ -492,7 +701,10 @@ fn code(ui: &mut Ui, st: &mut State) { | |||
| 492 | 701 | } else { | |
| 493 | 702 | &mut doc.text | |
| 494 | 703 | }; | |
| 495 | − | let out = egui::TextEdit::multiline(buf) | |
| 704 | + | if block { | |
| 705 | + | ui.visuals_mut().text_cursor.stroke = Stroke::NONE; | |
| 706 | + | } | |
| 707 | + | let mut edit = egui::TextEdit::multiline(buf) | |
| 496 | 708 | .id(edit_id) | |
| 497 | 709 | .font(font.clone()) | |
| 498 | 710 | .frame(egui::Frame::NONE) | |
| @@ -500,9 +712,30 @@ fn code(ui: &mut Ui, st: &mut State) { | |||
| 500 | 712 | .desired_width(f32::INFINITY) | |
| 501 | 713 | .desired_rows(1) | |
| 502 | 714 | .lock_focus(true) | |
| 503 | − | .layouter(&mut layouter) | |
| 504 | − | .show(ui); | |
| 715 | + | .layouter(&mut layouter); | |
| 716 | + | if vim_on { | |
| 717 | + | // Esc, Tab and the arrows belong to Vim, not to egui's | |
| 718 | + | // focus navigation. | |
| 719 | + | edit = edit.event_filter(EventFilter { | |
| 720 | + | tab: true, | |
| 721 | + | horizontal_arrows: true, | |
| 722 | + | vertical_arrows: true, | |
| 723 | + | escape: true, | |
| 724 | + | }); | |
| 725 | + | } | |
| 726 | + | let out = edit.show(ui); | |
| 505 | 727 | cursor = out.cursor_range.map(|r| r.primary.index.0); | |
| 728 | + | let resp = &out.response.response; | |
| 729 | + | if vim_on | |
| 730 | + | && (resp.clicked() | |
| 731 | + | || resp.dragged() | |
| 732 | + | || resp.drag_stopped() | |
| 733 | + | || resp.double_clicked()) | |
| 734 | + | { | |
| 735 | + | mouse = out | |
| 736 | + | .cursor_range | |
| 737 | + | .map(|r| (r.secondary.index.0, r.primary.index.0)); | |
| 738 | + | } | |
| 506 | 739 | ||
| 507 | 740 | let galley = &out.galley; | |
| 508 | 741 | let origin = out.galley_pos; | |
| @@ -609,6 +842,53 @@ fn code(ui: &mut Ui, st: &mut State) { | |||
| 609 | 842 | gutter_resp.on_hover_text(msg); | |
| 610 | 843 | } | |
| 611 | 844 | ||
| 845 | + | // Vim: block cursor, visible lines, scrolling. | |
| 846 | + | if vim_on { | |
| 847 | + | let n_chars = galley.text().chars().count(); | |
| 848 | + | let ci = doc.cursor_char.min(n_chars); | |
| 849 | + | let at = galley | |
| 850 | + | .pos_from_cursor(CCursor::new(ci)) | |
| 851 | + | .translate(origin.to_vec2()); | |
| 852 | + | let cell = Rect::from_min_size(at.min, vec2(char_w, row_h)); | |
| 853 | + | if block { | |
| 854 | + | let ch = galley.text().chars().nth(ci).filter(|c| !c.is_whitespace()); | |
| 855 | + | if focused { | |
| 856 | + | painter.rect_filled(cell, 0.0, p.text_display); | |
| 857 | + | if let Some(ch) = ch { | |
| 858 | + | painter.text(cell.min, Align2::LEFT_TOP, ch, font.clone(), p.black); | |
| 859 | + | } | |
| 860 | + | } else { | |
| 861 | + | painter.rect_stroke( | |
| 862 | + | cell, | |
| 863 | + | 0.0, | |
| 864 | + | Stroke::new(1.0, p.text_secondary), | |
| 865 | + | StrokeKind::Inside, | |
| 866 | + | ); | |
| 867 | + | } | |
| 868 | + | } | |
| 869 | + | *vim_view = vim::View { | |
| 870 | + | top: first, | |
| 871 | + | rows: (clip.height() / row_h).floor().max(1.0) as usize, | |
| 872 | + | }; | |
| 873 | + | match vim_scroll { | |
| 874 | + | Some(VimScroll::Cursor) => { | |
| 875 | + | ui.scroll_to_rect(cell.expand2(vec2(char_w * 4.0, row_h)), None) | |
| 876 | + | } | |
| 877 | + | Some(VimScroll::Align(a)) => ui.scroll_to_rect( | |
| 878 | + | row_rect(doc.cursor.0), | |
| 879 | + | Some(match a { | |
| 880 | + | vim::Align::Top => Align::Min, | |
| 881 | + | vim::Align::Center => Align::Center, | |
| 882 | + | vim::Align::Bottom => Align::Max, | |
| 883 | + | }), | |
| 884 | + | ), | |
| 885 | + | Some(VimScroll::Lines(n)) => { | |
| 886 | + | ui.scroll_with_delta(vec2(0.0, -(n as f32) * row_h)); | |
| 887 | + | } | |
| 888 | + | None => {} | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 612 | 892 | if let Some(line) = follow { | |
| 613 | 893 | ui.scroll_to_rect(row_rect(line), Some(Align::Center)); | |
| 614 | 894 | } | |
| @@ -621,7 +901,12 @@ fn code(ui: &mut Ui, st: &mut State) { | |||
| 621 | 901 | }); | |
| 622 | 902 | }); | |
| 623 | 903 | ||
| 624 | − | if let Some(ci) = cursor { | |
| 904 | + | if vim_on { | |
| 905 | + | if let Some((tail, head)) = mouse { | |
| 906 | + | vim.mouse(tail, head, &doc.text, &mut doc.cursor_char); | |
| 907 | + | doc.cursor = line_col(&doc.text, doc.cursor_char); | |
| 908 | + | } | |
| 909 | + | } else if let Some(ci) = cursor { | |
| 625 | 910 | doc.cursor_char = ci; | |
| 626 | 911 | doc.cursor = line_col(&doc.text, ci); | |
| 627 | 912 | } | |
| @@ -10,6 +10,7 @@ mod modals; | |||
| 10 | 10 | mod panes; | |
| 11 | 11 | mod state; | |
| 12 | 12 | mod theme; | |
| 13 | + | mod vim; | |
| 13 | 14 | mod widgets; | |
| 14 | 15 | ||
| 15 | 16 | use std::path::PathBuf; | |
| @@ -32,6 +33,10 @@ struct Args { | |||
| 32 | 33 | /// Colour theme, overriding the saved choice. | |
| 33 | 34 | #[arg(long, value_enum)] | |
| 34 | 35 | theme: Option<state::ThemePref>, | |
| 36 | + | ||
| 37 | + | /// Editor keybindings, overriding the saved choice. | |
| 38 | + | #[arg(long, value_enum)] | |
| 39 | + | keymap: Option<state::Keymap>, | |
| 35 | 40 | } | |
| 36 | 41 | ||
| 37 | 42 | fn main() -> eframe::Result { | |
| @@ -50,7 +55,11 @@ fn main() -> eframe::Result { | |||
| 50 | 55 | options, | |
| 51 | 56 | Box::new(move |cc| { | |
| 52 | 57 | Ok(Box::new(app::GuiApp::new( | |
| 53 | − | cc, args.files, args.clock, args.theme, | |
| 58 | + | cc, | |
| 59 | + | args.files, | |
| 60 | + | args.clock, | |
| 61 | + | args.theme, | |
| 62 | + | args.keymap, | |
| 54 | 63 | ))) | |
| 55 | 64 | }), | |
| 56 | 65 | ) | |
| @@ -8,6 +8,30 @@ use crate::state::{Action, Modal, State}; | |||
| 8 | 8 | use crate::theme::{BODY, HERO, palette}; | |
| 9 | 9 | use crate::widgets::{self, PillKind}; | |
| 10 | 10 | ||
| 11 | + | /// Shown first while the editor uses Vim keys. | |
| 12 | + | const VIM: (&str, &[(&str, &str)]) = ( | |
| 13 | + | "Editor (Vim)", | |
| 14 | + | &[ | |
| 15 | + | ("i a I A o O", "insert · append · open a line"), | |
| 16 | + | ("Esc", "back to Normal mode"), | |
| 17 | + | ("h j k l w b e", "move; counts work: 5j, 3w"), | |
| 18 | + | ("0 ^ $ gg G :42", "line start / end, top, bottom, line 42"), | |
| 19 | + | ("f t % { }", "find char, matching bracket, paragraph"), | |
| 20 | + | ("d c y > < + motion", "delete · change · yank · indent"), | |
| 21 | + | ("dd yy cc ciw ci\"", "lines, words, inside quotes"), | |
| 22 | + | ("x p P J ~ r", "delete char, put, join, case, replace"), | |
| 23 | + | ("u Ctrl-R .", "undo · redo · repeat"), | |
| 24 | + | ("v V", "visual · visual line"), | |
| 25 | + | ("/ ? n N * #", "search; :noh clears the highlight"), | |
| 26 | + | ("gd", "go to the label's definition"), | |
| 27 | + | ("qa … q @a", "record and play a macro"), | |
| 28 | + | (":w :q :wq :e :make", "write · close · open · build"), | |
| 29 | + | ("gt gT", "next / previous file"), | |
| 30 | + | ("Ctrl+V", "paste from the clipboard (y and d copy to it)"), | |
| 31 | + | ("Settings → Keybindings", "switch to standard keys"), | |
| 32 | + | ], | |
| 33 | + | ); | |
| 34 | + | ||
| 11 | 35 | const SHORTCUTS: &[(&str, &[(&str, &str)])] = &[ | |
| 12 | 36 | ( | |
| 13 | 37 | "File", | |
| @@ -160,7 +184,10 @@ pub fn show(ctx: &Context, st: &mut State) { | |||
| 160 | 184 | egui::ScrollArea::vertical() | |
| 161 | 185 | .max_height(420.0) | |
| 162 | 186 | .show(ui, |ui| { | |
| 163 | − | for (section, keys) in SHORTCUTS { | |
| 187 | + | let vim_on = st.prefs.keymap == crate::state::Keymap::Vim; | |
| 188 | + | let sections = | |
| 189 | + | vim_on.then_some(&VIM).into_iter().chain(SHORTCUTS.iter()); | |
| 190 | + | for (section, keys) in sections { | |
| 164 | 191 | ui.label(p.label(section).color(p.text_disabled)); | |
| 165 | 192 | ui.add_space(4.0); | |
| 166 | 193 | egui::Grid::new(section) | |
| @@ -6,6 +6,7 @@ use bench51_core::session::{MemSpace, Session}; | |||
| 6 | 6 | use serde::{Deserialize, Serialize}; | |
| 7 | 7 | ||
| 8 | 8 | use crate::layout::Pane; | |
| 9 | + | use crate::vim::{self, Vim}; | |
| 9 | 10 | ||
| 10 | 11 | /// Keil's two window layouts: building and debugging. | |
| 11 | 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| @@ -14,6 +15,13 @@ pub enum Mode { | |||
| 14 | 15 | Debug, | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 18 | + | /// How the code editor takes keys. | |
| 19 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] | |
| 20 | + | pub enum Keymap { | |
| 21 | + | Vim, | |
| 22 | + | Standard, | |
| 23 | + | } | |
| 24 | + | ||
| 17 | 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] | |
| 18 | 26 | pub enum ThemePref { | |
| 19 | 27 | System, | |
| @@ -22,10 +30,11 @@ pub enum ThemePref { | |||
| 22 | 30 | } | |
| 23 | 31 | ||
| 24 | 32 | /// Settings that survive restarts. | |
| 25 | − | #[derive(Serialize, Deserialize)] | |
| 33 | + | #[derive(Clone, Serialize, Deserialize)] | |
| 26 | 34 | #[serde(default)] | |
| 27 | 35 | pub struct Prefs { | |
| 28 | 36 | pub theme: ThemePref, | |
| 37 | + | pub keymap: Keymap, | |
| 29 | 38 | pub recent: Vec<PathBuf>, | |
| 30 | 39 | pub speed: usize, | |
| 31 | 40 | pub clock_mhz: f64, | |
| @@ -36,6 +45,7 @@ impl Default for Prefs { | |||
| 36 | 45 | fn default() -> Self { | |
| 37 | 46 | Self { | |
| 38 | 47 | theme: ThemePref::System, | |
| 48 | + | keymap: Keymap::Vim, | |
| 39 | 49 | recent: Vec::new(), | |
| 40 | 50 | speed: 5, | |
| 41 | 51 | clock_mhz: 11.0592, | |
| @@ -76,6 +86,8 @@ pub struct Document { | |||
| 76 | 86 | pub find_hit: Option<(usize, usize)>, | |
| 77 | 87 | /// Scroll the editor and disassembly to the PC on the next frame. | |
| 78 | 88 | pub follow_pc: bool, | |
| 89 | + | /// Vim undo history. | |
| 90 | + | pub history: vim::History, | |
| 79 | 91 | } | |
| 80 | 92 | ||
| 81 | 93 | impl Document { | |
| @@ -94,6 +106,7 @@ impl Document { | |||
| 94 | 106 | select: None, | |
| 95 | 107 | find_hit: None, | |
| 96 | 108 | follow_pc: false, | |
| 109 | + | history: vim::History::default(), | |
| 97 | 110 | } | |
| 98 | 111 | } | |
| 99 | 112 | ||
| @@ -154,6 +167,8 @@ pub enum Action { | |||
| 154 | 167 | OpenPath(PathBuf), | |
| 155 | 168 | Save, | |
| 156 | 169 | SaveAs, | |
| 170 | + | /// Save the active document to this path (`:w file`). | |
| 171 | + | SaveTo(PathBuf), | |
| 157 | 172 | /// Close a document, asking first if it has unsaved changes. | |
| 158 | 173 | Close(usize), | |
| 159 | 174 | /// Close without asking. | |
| @@ -182,6 +197,7 @@ pub enum Action { | |||
| 182 | 197 | ShowPane(Pane), | |
| 183 | 198 | ResetLayout, | |
| 184 | 199 | SetTheme(ThemePref), | |
| 200 | + | SetKeymap(Keymap), | |
| 185 | 201 | Zoom(f32), | |
| 186 | 202 | ZoomReset, | |
| 187 | 203 | Find, | |
| @@ -228,6 +244,11 @@ pub struct State { | |||
| 228 | 244 | pub build_tab: BuildTab, | |
| 229 | 245 | pub serial_input: String, | |
| 230 | 246 | pub find: FindBar, | |
| 247 | + | pub vim: Vim, | |
| 248 | + | /// Lines visible in the editor last frame, for Vim's `H M L` and paging. | |
| 249 | + | pub vim_view: vim::View, | |
| 250 | + | /// Give the editor keyboard focus on the next frame. | |
| 251 | + | pub focus_editor: bool, | |
| 231 | 252 | } | |
| 232 | 253 | ||
| 233 | 254 | impl State { | |
| @@ -254,6 +275,9 @@ impl State { | |||
| 254 | 275 | query: String::new(), | |
| 255 | 276 | focus: false, | |
| 256 | 277 | }, | |
| 278 | + | vim: Vim::default(), | |
| 279 | + | vim_view: vim::View::default(), | |
| 280 | + | focus_editor: false, | |
| 257 | 281 | } | |
| 258 | 282 | } | |
| 259 | 283 | ||
| @@ -272,6 +296,8 @@ impl State { | |||
| 272 | 296 | doc.session.speed = self.prefs.speed; | |
| 273 | 297 | self.docs.push(doc); | |
| 274 | 298 | self.active = self.docs.len() - 1; | |
| 299 | + | self.vim.abandon(); | |
| 300 | + | self.focus_editor = true; | |
| 275 | 301 | self.docs.last_mut().unwrap() | |
| 276 | 302 | } | |
| 277 | 303 | ||
| @@ -0,0 +1,84 @@ | |||
| 1 | + | //! `:` commands. | |
| 2 | + | ||
| 3 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 4 | + | pub enum Ex { | |
| 5 | + | Write(Option<String>), | |
| 6 | + | Quit { | |
| 7 | + | force: bool, | |
| 8 | + | }, | |
| 9 | + | WriteQuit, | |
| 10 | + | /// `:x`: write if changed, then quit. | |
| 11 | + | Exit, | |
| 12 | + | QuitAll { | |
| 13 | + | force: bool, | |
| 14 | + | }, | |
| 15 | + | Edit(String), | |
| 16 | + | /// 1-based line number. | |
| 17 | + | Line(usize), | |
| 18 | + | LastLine, | |
| 19 | + | NoHighlight, | |
| 20 | + | Make, | |
| 21 | + | Next, | |
| 22 | + | Prev, | |
| 23 | + | Unknown(String), | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | pub fn parse(input: &str) -> Ex { | |
| 27 | + | let s = input.trim(); | |
| 28 | + | if let Ok(n) = s.parse::<usize>() { | |
| 29 | + | return Ex::Line(n); | |
| 30 | + | } | |
| 31 | + | if s == "$" { | |
| 32 | + | return Ex::LastLine; | |
| 33 | + | } | |
| 34 | + | let (cmd, arg) = match s.find(char::is_whitespace) { | |
| 35 | + | Some(i) => (&s[..i], s[i..].trim()), | |
| 36 | + | None => (s, ""), | |
| 37 | + | }; | |
| 38 | + | let (cmd, bang) = match cmd.strip_suffix('!') { | |
| 39 | + | Some(c) => (c, true), | |
| 40 | + | None => (cmd, false), | |
| 41 | + | }; | |
| 42 | + | let arg = (!arg.is_empty()).then(|| arg.to_string()); | |
| 43 | + | match cmd { | |
| 44 | + | "w" | "write" => Ex::Write(arg), | |
| 45 | + | "q" | "quit" | "clo" | "close" => Ex::Quit { force: bang }, | |
| 46 | + | "wq" => Ex::WriteQuit, | |
| 47 | + | "x" | "xit" | "exi" | "exit" => Ex::Exit, | |
| 48 | + | "qa" | "qall" | "quita" | "quitall" => Ex::QuitAll { force: bang }, | |
| 49 | + | "e" | "edit" => match arg { | |
| 50 | + | Some(a) => Ex::Edit(a), | |
| 51 | + | None => Ex::Unknown(s.to_string()), | |
| 52 | + | }, | |
| 53 | + | "noh" | "nohl" | "nohlsearch" => Ex::NoHighlight, | |
| 54 | + | "mak" | "make" => Ex::Make, | |
| 55 | + | "bn" | "bnext" | "tabn" | "tabnext" => Ex::Next, | |
| 56 | + | "bp" | "bprevious" | "bN" | "bNext" | "tabp" | "tabprevious" | "tabN" => Ex::Prev, | |
| 57 | + | _ => Ex::Unknown(s.to_string()), | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | #[cfg(test)] | |
| 62 | + | mod tests { | |
| 63 | + | use super::{Ex, parse}; | |
| 64 | + | ||
| 65 | + | #[test] | |
| 66 | + | fn parses_common_commands() { | |
| 67 | + | assert_eq!(parse("w"), Ex::Write(None)); | |
| 68 | + | assert_eq!(parse("w out.asm "), Ex::Write(Some("out.asm".into()))); | |
| 69 | + | assert_eq!(parse("q!"), Ex::Quit { force: true }); | |
| 70 | + | assert_eq!(parse("wq"), Ex::WriteQuit); | |
| 71 | + | assert_eq!(parse("x"), Ex::Exit); | |
| 72 | + | assert_eq!(parse("qa!"), Ex::QuitAll { force: true }); | |
| 73 | + | assert_eq!(parse("42"), Ex::Line(42)); | |
| 74 | + | assert_eq!(parse("$"), Ex::LastLine); | |
| 75 | + | assert_eq!( | |
| 76 | + | parse("e examples/blink.asm"), | |
| 77 | + | Ex::Edit("examples/blink.asm".into()) | |
| 78 | + | ); | |
| 79 | + | assert_eq!(parse("noh"), Ex::NoHighlight); | |
| 80 | + | assert_eq!(parse("make"), Ex::Make); | |
| 81 | + | assert_eq!(parse("bn"), Ex::Next); | |
| 82 | + | assert_eq!(parse("frobnicate"), Ex::Unknown("frobnicate".into())); | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,128 @@ | |||
| 1 | + | //! Keys as the Vim engine sees them, and their conversion from egui events. | |
| 2 | + | ||
| 3 | + | use eframe::egui::{Event, Key}; | |
| 4 | + | ||
| 5 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 6 | + | pub enum VimKey { | |
| 7 | + | Char(char), | |
| 8 | + | Esc, | |
| 9 | + | Enter, | |
| 10 | + | Backspace, | |
| 11 | + | Delete, | |
| 12 | + | Tab, | |
| 13 | + | Up, | |
| 14 | + | Down, | |
| 15 | + | Left, | |
| 16 | + | Right, | |
| 17 | + | Home, | |
| 18 | + | End, | |
| 19 | + | PageUp, | |
| 20 | + | PageDown, | |
| 21 | + | /// Ctrl plus a lowercase letter. | |
| 22 | + | Ctrl(char), | |
| 23 | + | /// Ctrl+V: the system clipboard's text. | |
| 24 | + | Paste(String), | |
| 25 | + | /// Ctrl+C. | |
| 26 | + | Copy, | |
| 27 | + | /// Ctrl+X. | |
| 28 | + | Cut, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl VimKey { | |
| 32 | + | /// How the key is shown in the mode line while a command is pending. | |
| 33 | + | pub fn label(&self) -> String { | |
| 34 | + | match self { | |
| 35 | + | VimKey::Char(c) => c.to_string(), | |
| 36 | + | VimKey::Ctrl(c) => format!("^{}", c.to_ascii_uppercase()), | |
| 37 | + | VimKey::Esc => "<Esc>".into(), | |
| 38 | + | VimKey::Enter => "<CR>".into(), | |
| 39 | + | other => format!("<{other:?}>"), | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// The keys in a keyboard event; text events can carry several chars. | |
| 45 | + | pub fn from_egui(event: &Event) -> Vec<VimKey> { | |
| 46 | + | match event { | |
| 47 | + | Event::Text(t) => t.chars().map(VimKey::Char).collect(), | |
| 48 | + | Event::Paste(t) => vec![VimKey::Paste(t.clone())], | |
| 49 | + | Event::Copy => vec![VimKey::Copy], | |
| 50 | + | Event::Cut => vec![VimKey::Cut], | |
| 51 | + | Event::Key { | |
| 52 | + | key, | |
| 53 | + | pressed: true, | |
| 54 | + | modifiers, | |
| 55 | + | .. | |
| 56 | + | } => { | |
| 57 | + | if modifiers.ctrl && !modifiers.alt { | |
| 58 | + | let name = key.name(); | |
| 59 | + | if *key == Key::OpenBracket { | |
| 60 | + | return vec![VimKey::Esc]; | |
| 61 | + | } | |
| 62 | + | if name.len() == 1 && name.as_bytes()[0].is_ascii_alphabetic() { | |
| 63 | + | return vec![VimKey::Ctrl( | |
| 64 | + | name.to_ascii_lowercase().chars().next().unwrap(), | |
| 65 | + | )]; | |
| 66 | + | } | |
| 67 | + | return Vec::new(); | |
| 68 | + | } | |
| 69 | + | let k = match key { | |
| 70 | + | Key::Escape => VimKey::Esc, | |
| 71 | + | Key::Enter => VimKey::Enter, | |
| 72 | + | Key::Backspace => VimKey::Backspace, | |
| 73 | + | Key::Delete => VimKey::Delete, | |
| 74 | + | Key::Tab if !modifiers.shift => VimKey::Tab, | |
| 75 | + | Key::ArrowUp => VimKey::Up, | |
| 76 | + | Key::ArrowDown => VimKey::Down, | |
| 77 | + | Key::ArrowLeft => VimKey::Left, | |
| 78 | + | Key::ArrowRight => VimKey::Right, | |
| 79 | + | Key::Home => VimKey::Home, | |
| 80 | + | Key::End => VimKey::End, | |
| 81 | + | Key::PageUp => VimKey::PageUp, | |
| 82 | + | Key::PageDown => VimKey::PageDown, | |
| 83 | + | // Printable keys arrive as `Event::Text`. | |
| 84 | + | _ => return Vec::new(), | |
| 85 | + | }; | |
| 86 | + | vec![k] | |
| 87 | + | } | |
| 88 | + | _ => Vec::new(), | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Keys from a string, for tests and replays: `<Esc>`, `<CR>`, `<BS>`, | |
| 93 | + | /// `<C-r>` style names, everything else literally. | |
| 94 | + | #[cfg(test)] | |
| 95 | + | pub fn parse_keys(s: &str) -> Vec<VimKey> { | |
| 96 | + | let mut out = Vec::new(); | |
| 97 | + | let mut rest = s; | |
| 98 | + | while let Some(c) = rest.chars().next() { | |
| 99 | + | if c == '<' | |
| 100 | + | && let Some(end) = rest.find('>') | |
| 101 | + | { | |
| 102 | + | let name = &rest[1..end]; | |
| 103 | + | let k = match name { | |
| 104 | + | "Esc" => Some(VimKey::Esc), | |
| 105 | + | "CR" => Some(VimKey::Enter), | |
| 106 | + | "BS" => Some(VimKey::Backspace), | |
| 107 | + | "Del" => Some(VimKey::Delete), | |
| 108 | + | "Tab" => Some(VimKey::Tab), | |
| 109 | + | "Up" => Some(VimKey::Up), | |
| 110 | + | "Down" => Some(VimKey::Down), | |
| 111 | + | "Left" => Some(VimKey::Left), | |
| 112 | + | "Right" => Some(VimKey::Right), | |
| 113 | + | _ if name.starts_with("C-") && name.len() == 3 => { | |
| 114 | + | Some(VimKey::Ctrl(name.chars().nth(2).unwrap())) | |
| 115 | + | } | |
| 116 | + | _ => None, | |
| 117 | + | }; | |
| 118 | + | if let Some(k) = k { | |
| 119 | + | out.push(k); | |
| 120 | + | rest = &rest[end + 1..]; | |
| 121 | + | continue; | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | out.push(VimKey::Char(c)); | |
| 125 | + | rest = &rest[c.len_utf8()..]; | |
| 126 | + | } | |
| 127 | + | out | |
| 128 | + | } | |
| @@ -0,0 +1,1818 @@ | |||
| 1 | + | //! Vim keybindings for the code editor: modes, motions with counts, | |
| 2 | + | //! operators and text objects, registers, undo, `.` repeat, macros, search | |
| 3 | + | //! and `:` commands. The engine edits a `String` through a char-index | |
| 4 | + | //! cursor and knows nothing about egui, so it is tested on plain text. | |
| 5 | + | ||
| 6 | + | mod ex; | |
| 7 | + | mod keys; | |
| 8 | + | #[cfg(test)] | |
| 9 | + | mod tests; | |
| 10 | + | mod text; | |
| 11 | + | ||
| 12 | + | use std::collections::HashMap; | |
| 13 | + | ||
| 14 | + | pub use keys::{VimKey, from_egui}; | |
| 15 | + | use text::{Buf, Kind, auto_indent, is_blank, is_keyword}; | |
| 16 | + | ||
| 17 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | + | pub enum Mode { | |
| 19 | + | Normal, | |
| 20 | + | Insert, | |
| 21 | + | Visual, | |
| 22 | + | VisualLine, | |
| 23 | + | /// Typing a `:` command or a `/` `?` search. | |
| 24 | + | Command, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 28 | + | pub enum Align { | |
| 29 | + | Top, | |
| 30 | + | Center, | |
| 31 | + | Bottom, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// What the editor or the app has to do after a key. | |
| 35 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 36 | + | pub enum Effect { | |
| 37 | + | /// Yanked or deleted text, mirrored to the system clipboard. | |
| 38 | + | Clipboard(String), | |
| 39 | + | Save, | |
| 40 | + | SaveAs(String), | |
| 41 | + | Close { | |
| 42 | + | force: bool, | |
| 43 | + | }, | |
| 44 | + | Quit { | |
| 45 | + | force: bool, | |
| 46 | + | }, | |
| 47 | + | Edit(String), | |
| 48 | + | Make, | |
| 49 | + | NextDoc, | |
| 50 | + | PrevDoc, | |
| 51 | + | /// Scroll so the cursor line is at the top, centre or bottom. | |
| 52 | + | Scroll(Align), | |
| 53 | + | /// Scroll the view by this many lines (Ctrl-D/U/F/B). | |
| 54 | + | ScrollLines(isize), | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The lines currently visible, for `H M L` and paging. | |
| 58 | + | #[derive(Clone, Copy, Debug)] | |
| 59 | + | pub struct View { | |
| 60 | + | pub top: usize, | |
| 61 | + | pub rows: usize, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | impl Default for View { | |
| 65 | + | fn default() -> Self { | |
| 66 | + | Self { top: 0, rows: 30 } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Undo and redo snapshots of one document. | |
| 71 | + | #[derive(Default)] | |
| 72 | + | pub struct History { | |
| 73 | + | undo: Vec<(String, usize)>, | |
| 74 | + | redo: Vec<(String, usize)>, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | const HISTORY_LIMIT: usize = 1000; | |
| 78 | + | ||
| 79 | + | /// The document a key acts on. | |
| 80 | + | pub struct Cx<'a> { | |
| 81 | + | pub text: &'a mut String, | |
| 82 | + | /// Char index into `text`. | |
| 83 | + | pub cursor: &'a mut usize, | |
| 84 | + | pub history: &'a mut History, | |
| 85 | + | pub readonly: bool, | |
| 86 | + | /// Unsaved changes, for `:q`. | |
| 87 | + | pub dirty: bool, | |
| 88 | + | pub view: View, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[derive(Clone, Debug)] | |
| 92 | + | struct Reg { | |
| 93 | + | text: String, | |
| 94 | + | linewise: bool, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// The last search. It stays highlighted until `:noh`. | |
| 98 | + | #[derive(Clone, Debug)] | |
| 99 | + | pub struct Search { | |
| 100 | + | pub pattern: String, | |
| 101 | + | forward: bool, | |
| 102 | + | whole_word: bool, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 106 | + | enum Op { | |
| 107 | + | Delete, | |
| 108 | + | Change, | |
| 109 | + | Yank, | |
| 110 | + | Indent, | |
| 111 | + | Dedent, | |
| 112 | + | Case, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 116 | + | enum Motion { | |
| 117 | + | Left, | |
| 118 | + | Right, | |
| 119 | + | Up, | |
| 120 | + | Down, | |
| 121 | + | /// `+` / Enter and `-`: first non-blank of the next or previous line. | |
| 122 | + | NextLine, | |
| 123 | + | PrevLine, | |
| 124 | + | WordFwd(bool), | |
| 125 | + | WordBack(bool), | |
| 126 | + | WordEnd(bool), | |
| 127 | + | LineStart, | |
| 128 | + | FirstNonBlank, | |
| 129 | + | LineEnd, | |
| 130 | + | FileStart, | |
| 131 | + | FileEnd, | |
| 132 | + | Find(char, char), | |
| 133 | + | RepeatFind(bool), | |
| 134 | + | MatchPair, | |
| 135 | + | ParaFwd, | |
| 136 | + | ParaBack, | |
| 137 | + | ScreenTop, | |
| 138 | + | ScreenMid, | |
| 139 | + | ScreenBottom, | |
| 140 | + | HalfDown, | |
| 141 | + | HalfUp, | |
| 142 | + | PageDown, | |
| 143 | + | PageUp, | |
| 144 | + | SearchNext(bool), | |
| 145 | + | SearchWord(bool), | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 149 | + | enum Target { | |
| 150 | + | Motion(Motion), | |
| 151 | + | /// Text object: its character and whether it's an "a" object. | |
| 152 | + | Obj(char, bool), | |
| 153 | + | /// `dd`, `yy`, `cc`, `>>`: whole lines. | |
| 154 | + | Line, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 158 | + | enum Cmd { | |
| 159 | + | Move(Motion, Option<usize>), | |
| 160 | + | Op { | |
| 161 | + | op: Op, | |
| 162 | + | reg: Option<char>, | |
| 163 | + | count: Option<usize>, | |
| 164 | + | target: Target, | |
| 165 | + | }, | |
| 166 | + | VisualOp(Op, Option<char>), | |
| 167 | + | /// `viw`, `vi(`…: select a text object. | |
| 168 | + | VisualObj(char, bool), | |
| 169 | + | VisualJoin, | |
| 170 | + | VisualPaste(Option<char>), | |
| 171 | + | SwapAnchor, | |
| 172 | + | /// `d/pattern`, `c?pattern`: an operator over a search. | |
| 173 | + | OpSearch { | |
| 174 | + | op: Op, | |
| 175 | + | reg: Option<char>, | |
| 176 | + | forward: bool, | |
| 177 | + | }, | |
| 178 | + | Insert(char, usize), | |
| 179 | + | Paste { | |
| 180 | + | before: bool, | |
| 181 | + | reg: Option<char>, | |
| 182 | + | count: usize, | |
| 183 | + | }, | |
| 184 | + | Undo(usize), | |
| 185 | + | Redo(usize), | |
| 186 | + | Repeat(usize), | |
| 187 | + | Join(usize), | |
| 188 | + | ToggleCase(usize), | |
| 189 | + | Replace(char, usize), | |
| 190 | + | Visual(bool), | |
| 191 | + | Prompt(char), | |
| 192 | + | MacroRecord(char), | |
| 193 | + | MacroStop, | |
| 194 | + | MacroPlay(Option<char>, usize), | |
| 195 | + | Scroll(Align), | |
| 196 | + | GotoDef, | |
| 197 | + | NextTab, | |
| 198 | + | PrevTab, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | impl Cmd { | |
| 202 | + | /// Changes the text (so it's refused when read-only and undoable). | |
| 203 | + | fn modifies(&self) -> bool { | |
| 204 | + | match self { | |
| 205 | + | Cmd::Op { op, .. } | Cmd::VisualOp(op, _) => *op != Op::Yank, | |
| 206 | + | Cmd::VisualJoin | |
| 207 | + | | Cmd::VisualPaste(_) | |
| 208 | + | | Cmd::Insert(..) | |
| 209 | + | | Cmd::Paste { .. } | |
| 210 | + | | Cmd::Undo(_) | |
| 211 | + | | Cmd::Redo(_) | |
| 212 | + | | Cmd::Repeat(_) | |
| 213 | + | | Cmd::Join(_) | |
| 214 | + | | Cmd::ToggleCase(_) | |
| 215 | + | | Cmd::Replace(..) => true, | |
| 216 | + | _ => false, | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// Recorded for `.`. | |
| 221 | + | fn repeatable(&self) -> bool { | |
| 222 | + | self.modifies() && !matches!(self, Cmd::Undo(_) | Cmd::Redo(_) | Cmd::Repeat(_)) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | fn enters_insert(&self) -> bool { | |
| 226 | + | matches!(self, Cmd::Insert(..) | Cmd::Op { op: Op::Change, .. }) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | enum Parse { | |
| 231 | + | More, | |
| 232 | + | Bad, | |
| 233 | + | Done(Cmd), | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// A charwise range is `[start, end)`; a linewise one covers whole lines, | |
| 237 | + | /// including the last line's `\n` when there is one. | |
| 238 | + | #[derive(Clone, Copy, Debug)] | |
| 239 | + | struct Range { | |
| 240 | + | start: usize, | |
| 241 | + | end: usize, | |
| 242 | + | linewise: bool, | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | fn lines_range(buf: &Buf, l0: usize, l1: usize) -> Range { | |
| 246 | + | let start = buf.start_of_line(l0); | |
| 247 | + | let end = if l1 + 1 < buf.lines() { | |
| 248 | + | buf.start_of_line(l1 + 1) | |
| 249 | + | } else { | |
| 250 | + | buf.len() | |
| 251 | + | }; | |
| 252 | + | Range { | |
| 253 | + | start, | |
| 254 | + | end, | |
| 255 | + | linewise: true, | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | fn is_register(c: char) -> bool { | |
| 260 | + | c.is_ascii_alphabetic() || c == '"' || c == '_' | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | fn count_at(keys: &[VimKey], mut i: usize) -> (Option<usize>, usize) { | |
| 264 | + | let mut n: Option<usize> = None; | |
| 265 | + | while let Some(VimKey::Char(c)) = keys.get(i) { | |
| 266 | + | let Some(d) = c.to_digit(10) else { break }; | |
| 267 | + | if d == 0 && n.is_none() { | |
| 268 | + | break; // `0` is a motion | |
| 269 | + | } | |
| 270 | + | n = Some((n.unwrap_or(0) * 10 + d as usize).min(99_999)); | |
| 271 | + | i += 1; | |
| 272 | + | } | |
| 273 | + | (n, i) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// A motion starting at `keys[i]`, and the index after it. | |
| 277 | + | fn parse_motion(keys: &[VimKey], i: usize) -> Result<Option<(Motion, usize)>, ()> { | |
| 278 | + | use Motion::*; | |
| 279 | + | let Some(k) = keys.get(i) else { | |
| 280 | + | return Ok(None); | |
| 281 | + | }; | |
| 282 | + | let single = |m| Ok(Some((m, i + 1))); | |
| 283 | + | match k { | |
| 284 | + | VimKey::Left | VimKey::Backspace => single(Left), | |
| 285 | + | VimKey::Right => single(Right), | |
| 286 | + | VimKey::Up => single(Up), | |
| 287 | + | VimKey::Down => single(Down), | |
| 288 | + | VimKey::Enter => single(NextLine), | |
| 289 | + | VimKey::Home => single(LineStart), | |
| 290 | + | VimKey::End => single(LineEnd), | |
| 291 | + | VimKey::PageUp => single(PageUp), | |
| 292 | + | VimKey::PageDown => single(PageDown), | |
| 293 | + | VimKey::Ctrl('d') => single(HalfDown), | |
| 294 | + | VimKey::Ctrl('u') => single(HalfUp), | |
| 295 | + | VimKey::Ctrl('f') => single(PageDown), | |
| 296 | + | VimKey::Ctrl('b') => single(PageUp), | |
| 297 | + | VimKey::Ctrl('n') | VimKey::Ctrl('j') => single(Down), | |
| 298 | + | VimKey::Ctrl('p') => single(Up), | |
| 299 | + | VimKey::Char(c) => match c { | |
| 300 | + | 'h' => single(Left), | |
| 301 | + | 'l' | ' ' => single(Right), | |
| 302 | + | 'j' => single(Down), | |
| 303 | + | 'k' => single(Up), | |
| 304 | + | '+' => single(NextLine), | |
| 305 | + | '-' => single(PrevLine), | |
| 306 | + | 'w' => single(WordFwd(false)), | |
| 307 | + | 'W' => single(WordFwd(true)), | |
| 308 | + | 'b' => single(WordBack(false)), | |
| 309 | + | 'B' => single(WordBack(true)), | |
| 310 | + | 'e' => single(WordEnd(false)), | |
| 311 | + | 'E' => single(WordEnd(true)), | |
| 312 | + | '0' => single(LineStart), | |
| 313 | + | '^' => single(FirstNonBlank), | |
| 314 | + | '$' => single(LineEnd), | |
| 315 | + | 'G' => single(FileEnd), | |
| 316 | + | '%' => single(MatchPair), | |
| 317 | + | '}' => single(ParaFwd), | |
| 318 | + | '{' => single(ParaBack), | |
| 319 | + | 'H' => single(ScreenTop), | |
| 320 | + | 'M' => single(ScreenMid), | |
| 321 | + | 'L' => single(ScreenBottom), | |
| 322 | + | 'n' => single(SearchNext(false)), | |
| 323 | + | 'N' => single(SearchNext(true)), | |
| 324 | + | '*' => single(SearchWord(true)), | |
| 325 | + | '#' => single(SearchWord(false)), | |
| 326 | + | ';' => single(RepeatFind(false)), | |
| 327 | + | ',' => single(RepeatFind(true)), | |
| 328 | + | 'g' => match keys.get(i + 1) { | |
| 329 | + | None => Ok(None), | |
| 330 | + | Some(VimKey::Char('g')) => Ok(Some((FileStart, i + 2))), | |
| 331 | + | _ => Err(()), | |
| 332 | + | }, | |
| 333 | + | 'f' | 'F' | 't' | 'T' => match keys.get(i + 1) { | |
| 334 | + | None => Ok(None), | |
| 335 | + | Some(VimKey::Char(ch)) => Ok(Some((Find(*c, *ch), i + 2))), | |
| 336 | + | _ => Err(()), | |
| 337 | + | }, | |
| 338 | + | _ => Err(()), | |
| 339 | + | }, | |
| 340 | + | _ => Err(()), | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | fn parse(keys: &[VimKey], visual: bool, recording: bool) -> Parse { | |
| 345 | + | use VimKey::Char; | |
| 346 | + | let mut i = 0; | |
| 347 | + | let mut reg = None; | |
| 348 | + | if keys.first() == Some(&Char('"')) { | |
| 349 | + | match keys.get(1) { | |
| 350 | + | None => return Parse::More, | |
| 351 | + | Some(Char(c)) if is_register(*c) => { | |
| 352 | + | reg = Some(*c); | |
| 353 | + | i = 2; | |
| 354 | + | } | |
| 355 | + | _ => return Parse::Bad, | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | let (count, j) = count_at(keys, i); | |
| 359 | + | i = j; | |
| 360 | + | let Some(k) = keys.get(i) else { | |
| 361 | + | return Parse::More; | |
| 362 | + | }; | |
| 363 | + | let n = count.unwrap_or(1); | |
| 364 | + | let op = |op, target| { | |
| 365 | + | Parse::Done(Cmd::Op { | |
| 366 | + | op, | |
| 367 | + | reg, | |
| 368 | + | count, | |
| 369 | + | target, | |
| 370 | + | }) | |
| 371 | + | }; | |
| 372 | + | let next_char = |f: &dyn Fn(char) -> Cmd| match keys.get(i + 1) { | |
| 373 | + | None => Parse::More, | |
| 374 | + | Some(Char(c)) => Parse::Done(f(*c)), | |
| 375 | + | _ => Parse::Bad, | |
| 376 | + | }; | |
| 377 | + | ||
| 378 | + | if visual { | |
| 379 | + | let done = |cmd| Parse::Done(cmd); | |
| 380 | + | match k { | |
| 381 | + | Char('d' | 'x' | 'X' | 'D') | VimKey::Delete => { | |
| 382 | + | return done(Cmd::VisualOp(Op::Delete, reg)); | |
| 383 | + | } | |
| 384 | + | Char('c' | 's' | 'C' | 'S' | 'R') => return done(Cmd::VisualOp(Op::Change, reg)), | |
| 385 | + | Char('y' | 'Y') => return done(Cmd::VisualOp(Op::Yank, reg)), | |
| 386 | + | Char('>') => return done(Cmd::VisualOp(Op::Indent, reg)), | |
| 387 | + | Char('<') => return done(Cmd::VisualOp(Op::Dedent, reg)), | |
| 388 | + | Char('~' | 'u' | 'U') => return done(Cmd::VisualOp(Op::Case, reg)), | |
| 389 | + | Char('J') => return done(Cmd::VisualJoin), | |
| 390 | + | Char('p' | 'P') => return done(Cmd::VisualPaste(reg)), | |
| 391 | + | Char('o' | 'O') => return done(Cmd::SwapAnchor), | |
| 392 | + | Char('v') => return done(Cmd::Visual(false)), | |
| 393 | + | Char('V') => return done(Cmd::Visual(true)), | |
| 394 | + | Char(':') => return done(Cmd::Prompt(':')), | |
| 395 | + | Char(ia @ ('i' | 'a')) => { | |
| 396 | + | return match keys.get(i + 1) { | |
| 397 | + | None => Parse::More, | |
| 398 | + | Some(Char(obj)) => done(Cmd::VisualObj(*obj, *ia == 'a')), | |
| 399 | + | _ => Parse::Bad, | |
| 400 | + | }; | |
| 401 | + | } | |
| 402 | + | _ => {} | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | match k { | |
| 407 | + | Char(o @ ('d' | 'c' | 'y' | '>' | '<')) => { | |
| 408 | + | let o = *o; | |
| 409 | + | let kind = match o { | |
| 410 | + | 'd' => Op::Delete, | |
| 411 | + | 'c' => Op::Change, | |
| 412 | + | 'y' => Op::Yank, | |
| 413 | + | '>' => Op::Indent, | |
| 414 | + | _ => Op::Dedent, | |
| 415 | + | }; | |
| 416 | + | let (count2, j) = count_at(keys, i + 1); | |
| 417 | + | let count = match (count, count2) { | |
| 418 | + | (None, None) => None, | |
| 419 | + | (a, b) => Some(a.unwrap_or(1) * b.unwrap_or(1)), | |
| 420 | + | }; | |
| 421 | + | let op = |target| { | |
| 422 | + | Parse::Done(Cmd::Op { | |
| 423 | + | op: kind, | |
| 424 | + | reg, | |
| 425 | + | count, | |
| 426 | + | target, | |
| 427 | + | }) | |
| 428 | + | }; | |
| 429 | + | match keys.get(j) { | |
| 430 | + | None => Parse::More, | |
| 431 | + | Some(Char(c)) if *c == o => op(Target::Line), | |
| 432 | + | Some(Char(p @ ('/' | '?'))) => Parse::Done(Cmd::OpSearch { | |
| 433 | + | op: kind, | |
| 434 | + | reg, | |
| 435 | + | forward: *p == '/', | |
| 436 | + | }), | |
| 437 | + | Some(Char(ia @ ('i' | 'a'))) => match keys.get(j + 1) { | |
| 438 | + | None => Parse::More, | |
| 439 | + | Some(Char(obj)) if "wWsp\"'`()b[]{}B<>".contains(*obj) => { | |
| 440 | + | op(Target::Obj(*obj, *ia == 'a')) | |
| 441 | + | } | |
| 442 | + | _ => Parse::Bad, | |
| 443 | + | }, | |
| 444 | + | _ => match parse_motion(keys, j) { | |
| 445 | + | Ok(None) => Parse::More, | |
| 446 | + | Err(()) => Parse::Bad, | |
| 447 | + | Ok(Some((m, _))) => op(Target::Motion(m)), | |
| 448 | + | }, | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | Char('x') | VimKey::Delete => op(Op::Delete, Target::Motion(Motion::Right)), | |
| 452 | + | Char('X') => op(Op::Delete, Target::Motion(Motion::Left)), | |
| 453 | + | Char('D') => op(Op::Delete, Target::Motion(Motion::LineEnd)), | |
| 454 | + | Char('C') => op(Op::Change, Target::Motion(Motion::LineEnd)), | |
| 455 | + | Char('s') => op(Op::Change, Target::Motion(Motion::Right)), | |
| 456 | + | Char('S') => op(Op::Change, Target::Line), | |
| 457 | + | Char('Y') => op(Op::Yank, Target::Line), | |
| 458 | + | Char(c @ ('i' | 'a' | 'I' | 'A' | 'o' | 'O')) => Parse::Done(Cmd::Insert(*c, n)), | |
| 459 | + | Char(c @ ('p' | 'P')) => Parse::Done(Cmd::Paste { | |
| 460 | + | before: *c == 'P', | |
| 461 | + | reg, | |
| 462 | + | count: n, | |
| 463 | + | }), | |
| 464 | + | Char('u') => Parse::Done(Cmd::Undo(n)), | |
| 465 | + | VimKey::Ctrl('r') => Parse::Done(Cmd::Redo(n)), | |
| 466 | + | Char('.') => Parse::Done(Cmd::Repeat(n)), | |
| 467 | + | Char('J') => Parse::Done(Cmd::Join(n)), | |
| 468 | + | Char('~') => Parse::Done(Cmd::ToggleCase(n)), | |
| 469 | + | Char('r') => next_char(&|c| Cmd::Replace(c, n)), | |
| 470 | + | Char('v') => Parse::Done(Cmd::Visual(false)), | |
| 471 | + | Char('V') => Parse::Done(Cmd::Visual(true)), | |
| 472 | + | Char(c @ (':' | '/' | '?')) => Parse::Done(Cmd::Prompt(*c)), | |
| 473 | + | Char('q') if recording => Parse::Done(Cmd::MacroStop), | |
| 474 | + | Char('q') => match keys.get(i + 1) { | |
| 475 | + | None => Parse::More, | |
| 476 | + | Some(Char(c)) if c.is_ascii_lowercase() => Parse::Done(Cmd::MacroRecord(*c)), | |
| 477 | + | _ => Parse::Bad, | |
| 478 | + | }, | |
| 479 | + | Char('@') => match keys.get(i + 1) { | |
| 480 | + | None => Parse::More, | |
| 481 | + | Some(Char('@')) => Parse::Done(Cmd::MacroPlay(None, n)), | |
| 482 | + | Some(Char(c)) if c.is_ascii_lowercase() => Parse::Done(Cmd::MacroPlay(Some(*c), n)), | |
| 483 | + | _ => Parse::Bad, | |
| 484 | + | }, | |
| 485 | + | Char('z') => match keys.get(i + 1) { | |
| 486 | + | None => Parse::More, | |
| 487 | + | Some(Char('z' | '.')) => Parse::Done(Cmd::Scroll(Align::Center)), | |
| 488 | + | Some(Char('t') | VimKey::Enter) => Parse::Done(Cmd::Scroll(Align::Top)), | |
| 489 | + | Some(Char('b' | '-')) => Parse::Done(Cmd::Scroll(Align::Bottom)), | |
| 490 | + | _ => Parse::Bad, | |
| 491 | + | }, | |
| 492 | + | Char('g') => match keys.get(i + 1) { | |
| 493 | + | None => Parse::More, | |
| 494 | + | Some(Char('d')) => Parse::Done(Cmd::GotoDef), | |
| 495 | + | Some(Char('t')) => Parse::Done(Cmd::NextTab), | |
| 496 | + | Some(Char('T')) => Parse::Done(Cmd::PrevTab), | |
| 497 | + | Some(Char('g')) => Parse::Done(Cmd::Move(Motion::FileStart, count)), | |
| 498 | + | _ => Parse::Bad, | |
| 499 | + | }, | |
| 500 | + | _ => match parse_motion(keys, i) { | |
| 501 | + | Ok(None) => Parse::More, | |
| 502 | + | Err(()) => Parse::Bad, | |
| 503 | + | Ok(Some((m, _))) => Parse::Done(Cmd::Move(m, count)), | |
| 504 | + | }, | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | pub struct Vim { | |
| 509 | + | pub mode: Mode, | |
| 510 | + | pending: Vec<VimKey>, | |
| 511 | + | anchor: usize, | |
| 512 | + | /// Column `j`/`k` aim for; `usize::MAX` after `$`. | |
| 513 | + | want_col: Option<usize>, | |
| 514 | + | registers: HashMap<char, Reg>, | |
| 515 | + | last_find: Option<(char, char)>, | |
| 516 | + | pub search: Option<Search>, | |
| 517 | + | pub highlight: bool, | |
| 518 | + | /// The `:`, `/` or `?` prompt and what's been typed after it. | |
| 519 | + | pub cmdline: Option<(char, String)>, | |
| 520 | + | /// Status message, and whether it's an error. | |
| 521 | + | pub message: Option<(String, bool)>, | |
| 522 | + | /// Text and cursor before the change in progress, for undo. | |
| 523 | + | change_start: Option<(String, usize)>, | |
| 524 | + | /// Operator waiting for the `/` or `?` search that ends its range. | |
| 525 | + | search_op: Option<(Op, Option<char>)>, | |
| 526 | + | dot_rec: Option<Vec<VimKey>>, | |
| 527 | + | last_change: Vec<VimKey>, | |
| 528 | + | dot_replay: bool, | |
| 529 | + | macro_rec: Option<(char, Vec<VimKey>)>, | |
| 530 | + | macros: HashMap<char, Vec<VimKey>>, | |
| 531 | + | last_macro: Option<char>, | |
| 532 | + | macro_depth: u8, | |
| 533 | + | /// Count and command of the insert in progress (`3ix`, `2o`). | |
| 534 | + | insert_repeat: (usize, char), | |
| 535 | + | insert_keys: Vec<VimKey>, | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | impl Default for Vim { | |
| 539 | + | fn default() -> Self { | |
| 540 | + | Self { | |
| 541 | + | mode: Mode::Normal, | |
| 542 | + | pending: Vec::new(), | |
| 543 | + | anchor: 0, | |
| 544 | + | want_col: None, | |
| 545 | + | registers: HashMap::new(), | |
| 546 | + | last_find: None, | |
| 547 | + | search: None, | |
| 548 | + | highlight: false, | |
| 549 | + | cmdline: None, | |
| 550 | + | message: None, | |
| 551 | + | change_start: None, | |
| 552 | + | search_op: None, | |
| 553 | + | dot_rec: None, | |
| 554 | + | last_change: Vec::new(), | |
| 555 | + | dot_replay: false, | |
| 556 | + | macro_rec: None, | |
| 557 | + | macros: HashMap::new(), | |
| 558 | + | last_macro: None, | |
| 559 | + | macro_depth: 0, | |
| 560 | + | insert_repeat: (1, 'i'), | |
| 561 | + | insert_keys: Vec::new(), | |
| 562 | + | } | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | impl Vim { | |
| 567 | + | pub fn mode_label(&self) -> &'static str { | |
| 568 | + | match self.mode { | |
| 569 | + | Mode::Normal => "normal", | |
| 570 | + | Mode::Insert => "insert", | |
| 571 | + | Mode::Visual => "visual", | |
| 572 | + | Mode::VisualLine => "v-line", | |
| 573 | + | Mode::Command => "command", | |
| 574 | + | } | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | /// Keys of a command still being typed, e.g. `2d`. | |
| 578 | + | pub fn pending_label(&self) -> String { | |
| 579 | + | self.pending.iter().map(VimKey::label).collect() | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | pub fn recording(&self) -> Option<char> { | |
| 583 | + | self.macro_rec.as_ref().map(|r| r.0) | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | /// Normal, Visual and command-line modes show a block cursor. | |
| 587 | + | pub fn block_cursor(&self) -> bool { | |
| 588 | + | self.mode != Mode::Insert | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | /// Back to Normal mode without finishing anything, e.g. when another | |
| 592 | + | /// document becomes active. | |
| 593 | + | pub fn abandon(&mut self) { | |
| 594 | + | self.mode = Mode::Normal; | |
| 595 | + | self.pending.clear(); | |
| 596 | + | self.cmdline = None; | |
| 597 | + | self.change_start = None; | |
| 598 | + | self.search_op = None; | |
| 599 | + | self.dot_rec = None; | |
| 600 | + | self.want_col = None; | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// The mouse put the cursor at `head`, selecting from `tail` if they | |
| 604 | + | /// differ (egui's `[min, max)`; Vim's selection includes the cursor). | |
| 605 | + | pub fn mouse(&mut self, tail: usize, head: usize, text: &str, cursor: &mut usize) { | |
| 606 | + | self.pending.clear(); | |
| 607 | + | if self.mode == Mode::Command { | |
| 608 | + | self.cmdline = None; | |
| 609 | + | self.search_op = None; | |
| 610 | + | self.mode = Mode::Normal; | |
| 611 | + | } | |
| 612 | + | let buf = Buf::new(text); | |
| 613 | + | if tail != head && self.mode != Mode::Insert { | |
| 614 | + | let (a, b) = (tail.min(head), tail.max(head) - 1); | |
| 615 | + | if head > tail { | |
| 616 | + | self.anchor = a; | |
| 617 | + | *cursor = b; | |
| 618 | + | } else { | |
| 619 | + | self.anchor = b; | |
| 620 | + | *cursor = a; | |
| 621 | + | } | |
| 622 | + | if self.mode != Mode::VisualLine { | |
| 623 | + | self.mode = Mode::Visual; | |
| 624 | + | } | |
| 625 | + | } else { | |
| 626 | + | if matches!(self.mode, Mode::Visual | Mode::VisualLine) { | |
| 627 | + | self.mode = Mode::Normal; | |
| 628 | + | } | |
| 629 | + | *cursor = if self.mode == Mode::Insert { | |
| 630 | + | head.min(buf.len()) | |
| 631 | + | } else { | |
| 632 | + | buf.clamp_normal(head) | |
| 633 | + | }; | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | /// The Visual selection as a char range `[start, end)`. | |
| 638 | + | pub fn selection(&self, text: &str, cursor: usize) -> Option<(usize, usize)> { | |
| 639 | + | let (a, b) = (self.anchor.min(cursor), self.anchor.max(cursor)); | |
| 640 | + | let len = text.chars().count(); | |
| 641 | + | match self.mode { | |
| 642 | + | Mode::Visual => Some((a.min(len), (b + 1).min(len))), | |
| 643 | + | Mode::VisualLine => { | |
| 644 | + | let buf = Buf::new(text); | |
| 645 | + | Some((buf.line_start(a), buf.line_end(b))) | |
| 646 | + | } | |
| 647 | + | _ => None, | |
| 648 | + | } | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | /// Byte ranges of the highlighted search's matches in `text`. | |
| 652 | + | pub fn search_ranges(&self, text: &str) -> Vec<(usize, usize)> { | |
| 653 | + | let Some(s) = self.search.as_ref().filter(|_| self.highlight) else { | |
| 654 | + | return Vec::new(); | |
| 655 | + | }; | |
| 656 | + | let buf = Buf::new(text); | |
| 657 | + | let plen = s.pattern.chars().count(); | |
| 658 | + | if plen == 0 { | |
| 659 | + | return Vec::new(); | |
| 660 | + | } | |
| 661 | + | let bytes: Vec<usize> = text | |
| 662 | + | .char_indices() | |
| 663 | + | .map(|(b, _)| b) | |
| 664 | + | .chain([text.len()]) | |
| 665 | + | .collect(); | |
| 666 | + | let mut out = Vec::new(); | |
| 667 | + | let mut i = 0; | |
| 668 | + | while i + plen <= buf.len() && out.len() < 2000 { | |
| 669 | + | if matches_at(&buf, i, s) { | |
| 670 | + | out.push((bytes[i], bytes[i + plen])); | |
| 671 | + | i += plen; | |
| 672 | + | } else { | |
| 673 | + | i += 1; | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | out | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | /// Handles one key typed by the user. | |
| 680 | + | pub fn key(&mut self, key: VimKey, cx: &mut Cx) -> Vec<Effect> { | |
| 681 | + | let mut fx = Vec::new(); | |
| 682 | + | if self.mode != Mode::Command { | |
| 683 | + | self.message = None; | |
| 684 | + | } | |
| 685 | + | if let Some((_, keys)) = &mut self.macro_rec { | |
| 686 | + | keys.push(key.clone()); | |
| 687 | + | } | |
| 688 | + | self.handle(key, cx, &mut fx); | |
| 689 | + | fx | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | fn handle(&mut self, key: VimKey, cx: &mut Cx, fx: &mut Vec<Effect>) { | |
| 693 | + | match self.mode { | |
| 694 | + | Mode::Insert => self.insert_key(key, cx), | |
| 695 | + | Mode::Command => self.cmdline_key(key, cx, fx), | |
| 696 | + | _ => self.normal_key(key, cx, fx), | |
| 697 | + | } | |
| 698 | + | if matches!(self.mode, Mode::Normal | Mode::Visual | Mode::VisualLine) { | |
| 699 | + | let buf = Buf::new(cx.text); | |
| 700 | + | *cx.cursor = buf.clamp_normal(*cx.cursor); | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | fn error(&mut self, msg: impl Into<String>) { | |
| 705 | + | self.message = Some((msg.into(), true)); | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | // ---- Normal and Visual mode ---- | |
| 709 | + | ||
| 710 | + | fn normal_key(&mut self, key: VimKey, cx: &mut Cx, fx: &mut Vec<Effect>) { | |
| 711 | + | let visual = matches!(self.mode, Mode::Visual | Mode::VisualLine); | |
| 712 | + | match &key { | |
| 713 | + | VimKey::Esc | VimKey::Ctrl('c') => { | |
| 714 | + | self.pending.clear(); | |
| 715 | + | if visual { | |
| 716 | + | self.mode = Mode::Normal; | |
| 717 | + | } | |
| 718 | + | return; | |
| 719 | + | } | |
| 720 | + | VimKey::Copy => { | |
| 721 | + | self.pending.clear(); | |
| 722 | + | if visual { | |
| 723 | + | self.exec(Cmd::VisualOp(Op::Yank, None), cx, fx); | |
| 724 | + | } | |
| 725 | + | return; | |
| 726 | + | } | |
| 727 | + | VimKey::Cut => { | |
| 728 | + | self.pending.clear(); | |
| 729 | + | if visual { | |
| 730 | + | self.exec(Cmd::VisualOp(Op::Delete, None), cx, fx); | |
| 731 | + | } | |
| 732 | + | return; | |
| 733 | + | } | |
| 734 | + | VimKey::Paste(t) => { | |
| 735 | + | self.pending.clear(); | |
| 736 | + | let t = t.clone(); | |
| 737 | + | self.paste_text(&t, visual, cx); | |
| 738 | + | return; | |
| 739 | + | } | |
| 740 | + | _ => {} | |
| 741 | + | } | |
| 742 | + | self.pending.push(key); | |
| 743 | + | match parse(&self.pending, visual, self.macro_rec.is_some()) { | |
| 744 | + | Parse::More => {} | |
| 745 | + | Parse::Bad => self.pending.clear(), | |
| 746 | + | Parse::Done(cmd) => { | |
| 747 | + | let keys = std::mem::take(&mut self.pending); | |
| 748 | + | if !visual && !self.dot_replay && cmd.repeatable() && !cx.readonly { | |
| 749 | + | if cmd.enters_insert() { | |
| 750 | + | self.dot_rec = Some(keys); | |
| 751 | + | } else { | |
| 752 | + | self.last_change = keys; | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | self.exec(cmd, cx, fx); | |
| 756 | + | } | |
| 757 | + | } | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | fn begin_change(&mut self, cx: &Cx) { | |
| 761 | + | if self.change_start.is_none() { | |
| 762 | + | self.change_start = Some((cx.text.clone(), *cx.cursor)); | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | fn end_change(&mut self, cx: &mut Cx) { | |
| 767 | + | if let Some((text, cursor)) = self.change_start.take() | |
| 768 | + | && text != *cx.text | |
| 769 | + | { | |
| 770 | + | cx.history.undo.push((text, cursor)); | |
| 771 | + | if cx.history.undo.len() > HISTORY_LIMIT { | |
| 772 | + | cx.history.undo.remove(0); | |
| 773 | + | } | |
| 774 | + | cx.history.redo.clear(); | |
| 775 | + | } | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | fn exec(&mut self, cmd: Cmd, cx: &mut Cx, fx: &mut Vec<Effect>) { | |
| 779 | + | if cmd.modifies() && cx.readonly { | |
| 780 | + | self.error("read-only while debugging"); | |
| 781 | + | if matches!(self.mode, Mode::Visual | Mode::VisualLine) { | |
| 782 | + | self.mode = Mode::Normal; | |
| 783 | + | } | |
| 784 | + | return; | |
| 785 | + | } | |
| 786 | + | let tracked = | |
| 787 | + | cmd.modifies() && !matches!(cmd, Cmd::Undo(_) | Cmd::Redo(_) | Cmd::Repeat(_)); | |
| 788 | + | if tracked { | |
| 789 | + | self.begin_change(cx); | |
| 790 | + | } | |
| 791 | + | let mut buf = Buf::new(cx.text); | |
| 792 | + | let pos = (*cx.cursor).min(buf.len()); | |
| 793 | + | match cmd { | |
| 794 | + | Cmd::Move(m, count) => { | |
| 795 | + | if let Some((p, _)) = self.motion(m, count, &buf, pos, false, cx.view, fx) { | |
| 796 | + | *cx.cursor = p; | |
| 797 | + | } | |
| 798 | + | } | |
| 799 | + | Cmd::Op { | |
| 800 | + | op, | |
| 801 | + | reg, | |
| 802 | + | count, | |
| 803 | + | target, | |
| 804 | + | } => { | |
| 805 | + | if let Some(r) = self.op_range(op, count, target, &buf, pos, cx.view, fx) { | |
| 806 | + | self.apply(op, reg, r, &mut buf, cx, fx); | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | Cmd::VisualOp(op, reg) => { | |
| 810 | + | let r = self.visual_range(&buf, pos, op); | |
| 811 | + | self.mode = Mode::Normal; | |
| 812 | + | self.apply(op, reg, r, &mut buf, cx, fx); | |
| 813 | + | } | |
| 814 | + | Cmd::VisualJoin => { | |
| 815 | + | let (a, b) = (self.anchor.min(pos), self.anchor.max(pos)); | |
| 816 | + | let lines = buf.line_of(b) - buf.line_of(a) + 1; | |
| 817 | + | self.mode = Mode::Normal; | |
| 818 | + | *cx.cursor = a; | |
| 819 | + | self.join(lines.max(2), &mut buf, cx); | |
| 820 | + | } | |
| 821 | + | Cmd::VisualPaste(reg) => { | |
| 822 | + | if let Some(r) = self.get_reg(reg) { | |
| 823 | + | let range = self.visual_range(&buf, pos, Op::Delete); | |
| 824 | + | self.mode = Mode::Normal; | |
| 825 | + | let mut t = r.text.clone(); | |
| 826 | + | if range.linewise { | |
| 827 | + | // Whole lines in, whole lines out. | |
| 828 | + | if !t.ends_with('\n') { | |
| 829 | + | t.push('\n'); | |
| 830 | + | } | |
| 831 | + | if !buf.c[range.start..range.end].ends_with(&['\n']) { | |
| 832 | + | t.pop(); | |
| 833 | + | } | |
| 834 | + | } else if r.linewise { | |
| 835 | + | // Lines into part of a line go on lines of their own. | |
| 836 | + | t.insert(0, '\n'); | |
| 837 | + | } | |
| 838 | + | buf.remove(range.start, range.end); | |
| 839 | + | buf.insert(range.start, &t); | |
| 840 | + | *cx.cursor = range.start + usize::from(!range.linewise && r.linewise); | |
| 841 | + | *cx.text = buf.text(); | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | Cmd::VisualObj(c, around) => { | |
| 845 | + | if let Some((a, b, linewise)) = buf.object(pos, c, around) { | |
| 846 | + | self.anchor = a; | |
| 847 | + | if linewise { | |
| 848 | + | self.mode = Mode::VisualLine; | |
| 849 | + | *cx.cursor = b; | |
| 850 | + | } else { | |
| 851 | + | *cx.cursor = b.saturating_sub(1).max(a); | |
| 852 | + | } | |
| 853 | + | } | |
| 854 | + | } | |
| 855 | + | Cmd::OpSearch { op, reg, forward } => { | |
| 856 | + | if op != Op::Yank && cx.readonly { | |
| 857 | + | self.error("read-only while debugging"); | |
| 858 | + | } else { | |
| 859 | + | self.search_op = Some((op, reg)); | |
| 860 | + | self.mode = Mode::Command; | |
| 861 | + | self.cmdline = Some((if forward { '/' } else { '?' }, String::new())); | |
| 862 | + | } | |
| 863 | + | } | |
| 864 | + | Cmd::SwapAnchor => { | |
| 865 | + | std::mem::swap(&mut self.anchor, cx.cursor); | |
| 866 | + | } | |
| 867 | + | Cmd::Insert(kind, count) => self.start_insert(kind, count, &mut buf, pos, cx), | |
| 868 | + | Cmd::Paste { before, reg, count } => { | |
| 869 | + | if let Some(r) = self.get_reg(reg) { | |
| 870 | + | self.put(&r, before, count, &mut buf, pos, cx); | |
| 871 | + | } | |
| 872 | + | } | |
| 873 | + | Cmd::Undo(n) => self.undo(n, cx, true), | |
| 874 | + | Cmd::Redo(n) => self.undo(n, cx, false), | |
| 875 | + | Cmd::Repeat(n) => { | |
| 876 | + | let keys = self.last_change.clone(); | |
| 877 | + | if !keys.is_empty() { | |
| 878 | + | self.dot_replay = true; | |
| 879 | + | for _ in 0..n { | |
| 880 | + | for k in &keys { | |
| 881 | + | self.handle(k.clone(), cx, fx); | |
| 882 | + | } | |
| 883 | + | } | |
| 884 | + | self.dot_replay = false; | |
| 885 | + | } | |
| 886 | + | } | |
| 887 | + | Cmd::Join(n) => self.join(n.max(2), &mut buf, cx), | |
| 888 | + | Cmd::ToggleCase(n) => { | |
| 889 | + | let e = buf.line_end(pos); | |
| 890 | + | let end = (pos + n).min(e); | |
| 891 | + | for i in pos..end { | |
| 892 | + | buf.c[i] = toggle_case(buf.c[i]); | |
| 893 | + | } | |
| 894 | + | *cx.text = buf.text(); | |
| 895 | + | *cx.cursor = end; | |
| 896 | + | } | |
| 897 | + | Cmd::Replace(c, n) => { | |
| 898 | + | let e = buf.line_end(pos); | |
| 899 | + | if pos + n <= e { | |
| 900 | + | for i in pos..pos + n { | |
| 901 | + | buf.c[i] = c; | |
| 902 | + | } | |
| 903 | + | *cx.text = buf.text(); | |
| 904 | + | *cx.cursor = pos + n - 1; | |
| 905 | + | } | |
| 906 | + | } | |
| 907 | + | Cmd::Visual(line) => { | |
| 908 | + | let want = if line { Mode::VisualLine } else { Mode::Visual }; | |
| 909 | + | self.mode = match self.mode { | |
| 910 | + | m if m == want => Mode::Normal, | |
| 911 | + | Mode::Normal => { | |
| 912 | + | self.anchor = pos; | |
| 913 | + | want | |
| 914 | + | } | |
| 915 | + | _ => want, | |
| 916 | + | }; | |
| 917 | + | } | |
| 918 | + | Cmd::Prompt(p) => { | |
| 919 | + | self.mode = Mode::Command; | |
| 920 | + | self.cmdline = Some((p, String::new())); | |
| 921 | + | } | |
| 922 | + | Cmd::MacroRecord(c) => self.macro_rec = Some((c, Vec::new())), | |
| 923 | + | Cmd::MacroStop => { | |
| 924 | + | if let Some((c, mut keys)) = self.macro_rec.take() { | |
| 925 | + | keys.pop(); // the `q` that stopped it | |
| 926 | + | self.macros.insert(c, keys); | |
| 927 | + | } | |
| 928 | + | } | |
| 929 | + | Cmd::MacroPlay(reg, n) => { | |
| 930 | + | let Some(r) = reg.or(self.last_macro) else { | |
| 931 | + | return; | |
| 932 | + | }; | |
| 933 | + | self.last_macro = Some(r); | |
| 934 | + | let Some(keys) = self.macros.get(&r).cloned() else { | |
| 935 | + | self.error(format!("register {r} is empty")); | |
| 936 | + | return; | |
| 937 | + | }; | |
| 938 | + | if self.macro_depth >= 8 { | |
| 939 | + | return; | |
| 940 | + | } | |
| 941 | + | self.macro_depth += 1; | |
| 942 | + | for _ in 0..n { | |
| 943 | + | for k in &keys { | |
| 944 | + | self.handle(k.clone(), cx, fx); | |
| 945 | + | } | |
| 946 | + | } | |
| 947 | + | self.macro_depth -= 1; | |
| 948 | + | } | |
| 949 | + | Cmd::Scroll(a) => fx.push(Effect::Scroll(a)), | |
| 950 | + | Cmd::GotoDef => match self.find_definition(&buf, pos) { | |
| 951 | + | Some(p) => *cx.cursor = p, | |
| 952 | + | None => self.error("definition not found"), | |
| 953 | + | }, | |
| 954 | + | Cmd::NextTab => fx.push(Effect::NextDoc), | |
| 955 | + | Cmd::PrevTab => fx.push(Effect::PrevDoc), | |
| 956 | + | } | |
| 957 | + | if tracked && self.mode != Mode::Insert { | |
| 958 | + | self.end_change(cx); | |
| 959 | + | } | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | // ---- Motions ---- | |
| 963 | + | ||
| 964 | + | #[allow(clippy::too_many_arguments)] | |
| 965 | + | fn motion( | |
| 966 | + | &mut self, | |
| 967 | + | m: Motion, | |
| 968 | + | count: Option<usize>, | |
| 969 | + | buf: &Buf, | |
| 970 | + | pos: usize, | |
| 971 | + | for_op: bool, | |
| 972 | + | view: View, | |
| 973 | + | fx: &mut Vec<Effect>, | |
| 974 | + | ) -> Option<(usize, Kind)> { | |
| 975 | + | use Kind::*; | |
| 976 | + | use Motion::*; | |
| 977 | + | let n = count.unwrap_or(1).max(1); | |
| 978 | + | let line = buf.line_of(pos); | |
| 979 | + | let last = buf.lines() - 1; | |
| 980 | + | let fnb = |l: usize| buf.first_non_blank(buf.start_of_line(l.min(last))); | |
| 981 | + | let r = match m { | |
| 982 | + | Left => (pos.saturating_sub(n).max(buf.line_start(pos)), Exclusive), | |
| 983 | + | Right => { | |
| 984 | + | let s = buf.line_start(pos); | |
| 985 | + | let e = buf.line_end(pos); | |
| 986 | + | let max = if for_op { | |
| 987 | + | e | |
| 988 | + | } else { | |
| 989 | + | e.saturating_sub(1).max(s) | |
| 990 | + | }; | |
| 991 | + | ((pos + n).min(max), Exclusive) | |
| 992 | + | } | |
| 993 | + | Up | Down => { | |
| 994 | + | if (m == Up && line == 0) || (m == Down && line == last) { | |
| 995 | + | return None; | |
| 996 | + | } | |
| 997 | + | let tl = if m == Up { | |
| 998 | + | line.saturating_sub(n) | |
| 999 | + | } else { | |
| 1000 | + | (line + n).min(last) | |
| 1001 | + | }; | |
| 1002 | + | let col = self.want_col.unwrap_or(buf.col(pos)); | |
| 1003 | + | self.want_col = Some(col); | |
| 1004 | + | let target = if col == usize::MAX { | |
| 1005 | + | buf.clamp_normal(buf.line_end(buf.start_of_line(tl))) | |
| 1006 | + | } else { | |
| 1007 | + | buf.at_col(tl, col) | |
| 1008 | + | }; | |
| 1009 | + | return Some((target, Linewise)); | |
| 1010 | + | } | |
| 1011 | + | NextLine => { | |
| 1012 | + | if line == last { | |
| 1013 | + | return None; | |
| 1014 | + | } | |
| 1015 | + | (fnb(line + n), Linewise) | |
| 1016 | + | } | |
| 1017 | + | PrevLine => { | |
| 1018 | + | if line == 0 { | |
| 1019 | + | return None; | |
| 1020 | + | } | |
| 1021 | + | (fnb(line.saturating_sub(n)), Linewise) | |
| 1022 | + | } | |
| 1023 | + | WordFwd(big) => ((0..n).fold(pos, |p, _| buf.word_fwd(p, big)), Exclusive), | |
| 1024 | + | WordBack(big) => ((0..n).fold(pos, |p, _| buf.word_back(p, big)), Exclusive), | |
| 1025 | + | WordEnd(big) => ((0..n).fold(pos, |p, _| buf.word_end(p, big)), Inclusive), | |
| 1026 | + | LineStart => (buf.line_start(pos), Exclusive), | |
| 1027 | + | FirstNonBlank => (buf.first_non_blank(pos), Exclusive), | |
| 1028 | + | LineEnd => { | |
| 1029 | + | let s = buf.start_of_line((line + n - 1).min(last)); | |
| 1030 | + | let e = buf.line_end(s); | |
| 1031 | + | self.want_col = Some(usize::MAX); | |
| 1032 | + | if e == s { | |
| 1033 | + | return Some((s, Exclusive)); | |
| 1034 | + | } | |
| 1035 | + | return Some((e - 1, Inclusive)); | |
| 1036 | + | } | |
| 1037 | + | FileStart => (fnb(count.map_or(0, |c| c - 1)), Linewise), | |
| 1038 | + | FileEnd => (fnb(count.map_or(last, |c| c - 1)), Linewise), | |
| 1039 | + | Find(kind, ch) => { | |
| 1040 | + | self.last_find = Some((kind, ch)); | |
| 1041 | + | (buf.find_char(pos, kind, ch, n, false)?, find_kind(kind)) | |
| 1042 | + | } | |
| 1043 | + | RepeatFind(rev) => { | |
| 1044 | + | let (kind, ch) = self.last_find?; | |
| 1045 | + | let kind = if rev { | |
| 1046 | + | match kind { | |
| 1047 | + | 'f' => 'F', | |
| 1048 | + | 'F' => 'f', | |
| 1049 | + | 't' => 'T', | |
| 1050 | + | _ => 't', | |
| 1051 | + | } | |
| 1052 | + | } else { | |
| 1053 | + | kind | |
| 1054 | + | }; | |
| 1055 | + | (buf.find_char(pos, kind, ch, n, true)?, find_kind(kind)) | |
| 1056 | + | } | |
| 1057 | + | MatchPair => (buf.match_pair(pos)?, Inclusive), | |
| 1058 | + | ParaFwd => ((0..n).fold(pos, |p, _| buf.paragraph(p, true)), Exclusive), | |
| 1059 | + | ParaBack => ((0..n).fold(pos, |p, _| buf.paragraph(p, false)), Exclusive), | |
| 1060 | + | ScreenTop | ScreenMid | ScreenBottom => { | |
| 1061 | + | let top = view.top.min(last); | |
| 1062 | + | let bottom = (view.top + view.rows.max(1) - 1).min(last); | |
| 1063 | + | let l = match m { | |
| 1064 | + | ScreenTop => (top + n - 1).min(bottom), | |
| 1065 | + | ScreenMid => (top + bottom) / 2, | |
| 1066 | + | _ => bottom.saturating_sub(n - 1).max(top), | |
| 1067 | + | }; | |
| 1068 | + | (fnb(l), Linewise) | |
| 1069 | + | } | |
| 1070 | + | HalfDown | HalfUp | PageDown | PageUp => { | |
| 1071 | + | let step = n * match m { | |
| 1072 | + | HalfDown | HalfUp => (view.rows / 2).max(1), | |
| 1073 | + | _ => view.rows.saturating_sub(2).max(1), | |
| 1074 | + | }; | |
| 1075 | + | let down = matches!(m, HalfDown | PageDown); | |
| 1076 | + | let tl = if down { | |
| 1077 | + | (line + step).min(last) | |
| 1078 | + | } else { | |
| 1079 | + | line.saturating_sub(step) | |
| 1080 | + | }; | |
| 1081 | + | if !for_op { | |
| 1082 | + | let delta = step as isize; | |
| 1083 | + | fx.push(Effect::ScrollLines(if down { delta } else { -delta })); | |
| 1084 | + | } | |
| 1085 | + | (fnb(tl), Linewise) | |
| 1086 | + | } | |
| 1087 | + | SearchNext(rev) => { | |
| 1088 | + | let s = self.search.clone()?; | |
| 1089 | + | self.highlight = true; | |
| 1090 | + | let fwd = s.forward != rev; | |
| 1091 | + | let mut p = pos; | |
| 1092 | + | for _ in 0..n { | |
| 1093 | + | p = self.find_next(buf, p, fwd, &s)?; | |
| 1094 | + | } | |
| 1095 | + | (p, Exclusive) | |
| 1096 | + | } | |
| 1097 | + | SearchWord(fwd) => { | |
| 1098 | + | let word = word_at(buf, pos)?; | |
| 1099 | + | let s = Search { | |
| 1100 | + | pattern: word, | |
| 1101 | + | forward: fwd, | |
| 1102 | + | whole_word: true, | |
| 1103 | + | }; | |
| 1104 | + | self.search = Some(s.clone()); | |
| 1105 | + | self.highlight = true; | |
| 1106 | + | (self.find_next(buf, pos, fwd, &s)?, Exclusive) | |
| 1107 | + | } | |
| 1108 | + | }; | |
| 1109 | + | if !for_op || !matches!(m, Up | Down) { | |
| 1110 | + | self.want_col = None; | |
| 1111 | + | } | |
| 1112 | + | Some(r) | |
| 1113 | + | } | |
| 1114 | + | ||
| 1115 | + | fn find_next(&mut self, buf: &Buf, from: usize, fwd: bool, s: &Search) -> Option<usize> { | |
| 1116 | + | let n = buf.len(); | |
| 1117 | + | if n == 0 { | |
| 1118 | + | return None; | |
| 1119 | + | } | |
| 1120 | + | let order: Box<dyn Iterator<Item = usize>> = if fwd { | |
| 1121 | + | Box::new((from + 1..n).chain(0..=from.min(n - 1))) | |
| 1122 | + | } else { | |
| 1123 | + | Box::new((0..from).rev().chain((from..n).rev())) | |
| 1124 | + | }; | |
| 1125 | + | for i in order { | |
| 1126 | + | if matches_at(buf, i, s) { | |
| 1127 | + | if (fwd && i <= from) || (!fwd && i >= from) { | |
| 1128 | + | self.message = Some(( | |
| 1129 | + | if fwd { | |
| 1130 | + | "search hit BOTTOM, continuing at TOP" | |
| 1131 | + | } else { | |
| 1132 | + | "search hit TOP, continuing at BOTTOM" | |
| 1133 | + | } | |
| 1134 | + | .into(), | |
| 1135 | + | false, | |
| 1136 | + | )); | |
| 1137 | + | } | |
| 1138 | + | return Some(i); | |
| 1139 | + | } | |
| 1140 | + | } | |
| 1141 | + | self.error(format!("E486: Pattern not found: {}", s.pattern)); | |
| 1142 | + | None | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | fn find_definition(&self, buf: &Buf, pos: usize) -> Option<usize> { | |
| 1146 | + | let word = word_at(buf, pos)?.to_ascii_uppercase(); | |
| 1147 | + | (0..buf.lines()).find_map(|l| { | |
| 1148 | + | let s = buf.start_of_line(l); | |
| 1149 | + | let line = buf.line_text(s); | |
| 1150 | + | let t = line.trim_start(); | |
| 1151 | + | let rest = t.get(word.len()..)?; | |
| 1152 | + | if !t[..word.len()].eq_ignore_ascii_case(&word) { | |
| 1153 | + | return None; | |
| 1154 | + | } | |
| 1155 | + | let rest_up = rest.to_ascii_uppercase(); | |
| 1156 | + | let defines = rest.starts_with(':') | |
| 1157 | + | || ["EQU", "SET", "BIT", "DATA", "IDATA", "XDATA", "CODE"] | |
| 1158 | + | .iter() | |
| 1159 | + | .any(|d| { | |
| 1160 | + | rest_up.trim_start().starts_with(d) && rest.starts_with(char::is_whitespace) | |
| 1161 | + | }); | |
| 1162 | + | defines.then(|| buf.first_non_blank(s)) | |
| 1163 | + | }) | |
| 1164 | + | } | |
| 1165 | + | ||
| 1166 | + | // ---- Operators ---- | |
| 1167 | + | ||
| 1168 | + | #[allow(clippy::too_many_arguments)] | |
| 1169 | + | fn op_range( | |
| 1170 | + | &mut self, | |
| 1171 | + | op: Op, | |
| 1172 | + | count: Option<usize>, | |
| 1173 | + | target: Target, | |
| 1174 | + | buf: &Buf, | |
| 1175 | + | pos: usize, | |
| 1176 | + | view: View, | |
| 1177 | + | fx: &mut Vec<Effect>, | |
| 1178 | + | ) -> Option<Range> { | |
| 1179 | + | let line = buf.line_of(pos); | |
| 1180 | + | match target { | |
| 1181 | + | Target::Line => { | |
| 1182 | + | let l1 = (line + count.unwrap_or(1) - 1).min(buf.lines() - 1); | |
| 1183 | + | Some(lines_range(buf, line, l1)) | |
| 1184 | + | } | |
| 1185 | + | Target::Obj(c, around) => { | |
| 1186 | + | let (a, b, linewise) = buf.object(pos, c, around)?; | |
| 1187 | + | Some(if linewise { | |
| 1188 | + | lines_range(buf, buf.line_of(a), buf.line_of(b)) | |
| 1189 | + | } else { | |
| 1190 | + | Range { | |
| 1191 | + | start: a, | |
| 1192 | + | end: b, | |
| 1193 | + | linewise: false, | |
| 1194 | + | } | |
| 1195 | + | }) | |
| 1196 | + | } | |
| 1197 | + | Target::Motion(m) => { | |
| 1198 | + | // `cw` changes to the end of the word, like `ce`. | |
| 1199 | + | if op == Op::Change | |
| 1200 | + | && let Motion::WordFwd(big) = m | |
| 1201 | + | && pos < buf.len() | |
| 1202 | + | && !buf.c[pos].is_whitespace() | |
| 1203 | + | { | |
| 1204 | + | let mut end = buf.run_end(pos, big); | |
| 1205 | + | for _ in 1..count.unwrap_or(1) { | |
| 1206 | + | end = buf.word_end(end, big); | |
| 1207 | + | } | |
| 1208 | + | return Some(Range { | |
| 1209 | + | start: pos, | |
| 1210 | + | end: end + 1, | |
| 1211 | + | linewise: false, | |
| 1212 | + | }); | |
| 1213 | + | } | |
| 1214 | + | let (to, kind) = self.motion(m, count, buf, pos, true, view, fx)?; | |
| 1215 | + | let (a, b) = (pos.min(to), pos.max(to)); | |
| 1216 | + | Some(match kind { | |
| 1217 | + | Kind::Linewise => lines_range(buf, buf.line_of(a), buf.line_of(b)), | |
| 1218 | + | Kind::Inclusive => Range { | |
| 1219 | + | start: a, | |
| 1220 | + | end: (b + 1).min(buf.len()), | |
| 1221 | + | linewise: false, | |
| 1222 | + | }, | |
| 1223 | + | Kind::Exclusive => { | |
| 1224 | + | // An exclusive motion that ends at the start of a | |
| 1225 | + | // later line stops at the end of the line before | |
| 1226 | + | // (`dw` on a line's last word), or covers whole | |
| 1227 | + | // lines if it started at the indentation (`d}`). | |
| 1228 | + | if b > a && buf.col(b) == 0 && buf.line_of(b) > buf.line_of(a) { | |
| 1229 | + | if buf.col(a) <= buf.col(buf.first_non_blank(a)) { | |
| 1230 | + | return Some(lines_range(buf, buf.line_of(a), buf.line_of(b) - 1)); | |
| 1231 | + | } | |
| 1232 | + | return Some(Range { | |
| 1233 | + | start: a, | |
| 1234 | + | end: b - 1, | |
| 1235 | + | linewise: false, | |
| 1236 | + | }); | |
| 1237 | + | } | |
| 1238 | + | Range { | |
| 1239 | + | start: a, | |
| 1240 | + | end: b, | |
| 1241 | + | linewise: false, | |
| 1242 | + | } | |
| 1243 | + | } | |
| 1244 | + | }) | |
| 1245 | + | } | |
| 1246 | + | } | |
| 1247 | + | } | |
| 1248 | + | ||
| 1249 | + | fn visual_range(&self, buf: &Buf, pos: usize, op: Op) -> Range { | |
| 1250 | + | let (a, b) = (self.anchor.min(pos), self.anchor.max(pos)); | |
| 1251 | + | if self.mode == Mode::VisualLine || matches!(op, Op::Indent | Op::Dedent) { | |
| 1252 | + | lines_range(buf, buf.line_of(a), buf.line_of(b)) | |
| 1253 | + | } else { | |
| 1254 | + | Range { | |
| 1255 | + | start: a, | |
| 1256 | + | end: (b + 1).min(buf.len()), | |
| 1257 | + | linewise: false, | |
| 1258 | + | } | |
| 1259 | + | } | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | fn apply( | |
| 1263 | + | &mut self, | |
| 1264 | + | op: Op, | |
| 1265 | + | reg: Option<char>, | |
| 1266 | + | r: Range, | |
| 1267 | + | buf: &mut Buf, | |
| 1268 | + | cx: &mut Cx, | |
| 1269 | + | fx: &mut Vec<Effect>, | |
| 1270 | + | ) { | |
| 1271 | + | let pos = *cx.cursor; | |
| 1272 | + | let mut text: String = buf.c[r.start..r.end].iter().collect(); | |
| 1273 | + | if r.linewise && !text.ends_with('\n') { | |
| 1274 | + | text.push('\n'); | |
| 1275 | + | } | |
| 1276 | + | match op { | |
| 1277 | + | Op::Yank => { | |
| 1278 | + | self.set_reg(reg, text, r.linewise, fx); | |
| 1279 | + | *cx.cursor = if r.linewise { | |
| 1280 | + | if r.start < buf.line_start(pos) { | |
| 1281 | + | buf.at_col(buf.line_of(r.start), buf.col(pos)) | |
| 1282 | + | } else { | |
| 1283 | + | pos | |
| 1284 | + | } | |
| 1285 | + | } else { | |
| 1286 | + | r.start.min(pos) | |
| 1287 | + | }; | |
| 1288 | + | } | |
| 1289 | + | Op::Delete => { | |
| 1290 | + | self.set_reg(reg, text, r.linewise, fx); | |
| 1291 | + | let mut start = r.start; | |
| 1292 | + | // Deleting the last lines also takes the `\n` before them. | |
| 1293 | + | if r.linewise | |
| 1294 | + | && r.end == buf.len() | |
| 1295 | + | && start > 0 | |
| 1296 | + | && !buf.c[r.start..r.end].ends_with(&['\n']) | |
| 1297 | + | { | |
| 1298 | + | start -= 1; | |
| 1299 | + | } | |
| 1300 | + | buf.remove(start, r.end); | |
| 1301 | + | *cx.text = buf.text(); | |
| 1302 | + | *cx.cursor = if r.linewise { | |
| 1303 | + | buf.first_non_blank(start.min(buf.len())) | |
| 1304 | + | } else { | |
| 1305 | + | start | |
| 1306 | + | }; | |
| 1307 | + | } | |
| 1308 | + | Op::Change => { | |
| 1309 | + | self.set_reg(reg, text, r.linewise, fx); | |
| 1310 | + | if r.linewise { | |
| 1311 | + | let indent: String = buf.c[r.start..r.end] | |
| 1312 | + | .iter() | |
| 1313 | + | .take_while(|&&c| is_blank(c)) | |
| 1314 | + | .collect(); | |
| 1315 | + | let end = if buf.c[r.start..r.end].ends_with(&['\n']) { | |
| 1316 | + | r.end - 1 | |
| 1317 | + | } else { | |
| 1318 | + | r.end | |
| 1319 | + | }; | |
| 1320 | + | buf.remove(r.start, end); | |
| 1321 | + | let n = buf.insert(r.start, &indent); | |
| 1322 | + | *cx.cursor = r.start + n; | |
| 1323 | + | } else { | |
| 1324 | + | buf.remove(r.start, r.end); | |
| 1325 | + | *cx.cursor = r.start; | |
| 1326 | + | } | |
| 1327 | + | *cx.text = buf.text(); | |
| 1328 | + | self.mode = Mode::Insert; | |
| 1329 | + | self.insert_repeat = (1, 'c'); | |
| 1330 | + | self.insert_keys.clear(); | |
| 1331 | + | } | |
| 1332 | + | Op::Indent | Op::Dedent => { | |
| 1333 | + | let (l0, l1) = ( | |
| 1334 | + | buf.line_of(r.start), | |
| 1335 | + | buf.line_of(r.end.saturating_sub(1).max(r.start)), | |
| 1336 | + | ); | |
| 1337 | + | for l in l0..=l1 { | |
| 1338 | + | let s = buf.start_of_line(l); | |
| 1339 | + | let e = buf.line_end(s); | |
| 1340 | + | if op == Op::Indent { | |
| 1341 | + | if e > s { | |
| 1342 | + | buf.insert(s, " "); | |
| 1343 | + | } | |
| 1344 | + | } else { | |
| 1345 | + | let n = if buf.c.get(s) == Some(&'\t') { | |
| 1346 | + | 1 | |
| 1347 | + | } else { | |
| 1348 | + | buf.c[s..e] | |
| 1349 | + | .iter() | |
| 1350 | + | .take(8) | |
| 1351 | + | .take_while(|&&c| c == ' ') | |
| 1352 | + | .count() | |
| 1353 | + | }; | |
| 1354 | + | buf.remove(s, s + n); | |
| 1355 | + | } | |
| 1356 | + | } | |
| 1357 | + | *cx.text = buf.text(); | |
| 1358 | + | *cx.cursor = buf.first_non_blank(buf.start_of_line(l0)); | |
| 1359 | + | } | |
| 1360 | + | Op::Case => { | |
| 1361 | + | for i in r.start..r.end { | |
| 1362 | + | buf.c[i] = toggle_case(buf.c[i]); | |
| 1363 | + | } | |
| 1364 | + | *cx.text = buf.text(); | |
| 1365 | + | *cx.cursor = r.start; | |
| 1366 | + | } | |
| 1367 | + | } | |
| 1368 | + | } | |
| 1369 | + | ||
| 1370 | + | fn set_reg(&mut self, reg: Option<char>, text: String, linewise: bool, fx: &mut Vec<Effect>) { | |
| 1371 | + | match reg { | |
| 1372 | + | Some('_') => return, | |
| 1373 | + | Some(c) if c.is_ascii_lowercase() => { | |
| 1374 | + | self.registers.insert( | |
| 1375 | + | c, | |
| 1376 | + | Reg { | |
| 1377 | + | text: text.clone(), | |
| 1378 | + | linewise, | |
| 1379 | + | }, | |
| 1380 | + | ); | |
| 1381 | + | } | |
| 1382 | + | Some(c) if c.is_ascii_uppercase() => { | |
| 1383 | + | let r = self.registers.entry(c.to_ascii_lowercase()).or_insert(Reg { | |
| 1384 | + | text: String::new(), | |
| 1385 | + | linewise, | |
| 1386 | + | }); | |
| 1387 | + | r.text.push_str(&text); | |
| 1388 | + | } | |
| 1389 | + | _ => {} | |
| 1390 | + | } | |
| 1391 | + | fx.push(Effect::Clipboard(text.clone())); | |
| 1392 | + | self.registers.insert('"', Reg { text, linewise }); | |
| 1393 | + | } | |
| 1394 | + | ||
| 1395 | + | fn get_reg(&mut self, reg: Option<char>) -> Option<Reg> { | |
| 1396 | + | let c = reg.unwrap_or('"').to_ascii_lowercase(); | |
| 1397 | + | let r = self.registers.get(&c).cloned(); | |
| 1398 | + | if r.is_none() { | |
| 1399 | + | self.error(format!("E353: Nothing in register {c}")); | |
| 1400 | + | } | |
| 1401 | + | r | |
| 1402 | + | } | |
| 1403 | + | ||
| 1404 | + | fn put(&mut self, r: &Reg, before: bool, count: usize, buf: &mut Buf, pos: usize, cx: &mut Cx) { | |
| 1405 | + | if r.linewise { | |
| 1406 | + | let mut t = r.text.repeat(count); | |
| 1407 | + | if !t.ends_with('\n') { | |
| 1408 | + | t.push('\n'); | |
| 1409 | + | } | |
| 1410 | + | let at = if before { | |
| 1411 | + | buf.line_start(pos) | |
| 1412 | + | } else { | |
| 1413 | + | let e = buf.line_end(pos); | |
| 1414 | + | if e == buf.len() { | |
| 1415 | + | // After the last line: the newline goes in front. | |
| 1416 | + | t.pop(); | |
| 1417 | + | t.insert(0, '\n'); | |
| 1418 | + | e | |
| 1419 | + | } else { | |
| 1420 | + | e + 1 | |
| 1421 | + | } | |
| 1422 | + | }; | |
| 1423 | + | buf.insert(at, &t); | |
| 1424 | + | let first = if !before && at == buf.line_end(pos) && buf.c.get(at) == Some(&'\n') { | |
| 1425 | + | at + 1 | |
| 1426 | + | } else { | |
| 1427 | + | at | |
| 1428 | + | }; | |
| 1429 | + | *cx.text = buf.text(); | |
| 1430 | + | *cx.cursor = buf.first_non_blank(first); | |
| 1431 | + | } else { | |
| 1432 | + | let t = r.text.repeat(count); | |
| 1433 | + | let at = if before || buf.line_end(pos) == pos { | |
| 1434 | + | pos | |
| 1435 | + | } else { | |
| 1436 | + | pos + 1 | |
| 1437 | + | }; | |
| 1438 | + | let n = buf.insert(at, &t); | |
| 1439 | + | *cx.text = buf.text(); | |
| 1440 | + | *cx.cursor = at + n.saturating_sub(1); | |
| 1441 | + | } | |
| 1442 | + | } | |
| 1443 | + | ||
| 1444 | + | /// Ctrl+V: the system clipboard, before the cursor or over the selection. | |
| 1445 | + | fn paste_text(&mut self, t: &str, visual: bool, cx: &mut Cx) { | |
| 1446 | + | if cx.readonly { | |
| 1447 | + | self.error("read-only while debugging"); | |
| 1448 | + | return; | |
| 1449 | + | } | |
| 1450 | + | let r = Reg { | |
| 1451 | + | text: t.to_string(), | |
| 1452 | + | linewise: t.ends_with('\n'), | |
| 1453 | + | }; | |
| 1454 | + | self.begin_change(cx); | |
| 1455 | + | let mut buf = Buf::new(cx.text); | |
| 1456 | + | let pos = (*cx.cursor).min(buf.len()); | |
| 1457 | + | if visual { | |
| 1458 | + | let range = self.visual_range(&buf, pos, Op::Delete); | |
| 1459 | + | self.mode = Mode::Normal; | |
| 1460 | + | buf.remove(range.start, range.end); | |
| 1461 | + | let n = buf.insert(range.start, t); | |
| 1462 | + | *cx.text = buf.text(); | |
| 1463 | + | *cx.cursor = range.start + n.saturating_sub(1); | |
| 1464 | + | } else { | |
| 1465 | + | self.put(&r, true, 1, &mut buf, pos, cx); | |
| 1466 | + | } | |
| 1467 | + | self.end_change(cx); | |
| 1468 | + | } | |
| 1469 | + | ||
| 1470 | + | fn join(&mut self, lines: usize, buf: &mut Buf, cx: &mut Cx) { | |
| 1471 | + | let mut pos = *cx.cursor; | |
| 1472 | + | for _ in 1..lines { | |
| 1473 | + | let e = buf.line_end(pos); | |
| 1474 | + | if e >= buf.len() { | |
| 1475 | + | break; | |
| 1476 | + | } | |
| 1477 | + | let mut f = e + 1; | |
| 1478 | + | while f < buf.len() && is_blank(buf.c[f]) { | |
| 1479 | + | f += 1; | |
| 1480 | + | } | |
| 1481 | + | buf.remove(e, f); | |
| 1482 | + | let line_empty = e == buf.line_start(e); | |
| 1483 | + | let ends_blank = e > 0 && is_blank(buf.c[e - 1]); | |
| 1484 | + | let next_empty = e >= buf.len() || buf.c[e] == '\n' || buf.c[e] == ')'; | |
| 1485 | + | if !line_empty && !ends_blank && !next_empty { | |
| 1486 | + | buf.insert(e, " "); | |
| 1487 | + | } | |
| 1488 | + | pos = e; | |
| 1489 | + | } | |
| 1490 | + | *cx.text = buf.text(); | |
| 1491 | + | *cx.cursor = pos; | |
| 1492 | + | } | |
| 1493 | + | ||
| 1494 | + | fn undo(&mut self, n: usize, cx: &mut Cx, undo: bool) { | |
| 1495 | + | for _ in 0..n { | |
| 1496 | + | let (from, to) = if undo { | |
| 1497 | + | (&mut cx.history.undo, &mut cx.history.redo) | |
| 1498 | + | } else { | |
| 1499 | + | (&mut cx.history.redo, &mut cx.history.undo) | |
| 1500 | + | }; | |
| 1501 | + | let Some((text, cursor)) = from.pop() else { | |
| 1502 | + | self.error(if undo { | |
| 1503 | + | "Already at oldest change" | |
| 1504 | + | } else { | |
| 1505 | + | "Already at newest change" | |
| 1506 | + | }); | |
| 1507 | + | return; | |
| 1508 | + | }; | |
| 1509 | + | to.push((std::mem::replace(cx.text, text), *cx.cursor)); | |
| 1510 | + | *cx.cursor = cursor; | |
| 1511 | + | } | |
| 1512 | + | } | |
| 1513 | + | ||
| 1514 | + | // ---- Insert mode ---- | |
| 1515 | + | ||
| 1516 | + | fn start_insert(&mut self, kind: char, count: usize, buf: &mut Buf, pos: usize, cx: &mut Cx) { | |
| 1517 | + | let s = buf.line_start(pos); | |
| 1518 | + | let e = buf.line_end(pos); | |
| 1519 | + | *cx.cursor = match kind { | |
| 1520 | + | 'a' if e > pos => pos + 1, | |
| 1521 | + | 'I' => (s..e).find(|&i| !is_blank(buf.c[i])).unwrap_or(e), | |
| 1522 | + | 'A' => e, | |
| 1523 | + | 'o' | 'O' => { | |
| 1524 | + | let indent = auto_indent(&buf.line_text(pos)); | |
| 1525 | + | let at = if kind == 'o' { e } else { s }; | |
| 1526 | + | let line = if kind == 'o' { | |
| 1527 | + | format!("\n{indent}") | |
| 1528 | + | } else { | |
| 1529 | + | format!("{indent}\n") | |
| 1530 | + | }; | |
| 1531 | + | buf.insert(at, &line); | |
| 1532 | + | *cx.text = buf.text(); | |
| 1533 | + | at + indent.chars().count() + usize::from(kind == 'o') | |
| 1534 | + | } | |
| 1535 | + | _ => pos, | |
| 1536 | + | }; | |
| 1537 | + | self.mode = Mode::Insert; | |
| 1538 | + | self.insert_repeat = (count, kind); | |
| 1539 | + | self.insert_keys.clear(); | |
| 1540 | + | } | |
| 1541 | + | ||
| 1542 | + | fn insert_key(&mut self, key: VimKey, cx: &mut Cx) { | |
| 1543 | + | if let Some(rec) = &mut self.dot_rec | |
| 1544 | + | && !self.dot_replay | |
| 1545 | + | { | |
| 1546 | + | rec.push(key.clone()); | |
| 1547 | + | } | |
| 1548 | + | if cx.readonly || matches!(key, VimKey::Esc | VimKey::Copy | VimKey::Ctrl('c')) { | |
| 1549 | + | self.leave_insert(cx); | |
| 1550 | + | return; | |
| 1551 | + | } | |
| 1552 | + | if matches!( | |
| 1553 | + | key, | |
| 1554 | + | VimKey::Char(_) | VimKey::Enter | VimKey::Tab | VimKey::Backspace | VimKey::Paste(_) | |
| 1555 | + | ) { | |
| 1556 | + | self.insert_keys.push(key.clone()); | |
| 1557 | + | } | |
| 1558 | + | self.type_key(&key, cx); | |
| 1559 | + | } | |
| 1560 | + | ||
| 1561 | + | /// One key's effect on the text in Insert mode. | |
| 1562 | + | fn type_key(&mut self, key: &VimKey, cx: &mut Cx) { | |
| 1563 | + | let mut buf = Buf::new(cx.text); | |
| 1564 | + | let pos = (*cx.cursor).min(buf.len()); | |
| 1565 | + | let s = buf.line_start(pos); | |
| 1566 | + | let mut changed = true; | |
| 1567 | + | match key { | |
| 1568 | + | VimKey::Char(c) => *cx.cursor = pos + buf.insert(pos, &c.to_string()), | |
| 1569 | + | VimKey::Paste(t) => *cx.cursor = pos + buf.insert(pos, t), | |
| 1570 | + | VimKey::Enter => { | |
| 1571 | + | let indent = auto_indent(&buf.c[s..pos].iter().collect::<String>()); | |
| 1572 | + | *cx.cursor = pos + buf.insert(pos, &format!("\n{indent}")); | |
| 1573 | + | } | |
| 1574 | + | VimKey::Tab => { | |
| 1575 | + | let n = 8 - (pos - s) % 8; | |
| 1576 | + | *cx.cursor = pos + buf.insert(pos, &" ".repeat(n)); | |
| 1577 | + | } | |
| 1578 | + | VimKey::Backspace if pos > 0 => { | |
| 1579 | + | buf.remove(pos - 1, pos); | |
| 1580 | + | *cx.cursor = pos - 1; | |
| 1581 | + | } | |
| 1582 | + | VimKey::Delete if pos < buf.len() => { | |
| 1583 | + | buf.remove(pos, pos + 1); | |
| 1584 | + | } | |
| 1585 | + | VimKey::Ctrl('w') if pos > s => { | |
| 1586 | + | let mut a = pos; | |
| 1587 | + | while a > s && is_blank(buf.c[a - 1]) { | |
| 1588 | + | a -= 1; | |
| 1589 | + | } | |
| 1590 | + | let kw = a > s && is_keyword(buf.c[a - 1]); | |
| 1591 | + | while a > s && !is_blank(buf.c[a - 1]) && is_keyword(buf.c[a - 1]) == kw { | |
| 1592 | + | a -= 1; | |
| 1593 | + | } | |
| 1594 | + | buf.remove(a, pos); | |
| 1595 | + | *cx.cursor = a; | |
| 1596 | + | } | |
| 1597 | + | VimKey::Ctrl('u') if pos > s => { | |
| 1598 | + | buf.remove(s, pos); | |
| 1599 | + | *cx.cursor = s; | |
| 1600 | + | } | |
| 1601 | + | other => { | |
| 1602 | + | changed = false; | |
| 1603 | + | let e = buf.line_end(pos); | |
| 1604 | + | let line = buf.line_of(pos); | |
| 1605 | + | *cx.cursor = match other { | |
| 1606 | + | VimKey::Left => pos.saturating_sub(1).max(s), | |
| 1607 | + | VimKey::Right => (pos + 1).min(e), | |
| 1608 | + | VimKey::Home => s, | |
| 1609 | + | VimKey::End => e, | |
| 1610 | + | VimKey::Up if line > 0 => { | |
| 1611 | + | let s2 = buf.start_of_line(line - 1); | |
| 1612 | + | (s2 + (pos - s)).min(buf.line_end(s2)) | |
| 1613 | + | } | |
| 1614 | + | VimKey::Down if line + 1 < buf.lines() => { | |
| 1615 | + | let s2 = buf.start_of_line(line + 1); | |
| 1616 | + | (s2 + (pos - s)).min(buf.line_end(s2)) | |
| 1617 | + | } | |
| 1618 | + | _ => pos, | |
| 1619 | + | }; | |
| 1620 | + | } | |
| 1621 | + | } | |
| 1622 | + | if changed { | |
| 1623 | + | *cx.text = buf.text(); | |
| 1624 | + | } | |
| 1625 | + | } | |
| 1626 | + | ||
| 1627 | + | fn leave_insert(&mut self, cx: &mut Cx) { | |
| 1628 | + | let (count, kind) = self.insert_repeat; | |
| 1629 | + | if count > 1 && !self.insert_keys.is_empty() && !cx.readonly { | |
| 1630 | + | let keys = std::mem::take(&mut self.insert_keys); | |
| 1631 | + | for _ in 1..count { | |
| 1632 | + | if matches!(kind, 'o' | 'O') { | |
| 1633 | + | let buf = Buf::new(cx.text); | |
| 1634 | + | let pos = (*cx.cursor).min(buf.len()); | |
| 1635 | + | let indent = auto_indent(&buf.line_text(pos)); | |
| 1636 | + | let mut buf = buf; | |
| 1637 | + | let e = buf.line_end(pos); | |
| 1638 | + | *cx.cursor = e + buf.insert(e, &format!("\n{indent}")); | |
| 1639 | + | *cx.text = buf.text(); | |
| 1640 | + | } | |
| 1641 | + | for k in &keys { | |
| 1642 | + | self.type_key(k, cx); | |
| 1643 | + | } | |
| 1644 | + | } | |
| 1645 | + | } | |
| 1646 | + | self.insert_keys.clear(); | |
| 1647 | + | self.mode = Mode::Normal; | |
| 1648 | + | let buf = Buf::new(cx.text); | |
| 1649 | + | if *cx.cursor > buf.line_start(*cx.cursor) { | |
| 1650 | + | *cx.cursor -= 1; | |
| 1651 | + | } | |
| 1652 | + | self.end_change(cx); | |
| 1653 | + | if let Some(rec) = self.dot_rec.take() { | |
| 1654 | + | self.last_change = rec; | |
| 1655 | + | } | |
| 1656 | + | } | |
| 1657 | + | ||
| 1658 | + | // ---- Command line ---- | |
| 1659 | + | ||
| 1660 | + | fn cmdline_key(&mut self, key: VimKey, cx: &mut Cx, fx: &mut Vec<Effect>) { | |
| 1661 | + | let Some((prompt, input)) = &mut self.cmdline else { | |
| 1662 | + | self.mode = Mode::Normal; | |
| 1663 | + | return; | |
| 1664 | + | }; | |
| 1665 | + | match key { | |
| 1666 | + | VimKey::Esc | VimKey::Copy | VimKey::Ctrl('c') => { | |
| 1667 | + | self.cmdline = None; | |
| 1668 | + | self.search_op = None; | |
| 1669 | + | self.mode = Mode::Normal; | |
| 1670 | + | } | |
| 1671 | + | VimKey::Backspace => { | |
| 1672 | + | if input.pop().is_none() { | |
| 1673 | + | self.cmdline = None; | |
| 1674 | + | self.search_op = None; | |
| 1675 | + | self.mode = Mode::Normal; | |
| 1676 | + | } | |
| 1677 | + | } | |
| 1678 | + | VimKey::Char(c) => input.push(c), | |
| 1679 | + | VimKey::Paste(t) => input.push_str(t.lines().next().unwrap_or("")), | |
| 1680 | + | VimKey::Enter => { | |
| 1681 | + | let (prompt, input) = (*prompt, std::mem::take(input)); | |
| 1682 | + | self.cmdline = None; | |
| 1683 | + | self.mode = Mode::Normal; | |
| 1684 | + | if prompt == ':' { | |
| 1685 | + | self.run_ex(&input, cx, fx); | |
| 1686 | + | } else { | |
| 1687 | + | self.run_search(prompt == '/', input, cx, fx); | |
| 1688 | + | } | |
| 1689 | + | } | |
| 1690 | + | _ => {} | |
| 1691 | + | } | |
| 1692 | + | } | |
| 1693 | + | ||
| 1694 | + | fn run_search(&mut self, forward: bool, input: String, cx: &mut Cx, fx: &mut Vec<Effect>) { | |
| 1695 | + | let op = self.search_op.take(); | |
| 1696 | + | let pattern = if input.is_empty() { | |
| 1697 | + | match &self.search { | |
| 1698 | + | Some(s) => s.pattern.clone(), | |
| 1699 | + | None => return, | |
| 1700 | + | } | |
| 1701 | + | } else { | |
| 1702 | + | input | |
| 1703 | + | }; | |
| 1704 | + | let s = Search { | |
| 1705 | + | pattern, | |
| 1706 | + | forward, | |
| 1707 | + | whole_word: false, | |
| 1708 | + | }; | |
| 1709 | + | self.search = Some(s.clone()); | |
| 1710 | + | self.highlight = true; | |
| 1711 | + | let mut buf = Buf::new(cx.text); | |
| 1712 | + | let pos = *cx.cursor; | |
| 1713 | + | let Some(p) = self.find_next(&buf, pos, forward, &s) else { | |
| 1714 | + | return; | |
| 1715 | + | }; | |
| 1716 | + | let Some((op, reg)) = op else { | |
| 1717 | + | *cx.cursor = p; | |
| 1718 | + | return; | |
| 1719 | + | }; | |
| 1720 | + | let r = Range { | |
| 1721 | + | start: pos.min(p), | |
| 1722 | + | end: pos.max(p), | |
| 1723 | + | linewise: false, | |
| 1724 | + | }; | |
| 1725 | + | if op != Op::Yank { | |
| 1726 | + | self.begin_change(cx); | |
| 1727 | + | } | |
| 1728 | + | self.apply(op, reg, r, &mut buf, cx, fx); | |
| 1729 | + | if op != Op::Yank && self.mode != Mode::Insert { | |
| 1730 | + | self.end_change(cx); | |
| 1731 | + | } | |
| 1732 | + | } | |
| 1733 | + | ||
| 1734 | + | fn run_ex(&mut self, input: &str, cx: &mut Cx, fx: &mut Vec<Effect>) { | |
| 1735 | + | use ex::Ex; | |
| 1736 | + | let buf = Buf::new(cx.text); | |
| 1737 | + | match ex::parse(input) { | |
| 1738 | + | Ex::Write(None) => fx.push(Effect::Save), | |
| 1739 | + | Ex::Write(Some(p)) => fx.push(Effect::SaveAs(p)), | |
| 1740 | + | Ex::Quit { force } => { | |
| 1741 | + | if !force && cx.dirty { | |
| 1742 | + | self.error("E37: No write since last change (add ! to override)"); | |
| 1743 | + | } else { | |
| 1744 | + | fx.push(Effect::Close { force }); | |
| 1745 | + | } | |
| 1746 | + | } | |
| 1747 | + | Ex::WriteQuit => fx.extend([Effect::Save, Effect::Close { force: false }]), | |
| 1748 | + | Ex::Exit => { | |
| 1749 | + | if cx.dirty { | |
| 1750 | + | fx.push(Effect::Save); | |
| 1751 | + | } | |
| 1752 | + | fx.push(Effect::Close { force: false }); | |
| 1753 | + | } | |
| 1754 | + | Ex::QuitAll { force } => fx.push(Effect::Quit { force }), | |
| 1755 | + | Ex::Edit(p) => fx.push(Effect::Edit(p)), | |
| 1756 | + | Ex::Line(n) => *cx.cursor = buf.first_non_blank(buf.start_of_line(n.saturating_sub(1))), | |
| 1757 | + | Ex::LastLine => *cx.cursor = buf.first_non_blank(buf.start_of_line(buf.lines() - 1)), | |
| 1758 | + | Ex::NoHighlight => self.highlight = false, | |
| 1759 | + | Ex::Make => fx.push(Effect::Make), | |
| 1760 | + | Ex::Next => fx.push(Effect::NextDoc), | |
| 1761 | + | Ex::Prev => fx.push(Effect::PrevDoc), | |
| 1762 | + | Ex::Unknown(s) => self.error(format!("E492: Not an editor command: {s}")), | |
| 1763 | + | } | |
| 1764 | + | } | |
| 1765 | + | } | |
| 1766 | + | ||
| 1767 | + | fn find_kind(kind: char) -> Kind { | |
| 1768 | + | if matches!(kind, 'f' | 't') { | |
| 1769 | + | Kind::Inclusive | |
| 1770 | + | } else { | |
| 1771 | + | Kind::Exclusive | |
| 1772 | + | } | |
| 1773 | + | } | |
| 1774 | + | ||
| 1775 | + | fn toggle_case(c: char) -> char { | |
| 1776 | + | if c.is_lowercase() { | |
| 1777 | + | c.to_uppercase().next().unwrap_or(c) | |
| 1778 | + | } else { | |
| 1779 | + | c.to_lowercase().next().unwrap_or(c) | |
| 1780 | + | } | |
| 1781 | + | } | |
| 1782 | + | ||
| 1783 | + | /// The keyword under (or after, on the same line) the cursor. | |
| 1784 | + | fn word_at(buf: &Buf, pos: usize) -> Option<String> { | |
| 1785 | + | let e = buf.line_end(pos); | |
| 1786 | + | let start = (pos..e).find(|&i| is_keyword(buf.c[i]))?; | |
| 1787 | + | let mut a = start; | |
| 1788 | + | while a > buf.line_start(pos) && is_keyword(buf.c[a - 1]) { | |
| 1789 | + | a -= 1; | |
| 1790 | + | } | |
| 1791 | + | let mut b = start; | |
| 1792 | + | while b < e && is_keyword(buf.c[b]) { | |
| 1793 | + | b += 1; | |
| 1794 | + | } | |
| 1795 | + | Some(buf.c[a..b].iter().collect()) | |
| 1796 | + | } | |
| 1797 | + | ||
| 1798 | + | /// Smartcase: case-insensitive unless the pattern has capitals. | |
| 1799 | + | fn matches_at(buf: &Buf, i: usize, s: &Search) -> bool { | |
| 1800 | + | let pat: Vec<char> = s.pattern.chars().collect(); | |
| 1801 | + | if pat.is_empty() || i + pat.len() > buf.len() { | |
| 1802 | + | return false; | |
| 1803 | + | } | |
| 1804 | + | let exact = pat.iter().any(|c| c.is_uppercase()); | |
| 1805 | + | let eq = |a: char, b: char| { | |
| 1806 | + | if exact { | |
| 1807 | + | a == b | |
| 1808 | + | } else { | |
| 1809 | + | a.to_lowercase().eq(b.to_lowercase()) | |
| 1810 | + | } | |
| 1811 | + | }; | |
| 1812 | + | if !pat.iter().enumerate().all(|(k, &p)| eq(buf.c[i + k], p)) { | |
| 1813 | + | return false; | |
| 1814 | + | } | |
| 1815 | + | !s.whole_word | |
| 1816 | + | || ((i == 0 || !is_keyword(buf.c[i - 1])) | |
| 1817 | + | && (i + pat.len() == buf.len() || !is_keyword(buf.c[i + pat.len()]))) | |
| 1818 | + | } | |
| @@ -0,0 +1,358 @@ | |||
| 1 | + | //! Engine tests. Buffers are written with `|` marking the cursor. | |
| 2 | + | ||
| 3 | + | use super::keys::parse_keys; | |
| 4 | + | use super::{Cx, Effect, History, Mode, View, Vim}; | |
| 5 | + | ||
| 6 | + | struct T { | |
| 7 | + | vim: Vim, | |
| 8 | + | text: String, | |
| 9 | + | cursor: usize, | |
| 10 | + | history: History, | |
| 11 | + | readonly: bool, | |
| 12 | + | dirty: bool, | |
| 13 | + | fx: Vec<Effect>, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl T { | |
| 17 | + | fn new(marked: &str) -> Self { | |
| 18 | + | let cursor = marked.chars().position(|c| c == '|').unwrap_or(0); | |
| 19 | + | Self { | |
| 20 | + | vim: Vim::default(), | |
| 21 | + | text: marked.replace('|', ""), | |
| 22 | + | cursor, | |
| 23 | + | history: History::default(), | |
| 24 | + | readonly: false, | |
| 25 | + | dirty: false, | |
| 26 | + | fx: Vec::new(), | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | fn keys(&mut self, keys: &str) -> &mut Self { | |
| 31 | + | for k in parse_keys(keys) { | |
| 32 | + | let mut cx = Cx { | |
| 33 | + | text: &mut self.text, | |
| 34 | + | cursor: &mut self.cursor, | |
| 35 | + | history: &mut self.history, | |
| 36 | + | readonly: self.readonly, | |
| 37 | + | dirty: self.dirty, | |
| 38 | + | view: View { top: 0, rows: 10 }, | |
| 39 | + | }; | |
| 40 | + | let fx = self.vim.key(k, &mut cx); | |
| 41 | + | self.fx.extend(fx); | |
| 42 | + | } | |
| 43 | + | self | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn show(&self) -> String { | |
| 47 | + | let mut s: Vec<char> = self.text.chars().collect(); | |
| 48 | + | s.insert(self.cursor.min(s.len()), '|'); | |
| 49 | + | s.into_iter().collect() | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn after(start: &str, keys: &str) -> String { | |
| 54 | + | T::new(start).keys(keys).show() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[test] | |
| 58 | + | fn word_and_line_motions() { | |
| 59 | + | assert_eq!(after("|MOV A,#1 ; c", "w"), "MOV |A,#1 ; c"); | |
| 60 | + | assert_eq!(after("|MOV A,#1 ; c", "ww"), "MOV A|,#1 ; c"); | |
| 61 | + | assert_eq!(after("|MOV A,#1 ; c", "W"), "MOV |A,#1 ; c"); | |
| 62 | + | assert_eq!(after("|MOV A,#1 ; c", "2W"), "MOV A,#1 |; c"); | |
| 63 | + | assert_eq!(after("MOV A,#1 ; |c", "b"), "MOV A,#1 |; c"); | |
| 64 | + | assert_eq!(after("|MOV A,#1", "e"), "MO|V A,#1"); | |
| 65 | + | assert_eq!(after(" MOV |A", "0"), "| MOV A"); | |
| 66 | + | assert_eq!(after(" MOV |A", "^"), " |MOV A"); | |
| 67 | + | assert_eq!(after(" |MOV A", "$"), " MOV |A"); | |
| 68 | + | assert_eq!(after("foo\n\n|bar", "b"), "foo\n|\nbar"); | |
| 69 | + | assert_eq!(after("|foo\n\nbar", "w"), "foo\n|\nbar"); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[test] | |
| 73 | + | fn vertical_motions_keep_the_column() { | |
| 74 | + | assert_eq!(after("abcd|ef\nab\nabcdef", "j"), "abcdef\na|b\nabcdef"); | |
| 75 | + | assert_eq!(after("abcd|ef\nab\nabcdef", "jj"), "abcdef\nab\nabcd|ef"); | |
| 76 | + | assert_eq!(after("a|bc\nabcdef", "$j"), "abc\nabcde|f"); | |
| 77 | + | assert_eq!(after("a\nb\nc\n|d", "gg"), "|a\nb\nc\nd"); | |
| 78 | + | assert_eq!(after("|a\nb\nc\nd", "G"), "a\nb\nc\n|d"); | |
| 79 | + | assert_eq!(after("|a\nb\nc\nd", "3G"), "a\nb\n|c\nd"); | |
| 80 | + | assert_eq!(after("|a\nb\nc\nd", "2j"), "a\nb\n|c\nd"); | |
| 81 | + | assert_eq!(after("|a\n b", "<CR>"), "a\n |b"); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | #[test] | |
| 85 | + | fn find_and_match_motions() { | |
| 86 | + | assert_eq!(after("|a,b,c,d", "f,"), "a|,b,c,d"); | |
| 87 | + | assert_eq!(after("|a,b,c,d", "f,;"), "a,b|,c,d"); | |
| 88 | + | assert_eq!(after("|a,b,c,d", "f,;,"), "a|,b,c,d"); | |
| 89 | + | assert_eq!(after("|a,b,c,d", "2f,"), "a,b|,c,d"); | |
| 90 | + | assert_eq!(after("|a,b,c,d", "t,"), "|a,b,c,d"); | |
| 91 | + | assert_eq!(after("|ab,c", "t,"), "a|b,c"); | |
| 92 | + | assert_eq!(after("a,b,|c", "F,"), "a,b|,c"); | |
| 93 | + | assert_eq!(after("MOV A,|#(1+(2))", "%"), "MOV A,#(1+(2)|)"); | |
| 94 | + | assert_eq!(after("MOV A,#(1+(2)|)", "%"), "MOV A,#|(1+(2))"); | |
| 95 | + | assert_eq!(after("|a\nb\n\nc\nd", "}"), "a\nb\n|\nc\nd"); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[test] | |
| 99 | + | fn delete_yank_put_lines() { | |
| 100 | + | assert_eq!(after("a\n|b\nc", "dd"), "a\n|c"); | |
| 101 | + | assert_eq!(after("a\nb\n|c", "dd"), "a\n|b"); | |
| 102 | + | assert_eq!(after("|a\nb\nc", "2dd"), "|c"); | |
| 103 | + | assert_eq!(after("|a\nb\nc", "dj"), "|c"); | |
| 104 | + | assert_eq!(after("a\n|b\nc", "dk"), "|c"); | |
| 105 | + | assert_eq!(after("|a\nb", "yyp"), "a\n|a\nb"); | |
| 106 | + | assert_eq!(after("a\n|b", "yyp"), "a\nb\n|b"); | |
| 107 | + | assert_eq!(after("a\n|b", "yyP"), "a\n|b\nb"); | |
| 108 | + | assert_eq!(after("| a\nb", "ddp"), "b\n |a"); | |
| 109 | + | assert_eq!(after("|a\nb\nc", "dG"), "|"); | |
| 110 | + | assert_eq!(after("|para\none\n\nnext", "d}"), "|\nnext"); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[test] | |
| 114 | + | fn charwise_operators() { | |
| 115 | + | assert_eq!(after("foo |bar\nbaz", "dw"), "foo| \nbaz"); | |
| 116 | + | assert_eq!(after("|foo bar", "dw"), "|bar"); | |
| 117 | + | assert_eq!(after("|foo bar", "d2w"), "|"); | |
| 118 | + | assert_eq!(after("|MOV A", "cwMOVX<Esc>"), "MOV|X A"); | |
| 119 | + | assert_eq!(after("MO|V A", "cw<Esc>"), "M|O A"); | |
| 120 | + | assert_eq!(after("MOV A,B|B", "ciwR7<Esc>"), "MOV A,R|7"); | |
| 121 | + | assert_eq!(after("DB \"hel|lo\", 0", "ci\"bye<Esc>"), "DB \"by|e\", 0"); | |
| 122 | + | assert_eq!(after("DB \"hel|lo\", 0", "da\""), "DB |, 0"); | |
| 123 | + | assert_eq!(after("MOV A,#(1+|2)", "di("), "MOV A,#(|)"); | |
| 124 | + | assert_eq!(after("MOV A,#(1+|2)", "da("), "MOV A,|#"); | |
| 125 | + | assert_eq!(after("a|bc", "x"), "a|c"); | |
| 126 | + | assert_eq!(after("ab|c", "x"), "a|b"); | |
| 127 | + | assert_eq!(after("ab|c", "X"), "a|c"); | |
| 128 | + | assert_eq!(after("a|bcd", "D"), "|a"); | |
| 129 | + | assert_eq!(after("a|bcd", "Cx<Esc>"), "a|x"); | |
| 130 | + | assert_eq!(after("a|bcd", "sX<Esc>"), "a|Xcd"); | |
| 131 | + | assert_eq!(after("|MOV A,B", "dt,"), "|,B"); | |
| 132 | + | assert_eq!(after("|MOV A,B", "df,"), "|B"); | |
| 133 | + | assert_eq!(after("|MOV A,B", "y$P"), "MOV A,|BMOV A,B"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn indent_join_case_replace() { | |
| 138 | + | assert_eq!(after("|MOV A,B", ">>"), " |MOV A,B"); | |
| 139 | + | assert_eq!(after(" MOV |A,B", "<<"), "|MOV A,B"); | |
| 140 | + | assert_eq!(after("|a\nb", "2>>"), " |a\n b"); | |
| 141 | + | assert_eq!(after("|a\n b", "J"), "a| b"); | |
| 142 | + | assert_eq!(after("|a\nb\nc", "3J"), "a b| c"); | |
| 143 | + | assert_eq!(after("|abc", "~~"), "AB|c"); | |
| 144 | + | assert_eq!(after("|abc", "rx"), "|xbc"); | |
| 145 | + | assert_eq!(after("|abc", "2rx"), "x|xc"); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn undo_redo_and_dot() { | |
| 150 | + | assert_eq!(after("|abc", "xxu"), "|bc"); | |
| 151 | + | assert_eq!(after("|abc", "xxuu"), "|abc"); | |
| 152 | + | assert_eq!(after("|abc", "xxuu<C-r>"), "|bc"); | |
| 153 | + | assert_eq!(after("|abc", "ixy<Esc>u"), "|abc"); | |
| 154 | + | assert_eq!(after("|a b c d", "dw."), "|c d"); | |
| 155 | + | assert_eq!(after("|a b c d", "dw2."), "|d"); | |
| 156 | + | assert_eq!(after("|ab\ncd", "Ax<Esc>j."), "abx\ncd|x"); | |
| 157 | + | assert_eq!(after("|a b", "cwX<Esc>w."), "X |X"); | |
| 158 | + | // Undoing a `.` undoes the whole repeated change. | |
| 159 | + | assert_eq!(after("|ab\ncd", "Axy<Esc>j.u"), "abxy\nc|d"); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn insert_mode_and_counts() { | |
| 164 | + | assert_eq!(after("|", "3ix<Esc>"), "xx|x"); | |
| 165 | + | assert_eq!(after("a|b", "ix<Esc>"), "a|xb"); | |
| 166 | + | assert_eq!(after("a|b", "ax<Esc>"), "ab|x"); | |
| 167 | + | assert_eq!(after(" a|b", "Ix<Esc>"), " |xab"); | |
| 168 | + | assert_eq!(after("a|b", "Ax<Esc>"), "ab|x"); | |
| 169 | + | assert_eq!(after("a|b", "ix<BS>y<Esc>"), "a|yb"); | |
| 170 | + | assert_eq!(after("|", "i<Tab>x<Esc>"), " |x"); | |
| 171 | + | assert_eq!(after("ab|", "a<CR>c<Esc>"), "ab\n|c"); | |
| 172 | + | assert_eq!(after("|a", "2ox<Esc>"), "a\nx\n|x"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 176 | + | fn new_lines_line_up_after_labels() { | |
| 177 | + | assert_eq!( | |
| 178 | + | after("|main: MOV A,#1", "oNOP<Esc>"), | |
| 179 | + | "main: MOV A,#1\n NO|P" | |
| 180 | + | ); | |
| 181 | + | assert_eq!(after("|loop:", "oNOP<Esc>"), "loop:\n NO|P"); | |
| 182 | + | assert_eq!( | |
| 183 | + | after("| MOV A,B", "oX<Esc>"), | |
| 184 | + | " MOV A,B\n |X" | |
| 185 | + | ); | |
| 186 | + | assert_eq!(after("|; comment", "oX<Esc>"), "; comment\n|X"); | |
| 187 | + | assert_eq!(after(" |NOP", "OX<Esc>"), " |X\n NOP"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn visual_mode() { | |
| 192 | + | let mut t = T::new("|abcdef"); | |
| 193 | + | t.keys("lvlly"); | |
| 194 | + | assert_eq!(t.show(), "a|bcdef"); | |
| 195 | + | assert!(t.fx.contains(&Effect::Clipboard("bcd".into()))); | |
| 196 | + | t.keys("$p"); | |
| 197 | + | assert_eq!(t.show(), "abcdefbc|d"); | |
| 198 | + | assert_eq!(after("a\n|b\nc\nd", "Vjd"), "a\n|d"); | |
| 199 | + | assert_eq!(after("|abcdef", "vld"), "|cdef"); | |
| 200 | + | assert_eq!(after("|abcdef", "vlcX<Esc>"), "|Xcdef"); | |
| 201 | + | assert_eq!(after("|a\nb", "Vj>"), " |a\n b"); | |
| 202 | + | assert_eq!(after("|abc", "vl~"), "|ABc"); | |
| 203 | + | assert_eq!(after("|ab cd", "yiwwviwp"), "ab |ab"); | |
| 204 | + | let mut t = T::new("|abc"); | |
| 205 | + | t.keys("v"); | |
| 206 | + | assert_eq!(t.vim.selection(&t.text, t.cursor), Some((0, 1))); | |
| 207 | + | t.keys("l"); | |
| 208 | + | assert_eq!(t.vim.selection(&t.text, t.cursor), Some((0, 2))); | |
| 209 | + | t.keys("<Esc>"); | |
| 210 | + | assert_eq!(t.vim.mode, Mode::Normal); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[test] | |
| 214 | + | fn registers_and_clipboard() { | |
| 215 | + | let mut t = T::new("|one\ntwo"); | |
| 216 | + | t.keys("\"ayyj\"byy\"ap"); | |
| 217 | + | assert_eq!(t.show(), "one\ntwo\n|one"); | |
| 218 | + | assert!(t.fx.contains(&Effect::Clipboard("one\n".into()))); | |
| 219 | + | // The black hole register doesn't touch the clipboard or `"`. | |
| 220 | + | let mut t = T::new("|ab"); | |
| 221 | + | t.keys("yl\"_xp"); | |
| 222 | + | assert_eq!(t.show(), "b|a"); | |
| 223 | + | assert_eq!(t.fx, vec![Effect::Clipboard("a".into())]); | |
| 224 | + | // Ctrl+V pastes the system clipboard before the cursor. | |
| 225 | + | let mut t = T::new("a|b"); | |
| 226 | + | t.vim.key( | |
| 227 | + | super::VimKey::Paste("XY".into()), | |
| 228 | + | &mut Cx { | |
| 229 | + | text: &mut t.text, | |
| 230 | + | cursor: &mut t.cursor, | |
| 231 | + | history: &mut t.history, | |
| 232 | + | readonly: false, | |
| 233 | + | dirty: false, | |
| 234 | + | view: View::default(), | |
| 235 | + | }, | |
| 236 | + | ); | |
| 237 | + | assert_eq!(t.show(), "aX|Yb"); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn search() { | |
| 242 | + | assert_eq!(after("|foo bar\nbaz bar", "/bar<CR>"), "foo |bar\nbaz bar"); | |
| 243 | + | assert_eq!(after("|foo bar\nbaz bar", "/bar<CR>n"), "foo bar\nbaz |bar"); | |
| 244 | + | assert_eq!( | |
| 245 | + | after("|foo bar\nbaz bar", "/bar<CR>nn"), | |
| 246 | + | "foo |bar\nbaz bar" | |
| 247 | + | ); | |
| 248 | + | assert_eq!(after("|foo bar\nbaz bar", "/bar<CR>N"), "foo bar\nbaz |bar"); | |
| 249 | + | assert_eq!(after("|foo bar\nbaz bar", "?baz<CR>"), "foo bar\n|baz bar"); | |
| 250 | + | assert_eq!(after("|foo Bar bar", "/bar<CR>"), "foo |Bar bar"); | |
| 251 | + | assert_eq!(after("|foo Bar bar", "/Bar<CR>n"), "foo |Bar bar"); | |
| 252 | + | assert_eq!(after("|loop loops loop", "*"), "loop loops |loop"); | |
| 253 | + | assert_eq!(after("|foo bar", "d/bar<CR>"), "|bar"); | |
| 254 | + | assert_eq!(after("|foo bar", "d/zzz<CR>"), "|foo bar"); | |
| 255 | + | assert_eq!(after("|foo bar", "d/<Esc>x"), "|oo bar"); | |
| 256 | + | let mut t = T::new("|foo bar bar"); | |
| 257 | + | t.keys("/bar<CR>"); | |
| 258 | + | assert_eq!(t.vim.search_ranges(&t.text), vec![(4, 7), (8, 11)]); | |
| 259 | + | t.keys(":noh<CR>"); | |
| 260 | + | assert!(t.vim.search_ranges(&t.text).is_empty()); | |
| 261 | + | let mut t = T::new("|foo"); | |
| 262 | + | t.keys("/zzz<CR>"); | |
| 263 | + | assert_eq!(t.show(), "|foo"); | |
| 264 | + | assert!(t.vim.message.as_ref().is_some_and(|m| m.1)); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn macros() { | |
| 269 | + | assert_eq!(after("|1\n2\n3", "qaA;<Esc>jq@a"), "1;\n2;\n|3"); | |
| 270 | + | assert_eq!(after("|1\n2\n3\n4", "qaA;<Esc>jq2@a"), "1;\n2;\n3;\n|4"); | |
| 271 | + | assert_eq!(after("|1\n2\n3", "qaA;<Esc>jq@a@@"), "1;\n2;\n3|;"); | |
| 272 | + | let mut t = T::new("|x"); | |
| 273 | + | t.keys("qa"); | |
| 274 | + | assert_eq!(t.vim.recording(), Some('a')); | |
| 275 | + | t.keys("q"); | |
| 276 | + | assert_eq!(t.vim.recording(), None); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn ex_commands() { | |
| 281 | + | let mut t = T::new("|a\nb\nc"); | |
| 282 | + | t.keys(":w<CR>:wq<CR>:qa!<CR>:make<CR>:bn<CR>"); | |
| 283 | + | assert_eq!( | |
| 284 | + | t.fx, | |
| 285 | + | vec![ | |
| 286 | + | Effect::Save, | |
| 287 | + | Effect::Save, | |
| 288 | + | Effect::Close { force: false }, | |
| 289 | + | Effect::Quit { force: true }, | |
| 290 | + | Effect::Make, | |
| 291 | + | Effect::NextDoc, | |
| 292 | + | ] | |
| 293 | + | ); | |
| 294 | + | assert_eq!(after("|a\nb\n c", ":3<CR>"), "a\nb\n |c"); | |
| 295 | + | assert_eq!(after("|a\nb\n c", ":$<CR>"), "a\nb\n |c"); | |
| 296 | + | let mut t = T::new("|a"); | |
| 297 | + | t.dirty = true; | |
| 298 | + | t.keys(":q<CR>"); | |
| 299 | + | assert!(t.fx.is_empty()); | |
| 300 | + | assert!( | |
| 301 | + | t.vim | |
| 302 | + | .message | |
| 303 | + | .as_ref() | |
| 304 | + | .is_some_and(|m| m.0.starts_with("E37")) | |
| 305 | + | ); | |
| 306 | + | t.keys(":q!<CR>"); | |
| 307 | + | assert_eq!(t.fx, vec![Effect::Close { force: true }]); | |
| 308 | + | let mut t = T::new("|a"); | |
| 309 | + | t.keys(":wat<CR>"); | |
| 310 | + | assert!( | |
| 311 | + | t.vim | |
| 312 | + | .message | |
| 313 | + | .as_ref() | |
| 314 | + | .is_some_and(|m| m.0.starts_with("E492")) | |
| 315 | + | ); | |
| 316 | + | assert_eq!(t.vim.mode, Mode::Normal); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[test] | |
| 320 | + | fn read_only_allows_moving_and_yanking() { | |
| 321 | + | let mut t = T::new("|abc\ndef"); | |
| 322 | + | t.readonly = true; | |
| 323 | + | t.keys("x"); | |
| 324 | + | assert_eq!(t.show(), "|abc\ndef"); | |
| 325 | + | assert!(t.vim.message.as_ref().is_some_and(|m| m.1)); | |
| 326 | + | t.keys("ix"); | |
| 327 | + | assert_eq!(t.vim.mode, Mode::Normal); | |
| 328 | + | t.keys("jlyl"); | |
| 329 | + | assert_eq!(t.show(), "abc\nd|ef"); | |
| 330 | + | assert!(t.fx.contains(&Effect::Clipboard("e".into()))); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | #[test] | |
| 334 | + | fn goto_definition_and_tabs() { | |
| 335 | + | assert_eq!( | |
| 336 | + | after(" SJMP |loop\nloop: NOP", "gd"), | |
| 337 | + | " SJMP loop\n|loop: NOP" | |
| 338 | + | ); | |
| 339 | + | assert_eq!( | |
| 340 | + | after("MOV A,#|RELOAD\nRELOAD EQU 5", "gd"), | |
| 341 | + | "MOV A,#RELOAD\n|RELOAD EQU 5" | |
| 342 | + | ); | |
| 343 | + | let mut t = T::new("|a"); | |
| 344 | + | t.keys("gtgT"); | |
| 345 | + | assert_eq!(t.fx, vec![Effect::NextDoc, Effect::PrevDoc]); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | #[test] | |
| 349 | + | fn escape_and_pending() { | |
| 350 | + | let mut t = T::new("|abc"); | |
| 351 | + | t.keys("2d"); | |
| 352 | + | assert_eq!(t.vim.pending_label(), "2d"); | |
| 353 | + | t.keys("<Esc>"); | |
| 354 | + | assert_eq!(t.vim.pending_label(), ""); | |
| 355 | + | assert_eq!(t.show(), "|abc"); | |
| 356 | + | assert_eq!(after("ab|c", "i<Esc>"), "a|bc"); | |
| 357 | + | assert_eq!(after("|abc", "i<Esc>"), "|abc"); | |
| 358 | + | } | |
| @@ -0,0 +1,488 @@ | |||
| 1 | + | //! Char-indexed text helpers for the Vim engine: lines, columns, word | |
| 2 | + | //! classes, motions and text objects. Positions are char indices. | |
| 3 | + | ||
| 4 | + | /// How a motion's range is taken by an operator (`:help exclusive`). | |
| 5 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 6 | + | pub enum Kind { | |
| 7 | + | Exclusive, | |
| 8 | + | Inclusive, | |
| 9 | + | Linewise, | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | pub struct Buf { | |
| 13 | + | pub c: Vec<char>, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | pub fn is_blank(ch: char) -> bool { | |
| 17 | + | ch == ' ' || ch == '\t' | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | fn is_space(ch: char) -> bool { | |
| 21 | + | ch.is_whitespace() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | pub fn is_keyword(ch: char) -> bool { | |
| 25 | + | ch.is_alphanumeric() || ch == '_' | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// 0 whitespace, 1 keyword, 2 punctuation; `big` words only tell | |
| 29 | + | /// whitespace from the rest. | |
| 30 | + | fn class(ch: char, big: bool) -> u8 { | |
| 31 | + | if is_space(ch) { | |
| 32 | + | 0 | |
| 33 | + | } else if big || is_keyword(ch) { | |
| 34 | + | 1 | |
| 35 | + | } else { | |
| 36 | + | 2 | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl Buf { | |
| 41 | + | pub fn new(text: &str) -> Self { | |
| 42 | + | Self { | |
| 43 | + | c: text.chars().collect(), | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub fn text(&self) -> String { | |
| 48 | + | self.c.iter().collect() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | pub fn len(&self) -> usize { | |
| 52 | + | self.c.len() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn line_start(&self, pos: usize) -> usize { | |
| 56 | + | let mut i = pos.min(self.len()); | |
| 57 | + | while i > 0 && self.c[i - 1] != '\n' { | |
| 58 | + | i -= 1; | |
| 59 | + | } | |
| 60 | + | i | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Index of the line's `\n`, or the text length on the last line. | |
| 64 | + | pub fn line_end(&self, pos: usize) -> usize { | |
| 65 | + | let mut i = pos.min(self.len()); | |
| 66 | + | while i < self.len() && self.c[i] != '\n' { | |
| 67 | + | i += 1; | |
| 68 | + | } | |
| 69 | + | i | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | pub fn line_of(&self, pos: usize) -> usize { | |
| 73 | + | self.c[..pos.min(self.len())] | |
| 74 | + | .iter() | |
| 75 | + | .filter(|&&ch| ch == '\n') | |
| 76 | + | .count() | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | pub fn lines(&self) -> usize { | |
| 80 | + | self.c.iter().filter(|&&ch| ch == '\n').count() + 1 | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Start of `line`, clamped to the last line. | |
| 84 | + | pub fn start_of_line(&self, line: usize) -> usize { | |
| 85 | + | if line == 0 { | |
| 86 | + | return 0; | |
| 87 | + | } | |
| 88 | + | let mut seen = 0; | |
| 89 | + | let mut last_start = 0; | |
| 90 | + | for (i, &ch) in self.c.iter().enumerate() { | |
| 91 | + | if ch == '\n' { | |
| 92 | + | seen += 1; | |
| 93 | + | last_start = i + 1; | |
| 94 | + | if seen == line { | |
| 95 | + | return i + 1; | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | last_start | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | pub fn col(&self, pos: usize) -> usize { | |
| 103 | + | pos - self.line_start(pos) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | pub fn line_is_blank(&self, line: usize) -> bool { | |
| 107 | + | let s = self.start_of_line(line); | |
| 108 | + | let e = self.line_end(s); | |
| 109 | + | self.c[s..e].iter().all(|&ch| is_blank(ch)) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | pub fn line_text(&self, pos: usize) -> String { | |
| 113 | + | let s = self.line_start(pos); | |
| 114 | + | self.c[s..self.line_end(s)].iter().collect() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Position on `line` at `col`, clamped to the line's last char. | |
| 118 | + | pub fn at_col(&self, line: usize, col: usize) -> usize { | |
| 119 | + | let s = self.start_of_line(line); | |
| 120 | + | let e = self.line_end(s); | |
| 121 | + | if e == s { s } else { (s + col).min(e - 1) } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | pub fn first_non_blank(&self, pos: usize) -> usize { | |
| 125 | + | let s = self.line_start(pos); | |
| 126 | + | let e = self.line_end(s); | |
| 127 | + | let mut i = s; | |
| 128 | + | while i < e && is_blank(self.c[i]) { | |
| 129 | + | i += 1; | |
| 130 | + | } | |
| 131 | + | if i == e && e > s { e - 1 } else { i } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// In Normal mode the cursor sits on a character, never on the `\n` | |
| 135 | + | /// (except on an empty line). | |
| 136 | + | pub fn clamp_normal(&self, pos: usize) -> usize { | |
| 137 | + | let pos = pos.min(self.len()); | |
| 138 | + | let s = self.line_start(pos); | |
| 139 | + | let e = self.line_end(pos); | |
| 140 | + | if e > s && pos >= e { e - 1 } else { pos } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | pub fn insert(&mut self, at: usize, s: &str) -> usize { | |
| 144 | + | let new: Vec<char> = s.chars().collect(); | |
| 145 | + | let n = new.len(); | |
| 146 | + | self.c.splice(at..at, new); | |
| 147 | + | n | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | pub fn remove(&mut self, start: usize, end: usize) -> String { | |
| 151 | + | self.c.drain(start..end).collect() | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // ---- Motions ---- | |
| 155 | + | ||
| 156 | + | pub fn word_fwd(&self, pos: usize, big: bool) -> usize { | |
| 157 | + | let n = self.len(); | |
| 158 | + | if pos >= n { | |
| 159 | + | return n; | |
| 160 | + | } | |
| 161 | + | let mut i = pos; | |
| 162 | + | let cls = class(self.c[i], big); | |
| 163 | + | if cls != 0 { | |
| 164 | + | while i < n && class(self.c[i], big) == cls { | |
| 165 | + | i += 1; | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | while i < n && is_space(self.c[i]) { | |
| 169 | + | // An empty line counts as a word. | |
| 170 | + | if self.c[i] == '\n' && i + 1 < n && self.c[i + 1] == '\n' { | |
| 171 | + | return i + 1; | |
| 172 | + | } | |
| 173 | + | i += 1; | |
| 174 | + | } | |
| 175 | + | i | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | pub fn word_back(&self, pos: usize, big: bool) -> usize { | |
| 179 | + | if pos == 0 { | |
| 180 | + | return 0; | |
| 181 | + | } | |
| 182 | + | let mut i = pos - 1; | |
| 183 | + | while is_space(self.c[i]) { | |
| 184 | + | let empty_line = self.c[i] == '\n' && (i == 0 || self.c[i - 1] == '\n'); | |
| 185 | + | if empty_line && i < pos { | |
| 186 | + | return i; | |
| 187 | + | } | |
| 188 | + | if i == 0 { | |
| 189 | + | return 0; | |
| 190 | + | } | |
| 191 | + | i -= 1; | |
| 192 | + | } | |
| 193 | + | let cls = class(self.c[i], big); | |
| 194 | + | while i > 0 && class(self.c[i - 1], big) == cls { | |
| 195 | + | i -= 1; | |
| 196 | + | } | |
| 197 | + | i | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | pub fn word_end(&self, pos: usize, big: bool) -> usize { | |
| 201 | + | let n = self.len(); | |
| 202 | + | if n == 0 { | |
| 203 | + | return 0; | |
| 204 | + | } | |
| 205 | + | let mut i = pos + 1; | |
| 206 | + | while i < n && is_space(self.c[i]) { | |
| 207 | + | i += 1; | |
| 208 | + | } | |
| 209 | + | if i >= n { | |
| 210 | + | return n - 1; | |
| 211 | + | } | |
| 212 | + | let cls = class(self.c[i], big); | |
| 213 | + | while i + 1 < n && class(self.c[i + 1], big) == cls { | |
| 214 | + | i += 1; | |
| 215 | + | } | |
| 216 | + | i | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// End (inclusive) of the run of same-class chars starting at `pos`. | |
| 220 | + | pub fn run_end(&self, pos: usize, big: bool) -> usize { | |
| 221 | + | let cls = class(self.c[pos], big); | |
| 222 | + | let mut i = pos; | |
| 223 | + | while i + 1 < self.len() && self.c[i + 1] != '\n' && class(self.c[i + 1], big) == cls { | |
| 224 | + | i += 1; | |
| 225 | + | } | |
| 226 | + | i | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// `f`/`t` (forward) or `F`/`T` (backward) to `ch` on the current line. | |
| 230 | + | /// `repeat` is for `;` and `,`, where `t` mustn't stay stuck right | |
| 231 | + | /// before the char it stopped at last time. | |
| 232 | + | pub fn find_char( | |
| 233 | + | &self, | |
| 234 | + | pos: usize, | |
| 235 | + | kind: char, | |
| 236 | + | ch: char, | |
| 237 | + | count: usize, | |
| 238 | + | repeat: bool, | |
| 239 | + | ) -> Option<usize> { | |
| 240 | + | let s = self.line_start(pos); | |
| 241 | + | let e = self.line_end(pos); | |
| 242 | + | let mut i = pos; | |
| 243 | + | for k in 0..count { | |
| 244 | + | let skip = repeat && matches!(kind, 't' | 'T') && k == 0; | |
| 245 | + | i = match kind { | |
| 246 | + | 'f' | 't' => { | |
| 247 | + | let from = i + 1 + usize::from(skip && i + 1 < e && self.c[i + 1] == ch); | |
| 248 | + | (from..e).find(|&j| self.c[j] == ch)? | |
| 249 | + | } | |
| 250 | + | _ => { | |
| 251 | + | let to = if skip && i > s && self.c[i - 1] == ch { | |
| 252 | + | i - 1 | |
| 253 | + | } else { | |
| 254 | + | i | |
| 255 | + | }; | |
| 256 | + | (s..to).rev().find(|&j| self.c[j] == ch)? | |
| 257 | + | } | |
| 258 | + | }; | |
| 259 | + | } | |
| 260 | + | Some(match kind { | |
| 261 | + | 't' => i - 1, | |
| 262 | + | 'T' => i + 1, | |
| 263 | + | _ => i, | |
| 264 | + | }) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// `%`: the bracket matching the first one at or after `pos` on its line. | |
| 268 | + | pub fn match_pair(&self, pos: usize) -> Option<usize> { | |
| 269 | + | let e = self.line_end(pos); | |
| 270 | + | let start = (pos..e).find(|&i| "()[]{}".contains(self.c[i]))?; | |
| 271 | + | let open = self.c[start]; | |
| 272 | + | let (close, fwd) = match open { | |
| 273 | + | '(' => (')', true), | |
| 274 | + | '[' => (']', true), | |
| 275 | + | '{' => ('}', true), | |
| 276 | + | ')' => ('(', false), | |
| 277 | + | ']' => ('[', false), | |
| 278 | + | _ => ('{', false), | |
| 279 | + | }; | |
| 280 | + | let mut depth = 0usize; | |
| 281 | + | if fwd { | |
| 282 | + | for i in start..self.len() { | |
| 283 | + | if self.c[i] == open { | |
| 284 | + | depth += 1; | |
| 285 | + | } else if self.c[i] == close { | |
| 286 | + | depth -= 1; | |
| 287 | + | if depth == 0 { | |
| 288 | + | return Some(i); | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } else { | |
| 293 | + | for i in (0..=start).rev() { | |
| 294 | + | if self.c[i] == open { | |
| 295 | + | depth += 1; | |
| 296 | + | } else if self.c[i] == close { | |
| 297 | + | depth -= 1; | |
| 298 | + | if depth == 0 { | |
| 299 | + | return Some(i); | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | None | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | /// `}` (forward) or `{`: the next blank line past the current paragraph. | |
| 308 | + | pub fn paragraph(&self, pos: usize, fwd: bool) -> usize { | |
| 309 | + | let last = self.lines() - 1; | |
| 310 | + | let mut l = self.line_of(pos); | |
| 311 | + | if fwd { | |
| 312 | + | while l < last && self.line_is_blank(l) { | |
| 313 | + | l += 1; | |
| 314 | + | } | |
| 315 | + | while l < last && !self.line_is_blank(l) { | |
| 316 | + | l += 1; | |
| 317 | + | } | |
| 318 | + | if l == last && !self.line_is_blank(l) { | |
| 319 | + | return self.len(); | |
| 320 | + | } | |
| 321 | + | } else { | |
| 322 | + | while l > 0 && self.line_is_blank(l) { | |
| 323 | + | l -= 1; | |
| 324 | + | } | |
| 325 | + | while l > 0 && !self.line_is_blank(l) { | |
| 326 | + | l -= 1; | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | self.start_of_line(l) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Text object `c` (`w W " ' ( b [ { B < p`) around `pos`. | |
| 333 | + | pub fn object(&self, pos: usize, c: char, around: bool) -> Option<(usize, usize, bool)> { | |
| 334 | + | match c { | |
| 335 | + | 'w' => self.obj_word(pos, false, around), | |
| 336 | + | 'W' => self.obj_word(pos, true, around), | |
| 337 | + | '"' | '\'' | '`' => self.obj_quote(pos, c, around), | |
| 338 | + | '(' | ')' | 'b' => self.obj_paren(pos, '(', ')', around), | |
| 339 | + | '[' | ']' => self.obj_paren(pos, '[', ']', around), | |
| 340 | + | '{' | '}' | 'B' => self.obj_paren(pos, '{', '}', around), | |
| 341 | + | '<' | '>' => self.obj_paren(pos, '<', '>', around), | |
| 342 | + | 'p' => self.obj_paragraph(pos, around), | |
| 343 | + | _ => None, | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // ---- Text objects ---- | |
| 348 | + | // | |
| 349 | + | // Charwise objects return (start, end exclusive, false); linewise ones | |
| 350 | + | // return (a position on the first line, one on the last line, true). | |
| 351 | + | ||
| 352 | + | pub fn obj_word(&self, pos: usize, big: bool, around: bool) -> Option<(usize, usize, bool)> { | |
| 353 | + | if pos >= self.len() || self.c[pos] == '\n' { | |
| 354 | + | return None; | |
| 355 | + | } | |
| 356 | + | let s = self.line_start(pos); | |
| 357 | + | let e = self.line_end(pos); | |
| 358 | + | let cls = class(self.c[pos], big); | |
| 359 | + | let mut a = pos; | |
| 360 | + | while a > s && class(self.c[a - 1], big) == cls { | |
| 361 | + | a -= 1; | |
| 362 | + | } | |
| 363 | + | let mut b = pos + 1; | |
| 364 | + | while b < e && class(self.c[b], big) == cls { | |
| 365 | + | b += 1; | |
| 366 | + | } | |
| 367 | + | if around { | |
| 368 | + | let b2 = (b..e).find(|&i| !is_blank(self.c[i])).unwrap_or(e); | |
| 369 | + | if b2 > b { | |
| 370 | + | b = b2; | |
| 371 | + | } else { | |
| 372 | + | while a > s && is_blank(self.c[a - 1]) { | |
| 373 | + | a -= 1; | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | Some((a, b, false)) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | pub fn obj_quote(&self, pos: usize, q: char, around: bool) -> Option<(usize, usize, bool)> { | |
| 381 | + | let s = self.line_start(pos); | |
| 382 | + | let e = self.line_end(pos); | |
| 383 | + | let quotes: Vec<usize> = (s..e).filter(|&i| self.c[i] == q).collect(); | |
| 384 | + | let pair = quotes | |
| 385 | + | .as_chunks::<2>() | |
| 386 | + | .0 | |
| 387 | + | .iter() | |
| 388 | + | .find(|[_, close]| pos <= *close) | |
| 389 | + | .map(|&[open, close]| (open, close))?; | |
| 390 | + | let (open, close) = pair; | |
| 391 | + | if around { | |
| 392 | + | let mut b = close + 1; | |
| 393 | + | while b < e && is_blank(self.c[b]) { | |
| 394 | + | b += 1; | |
| 395 | + | } | |
| 396 | + | Some((open, b, false)) | |
| 397 | + | } else { | |
| 398 | + | Some((open + 1, close, false)) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | pub fn obj_paren( | |
| 403 | + | &self, | |
| 404 | + | pos: usize, | |
| 405 | + | open: char, | |
| 406 | + | close: char, | |
| 407 | + | around: bool, | |
| 408 | + | ) -> Option<(usize, usize, bool)> { | |
| 409 | + | let mut depth = 0usize; | |
| 410 | + | let mut o = None; | |
| 411 | + | let start = if pos < self.len() && self.c[pos] == close { | |
| 412 | + | pos.checked_sub(1)? | |
| 413 | + | } else { | |
| 414 | + | pos | |
| 415 | + | }; | |
| 416 | + | for i in (0..=start.min(self.len().saturating_sub(1))).rev() { | |
| 417 | + | if self.c[i] == close && i != pos { | |
| 418 | + | depth += 1; | |
| 419 | + | } else if self.c[i] == open { | |
| 420 | + | if depth == 0 { | |
| 421 | + | o = Some(i); | |
| 422 | + | break; | |
| 423 | + | } | |
| 424 | + | depth -= 1; | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | let o = o?; | |
| 428 | + | let mut depth = 0usize; | |
| 429 | + | let c = (o + 1..self.len()).find(|&i| { | |
| 430 | + | if self.c[i] == open { | |
| 431 | + | depth += 1; | |
| 432 | + | } else if self.c[i] == close { | |
| 433 | + | if depth == 0 { | |
| 434 | + | return true; | |
| 435 | + | } | |
| 436 | + | depth -= 1; | |
| 437 | + | } | |
| 438 | + | false | |
| 439 | + | })?; | |
| 440 | + | Some(if around { | |
| 441 | + | (o, c + 1, false) | |
| 442 | + | } else { | |
| 443 | + | (o + 1, c, false) | |
| 444 | + | }) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | pub fn obj_paragraph(&self, pos: usize, around: bool) -> Option<(usize, usize, bool)> { | |
| 448 | + | let last = self.lines() - 1; | |
| 449 | + | let l = self.line_of(pos); | |
| 450 | + | let blank = self.line_is_blank(l); | |
| 451 | + | let mut a = l; | |
| 452 | + | while a > 0 && self.line_is_blank(a - 1) == blank { | |
| 453 | + | a -= 1; | |
| 454 | + | } | |
| 455 | + | let mut b = l; | |
| 456 | + | while b < last && self.line_is_blank(b + 1) == blank { | |
| 457 | + | b += 1; | |
| 458 | + | } | |
| 459 | + | if around { | |
| 460 | + | while b < last && self.line_is_blank(b + 1) != blank { | |
| 461 | + | b += 1; | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | Some((self.start_of_line(a), self.start_of_line(b), true)) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | /// Indentation for a new line after `line`: its leading whitespace, or for | |
| 469 | + | /// a `label:` line, the column where its instruction starts (8 if bare). | |
| 470 | + | pub fn auto_indent(line: &str) -> String { | |
| 471 | + | let lead: String = line.chars().take_while(|&c| is_blank(c)).collect(); | |
| 472 | + | if !lead.is_empty() || line.trim().is_empty() { | |
| 473 | + | return lead; | |
| 474 | + | } | |
| 475 | + | let label_end = line | |
| 476 | + | .char_indices() | |
| 477 | + | .find(|&(_, c)| !(is_keyword(c) || c == '?')) | |
| 478 | + | .map_or(line.len(), |(i, _)| i); | |
| 479 | + | if label_end > 0 && line[label_end..].starts_with(':') { | |
| 480 | + | let rest = &line[label_end + 1..]; | |
| 481 | + | if rest.trim().is_empty() || rest.trim_start().starts_with(';') { | |
| 482 | + | return " ".repeat(8); | |
| 483 | + | } | |
| 484 | + | let pad = rest.chars().take_while(|&c| is_blank(c)).count(); | |
| 485 | + | return " ".repeat(line[..label_end].chars().count() + 1 + pad); | |
| 486 | + | } | |
| 487 | + | String::new() | |
| 488 | + | } | |