bench512a1ff04
GUI: Vim keybindings by default, Settings menu to switch
The focused editor's keys go to the Vim engine: block cursor, mode line, mouse selections become Visual mode, yanks reach the system clipboard and Ctrl+V pastes. A new Settings menu holds Keybindings (Vim / Standard) and Theme, and --keymap picks one for a run. Command-line --theme, --keymap and --clock no longer overwrite the saved settings.
+502 −44
8 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
| @@ -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 | ||