Krolyxon
Commit 2a1ff04

Branches

Tags

Refs →

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.
Author
krolyxon
Date
2026-11-01 09:45 +0530
Parent
0103561
Commit
2a1ff0423c64db52b956d0a70ab8ef3c33d99c27

+502 −44

8 files changed

  1. M CHANGELOG.md +21 −0
  2. M README.md +33 −5
  3. M crates/bench51-gui/src/app.rs +56 −8
  4. M crates/bench51-gui/src/chrome.rs +26 −12
  5. M crates/bench51-gui/src/editor.rs +301 −16
  6. M crates/bench51-gui/src/main.rs +10 −1
  7. M crates/bench51-gui/src/modals.rs +28 −1
  8. M crates/bench51-gui/src/state.rs +27 −1
M CHANGELOG.md View
@@ -4,6 +4,27 @@ All notable changes to bench51. The format follows
44 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
55 [Semantic Versioning](https://semver.org/).
66
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+
728 ## [0.2.0] - 2026-11-01
829
930 ### Added
M README.md View
@@ -65,8 +65,8 @@ Linux, in a window or right in the terminal.
6565 UART #1, Ports, Timers and Interrupts windows. Every window docks and tabs
6666 wherever you drag it; the edit and debug layouts are remembered separately,
6767 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.
7070 - **Two-pass assembler** for the full 8051 instruction set (all 255 opcodes):
7171 - labels, expressions (`+ - * / MOD AND OR XOR NOT SHL SHR HIGH LOW`, `$`)
7272 - number formats `42`, `2Ah`, `0x2A`, `101010b`, `52o`, `'*'`
@@ -132,9 +132,10 @@ bench51-gui firmware.hex # disassemble & debug a HEX file
132132 bench51-gui --theme light
133133 ```
134134
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.
138139
139140 On Linux it runs on Wayland and X11 and uses the XDG desktop portal for file
140141 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
164165 bit chips in Registers, Timers and Interrupts to flip SFR bits. Files can be
165166 dropped onto the window.
166167
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+
167195 ## Terminal version
168196
169197 `bench51` is the same IDE in a terminal, the way `emacs -nw` is Emacs
M crates/bench51-gui/src/app.rs View
@@ -10,9 +10,11 @@ use eframe::egui::{
1010 use egui_tiles::Tree;
1111
1212 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+};
1416 use crate::theme::{self, palette};
15−use crate::{chrome, modals};
17+use crate::{chrome, editor, modals};
1618
1719 /// One simulation chunk, as in the TUI's frame loop.
1820 const TICK: f64 = 1.0 / 30.0;
@@ -43,6 +45,10 @@ pub struct GuiApp {
4345 /// The user confirmed quitting with unsaved changes.
4446 allow_close: bool,
4547 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,
4652 }
4753
4854 fn theme_preference(t: ThemePref) -> ThemePreference {
@@ -76,6 +82,7 @@ impl GuiApp {
7682 files: Vec<PathBuf>,
7783 clock: Option<f64>,
7884 theme: Option<ThemePref>,
85+ keymap: Option<Keymap>,
7986 ) -> Self {
8087 let ctx = &cc.egui_ctx;
8188 theme::install(ctx);
@@ -83,16 +90,23 @@ impl GuiApp {
8390 .storage
8491 .and_then(|s| eframe::get_value(s, "prefs"))
8592 .unwrap_or_default();
93+ let saved_prefs = prefs.clone();
8694 if let Some(c) = clock {
8795 prefs.clock_mhz = c;
8896 }
8997 if let Some(t) = theme {
9098 prefs.theme = t;
9199 }
100+ if let Some(k) = keymap {
101+ prefs.keymap = k;
102+ }
92103 prefs.speed = prefs.speed.min(bench51_core::session::SPEEDS.len() - 1);
93104 ctx.set_theme(theme_preference(prefs.theme));
94105 ctx.set_zoom_factor(prefs.zoom.clamp(0.5, 3.0));
106+ let cli = prefs.clone();
95107 let mut app = Self {
108+ saved_prefs,
109+ cli,
96110 st: State::new(prefs),
97111 edit_tree: load_tree(cc, "edit_layout", layout::edit_tree),
98112 debug_tree: load_tree(cc, "debug_layout", layout::debug_tree),
@@ -125,6 +139,12 @@ impl GuiApp {
125139 let none = Modifiers::NONE;
126140 let running = self.st.running();
127141 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))));
128148 // Most specific first: `consume_key` ignores extra Shift.
129149 let table: [(Modifiers, Key, Action); 25] = [
130150 (cmd_shift, Key::S, Action::SaveAs),
@@ -155,6 +175,9 @@ impl GuiApp {
155175 ];
156176 ctx.input_mut(|i| {
157177 for (mods, key, action) in table {
178+ if vim_editor && action == Action::Find {
179+ continue;
180+ }
158181 if i.consume_key(mods, key) {
159182 self.st.actions.push(action);
160183 }
@@ -299,13 +322,16 @@ impl GuiApp {
299322 }
300323 }
301324
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>) {
303328 let dir = self.start_dir();
304329 let Some(doc) = self.st.doc_mut() else {
305330 return;
306331 };
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()),
309335 _ => {
310336 let name = doc.name();
311337 let stem = name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s);
@@ -350,6 +376,8 @@ impl GuiApp {
350376 if self.st.active >= i && self.st.active > 0 {
351377 self.st.active -= 1;
352378 }
379+ self.st.vim.abandon();
380+ self.st.focus_editor = true;
353381 if self.st.docs.is_empty() {
354382 self.st.mode = Mode::Edit;
355383 self.st.active = 0;
@@ -368,7 +396,9 @@ impl GuiApp {
368396 if i < self.st.docs.len() && i != self.st.active {
369397 self.pause_active();
370398 self.st.active = i;
399+ self.st.vim.abandon();
371400 }
401+ self.st.focus_editor = true;
372402 }
373403
374404 // ---- Build & debug ----
@@ -461,8 +491,9 @@ impl GuiApp {
461491 }
462492 Action::Open => self.open_dialog(),
463493 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)),
466497 Action::Close(i) => {
467498 if self.st.docs.get(i).is_some_and(|d| d.dirty()) {
468499 self.st.modal = Some(Modal::ConfirmClose(i));
@@ -554,6 +585,11 @@ impl GuiApp {
554585 self.st.prefs.theme = t;
555586 ctx.set_theme(theme_preference(t));
556587 }
588+ Action::SetKeymap(k) => {
589+ self.st.prefs.keymap = k;
590+ self.st.vim.abandon();
591+ self.st.focus_editor = true;
592+ }
557593 Action::Zoom(d) => ctx.set_zoom_factor((ctx.zoom_factor() + d).clamp(0.5, 3.0)),
558594 Action::ZoomReset => ctx.set_zoom_factor(1.0),
559595 Action::Find => {
@@ -643,7 +679,19 @@ impl eframe::App for GuiApp {
643679 }
644680
645681 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);
647695 eframe::set_value(storage, "edit_layout", &self.edit_tree);
648696 eframe::set_value(storage, "debug_layout", &self.debug_tree);
649697 }
M crates/bench51-gui/src/chrome.rs View
@@ -6,7 +6,7 @@ use eframe::egui::{self, Align, Button, FontId, Layout, RichText, Stroke, Ui};
66
77 use crate::icons::Icon;
88 use crate::layout::Pane;
9−use crate::state::{Action, Modal, Mode, State, ThemePref};
9+use crate::state::{Action, Keymap, Modal, Mode, State, ThemePref};
1010 use crate::theme::{ACCENT, LABEL, LABEL_TRACKING, palette};
1111 use crate::widgets;
1212
@@ -99,17 +99,6 @@ pub fn menu_bar(ui: &mut Ui, st: &mut State) {
9999 }
100100 }
101101 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− });
113102 if item(ui, "Zoom in", "Ctrl++") {
114103 acts.push(Action::Zoom(0.1));
115104 }
@@ -185,6 +174,26 @@ pub fn menu_bar(ui: &mut Ui, st: &mut State) {
185174 }
186175 }
187176 });
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+ });
188197 ui.menu_button(title(ui, "Help"), |ui| {
189198 if item(ui, "Keyboard shortcuts", "F1") {
190199 acts.push(Action::Modal(Modal::Shortcuts));
@@ -374,6 +383,11 @@ pub fn status_bar(ui: &mut Ui, st: &State) {
374383 "edit"
375384 };
376385 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));
377391 // Running: a dot that blinks in hard 2 Hz steps.
378392 let (r, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
379393 let on = !live || (ui.input(|i| i.time) * 4.0) as i64 % 2 == 0;
M crates/bench51-gui/src/editor.rs View
@@ -4,9 +4,12 @@
44 use std::collections::{HashMap, HashSet};
55
66 use bench51_core::syntax::tokens;
7+use std::path::PathBuf;
8+
79 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,
1013 cache::{ComputerMut, FrameCache},
1114 pos2,
1215 text::{CCursor, CCursorRange, LayoutJob},
@@ -14,8 +17,9 @@ use eframe::egui::{
1417 vec2,
1518 };
1619
17−use crate::state::{Action, Document, Mode, State};
20+use crate::state::{Action, Document, Keymap, Mode, State};
1821 use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette};
22+use crate::vim::{self, VimKey};
1923 use crate::widgets::{self, PillKind};
2024
2125 const MARGIN: Vec2 = vec2(10.0, 8.0);
@@ -29,7 +33,173 @@ pub fn show(ui: &mut Ui, st: &mut State) {
2933 if st.find.open {
3034 find_bar(ui, st);
3135 }
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)
33203 }
34204
35205 // ------------------------------------------------------------- tabs, find
@@ -169,6 +339,7 @@ fn find_bar(ui: &mut Ui, st: &mut State) {
169339 }
170340 if close {
171341 st.find.open = false;
342+ st.focus_editor = true;
172343 }
173344 }
174345
@@ -373,12 +544,34 @@ struct Marks {
373544
374545 fn code(ui: &mut Ui, st: &mut State) {
375546 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+ };
376562 let query = if st.find.open {
377563 st.find.query.clone()
378564 } else {
379565 String::new()
380566 };
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 {
382575 return;
383576 };
384577 let p = palette(ui.ctx());
@@ -421,13 +614,8 @@ fn code(ui: &mut Ui, st: &mut State) {
421614 0.0
422615 };
423616 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();
426617
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)) {
431619 insert_tab(&ctx, edit_id, &mut doc.text);
432620 }
433621
@@ -459,9 +647,30 @@ fn code(ui: &mut Ui, st: &mut State) {
459647 }
460648 let hit = doc.find_hit;
461649
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+ };
463671 let mut clicked_line = None;
464672 let mut cursor = None;
673+ let mut mouse = None;
465674
466675 egui::ScrollArea::both()
467676 .id_salt(("code", doc.id))
@@ -492,7 +701,10 @@ fn code(ui: &mut Ui, st: &mut State) {
492701 } else {
493702 &mut doc.text
494703 };
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)
496708 .id(edit_id)
497709 .font(font.clone())
498710 .frame(egui::Frame::NONE)
@@ -500,9 +712,30 @@ fn code(ui: &mut Ui, st: &mut State) {
500712 .desired_width(f32::INFINITY)
501713 .desired_rows(1)
502714 .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);
505727 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+ }
506739
507740 let galley = &out.galley;
508741 let origin = out.galley_pos;
@@ -609,6 +842,53 @@ fn code(ui: &mut Ui, st: &mut State) {
609842 gutter_resp.on_hover_text(msg);
610843 }
611844
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+
612892 if let Some(line) = follow {
613893 ui.scroll_to_rect(row_rect(line), Some(Align::Center));
614894 }
@@ -621,7 +901,12 @@ fn code(ui: &mut Ui, st: &mut State) {
621901 });
622902 });
623903
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 {
625910 doc.cursor_char = ci;
626911 doc.cursor = line_col(&doc.text, ci);
627912 }
M crates/bench51-gui/src/main.rs View
@@ -10,6 +10,7 @@ mod modals;
1010 mod panes;
1111 mod state;
1212 mod theme;
13+mod vim;
1314 mod widgets;
1415
1516 use std::path::PathBuf;
@@ -32,6 +33,10 @@ struct Args {
3233 /// Colour theme, overriding the saved choice.
3334 #[arg(long, value_enum)]
3435 theme: Option<state::ThemePref>,
36+
37+ /// Editor keybindings, overriding the saved choice.
38+ #[arg(long, value_enum)]
39+ keymap: Option<state::Keymap>,
3540 }
3641
3742 fn main() -> eframe::Result {
@@ -50,7 +55,11 @@ fn main() -> eframe::Result {
5055 options,
5156 Box::new(move |cc| {
5257 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,
5463 )))
5564 }),
5665 )
M crates/bench51-gui/src/modals.rs View
@@ -8,6 +8,30 @@ use crate::state::{Action, Modal, State};
88 use crate::theme::{BODY, HERO, palette};
99 use crate::widgets::{self, PillKind};
1010
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+
1135 const SHORTCUTS: &[(&str, &[(&str, &str)])] = &[
1236 (
1337 "File",
@@ -160,7 +184,10 @@ pub fn show(ctx: &Context, st: &mut State) {
160184 egui::ScrollArea::vertical()
161185 .max_height(420.0)
162186 .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 {
164191 ui.label(p.label(section).color(p.text_disabled));
165192 ui.add_space(4.0);
166193 egui::Grid::new(section)
M crates/bench51-gui/src/state.rs View
@@ -6,6 +6,7 @@ use bench51_core::session::{MemSpace, Session};
66 use serde::{Deserialize, Serialize};
77
88 use crate::layout::Pane;
9+use crate::vim::{self, Vim};
910
1011 /// Keil's two window layouts: building and debugging.
1112 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -14,6 +15,13 @@ pub enum Mode {
1415 Debug,
1516 }
1617
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+
1725 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
1826 pub enum ThemePref {
1927 System,
@@ -22,10 +30,11 @@ pub enum ThemePref {
2230 }
2331
2432 /// Settings that survive restarts.
25−#[derive(Serialize, Deserialize)]
33+#[derive(Clone, Serialize, Deserialize)]
2634 #[serde(default)]
2735 pub struct Prefs {
2836 pub theme: ThemePref,
37+ pub keymap: Keymap,
2938 pub recent: Vec<PathBuf>,
3039 pub speed: usize,
3140 pub clock_mhz: f64,
@@ -36,6 +45,7 @@ impl Default for Prefs {
3645 fn default() -> Self {
3746 Self {
3847 theme: ThemePref::System,
48+ keymap: Keymap::Vim,
3949 recent: Vec::new(),
4050 speed: 5,
4151 clock_mhz: 11.0592,
@@ -76,6 +86,8 @@ pub struct Document {
7686 pub find_hit: Option<(usize, usize)>,
7787 /// Scroll the editor and disassembly to the PC on the next frame.
7888 pub follow_pc: bool,
89+ /// Vim undo history.
90+ pub history: vim::History,
7991 }
8092
8193 impl Document {
@@ -94,6 +106,7 @@ impl Document {
94106 select: None,
95107 find_hit: None,
96108 follow_pc: false,
109+ history: vim::History::default(),
97110 }
98111 }
99112
@@ -154,6 +167,8 @@ pub enum Action {
154167 OpenPath(PathBuf),
155168 Save,
156169 SaveAs,
170+ /// Save the active document to this path (`:w file`).
171+ SaveTo(PathBuf),
157172 /// Close a document, asking first if it has unsaved changes.
158173 Close(usize),
159174 /// Close without asking.
@@ -182,6 +197,7 @@ pub enum Action {
182197 ShowPane(Pane),
183198 ResetLayout,
184199 SetTheme(ThemePref),
200+ SetKeymap(Keymap),
185201 Zoom(f32),
186202 ZoomReset,
187203 Find,
@@ -228,6 +244,11 @@ pub struct State {
228244 pub build_tab: BuildTab,
229245 pub serial_input: String,
230246 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,
231252 }
232253
233254 impl State {
@@ -254,6 +275,9 @@ impl State {
254275 query: String::new(),
255276 focus: false,
256277 },
278+ vim: Vim::default(),
279+ vim_view: vim::View::default(),
280+ focus_editor: false,
257281 }
258282 }
259283
@@ -272,6 +296,8 @@ impl State {
272296 doc.session.speed = self.prefs.speed;
273297 self.docs.push(doc);
274298 self.active = self.docs.len() - 1;
299+ self.vim.abandon();
300+ self.focus_editor = true;
275301 self.docs.last_mut().unwrap()
276302 }
277303