escriba_runtime/lib.rs
1//! `escriba-runtime` — editor state machine.
2//!
3//! Wraps everything: `BufferSet`, `ModalState`, `Keymap`, `CommandRegistry`,
4//! `Layout`. Exposes `tick(input)` which advances one frame's worth of
5//! state given one input event. Pure — no rendering, no I/O beyond file
6//! save/load through `BufferSet`.
7
8extern crate self as escriba_runtime;
9
10mod plugin_host;
11pub use plugin_host::{LazyTrigger, PluginHost};
12
13mod operator_pending;
14pub mod status;
15
16pub use operator_pending::{OpState, OperatorPending};
17pub use status::{PromptKind, StatusModel};
18
19use std::collections::HashMap;
20
21use awase::KeyRepeatGate;
22use escriba_buffer::BufferSet;
23use escriba_buffer::TextRev;
24use escriba_command::CommandRegistry;
25use escriba_core::{
26 Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList,
27 Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
28};
29use escriba_input::{InputOutcome, translate_app_event};
30use escriba_keymap::{Key, Keymap};
31use escriba_madoguchi::{Negai, Outcome};
32use escriba_mode::ModalState;
33use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
34use escriba_ui::chrome::{ChromePalette, FleetTheme};
35use escriba_ui::splash::Splash;
36use escriba_ui::{Layout, Viewport, Window};
37use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
38use madori::AppEvent;
39use std::time::Instant;
40
41/// Full editor state — the single Rust value the binary hands to the
42/// renderer each frame.
43pub struct EditorState {
44 pub buffers: BufferSet,
45 pub modal: ModalState,
46 /// Search session — the committed pattern, its matches, the live `/`
47 /// prompt and history. Owns no buffer or cursor; it answers questions
48 /// about text and this runtime applies the answers.
49 pub search: SearchState,
50 pub keymap: Keymap,
51 pub commands: CommandRegistry,
52 pub layout: Layout,
53 pub active: BufferId,
54 /// The single typed home for cursor state. Phase-1 holds one primary
55 /// [`Position`]; reads go through [`Self::cursor`], writes through
56 /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
57 /// `Position` field beside an unused multi-caret type to desync.
58 cursors: Cursors,
59 pub quit_requested: bool,
60 /// Messages surfaced to the user (status line / `:messages`) — the
61 /// sink for the tatara-lisp `(message …)` effect and other feedback.
62 pub messages: Vec<String>,
63 /// Which match the cursor last landed on (0-based) — the `[3/17]`
64 /// numerator, ANCHORED to the text revision it was computed against.
65 ///
66 /// The anchor is what removes the manual invalidation this field used to
67 /// need. An ordinal indexes a match set; when the text changes the set
68 /// changes underneath it and the number silently means something else.
69 /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
70 /// forgetting to clear is no longer a thing that can be forgotten.
71 search_at: Option<Anchored<usize, TextRev>>,
72 /// The last text change, for `.`.
73 ///
74 /// An action plus whatever was typed while it held Insert open. Both
75 /// halves are needed: `cw` alone is not a change, it is the FIRST HALF of
76 /// one — the text that followed is the rest, and replaying without it
77 /// would delete a word and leave the buffer in Insert.
78 last_change: Option<LastChange>,
79 /// True while an insert session belonging to `last_change` is open, so
80 /// typed characters are appended to it. Cleared on leaving Insert.
81 recording_insert: bool,
82
83 /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
84 /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
85 /// makes a search a place you can come back from.
86 pub jumps: JumpList,
87 /// Generic editor option store (name → value). Written by the
88 /// tatara-lisp `(set-option …)` effect and the declarative
89 /// `defoption` apply path; typed accessors layer on top later.
90 pub options: HashMap<String, String>,
91 /// Cached embedded tatara-lisp runtime, built lazily on first
92 /// `run_lisp`. Caching avoids re-installing the ~175-definition full
93 /// stdlib on every call; the interpreter's top-level env also
94 /// persists across calls, giving REPL-like session semantics (an
95 /// earlier `(define …)` is visible to a later `run_lisp`).
96 lisp_vm: Option<EscribaVm>,
97 /// Keys accumulated for an in-progress multi-key sequence — e.g.
98 /// holding `[,, f]` while waiting for the final key of
99 /// `<leader>ff`. Empty when not mid-sequence. Lives on
100 /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
101 /// depend on `escriba-keymap`'s `Key`.
102 pub pending_keys: Vec<Key>,
103 /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
104 /// the windowing system deliver one `KeyDown` per repeat tick
105 /// (~30-50ms); without a gate those flood the motion path and thrash
106 /// the viewport. The gate lets ONE event per `min_interval` (80ms
107 /// default — ~12 intentional taps/sec still pass) reach the editor in
108 /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
109 /// the same one mado uses) is reused — not reinvented.
110 repeat_gate: KeyRepeatGate<Key>,
111 /// Runtime lazy-activation host for USER plugin caixas (the bundled
112 /// default catalog is applied eagerly at boot, not through here).
113 /// A command / filetype-open / event fires the matching plugins'
114 /// entries through the escriba-lisp apply paths. See [`PluginHost`].
115 pub plugin_host: PluginHost,
116 /// The unnamed register — the home for text an operator yanks or
117 /// deletes (`Operator::leaves_register`). `None` until the first
118 /// register-leaving operator runs. Phase-1 holds the single unnamed
119 /// register; named registers (`"ay`) layer on later.
120 register: Option<String>,
121 /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
122 /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
123 /// action passes through it; only an operator-then-motion pair is rewritten
124 /// into an [`Action::ApplyOperator`].
125 op_pending: zenmai::Stateful<OperatorPending>,
126 /// Monotonic refresh-generation stamp — the root of the sealed refresh
127 /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
128 /// action + resize; the renderer gates on it so an idle frame does zero
129 /// re-highlight / re-shape, and a stale frame is unreachable.
130 edit_gen: EditGen,
131 /// The accumulated dirty region since the renderer last drained it (M1).
132 /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
133 /// always covers the changed region (`Damage ⊇ changed`); the renderer
134 /// drains it with [`take_damage`](Self::take_damage) to scope its work.
135 damage: Damage,
136 /// The theme every face paints with.
137 ///
138 /// ONE owner. Before this, `(deftheme :preset …)` parsed, validated,
139 /// resolved to a real `FleetTheme` — and then nothing consumed it,
140 /// because each renderer called `ChromePalette::prescribed()` at every
141 /// paint site. The declaration was honoured on paper only. Holding it
142 /// here means a face reads the operator's theme the same way it reads
143 /// the cursor: from the state, per frame.
144 theme: FleetTheme,
145 /// `theme` resolved to concrete colours — cached because it is a plain
146 /// `Copy` struct read many times per frame, and re-derived only in
147 /// [`set_theme`](Self::set_theme), so the two cannot disagree.
148 chrome: ChromePalette,
149 /// How deep the current command dispatch is nested.
150 ///
151 /// `Negai::RunCommand` lets a command invoke a command, which is useful
152 /// and which can also recurse forever. The budget makes the runaway
153 /// bounded and REPORTED rather than a stack overflow — the difference
154 /// between a typed refusal and the editor dying under the operator.
155 dispatch_depth: u8,
156 /// Every live result list — diagnostics, hunks, grep hits, TODOs.
157 ///
158 /// Public so a producer outside the runtime can publish into it once the
159 /// courier lands; today the only producer is the marker scan.
160 pub results: escriba_shirube::ListRegistry,
161 /// The open picker, if any.
162 ///
163 /// `Option<Picker>` on the state, exactly like `splash` — deliberately
164 /// NOT a `Mode` variant. A mode is a state keys are interpreted IN; this
165 /// is a surface that OWNS keys while it is up, which is a different
166 /// thing and composes differently with the keymap.
167 picker: Option<escriba_ui::picker::Picker>,
168 /// The git-index generation. See [`world`](Self::world) — every axis the
169 /// world can move is emitted unconditionally, so a producer anchoring on
170 /// one is not born permanently stale.
171 index_rev: escriba_shirube::IndexRev,
172 /// The external-session generation (LSP restart, debug session, test run).
173 session_gen: escriba_shirube::SessionGen,
174 /// Extension → language facts, populated from `(defmode …)`.
175 ///
176 /// The consumer `:commentstring` never had. Public so the binary's apply
177 /// pass can fill it the way it fills the keymap and the option store.
178 pub filetypes: escriba_core::FiletypeTable,
179 /// The start screen, while it is up.
180 ///
181 /// `Some` only between boot and the first keypress, and only when the
182 /// editor opened with no file. It is deliberately NOT a `Mode`: a mode
183 /// is a state keys are interpreted *in*, and the splash interprets
184 /// exactly one key before it is gone. Modelling it as `Option<Splash>`
185 /// keeps the modal state machine's variant set — and every exhaustive
186 /// match over it — untouched.
187 splash: Option<Splash>,
188}
189
190/// What the start screen did with a keypress.
191///
192/// Total, and matched exhaustively at its one call site, so a future
193/// outcome (a menu that opens a submenu, say) is a compile error rather
194/// than a key that silently falls through to the buffer.
195enum SplashKey {
196 /// No start screen is up — the key is the buffer's.
197 NotShowing,
198 /// The key selected a menu entry; run this.
199 Ran(Action),
200 /// The screen is gone and the key was not a menu key, so it still
201 /// means whatever it normally means. Anything else would make the
202 /// first keystroke after boot vanish.
203 Dismissed,
204}
205
206// ─── The counter, and the one place slips become mutations ───────────────
207
208/// `EditorState` read through the counter.
209///
210/// Borrowed, never copied: building it is free, so a command dispatch does
211/// not pay for a snapshot of the buffers.
212pub struct EditorWindow<'a> {
213 state: &'a EditorState,
214}
215
216impl escriba_madoguchi::CursorView for EditorWindow<'_> {
217 fn position(&self) -> Position {
218 self.state.cursor()
219 }
220 fn mode(&self) -> Mode {
221 self.state.modal.mode()
222 }
223}
224
225impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
226 fn filetype(&self) -> Option<&escriba_core::Filetype> {
227 let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
228 self.state.filetypes.resolve(path)
229 }
230}
231
232impl escriba_madoguchi::SearchView for EditorWindow<'_> {
233 fn pattern(&self) -> Option<&str> {
234 self.state.search.committed_pattern()
235 }
236 fn match_count(&self) -> Option<usize> {
237 // `None` means "nothing committed", which is not the same as zero
238 // matches — a distinction the status line already makes and that a
239 // handler must not have to re-derive.
240 self.state
241 .search
242 .committed_pattern()
243 .map(|_| self.state.search.match_count())
244 }
245 fn is_prompting(&self) -> bool {
246 self.state.search.is_prompting()
247 }
248}
249
250impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
251 fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
252 self.buffer(self.state.active)
253 }
254 fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
255 self.state
256 .buffers
257 .get(id)
258 .map(|b| b as &dyn escriba_madoguchi::BufferView)
259 }
260 fn buffer_ids(&self) -> Vec<BufferId> {
261 self.state.buffers.ids()
262 }
263 fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
264 self
265 }
266 fn option(&self, name: &str) -> Option<&str> {
267 self.state.options.get(name).map(String::as_str)
268 }
269 fn search(&self) -> &dyn escriba_madoguchi::SearchView {
270 self
271 }
272 fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
273 self
274 }
275}
276
277impl EditorState {
278 /// A read-only window onto this editor.
279 #[must_use]
280 pub fn window(&self) -> EditorWindow<'_> {
281 EditorWindow { state: self }
282 }
283
284 /// Honour an [`Outcome`] — the ONLY place slips become mutations.
285 ///
286 /// Every `&mut self` in the dispatch path lives here. A command cannot
287 /// reach editor state, so if the editor ends up in a state nobody
288 /// designed, this function is where it happened; that narrowing is the
289 /// whole return on the seam.
290 ///
291 /// A failed outcome's slips are DROPPED rather than half-applied: a
292 /// handler that reported failure has no business also mutating, and
293 /// applying part of what it asked for is how an editor reaches a state
294 /// nobody designed.
295 pub fn interpret(&mut self, outcome: Outcome) {
296 if let Some(m) = outcome.verdict.message() {
297 self.messages.push(m.to_string());
298 self.damage = self.damage.join(Damage::Viewport);
299 self.bump_gen();
300 }
301 if outcome.verdict.is_failure() {
302 return;
303 }
304 for slip in outcome.slips {
305 self.honour(slip);
306 }
307 }
308
309 /// Lower an [`Action`] to slips, when it has an exact slip equivalent.
310 ///
311 /// `None` means "editor mechanics" — 23 of the 30 variants are prompt
312 /// editing, the operator-pending FSM, motion resolution, the jumplist,
313 /// the dot register. Those are the KEYMAP's vocabulary, not the AUTHORED
314 /// one, and forcing them into `Negai` would put `PromptClearToStart` and
315 /// `SearchPreviewStep` in front of every plugin author and make the
316 /// capability question meaningless (what capability does a caret move
317 /// read?). One type serving two vocabularies is the mistake this avoids.
318 ///
319 /// The plan's M3 predicate was "apply_resolved contains zero `self.`
320 /// mutations", which would have forced exactly that. Amended: the
321 /// invariant worth having is ONE IMPLEMENTATION PER MUTATION, not one
322 /// vocabulary. See docs/backlog-plan.md §V Phase 1.
323 fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
324 Some(match action {
325 Action::Quit => vec![Negai::Quit],
326 Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
327 Action::Save => vec![Negai::Save { buffer: active }],
328 Action::Undo => vec![Negai::Undo { buffer: active }],
329 Action::Redo => vec![Negai::Redo { buffer: active }],
330 // `apply_edit` was a STUB that did nothing, so this action was a
331 // silent no-op while `Negai::Edit` applied for real. Lowering it
332 // makes keymap-originated edits work for the first time — and
333 // nothing binds it today, so the duplication goes away at zero
334 // risk.
335 Action::Edit(edit) => vec![Negai::Edit {
336 buffer: active,
337 edit: edit.clone(),
338 }],
339 _ => return None,
340 })
341 }
342
343 /// What the world currently is, for freshness.
344 ///
345 /// One text axis per open buffer. A list sealed against this is fresh
346 /// exactly while the buffers it depends on are unchanged — and a buffer
347 /// that has since CLOSED drops out, which makes lists about it stale
348 /// rather than silently kept.
349 #[must_use]
350 pub fn world(&self) -> escriba_shirube::Anchor {
351 let mut a = escriba_shirube::Anchor::new();
352 for id in self.buffers.ids() {
353 if let Some(b) = self.buffers.get(id) {
354 a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
355 }
356 }
357 // Every axis the world can move, ALWAYS present — not only the ones
358 // some producer happens to use today.
359 //
360 // `Anchor::is_fresh` treats an ABSENT axis as stale, deliberately:
361 // unknowable is not unchanged. The consequence, unnoticed until a
362 // recon pass went looking, is that a list anchored on an axis this
363 // function never emits is born PERMANENTLY stale — `]c` would answer
364 // "that list is out of date" forever, and nothing would say why. The
365 // two-axis model was built for git hunks and then only ever fed one
366 // axis.
367 //
368 // Emitting them unconditionally means a producer can anchor on any
369 // axis and get an honest answer. A counter that never moves reads as
370 // "unchanged", which is exactly right for a plane escriba does not
371 // track yet.
372 a = a.on(escriba_shirube::Axis::Index(self.index_rev));
373 a.on(escriba_shirube::Axis::Session(self.session_gen))
374 }
375
376 /// Where the cursor is, WITH the buffer it is in.
377 ///
378 /// Every jumplist push goes through this. A bare `Position` is what let
379 /// `<C-o>` return to the right line in the wrong file.
380 #[must_use]
381 pub fn spot(&self) -> escriba_core::Spot {
382 escriba_core::Spot::new(self.active, self.cursor())
383 }
384
385 /// Move to a `Spot`, switching buffer if it names another one.
386 ///
387 /// The read half of [`spot`](Self::spot). `<C-o>` and `<C-i>` both land
388 /// here so neither can forget the buffer.
389 fn goto_spot(&mut self, s: escriba_core::Spot) {
390 if s.buffer != self.active && self.buffers.get(s.buffer).is_some() {
391 self.active = s.buffer;
392 }
393 let clamped = self
394 .buffers
395 .get(self.active)
396 .map_or(s.pos, |b| b.clamp(s.pos));
397 self.set_cursor(clamped);
398 }
399
400 /// Advance the git-index generation — every list anchored on
401 /// `Axis::Index` goes stale.
402 ///
403 /// Not called yet; a git layer calls it after a stage/reset. Present so
404 /// the axis is WIRED rather than declared, because an axis nothing can
405 /// move is indistinguishable from an axis that does not exist.
406 pub fn bump_index_rev(&mut self) {
407 self.index_rev = escriba_shirube::IndexRev(self.index_rev.0.wrapping_add(1));
408 }
409
410 /// Advance the external-session generation — LSP restart, debug session,
411 /// test-runner invocation. See [`bump_index_rev`](Self::bump_index_rev).
412 pub fn bump_session_gen(&mut self) {
413 self.session_gen = escriba_shirube::SessionGen(self.session_gen.0.wrapping_add(1));
414 }
415
416 /// Move the cursor to the next/previous finding in `list`.
417 ///
418 /// Reports the wrap, because `n`/`N` do and a reader losing their place
419 /// in a long file is the same problem either way.
420 fn walk_list(&mut self, list: &str, forward: bool) {
421 let world = self.world();
422 let Some(result) = self.results.get(list) else {
423 let mut m = String::from("no list named ");
424 m.push_str(list);
425 self.messages.push(m);
426 return;
427 };
428 if result.is_stale(&world) {
429 self.messages
430 .push("that list is out of date — run it again".to_string());
431 return;
432 }
433 let here = (Some(self.active), self.cursor().line);
434 let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
435 else {
436 let mut m = String::from("no entries in ");
437 m.push_str(list);
438 self.messages.push(m);
439 return;
440 };
441 let site = found.site.clone();
442 let msg = found.message.clone();
443 self.jump_to_site(&site);
444 self.messages.push(msg);
445 }
446
447 /// Move the cursor to a located finding's SITE — the one operation that
448 /// cannot drop the buffer half of a location.
449 ///
450 /// A `Site` is `(buffer, range)`. Every jumper before this re-derived the
451 /// move itself and clamped against `self.active`, so a finding in another
452 /// file landed on the right LINE in the WRONG file. `on_line` and
453 /// `worst_on_line` already filter by buffer, so the gutter and the walker
454 /// disagreed — latent only because the first producer scanned one buffer.
455 ///
456 /// Every future producer (diagnostics, hunks, grep hits, test failures)
457 /// is cross-file by nature, which is why this is a shared operation
458 /// rather than a fix at the one call site that has it wrong today.
459 ///
460 /// Always a FAR jump: it pushes the jumplist, so `<C-o>` returns from a
461 /// `]t` exactly as it returns from an `n`.
462 pub fn jump_to_site(&mut self, site: &escriba_shirube::Site) {
463 self.jumps.push(self.spot());
464 // Switch buffers FIRST — clamping against the wrong buffer is how the
465 // position gets silently mangled before anyone can notice.
466 if let Some(target) = site.buffer {
467 if target != self.active && self.buffers.get(target).is_some() {
468 self.active = target;
469 self.refollow_cursor();
470 }
471 }
472 let to = site.range.start;
473 let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
474 self.set_cursor(clamped);
475 }
476
477 /// Close a buffer, keeping "there is always an active buffer" true.
478 ///
479 /// The invariant is the whole reason this is not just
480 /// `self.buffers.close(id)`. `EditorState::active` is a `BufferId`, not
481 /// an `Option`, so a dangling active is not a degraded state — it is a
482 /// state where every read of the active buffer returns `None` and the
483 /// editor renders `<no buffer>` forever. Closing the last buffer opens a
484 /// scratch rather than emptying the set, which is what vim's `:bd` does
485 /// and what the type demands.
486 fn close_buffer(&mut self, id: BufferId) {
487 if self.buffers.close(id).is_none() {
488 self.messages.push("no such buffer".to_string());
489 return;
490 }
491 if self.active != id {
492 return;
493 }
494 // The active buffer went. Prefer the next one by id so repeated
495 // closes walk forward predictably rather than jumping around.
496 let next = self.buffers.ids().into_iter().find(|b| *b > id);
497 self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
498 Some(b) => b,
499 None => self.buffers.scratch(""),
500 };
501 self.set_cursor(Position::ZERO);
502 if let Some(w) = self
503 .layout
504 .windows
505 .iter_mut()
506 .find(|w| w.id == self.layout.active)
507 {
508 w.buffer_id = self.active;
509 }
510 }
511
512 /// Move to the next or previous buffer, wrapping.
513 fn cycle_buffer(&mut self, forward: bool) {
514 let ids = self.buffers.ids();
515 if ids.len() < 2 {
516 self.messages.push("only one buffer".to_string());
517 return;
518 }
519 let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
520 let next = if forward {
521 (at + 1) % ids.len()
522 } else {
523 (at + ids.len() - 1) % ids.len()
524 };
525 self.active = ids[next];
526 self.set_cursor(Position::ZERO);
527 if let Some(w) = self
528 .layout
529 .windows
530 .iter_mut()
531 .find(|w| w.id == self.layout.active)
532 {
533 w.buffer_id = self.active;
534 }
535 }
536
537 /// Re-clamp the cursor and re-contain the viewport after a buffer
538 /// mutation.
539 ///
540 /// An undo can SHRINK the buffer under a cursor that was legal a moment
541 /// ago, leaving it out of bounds and its viewport scrolled past the end.
542 /// The Action executor has always done this (`self.set_cursor(self.cursor())`
543 /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
544 /// and `:undo` did not — two implementations of one operation, already
545 /// drifted within one milestone of being written. Naming it once is the
546 /// fix; lowering the Action arms onto the same slips is what keeps it
547 /// fixed.
548 fn refollow(&mut self) {
549 self.set_cursor(self.cursor());
550 }
551
552 /// Apply one slip and record what it damaged.
553 ///
554 /// The bookkeeping wrapper. The Action executor calls
555 /// [`honour_one`](Self::honour_one) directly because it does its own,
556 /// wider bookkeeping (the dot register, the S3 damage seal) around a
557 /// whole action.
558 fn honour(&mut self, slip: Negai) {
559 let touches_text = slip.touches_text();
560 self.honour_one(slip);
561 self.damage = self.damage.join(if touches_text {
562 Damage::Full
563 } else {
564 Damage::Viewport
565 });
566 self.bump_gen();
567 }
568
569 /// Apply one slip. THE single implementation of every mutation a slip
570 /// can ask for.
571 ///
572 /// Total over `Negai`: a new request variant is a compile error here
573 /// rather than a request silently ignored — the same failure Phase 0
574 /// removed one layer up.
575 fn honour_one(&mut self, slip: Negai) {
576 match slip {
577 Negai::Edit { buffer, edit } => {
578 if let Some(b) = self.buffers.get_mut(buffer) {
579 let _ = b.apply(&edit);
580 }
581 self.refollow();
582 }
583 Negai::SetCursor { buffer, to } => {
584 // Clamping is the interpreter's job, exactly so that no
585 // handler has to re-implement it and get it wrong.
586 let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
587 self.set_cursor(clamped);
588 }
589 Negai::EnterMode(m) => self.modal.enter(m),
590 Negai::OpenPicker(source) => self.open_picker(source),
591 Negai::GrepProject { pattern } => self.grep_project(&pattern),
592 Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
593 Negai::FocusBuffer(id) => {
594 if self.buffers.get(id).is_some() {
595 self.active = id;
596 }
597 }
598 Negai::OpenPath(path) => match self.buffers.open(&path) {
599 Ok(id) => self.active = id,
600 Err(e) => self.messages.push(e.to_string()),
601 },
602 Negai::CloseBuffer(id) => self.close_buffer(id),
603 Negai::Save { buffer } => {
604 if let Some(b) = self.buffers.get_mut(buffer) {
605 if let Err(e) = b.save() {
606 self.messages.push(e.to_string());
607 }
608 }
609 self.refollow();
610 }
611 Negai::Undo { buffer } => {
612 if let Some(b) = self.buffers.get_mut(buffer) {
613 let _ = b.undo();
614 }
615 self.refollow();
616 }
617 Negai::Redo { buffer } => {
618 if let Some(b) = self.buffers.get_mut(buffer) {
619 let _ = b.redo();
620 }
621 self.refollow();
622 }
623 Negai::Yank { text, .. } => self.register = Some(text),
624 Negai::ClearSearchHighlight => self.search.clear_highlight(),
625 Negai::SetOption { name, value } => {
626 self.options.insert(name, value);
627 }
628 Negai::InsertText(text) => self.insert_text(&text),
629 Negai::RunCommand { name, args } => self.run_command(&name, &args),
630 Negai::PublishFindings { list, findings } => {
631 let world = self.world();
632 self.results
633 .publish(list, escriba_shirube::ResultList::new(findings, world));
634 }
635 Negai::WalkList { list, forward } => self.walk_list(&list, forward),
636 Negai::Message(m) => self.messages.push(m),
637 Negai::Quit => self.quit_requested = true,
638 // Both suspend the dispatch and need machinery that does not
639 // exist yet — the courier (Phase 5) and the AwaitKey resume
640 // (M3). Announced, never silently dropped: a slip that vanishes
641 // is the class Phase 0 sealed.
642 Negai::Errand(_) | Negai::AwaitKey { .. } => {
643 self.messages
644 .push("deferred work is not wired yet".to_string());
645 }
646 }
647 }
648}
649
650/// Outcome of feeding one key to the multi-key pending-stroke loop.
651enum SeqStep {
652 /// Key consumed into an in-progress sequence; wait for the next.
653 Pending,
654 /// A full bound sequence resolved — run this action.
655 Resolved(Action),
656 /// Key is not part of any sequence; hand it to single-key dispatch.
657 Passthrough,
658}
659
660/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
661/// therefore exists to debounce.
662///
663/// This is an ALLOW-LIST, and it used to be the complement — an exception list
664/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
665/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
666/// swallowed and someone noticed. The third growth is the signal that the
667/// default was backwards: almost every key in a modal editor is a discrete,
668/// deliberate press, and only a handful are ones you HOLD.
669///
670/// Inverting it makes the failure mode safe. Forgetting to list a key here now
671/// means it is ungated — one extra keypress honoured — instead of silently
672/// dropped, and a dropped key is indistinguishable from a dead one.
673///
674/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
675/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
676/// is keyed by KEY and the two presses fell inside one debounce window.
677const fn is_repeat_storm_candidate(key: &Key) -> bool {
678 matches!(
679 key,
680 // The four navigation keys a user actually holds down. `h`/`l` and
681 // `j`/`k` flood the motion path and thrash the viewport; everything
682 // else is pressed once and meant once.
683 Key::Char('h')
684 | Key::Char('j')
685 | Key::Char('k')
686 | Key::Char('l')
687 | Key::Left
688 | Key::Right
689 | Key::Up
690 | Key::Down
691 )
692}
693
694/// Turn a command failure into the sentence an operator should read.
695///
696/// The two failures mean genuinely different things and must not be reported
697/// the same way:
698///
699/// - `:flurb` — the operator typed a name that does not exist. "command not
700/// found" is exactly right; it says *you* made a typo.
701/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
702/// declares this, `--list-rc` counts it, and it is not built yet. Telling
703/// the operator "command not found" blames them for a gap we shipped.
704///
705/// The discriminator is the dotted form. `:action` takes action SYMBOLS
706/// (`picker.files`), never command names — that boundary is already pinned by
707/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
708/// dotted name that reached dispatch and resolved to nothing is a declared
709/// capability with no implementation, which is precisely what the 85 entries
710/// in `escriba/tests/action_resolution.rs` are.
711fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
712 use escriba_command::CommandError as E;
713 match e {
714 // Already the right words — the registry knew it was declared.
715 E::Unhandled(_) => e.to_string(),
716 E::NotFound(n) if n.contains('.') => {
717 let mut m = String::with_capacity(n.len() + 48);
718 m.push('`');
719 m.push_str(n);
720 m.push_str("` is declared but not implemented yet");
721 m
722 }
723 _ => {
724 let _ = name;
725 e.to_string()
726 }
727 }
728}
729
730/// What committing the open search prompt did.
731///
732/// The two commit paths — bare `/` and operated `d/` — used to own private
733/// copies of the whole sequence (read origin+skip, `accept`, three-arm
734/// match, `commit_step_skipping`), and they drifted: the operated one
735/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
736/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
737///
738/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
739/// a compile error in two places rather than a case one path quietly
740/// forgets. It does not make divergence impossible — the two paths
741/// genuinely differ at the landing step — it makes FORGETTING A CASE
742/// impossible, which is the failure that actually happened.
743enum CommitOutcome {
744 /// The prompt committed and a match was found.
745 Landed {
746 origin: usize,
747 step: escriba_search::Step,
748 },
749 /// Committed, but nothing matched. E486 already reported.
750 NotFound,
751 /// Nothing typed and no previous pattern. E35 already reported.
752 NoPrevious,
753 /// No prompt was open.
754 NoPrompt,
755}
756
757/// A replayable text change.
758#[derive(Debug, Clone)]
759struct LastChange {
760 /// The action that began the change.
761 action: Action,
762 /// How many times it ran.
763 count: u32,
764 /// Characters typed while the change held Insert mode open.
765 inserted: String,
766}
767
768impl EditorState {
769 /// Build a fresh editor with one buffer (scratch or file-backed).
770 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
771 let window = Window {
772 id: WindowId(1),
773 buffer_id: active,
774 viewport: Viewport {
775 top_line: 0,
776 left_column: 0,
777 visible_lines: 40,
778 visible_columns: 160,
779 },
780 };
781 Self {
782 buffers: initial,
783 modal: ModalState::new(),
784 search: SearchState::new(escriba_search::CaseMode::Smart),
785 search_at: None,
786 last_change: None,
787 recording_insert: false,
788 jumps: JumpList::new(),
789 keymap: Keymap::default_vim(),
790 commands: CommandRegistry::default_set(),
791 layout: Layout::single(window),
792 active,
793 cursors: Cursors::single(Position::ZERO),
794 quit_requested: false,
795 register: None,
796 op_pending: zenmai::Stateful::new(OpState::Resting),
797 messages: Vec::new(),
798 options: HashMap::new(),
799 lisp_vm: None,
800 pending_keys: Vec::new(),
801 repeat_gate: KeyRepeatGate::new(),
802 plugin_host: PluginHost::default(),
803 edit_gen: EditGen::default(),
804 damage: Damage::None,
805 // The FLEET default until an rc says otherwise — never a
806 // hand-written theme name, so a fleet re-point lands for free.
807 dispatch_depth: 0,
808 filetypes: escriba_core::FiletypeTable::new(),
809 results: escriba_shirube::ListRegistry::new(),
810 picker: None,
811 index_rev: escriba_shirube::IndexRev::default(),
812 session_gen: escriba_shirube::SessionGen::default(),
813 theme: FleetTheme::prescribed_default(),
814 chrome: ChromePalette::prescribed(),
815 splash: None,
816 }
817 }
818
819 /// The theme this editor is set to.
820 #[must_use]
821 pub const fn theme(&self) -> FleetTheme {
822 self.theme
823 }
824
825 /// The colours every face paints with — read once per frame.
826 #[must_use]
827 pub const fn chrome(&self) -> ChromePalette {
828 self.chrome
829 }
830
831 /// Point the editor at a theme. The wiring that makes
832 /// `(deftheme :preset …)` real.
833 ///
834 /// Bumps the refresh generation, because a theme change repaints
835 /// everything: the GPU face caches its shaped buffer against that
836 /// generation and would otherwise keep the old colours until an
837 /// unrelated edit happened to invalidate it.
838 pub fn set_theme(&mut self, theme: FleetTheme) {
839 if self.theme == theme {
840 return;
841 }
842 self.theme = theme;
843 self.chrome = ChromePalette::for_theme(theme);
844 self.damage = self.damage.join(Damage::Viewport);
845 self.bump_gen();
846 }
847
848 /// The start screen, if one is up. Renderers paint this INSTEAD of the
849 /// buffer pane; `None` is the ordinary editor.
850 #[must_use]
851 pub fn splash(&self) -> Option<&Splash> {
852 self.splash.as_ref()
853 }
854
855 /// Raise the start screen. The binary calls this at boot when no file
856 /// was named; an empty splash is refused so a face never has to render
857 /// a blank screen over a perfectly good buffer.
858 pub fn set_splash(&mut self, splash: Splash) {
859 if splash.is_empty() {
860 return;
861 }
862 self.splash = Some(splash);
863 self.damage = self.damage.join(Damage::Viewport);
864 self.bump_gen();
865 }
866
867 /// Take the start screen down. Idempotent; bumps the refresh generation
868 /// only when something actually changed, so dismissing twice does not
869 /// cost a repaint.
870 pub fn dismiss_splash(&mut self) {
871 if self.splash.take().is_some() {
872 self.damage = self.damage.join(Damage::Viewport);
873 self.bump_gen();
874 }
875 }
876
877 /// Offer `key` to the start screen.
878 ///
879 /// A menu key runs its entry; ANY other key simply takes the screen
880 /// down and is then handled normally — so the first thing an operator
881 /// types is never swallowed.
882 /// The open picker, for a face to paint.
883 #[must_use]
884 pub fn picker(&self) -> Option<&escriba_ui::picker::Picker> {
885 self.picker.as_ref()
886 }
887
888 /// Give an open picker the key.
889 ///
890 /// Runs BEFORE the keymap, and before the sequence stepper: while a
891 /// picker is up it owns every key, including ones it has no meaning for.
892 /// An overlay that let unknown keys fall through would edit the file
893 /// behind itself.
894 fn consume_picker_key(&mut self, key: &Key) -> escriba_ui::picker::Consumed {
895 use escriba_ui::picker::Consumed;
896 let Some(p) = self.picker.as_mut() else {
897 return Consumed::NotShowing;
898 };
899 let outcome = p.on_key(key);
900 match &outcome {
901 Consumed::Dismissed | Consumed::Chose(_) => {
902 self.picker = None;
903 self.bump_gen();
904 }
905 Consumed::Held => self.bump_gen(),
906 Consumed::NotShowing => {}
907 }
908 outcome
909 }
910
911 /// Lower an accepted pick into the ONE interpreter.
912 ///
913 /// The whole reason `Choice` is a closed enum: a new source must decide
914 /// here, and the compiler says so.
915 fn honour_choice(&mut self, choice: escriba_ui::picker::Choice) {
916 use escriba_ui::picker::Choice;
917 let slip = match choice {
918 Choice::Buffer(id) => Negai::FocusBuffer(id),
919 Choice::Command(name) => Negai::RunCommand {
920 name,
921 args: Vec::new(),
922 },
923 Choice::Location { path, line } => {
924 // Open FIRST, then jump: the buffer may not exist yet, and
925 // `jump_to_site` needs a BufferId. Two slips, one interpret.
926 self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
927 let site = escriba_shirube::Site::in_buffer(
928 self.active,
929 escriba_core::Range::new(
930 escriba_core::Position::new(line, 0),
931 escriba_core::Position::new(line, 1),
932 ),
933 );
934 self.jump_to_site(&site);
935 return;
936 }
937 };
938 self.interpret(Outcome::did(vec![slip]));
939 }
940
941 /// How many files a project grep will read, and how many hits it keeps.
942 ///
943 /// BOUNDED, and the bound is here rather than hidden, because this is a
944 /// SYNCHRONOUS scan on the editor's own thread. The interpreter already
945 /// does synchronous filesystem I/O (`OpenPath`, `Save`), so the posture
946 /// is not new — but those touch one file and this walks a tree, which is
947 /// the first one big enough to freeze the editor.
948 ///
949 /// The DESTINATION is the courier: an errand that scans off-thread and
950 /// delivers results as they arrive, with no ceiling at all. This bound is
951 /// the interim that ships a working grep meanwhile, and it is a real
952 /// limit — a match past the ceiling is NOT found, and the picker says so
953 /// rather than presenting a truncated list as complete.
954 const GREP_FILE_LIMIT: usize = 2_000;
955 const GREP_HIT_LIMIT: usize = 500;
956
957 /// Scan the working directory for `pattern`, bounded.
958 fn grep_project(&mut self, pattern: &str) {
959 use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
960 if pattern.is_empty() {
961 self.messages.push("grep: empty pattern".to_string());
962 return;
963 }
964 let mut items: Vec<PickerItem<Choice>> = Vec::new();
965 let mut files = 0usize;
966 let mut truncated = false;
967 let mut stack = vec![std::path::PathBuf::from(".")];
968 while let Some(dir) = stack.pop() {
969 let Ok(entries) = std::fs::read_dir(&dir) else {
970 continue;
971 };
972 for entry in entries.flatten() {
973 let path = entry.path();
974 let name = entry.file_name();
975 let name = name.to_string_lossy();
976 // Skip the places a project grep must never walk into. Not a
977 // gitignore implementation — that is the courier's job, with
978 // a real ignore crate. This is the honest floor.
979 if name.starts_with('.') || name == "target" || name == "node_modules" {
980 continue;
981 }
982 if entry.file_type().is_ok_and(|t| t.is_dir()) {
983 stack.push(path);
984 continue;
985 }
986 if files >= Self::GREP_FILE_LIMIT || items.len() >= Self::GREP_HIT_LIMIT {
987 truncated = true;
988 break;
989 }
990 files += 1;
991 let Ok(text) = std::fs::read_to_string(&path) else {
992 continue; // binary or unreadable — not an error worth reporting
993 };
994 for (n, line) in text.lines().enumerate() {
995 if !line.contains(pattern) {
996 continue;
997 }
998 if items.len() >= Self::GREP_HIT_LIMIT {
999 truncated = true;
1000 break;
1001 }
1002 let Ok(n) = u32::try_from(n) else { break };
1003 let mut label = String::with_capacity(80);
1004 label.push_str(&path.to_string_lossy());
1005 label.push(':');
1006 label.push_str(&(n + 1).to_string());
1007 label.push_str(" ");
1008 label.push_str(line.trim());
1009 items.push(PickerItem::new(
1010 Choice::Location {
1011 path: path.clone(),
1012 line: n,
1013 },
1014 label,
1015 ));
1016 }
1017 }
1018 }
1019 if items.is_empty() {
1020 let mut m = String::from("grep: no matches for ");
1021 m.push_str(pattern);
1022 self.messages.push(m);
1023 return;
1024 }
1025 if truncated {
1026 // Stated, never silent. A truncated list presented as complete is
1027 // the failure this whole codebase keeps finding.
1028 self.messages
1029 .push("grep: stopped at the scan limit — results are INCOMPLETE".to_string());
1030 }
1031 self.picker = Some(Picker::open(Source::Grep, items));
1032 self.bump_gen();
1033 }
1034
1035 /// Build and open a picker over `source`.
1036 fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
1037 use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1038 let (src, items) = match source {
1039 escriba_madoguchi::PickerSource::Buffers => (
1040 Source::Buffers,
1041 self.buffers
1042 .ids()
1043 .into_iter()
1044 .filter_map(|id| {
1045 let b = self.buffers.get(id)?;
1046 let label = b.path.as_ref().map_or_else(
1047 || String::from("[scratch]"),
1048 |p| p.to_string_lossy().into_owned(),
1049 );
1050 Some(PickerItem::new(Choice::Buffer(id), label))
1051 })
1052 .collect::<Vec<_>>(),
1053 ),
1054 escriba_madoguchi::PickerSource::Help => (
1055 Source::Help,
1056 self.keymap
1057 .entries_sorted()
1058 .into_iter()
1059 .map(|(mode, key, b)| {
1060 // "NORMAL gd goto definition" — searchable by key,
1061 // by mode, or by what it does, because a reader
1062 // arrives from any of the three.
1063 let mut label = String::with_capacity(48);
1064 label.push_str(mode.as_str());
1065 label.push_str(" ");
1066 // `{key:?}` because there is no shared key FORMATTER
1067 // in the fleet — awase owns the chord vocabulary but
1068 // escriba-keymap's `Key` has no Display. That gap
1069 // belongs to the keymap consolidation, not here, and
1070 // inventing a fourth spelling would make it worse.
1071 label.push_str(&format!("{key:?}"));
1072 label.push_str(" ");
1073 label.push_str(&b.description);
1074 // Accepting runs the binding's action if it names a
1075 // command; a typed Action has no name to run, so it
1076 // reports rather than pretending.
1077 let choice = match &b.action {
1078 escriba_core::Action::Command { name, .. } => {
1079 Choice::Command(name.clone())
1080 }
1081 other => Choice::Command(format!("{other:?}")),
1082 };
1083 PickerItem::new(choice, label)
1084 })
1085 .collect::<Vec<_>>(),
1086 ),
1087 escriba_madoguchi::PickerSource::Commands => (
1088 Source::Commands,
1089 self.commands
1090 .names()
1091 .into_iter()
1092 .map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
1093 .collect::<Vec<_>>(),
1094 ),
1095 };
1096 if items.is_empty() {
1097 self.messages.push("nothing to pick from".to_string());
1098 return;
1099 }
1100 self.picker = Some(Picker::open(src, items));
1101 self.bump_gen();
1102 }
1103
1104 fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
1105 let Some(splash) = self.splash.as_ref() else {
1106 return SplashKey::NotShowing;
1107 };
1108 let chosen = match key {
1109 Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
1110 _ => None,
1111 };
1112 self.dismiss_splash();
1113 chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
1114 }
1115
1116 /// The current refresh generation. A renderer caches its products against
1117 /// this; equality is the freshness test (an unchanged generation ⇒ the
1118 /// last frame is still valid, so skip the re-highlight + re-shape).
1119 #[must_use]
1120 pub fn edit_gen(&self) -> EditGen {
1121 self.edit_gen
1122 }
1123
1124 /// Advance the refresh generation (a mutation happened).
1125 fn bump_gen(&mut self) {
1126 self.edit_gen = self.edit_gen.next();
1127 }
1128
1129 /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
1130 #[must_use]
1131 pub fn damage(&self) -> Damage {
1132 self.damage
1133 }
1134
1135 /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
1136 /// renderer calls this once per frame to learn what to repaint, then the
1137 /// accumulator restarts — so damage never double-counts across frames.
1138 pub fn take_damage(&mut self) -> Damage {
1139 std::mem::replace(&mut self.damage, Damage::None)
1140 }
1141
1142 /// The line count of the active buffer (0 if none) — used to compute the
1143 /// [`Damage`] scope of a mutation.
1144 fn active_line_count(&self) -> u32 {
1145 self.buffers
1146 .get(self.active)
1147 .map_or(0, escriba_buffer::Buffer::line_count)
1148 }
1149
1150 /// Register a lazy USER plugin: its escriba entry is deferred until
1151 /// one of its `triggers` fires. Bundled defaults do NOT go through
1152 /// here — they are applied eagerly at boot. Empty `triggers` means
1153 /// the plugin never lazily activates (the binary applies eager
1154 /// plugins directly).
1155 pub fn register_lazy_plugin(
1156 &mut self,
1157 name: impl Into<String>,
1158 triggers: Vec<LazyTrigger>,
1159 entry_src: impl Into<String>,
1160 ) {
1161 self.plugin_host.register(name, triggers, entry_src);
1162 }
1163
1164 /// Apply a plugin entry's escriba-lisp to live state — the same
1165 /// keymap / command / option apply paths a user rc uses. Options are
1166 /// applied before keybinds so a plugin that sets `mapleader` resolves
1167 /// `<leader>` correctly. Returns the count of commands + keybinds it
1168 /// registered (best-effort; a malformed entry is skipped, not fatal).
1169 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
1170 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
1171 return 0;
1172 };
1173 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
1174 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
1175 if let Some(value) = self.options.get("mapleader") {
1176 if let Some(key) = escriba_lisp::parse_leader_key(value) {
1177 self.keymap.set_leader(key);
1178 }
1179 }
1180 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
1181 (cmd.registered + km.keybinds_applied) as usize
1182 }
1183
1184 /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
1185 /// Returns the number of plugins activated. Call when a buffer of a
1186 /// known filetype is opened.
1187 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
1188 let pending = self.plugin_host.pending_for_filetype(filetype);
1189 let n = pending.len();
1190 for src in pending {
1191 self.apply_plugin_entry(&src);
1192 }
1193 n
1194 }
1195
1196 /// Fire any lazy plugin gated on an `Event` trigger for `event`.
1197 /// Returns the number of plugins activated.
1198 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
1199 let pending = self.plugin_host.pending_for_event(event);
1200 let n = pending.len();
1201 for src in pending {
1202 self.apply_plugin_entry(&src);
1203 }
1204 n
1205 }
1206
1207 /// Advance one frame's worth of state given a raw madori event.
1208 ///
1209 /// Key events pass through the [`KeyRepeatGate`] first (see
1210 /// [`Self::tick_at`]); everything else is handled directly.
1211 pub fn tick(&mut self, event: &AppEvent) {
1212 self.tick_at(event, Instant::now());
1213 }
1214
1215 /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
1216 /// lets tests drive the debounce window without depending on the
1217 /// wall clock.
1218 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
1219 match translate_app_event(event) {
1220 InputOutcome::Key(k) => {
1221 if self.gate_key(&k, now) {
1222 self.on_key(&k);
1223 }
1224 }
1225 InputOutcome::Resized { .. } => {
1226 // Damage only. Each face owns its own geometry: the GPU
1227 // backend derives the grid in `RenderCallback::resize`, and
1228 // the ratatui face reads its area every frame. This arm used
1229 // to write `Window.rect`, which nothing ever read — so the
1230 // resize path was already doing no real work, it just looked
1231 // like it was.
1232 self.damage = self.damage.join(Damage::Viewport);
1233 self.bump_gen();
1234 }
1235 InputOutcome::Quit => self.quit_requested = true,
1236 InputOutcome::Focus(_) | InputOutcome::None => {}
1237 }
1238 }
1239
1240 /// Decide whether `key` survives the key-repeat gate at time `now`.
1241 ///
1242 /// Returns `true` when the key should be processed, `false` when it is
1243 /// an OS key-repeat storm tick that should be dropped. Gating applies
1244 /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
1245 /// are where a held `j`/`l` floods the motion path and thrashes the
1246 /// viewport. Insert and Command modes pass every key through ungated,
1247 /// because there "hold a key to repeat the character" is the intended
1248 /// behavior, not a storm to suppress.
1249 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
1250 match self.modal.mode() {
1251 Mode::Normal | Mode::Visual | Mode::VisualLine => {
1252 // The gate exists for HELD keys that flood the motion path and
1253 // thrash the viewport (`j`, `l`). It is wrong for the discrete
1254 // jumps: two `n` presses 10 ms apart mean two matches, and
1255 // swallowing the second is indistinguishable from a dead key —
1256 // the exact symptom the gate was added to prevent elsewhere.
1257 if is_repeat_storm_candidate(key) {
1258 return self.repeat_gate.try_pass_at(*key, now);
1259 }
1260 true
1261 }
1262 Mode::Insert | Mode::Command => true,
1263 }
1264 }
1265
1266 /// Dispatch a single key through the keymap + apply the resulting action.
1267 pub fn on_key(&mut self, key: &Key) {
1268 // An open picker owns EVERY key while it is up — before the splash,
1269 // before the sequence stepper, before the keymap.
1270 match self.consume_picker_key(key) {
1271 escriba_ui::picker::Consumed::NotShowing => {}
1272 escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
1273 escriba_ui::picker::Consumed::Chose(c) => {
1274 self.honour_choice(c);
1275 return;
1276 }
1277 }
1278 // The start screen owns the first keypress and nothing after it.
1279 match self.consume_splash_key(key) {
1280 SplashKey::NotShowing | SplashKey::Dismissed => {}
1281 SplashKey::Ran(action) => {
1282 self.apply(&action);
1283 return;
1284 }
1285 }
1286 // Multi-key sequence resolution runs first: a key that begins or
1287 // continues a bound sequence (`<leader>ff`, `gg`) is held or
1288 // resolved here before the single-key path sees it.
1289 match self.step_sequence(key) {
1290 SeqStep::Pending => return,
1291 SeqStep::Resolved(action) => {
1292 let count = self.modal.pending_count().unwrap_or(1);
1293 self.modal.clear_count();
1294 for _ in 0..count {
1295 self.apply(&action);
1296 if self.quit_requested {
1297 return;
1298 }
1299 }
1300 return;
1301 }
1302 SeqStep::Passthrough => {}
1303 }
1304 let counted = self.keymap.dispatch(&self.modal, key);
1305 // Count prefixes accumulate into modal state.
1306 if matches!(counted.action, Action::Pending) {
1307 if let Key::Char(c) = key {
1308 if c.is_ascii_digit() {
1309 let d = u32::from(*c as u8 - b'0');
1310 self.modal.append_count(d);
1311 }
1312 }
1313 return;
1314 }
1315 // The count flows through the operator-pending FSM (apply_counted), which
1316 // owns repetition: a bare motion runs count× , an operator captures its
1317 // count, and an operated motion multiplies the two. No naive outer loop.
1318 self.apply_counted(&counted.action, counted.count);
1319 // After applying, reset pending count.
1320 self.modal.clear_count();
1321 }
1322
1323 /// Advance the multi-key pending-stroke state machine for `key`.
1324 ///
1325 /// Sequences only apply in normal / visual modes — insert and
1326 /// command modes treat keys as literal text. Rules:
1327 /// - Mid-sequence: extend the pending prefix. Exact match →
1328 /// [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
1329 /// otherwise abort the sequence and re-process this key fresh.
1330 /// - Not mid-sequence: if `key` begins a bound sequence AND is not
1331 /// itself a complete single binding (single bindings win, so no
1332 /// chord timeout is needed) → start pending. Otherwise
1333 /// [`SeqStep::Passthrough`] to the single-key dispatcher.
1334 fn step_sequence(&mut self, key: &Key) -> SeqStep {
1335 let mode = self.modal.mode();
1336 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1337 return SeqStep::Passthrough;
1338 }
1339 if !self.pending_keys.is_empty() {
1340 let mut seq = self.pending_keys.clone();
1341 seq.push(key.clone());
1342 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1343 let action = b.action.clone();
1344 self.pending_keys.clear();
1345 return SeqStep::Resolved(action);
1346 }
1347 if self.keymap.is_sequence_prefix(mode, &seq) {
1348 self.pending_keys = seq;
1349 return SeqStep::Pending;
1350 }
1351 // The key broke the in-progress sequence — abort it and let
1352 // the key be re-processed as a fresh stroke below.
1353 self.pending_keys.clear();
1354 }
1355 let start = [key.clone()];
1356 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1357 self.pending_keys = start.to_vec();
1358 return SeqStep::Pending;
1359 }
1360 SeqStep::Passthrough
1361 }
1362
1363 /// The primary cursor position. The single read accessor — every
1364 /// renderer + motion path goes through it, so the underlying
1365 /// representation (today a single-cursor [`Cursors`]) can grow to
1366 /// multi-caret without changing read sites.
1367 #[must_use]
1368 pub fn cursor(&self) -> Position {
1369 self.cursors.primary()
1370 }
1371
1372 /// The **single** cursor-mutation path. Clamp the requested position to
1373 /// the active buffer's bounds, then scroll the active window's viewport
1374 /// to contain it on BOTH axes. Routing every cursor change through this
1375 /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1376 /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1377 /// typed home — there is no code path that advances the cursor without
1378 /// re-deriving the viewport from it, and no second `Position` field to
1379 /// fall out of sync.
1380 /// Re-assert the cursor-visibility invariant against the CURRENT
1381 /// viewport.
1382 ///
1383 /// A resize changes how much a face can show without moving the cursor,
1384 /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
1385 /// would sit off-screen until the operator happened to move it. Every
1386 /// face calls this after telling the runtime its new size.
1387 pub fn refollow_cursor(&mut self) {
1388 self.set_cursor(self.cursors.primary());
1389 }
1390
1391 fn set_cursor(&mut self, pos: Position) {
1392 let clamped = if let Some(buf) = self.buffers.get(self.active) {
1393 buf.clamp(pos)
1394 } else {
1395 pos
1396 };
1397 self.cursors.set_primary(clamped);
1398 if let Some(w) = self
1399 .layout
1400 .windows
1401 .iter_mut()
1402 .find(|w| w.id == self.layout.active)
1403 {
1404 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1405 }
1406 }
1407
1408 /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1409 fn apply(&mut self, action: &Action) {
1410 self.apply_counted(action, 1);
1411 }
1412
1413 /// Dispatch one resolved action with its count. Routes `(action, count)`
1414 /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1415 /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1416 /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1417 /// operator-then-motion pair is rewritten into a counted
1418 /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1419 /// composition — there is no naive outer repeat loop.
1420 fn apply_counted(&mut self, action: &Action, count: u32) {
1421 // An uncompilable pattern must not reach the operator machine.
1422 //
1423 // `SearchState::accept` puts the prompt BACK on a compile error so the
1424 // typed text is not lost — but the FSM had already transitioned out of
1425 // `AwaitingSearch` on the way in, so the prompt survived and the
1426 // OPERATOR did not, with nothing said about it. The `d` was simply
1427 // gone, and the corrected pattern then ran as a bare search.
1428 //
1429 // The machine is a pure `(State, Event) -> (State, effects)` and
1430 // cannot observe the result of an effect, so it cannot decide this
1431 // itself. The fix is to stop handing it an event it has no business
1432 // deciding: the runtime classifies the submit first, from state it
1433 // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1434 // the bare-`/<CR>` reuse path is untouched.
1435 //
1436 // Tier-honest: parse-rejected at the boundary, not
1437 // truly-unrepresentable.
1438 if matches!(action, Action::SubmitCommand) {
1439 if let Some(e) = self.search.prompt_error() {
1440 let mut m = String::from("E383: Invalid search string: ");
1441 m.push_str(&e.to_string());
1442 self.messages.push(m);
1443 return;
1444 }
1445 }
1446
1447 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1448 for _ in 0..times {
1449 self.apply_resolved(&resolved);
1450 if self.quit_requested {
1451 return;
1452 }
1453 }
1454 }
1455 }
1456
1457 /// The active buffer's text. Search is a pure function of it.
1458 /// The active buffer's text revision — the token an offset measured
1459 /// against it should carry.
1460 #[must_use]
1461 fn text_rev(&self) -> TextRev {
1462 self.buffers
1463 .get(self.active)
1464 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1465 }
1466
1467 fn active_text(&self) -> String {
1468 self.buffers
1469 .get(self.active)
1470 .map(escriba_buffer::Buffer::to_string)
1471 .unwrap_or_default()
1472 }
1473
1474 /// The cursor as a char offset — the coordinate search speaks.
1475 fn cursor_char(&self) -> usize {
1476 self.buffers
1477 .get(self.active)
1478 .and_then(|b| b.position_to_char(self.cursor()).ok())
1479 .unwrap_or(0)
1480 }
1481
1482 /// Move the cursor onto a match and report a wrap the way vim does.
1483 /// The status line as data — what every face draws.
1484 ///
1485 /// One model, so the two faces can only disagree about styling. Before
1486 /// this existed the GPU face built its own line from a fixed `format!()`
1487 /// and drew neither the prompt nor any message, which made a fully
1488 /// working `/` look like a dead key on escriba's default renderer.
1489 #[must_use]
1490 pub fn status_model(&self) -> StatusModel<'_> {
1491 let cursor = self.cursor();
1492 let prompt = self.search.prompt();
1493
1494 let kind = match prompt.map(|p| p.direction) {
1495 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1496 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1497 // Command mode with no search prompt open is an ex-command; the
1498 // typed `Option<Prompt>` is the discriminator, never a mode flag.
1499 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1500 None => PromptKind::None,
1501 };
1502
1503 StatusModel {
1504 mode: self.modal.mode(),
1505 line: cursor.line.saturating_add(1) as usize,
1506 column: cursor.column.saturating_add(1) as usize,
1507 prompt: kind,
1508 prompt_text: prompt
1509 .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1510 prompt_caret: prompt.map_or_else(
1511 || self.modal.minibuffer_caret(),
1512 escriba_search::Prompt::caret,
1513 ),
1514 count: self.match_count(),
1515 message: self.messages.last().map(String::as_str),
1516 }
1517 }
1518
1519 /// `[3/17]` for the current pattern.
1520 ///
1521 /// While a prompt is open the count describes the PREVIEW — the answer to
1522 /// "what would Enter do", which is the question being asked mid-typing.
1523 /// Once committed it describes where the cursor actually is.
1524 #[must_use]
1525 fn match_count(&self) -> MatchCount {
1526 if self.search.is_prompting() {
1527 let text = self.active_text();
1528 // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1529 // the same `None`, so a half-typed character class reported
1530 // `[0/0]` — telling the user their pattern matches nothing while
1531 // they are still writing it.
1532 return match self.search.preview(&text) {
1533 escriba_search::Preview::Landed { step, total } => {
1534 MatchCount::new(step.index, total)
1535 }
1536 escriba_search::Preview::NoMatch => MatchCount::None,
1537 escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1538 MatchCount::Idle
1539 }
1540 };
1541 }
1542 if self.search.pattern().is_none() {
1543 return MatchCount::Idle;
1544 }
1545 let total = self.search.matches().len();
1546 // Read THROUGH the anchor: an ordinal computed against text that has
1547 // since changed reads as absent, so a stale count cannot be displayed.
1548 let rev = self.text_rev();
1549 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1550 if total == 0 {
1551 MatchCount::None
1552 } else {
1553 MatchCount::Idle
1554 },
1555 |&i| MatchCount::new(i, total),
1556 )
1557 }
1558
1559 /// `.` — replay the last change at the cursor.
1560 ///
1561 /// Two steps, because a change can be two: run the action, then re-type
1562 /// whatever followed it. `cgn` + `.` is exactly this — change the next
1563 /// match, then repeat that whole gesture on the one after.
1564 fn repeat_last_change(&mut self) {
1565 let Some(change) = self.last_change.clone() else {
1566 self.messages
1567 .push("E32: No previous change to repeat".to_string());
1568 return;
1569 };
1570
1571 for _ in 0..change.count.max(1) {
1572 self.apply_resolved(&change.action);
1573 }
1574 for c in change.inserted.chars() {
1575 self.apply_resolved(&Action::InsertChar(c));
1576 }
1577 if self.modal.mode() == Mode::Insert {
1578 // A replayed change must not leave the editor in Insert — the
1579 // original ended with an Esc the recording deliberately does not
1580 // store, since it is punctuation rather than part of the change.
1581 self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1582 }
1583 // The replay wrote through `apply_resolved`, which re-records
1584 // `last_change` from the inner action. Put the ORIGINAL back so a
1585 // second `.` repeats the same change rather than a fragment of it.
1586 self.last_change = Some(change);
1587 self.recording_insert = false;
1588 }
1589
1590 /// Resolve a text object to the range it names.
1591 ///
1592 /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1593 /// match operates on THAT match rather than skipping to the next — which
1594 /// is what makes `cgn` then `.` walk matches one at a time instead of
1595 /// every other one.
1596 fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
1597 use escriba_core::TextObject as O;
1598 let at = self.cursor_char();
1599 let matches = self.search.matches();
1600
1601 // A match CONTAINING the cursor wins outright, whichever direction the
1602 // object names.
1603 //
1604 // Comparing only against `m.start` — which is what a `starts`-vector
1605 // plus `Bound::Inclusive` does — is right only when the cursor sits on
1606 // a match's FIRST character. One column further in, `start < at` and
1607 // the match is rejected, so `cgn` skipped the very instance the
1608 // operator was standing in and the rename silently missed it. vim
1609 // operates on the containing match from every interior column, and the
1610 // `starts`-only comparison cannot express "contains" because it never
1611 // looks at `m.end`.
1612 let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
1613 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
1614 match object {
1615 O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
1616 O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
1617 }
1618 })?;
1619
1620 let m = matches.get(idx)?;
1621 let buf = self.buffers.get(self.active)?;
1622 Some(Range {
1623 start: buf.char_to_position(m.start),
1624 end: buf.char_to_position(m.end),
1625 })
1626 }
1627
1628 fn land_on(&mut self, step: escriba_search::Step) {
1629 if let Some(buf) = self.buffers.get(self.active) {
1630 let pos = buf.char_to_position(step.target.start);
1631 self.set_cursor(pos);
1632 }
1633 // The `[3/17]` numerator. `Step` has carried this index since the
1634 // engine was written — `engine.rs` even names the counter as the
1635 // reason it exists — and every consumer discarded it until now.
1636 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
1637 }
1638
1639 /// vim's "search hit BOTTOM, continuing at TOP".
1640 ///
1641 /// One reporter, called by the two places a search can wrap: the shared
1642 /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
1643 /// commit would say it twice.
1644 fn report_wrap(&mut self, step: &escriba_search::Step) {
1645 if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
1646 self.messages.push(msg.to_string());
1647 }
1648 }
1649
1650 /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
1651 /// than failing silently — a search that appears to do nothing is
1652 /// indistinguishable from a dropped keystroke.
1653 fn jump_search(&mut self, reverse: bool) {
1654 // Using the matches re-lights them: `n` after an auto-clear shows you
1655 // what you are walking through.
1656 self.search.relight();
1657 // `n` is a far jump — record where we leave from so `<C-o>` works.
1658 self.jumps.push(self.spot());
1659 let at = self.cursor_char();
1660 match self.search.repeat(at, reverse) {
1661 Some(step) => {
1662 // `n` wrapping the file says so, same as a commit does.
1663 self.report_wrap(&step);
1664 self.land_on(step);
1665 }
1666 None => {
1667 let msg = self.search.pattern().map_or_else(
1668 || "E35: No previous regular expression".to_string(),
1669 |p| {
1670 let mut m = String::from("E486: Pattern not found: ");
1671 m.push_str(p.raw());
1672 m
1673 },
1674 );
1675 self.messages.push(msg);
1676 }
1677 }
1678 }
1679
1680 /// Move the cursor to where the in-progress pattern would land, without
1681 /// committing anything. vim's `incsearch`.
1682 ///
1683 /// A pattern that does not compile yet (`/a[`, mid-typing) previews
1684 /// nothing and reports nothing — an error toast on every keystroke of a
1685 /// character class would be unusable.
1686 fn preview_search(&mut self) {
1687 let text = self.active_text();
1688 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
1689 return;
1690 };
1691 let target = match self.search.preview(&text) {
1692 escriba_search::Preview::Landed { step, .. } => step.target.start,
1693 // Nothing to show: back to where the search started. Covers a
1694 // half-typed pattern and a pattern that finds nothing alike —
1695 // both mean "there is no match to preview".
1696 escriba_search::Preview::Idle
1697 | escriba_search::Preview::Incomplete
1698 | escriba_search::Preview::NoMatch => origin,
1699 };
1700 // A pattern that STOPS matching returns the cursor to the origin.
1701 //
1702 // Preview used to only ever move forward, so typing `ch` (a match) and
1703 // then `chz` (none) left the cursor parked on the `ch` match — a
1704 // preview showing a position the pattern no longer justifies, while
1705 // the count beside it read `[0/0]`. Restoring is also what makes
1706 // Escape's promise legible: at every keystroke the cursor is either on
1707 // a real match or back where you started, never on a stale one.
1708 if let Some(buf) = self.buffers.get(self.active) {
1709 let pos = buf.char_to_position(target);
1710 self.set_cursor(pos);
1711 }
1712 }
1713
1714 /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
1715 /// where the search lands, as ONE action.
1716 ///
1717 /// Split from [`Self::submit_search`] rather than sharing it because the
1718 /// two want opposite things from the commit: the bare `/` MOVES the cursor
1719 /// to the match, and an operated `/` must NOT — the cursor is the
1720 /// operator's start point, and moving it first would leave the operator
1721 /// with a zero-width range.
1722 /// Commit the open search prompt. The ONE copy of the sequence.
1723 ///
1724 /// Reports its own failures (E486 / E35) so neither caller has to carry a
1725 /// third copy of the message strings. `Accepted::Invalid` cannot reach
1726 /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
1727 /// boundary before the FSM or this method ever sees the submit.
1728 fn commit_search_prompt(&mut self) -> CommitOutcome {
1729 let text = self.active_text();
1730 let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
1731 else {
1732 return CommitOutcome::NoPrompt;
1733 };
1734
1735 match self.search.accept(&text) {
1736 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
1737 self.modal.clear_minibuffer();
1738 self.modal.enter(Mode::Normal);
1739 match self.search.commit_step_skipping(origin, skip) {
1740 Some(step) => {
1741 // The wrap notice belongs HERE, once, for both commit
1742 // paths. Reporting it in each caller is what let the
1743 // operated path lose it in the first place — and my
1744 // first attempt at this refactor duplicated it again
1745 // rather than moving it, which the red proof caught.
1746 self.report_wrap(&step);
1747 CommitOutcome::Landed { origin, step }
1748 }
1749 None => {
1750 self.report_pattern_not_found();
1751 CommitOutcome::NotFound
1752 }
1753 }
1754 }
1755 escriba_search::Accepted::NothingToRepeat => {
1756 self.modal.clear_minibuffer();
1757 self.modal.enter(Mode::Normal);
1758 self.messages
1759 .push("E35: No previous regular expression".to_string());
1760 CommitOutcome::NoPrevious
1761 }
1762 // Unreachable: the boundary guard in `apply_counted` returns early
1763 // on an uncompilable pattern, leaving the prompt open. Reported
1764 // rather than `unreachable!()` — a panic in the editor's commit
1765 // path is a worse failure than a duplicate message.
1766 escriba_search::Accepted::Invalid(e) => {
1767 let mut m = String::from("E383: Invalid search string: ");
1768 m.push_str(&e.to_string());
1769 self.messages.push(m);
1770 CommitOutcome::NoPrompt
1771 }
1772 }
1773 }
1774
1775 /// vim's E486, with the pattern named. One place, so every path that fails
1776 /// to find reports identically.
1777 fn report_pattern_not_found(&mut self) {
1778 let mut m = String::from("E486: Pattern not found");
1779 if let Some(p) = self.search.pattern() {
1780 m.push_str(": ");
1781 m.push_str(p.raw());
1782 }
1783 self.messages.push(m);
1784 }
1785
1786 /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
1787 ///
1788 /// The only difference from the operated path is that this one lands;
1789 /// everything else lives in `commit_search_prompt`.
1790 fn submit_search(&mut self) {
1791 match self.commit_search_prompt() {
1792 CommitOutcome::Landed { origin, step } => {
1793 if let Some(buf) = self.buffers.get(self.active) {
1794 let from = buf.char_to_position(origin);
1795 self.jumps.push(escriba_core::Spot::new(self.active, from));
1796 }
1797 self.land_on(step);
1798 }
1799 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1800 }
1801 }
1802
1803 /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
1804 /// search lands, as ONE action.
1805 ///
1806 /// The cursor must NOT move to the match first: it is the operator's start
1807 /// point. That is the whole reason this differs from the bare path, and
1808 /// now the only reason.
1809 fn submit_search_operated(&mut self, op: Operator) {
1810 match self.commit_search_prompt() {
1811 CommitOutcome::Landed { origin, step } => {
1812 if let Some(buf) = self.buffers.get(self.active) {
1813 let from = buf.char_to_position(origin);
1814 let target = buf.char_to_position(step.target.start);
1815 // Operating over a search is itself a far jump.
1816 self.jumps.push(escriba_core::Spot::new(self.active, from));
1817 self.set_cursor(from);
1818 self.apply_operator_to(op, target);
1819 }
1820 }
1821 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1822 }
1823 }
1824
1825 fn apply_resolved(&mut self, action: &Action) {
1826 // Snapshot the scope inputs before the mutation so the resulting
1827 // Damage covers the changed region (the S3 seal — conservative widen).
1828 let lines_before = self.active_line_count();
1829 // Snapshot for the dot register: the only reliable witness that this
1830 // action changed text is that the buffer's revision moved.
1831 let rev_before = self.text_rev();
1832 let cline_before = self.cursor().line;
1833 match action {
1834 // Every action with an exact slip equivalent goes through the
1835 // interpreter, so "undo" has ONE implementation rather than one
1836 // per entry point. These had already drifted: the executor
1837 // re-followed the viewport after undo and the M1 interpreter did
1838 // not, so `u` and `:undo` behaved differently within a milestone
1839 // of each other.
1840 // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
1841 // guard: a guard arm does not count toward exhaustiveness, so the
1842 // guarded form silently gave up the total match — the compiler
1843 // said so, and it was right. `lowering_and_dispatch_agree` pins
1844 // that this list and `lower` stay the same set.
1845 Action::Quit
1846 | Action::ClearSearchHighlight
1847 | Action::Save
1848 | Action::Undo
1849 | Action::Redo
1850 | Action::Edit(_) => {
1851 for slip in Self::lower(action, self.active).unwrap_or_default() {
1852 self.honour_one(slip);
1853 }
1854 }
1855 Action::Move(m) => self.apply_motion(*m),
1856 Action::SearchOpen(dir) => {
1857 // vim's `/` is the command-line with a different prompt char,
1858 // so we reuse Command mode; `search.prompt` is what tells a
1859 // later <CR> this is a search and not an ex-command.
1860 let origin = self.cursor_char();
1861 self.search.open(*dir, origin);
1862 self.modal.enter(Mode::Command);
1863 }
1864 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
1865 Action::SearchWord { reverse } => {
1866 let dir = if *reverse {
1867 SearchDirection::Backward
1868 } else {
1869 SearchDirection::Forward
1870 };
1871 let (text, at) = (self.active_text(), self.cursor_char());
1872 // `*` jumps, so it records too.
1873 self.jumps.push(self.spot());
1874 match self.search.search_word(&text, at, dir) {
1875 Some(step) => self.land_on(step),
1876 // vim beeps and stays put when there is no word under the
1877 // cursor; a silent no-op would look like a broken key.
1878 None => self
1879 .messages
1880 .push("E348: No string under cursor".to_string()),
1881 }
1882 }
1883 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
1884 Action::TextObject(object) => {
1885 // Bare `gn` moves onto the match. vim additionally starts a
1886 // Visual selection of it; escriba's Visual plumbing does not
1887 // carry a selection an operator can consume yet, so this
1888 // stops at the jump rather than faking a selection that
1889 // nothing would honour.
1890 if let Some(range) = self.resolve_object(*object) {
1891 self.jumps.push(self.spot());
1892 self.set_cursor(range.start);
1893 } else {
1894 self.report_pattern_not_found();
1895 }
1896 }
1897 Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
1898 Some(range) => self.apply_operator_over(*op, range),
1899 None => self.report_pattern_not_found(),
1900 },
1901 Action::RepeatLastChange => self.repeat_last_change(),
1902 Action::JumpBack => {
1903 let here = self.spot();
1904 if let Some(spot) = self.jumps.back(here) {
1905 self.goto_spot(spot);
1906 } else {
1907 self.messages
1908 .push("E662: At start of changelist".to_string());
1909 }
1910 }
1911 Action::JumpForward => {
1912 if let Some(spot) = self.jumps.forward() {
1913 self.goto_spot(spot);
1914 } else {
1915 self.messages.push("E663: At end of changelist".to_string());
1916 }
1917 }
1918 Action::ChangeMode(m) => {
1919 // Leaving the cmdline abandons any open search prompt and
1920 // returns the cursor home. The COMMITTED pattern survives —
1921 // cancelling a new search must not erase the old highlights.
1922 if *m == Mode::Normal && self.search.is_prompting() {
1923 if let Some(origin) = self.search.cancel() {
1924 if let Some(buf) = self.buffers.get(self.active) {
1925 let pos = buf.char_to_position(origin);
1926 self.set_cursor(pos);
1927 }
1928 }
1929 }
1930 self.modal.enter(*m);
1931 }
1932 Action::InsertChar(c) => self.insert_char(*c),
1933
1934 Action::SubmitCommand => {
1935 if self.search.is_prompting() {
1936 self.submit_search();
1937 } else {
1938 self.submit_command();
1939 }
1940 }
1941 Action::Command { name, args } => self.run_command(name, args),
1942 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
1943 // The operator-pending FSM consumes Operator keys (begins pending);
1944 // they never reach the executor. Defensive no-op for exhaustiveness.
1945 Action::Operator(_) => {}
1946 Action::PromptCaret { to } => {
1947 // Both prompts have a caret now, and the same keys move it.
1948 if self.search.is_prompting() {
1949 self.search.move_caret(*to);
1950 } else {
1951 self.modal.move_minibuffer_caret(*to);
1952 }
1953 }
1954 Action::SearchPreviewStep { forward } => {
1955 if self.search.is_prompting() {
1956 self.search.preview_step(*forward);
1957 self.preview_search();
1958 }
1959 }
1960 Action::PromptDelete => {
1961 if self.search.is_prompting() {
1962 self.search.delete_at_caret();
1963 self.preview_search();
1964 } else {
1965 self.modal.delete_minibuffer_at_caret();
1966 }
1967 }
1968 Action::PromptDeleteWord => {
1969 if self.search.is_prompting() {
1970 self.search.delete_word_before_caret();
1971 self.preview_search();
1972 }
1973 }
1974 Action::PromptClearToStart => {
1975 if self.search.is_prompting() {
1976 self.search.clear_before_caret();
1977 self.preview_search();
1978 }
1979 }
1980 Action::PromptBackspace => {
1981 self.prompt_backspace();
1982 // Shortening the pattern changes which matches exist, so the
1983 // preview must re-run — otherwise the cursor sits on a match
1984 // of a pattern that is no longer typed.
1985 if self.search.is_prompting() {
1986 self.preview_search();
1987 }
1988 }
1989 Action::PromptHistory { back } => {
1990 if self.search.is_prompting() {
1991 self.search.history_step(*back);
1992 // No minibuffer resync: the shadow is the ex-line's store
1993 // and nothing reads it while a search prompt is open, so
1994 // rewriting it here was maintaining a copy for no reader.
1995 self.preview_search();
1996 }
1997 }
1998 Action::Pending => {}
1999 }
2000 // Widen the dirty region by what this action touched (M1). Content
2001 // mutations that changed the line count run to end-of-document (every
2002 // line below shifted); an in-place edit or a cursor move is local;
2003 // arbitrary commands are conservatively Full. Never narrows.
2004 let lines_after = self.active_line_count();
2005 let cline_after = self.cursor().line;
2006 let d = match action {
2007 // A search repaints every highlight in the viewport, not just the
2008 // line the cursor left — so it must widen to Full. Treating it as a
2009 // cursor move would leave stale highlights on untouched lines.
2010 Action::SearchOpen(_)
2011 | Action::PromptHistory { .. }
2012 | Action::PromptBackspace
2013 | Action::PromptCaret { .. }
2014 | Action::SearchPreviewStep { .. }
2015 | Action::PromptDelete
2016 | Action::PromptDeleteWord
2017 | Action::PromptClearToStart
2018 | Action::SearchRepeat { .. }
2019 | Action::SearchWord { .. }
2020 | Action::ClearSearchHighlight
2021 | Action::SearchSubmitOperated { .. }
2022 // A replayed change can edit anywhere the original could, and a
2023 // match object can be anywhere in the document.
2024 | Action::RepeatLastChange
2025 | Action::TextObject(_)
2026 | Action::ApplyOperatorObject { .. }
2027 // A jump can land anywhere, so the viewport may scroll wholesale.
2028 | Action::JumpBack
2029 | Action::JumpForward => Damage::Full,
2030 Action::InsertChar(_)
2031 | Action::Edit(_)
2032 | Action::Undo
2033 | Action::Redo
2034 | Action::ApplyOperator { .. } => {
2035 if lines_after == lines_before {
2036 Damage::span(cline_before, cline_after)
2037 } else {
2038 Damage::Lines {
2039 from: cline_before.min(cline_after),
2040 to: u32::MAX,
2041 }
2042 }
2043 }
2044 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
2045 Action::Save => Damage::Viewport,
2046 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
2047 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
2048 };
2049 self.damage = self.damage.join(d);
2050 // Remember this change for `.`.
2051 //
2052 // Recorded from an OBSERVED MUTATION, not from the action's variant.
2053 // `text_effect()` is the wrong predicate here even though it looks
2054 // like the right one: it exists to decide cache invalidation, where
2055 // OVER-reporting is the safe direction, and the dot register needs the
2056 // opposite bias. Leaning on it meant `last_change` was set by actions
2057 // that changed no text at all, with two measured consequences:
2058 //
2059 // `iZ<Esc>` then `/a<CR>` then `.` — did nothing; the register held
2060 // `SubmitCommand`, whose replay reads an already-cleared
2061 // minibuffer.
2062 // `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
2063 // abandoned prompt left the register holding `InsertChar('q')`,
2064 // and `.` in Normal mode routes that to the text. A corrupting
2065 // register, not merely a lost one.
2066 //
2067 // Comparing the buffer's `TextRev` across the action answers the only
2068 // question that matters — did this actually change the text — and gets
2069 // the failed-operator case (`dgn` with no pattern) right for free.
2070 if self.recording_insert {
2071 match action {
2072 Action::InsertChar(c) => {
2073 if let Some(lc) = self.last_change.as_mut() {
2074 lc.inserted.push(*c);
2075 }
2076 }
2077 // Leaving Insert ends the session; the change is now whole.
2078 Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
2079 _ => {}
2080 }
2081 } else if self.text_rev() != rev_before
2082 && !matches!(
2083 action,
2084 Action::RepeatLastChange | Action::Undo | Action::Redo
2085 )
2086 {
2087 self.last_change = Some(LastChange {
2088 action: action.clone(),
2089 count: 1,
2090 inserted: String::new(),
2091 });
2092 self.recording_insert = self.modal.mode() == Mode::Insert;
2093 }
2094
2095 // The search is over the moment you move on or edit — clear the
2096 // highlight rather than leaving the buffer as confetti until an
2097 // explicit `:noh`, which is the remap nearly every vimrc carries.
2098 // Clearing suppresses without forgetting, so `n` still works.
2099 if action.highlight_effect() == HighlightEffect::Clear {
2100 self.search.clear_highlight();
2101 }
2102 // Text changed ⇒ every match offset cached against the old text is
2103 // wrong. `SearchState::refresh` existed for exactly this and had ZERO
2104 // callers, so inserting four characters left both renderers painting
2105 // the highlight four columns off.
2106 //
2107 // Gated on the typed classifier rather than on `bump_gen` (which fires
2108 // for pure cursor moves too): re-scanning the document on every `j`
2109 // would be a per-keystroke full pass for no reason.
2110 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
2111 let text = self.active_text();
2112 self.search.refresh(&text);
2113 // NO manual invalidation of `search_at` here, deliberately. It is
2114 // `Anchored` to the text revision, so an ordinal computed against
2115 // the old text now reads as `None` on its own. This is the line
2116 // that used to have to be remembered.
2117 }
2118 // An action reached the executor ⇒ visible state may have changed.
2119 // Advance the refresh generation so the renderer repaints (and
2120 // re-highlights) exactly once. A gated-out key never reaches here, so
2121 // a key-repeat storm does not spin the renderer.
2122 self.bump_gen();
2123 }
2124
2125 /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
2126 /// active buffer — **pure**: no cursor mutation, no side effects. This is
2127 /// the single motion-resolution source of truth that both [`apply_motion`]
2128 /// (move the cursor *to* the target) and [`apply_operator`] (use the target
2129 /// as the *other end* of an operated range) stand on. `None` only if there
2130 /// is no active buffer.
2131 ///
2132 /// [`apply_motion`]: Self::apply_motion
2133 /// [`apply_operator`]: Self::apply_operator
2134 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
2135 let buf = self.buffers.get(self.active)?;
2136 let pos = from;
2137 Some(match motion {
2138 // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
2139 // against the committed match list, so it is `None` (motion fails,
2140 // operator aborts, buffer untouched) when nothing is committed —
2141 // never a silent move to 0, which would delete to the file start.
2142 Motion::SearchNext | Motion::SearchPrev => {
2143 let at = buf.position_to_char(pos).ok()?;
2144 let step = self
2145 .search
2146 .repeat(at, matches!(motion, Motion::SearchPrev))?;
2147 buf.char_to_position(step.target.start)
2148 }
2149 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
2150 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
2151 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
2152 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
2153 Motion::LineStart => Position::new(pos.line, 0),
2154 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
2155 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
2156 Motion::DocStart => Position::ZERO,
2157 Motion::DocEnd => Position::new(
2158 buf.line_count().saturating_sub(1),
2159 buf.line_len_chars(buf.line_count().saturating_sub(1)),
2160 ),
2161 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
2162 Motion::WordStartPrev => word_prev(buf, pos),
2163 Motion::PageDown | Motion::HalfPageDown => {
2164 Position::new(pos.line.saturating_add(10), pos.column)
2165 }
2166 Motion::PageUp | Motion::HalfPageUp => {
2167 Position::new(pos.line.saturating_sub(10), pos.column)
2168 }
2169 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
2170 // Structural Lisp motions — stubs for phase 1.B; full paredit
2171 // semantics land when caixa-ast is wired to the active buffer.
2172 Motion::ForwardSexp
2173 | Motion::BackwardSexp
2174 | Motion::UpList
2175 | Motion::DownList
2176 | Motion::BeginningOfDefun
2177 | Motion::EndOfDefun
2178 | Motion::BeginningOfSexp
2179 | Motion::EndOfSexp => pos,
2180 })
2181 }
2182
2183 fn apply_motion(&mut self, motion: Motion) {
2184 // A bare search motion is a FAR JUMP and it REPORTS — it records into
2185 // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
2186 // when nothing matches. `resolve_motion` can do none of that: it is
2187 // deliberately pure because the OPERATOR path calls it to find a range
2188 // without moving the cursor. So `n` routes to the one executor that
2189 // owns those side effects, and `Action::SearchRepeat` routes to the
2190 // same place — one code path, two spellings.
2191 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2192 self.jump_search(matches!(motion, Motion::SearchPrev));
2193 return;
2194 }
2195 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
2196 return;
2197 };
2198 // The single cursor-mutation path clamps to the buffer and scrolls
2199 // the viewport to contain the cursor on both axes.
2200 self.set_cursor(pos);
2201 }
2202
2203 /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
2204 /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
2205 /// Composition is explicit: the motion resolves a target via
2206 /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
2207 /// `[cursor, target)` range. Register-leaving operators
2208 /// ([`Operator::leaves_register`]) capture the text first.
2209 fn apply_operator(&mut self, op: Operator, motion: Motion) {
2210 let from = self.cursor();
2211 let Some(to) = self.resolve_motion(from, motion) else {
2212 // A motion that cannot resolve aborts the operator with the buffer
2213 // untouched. A search motion says WHY — `dn` with no pattern armed
2214 // is otherwise indistinguishable from a dropped keystroke, which
2215 // is the same complaint that motivated E486 on the bare path.
2216 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2217 if self.search.pattern().is_none() {
2218 self.messages
2219 .push("E35: No previous regular expression".to_string());
2220 } else {
2221 self.report_pattern_not_found();
2222 }
2223 }
2224 return;
2225 };
2226 self.apply_operator_to(op, to);
2227 }
2228
2229 /// Apply `op` over `[cursor, to)`.
2230 ///
2231 /// Split out of [`Self::apply_operator`] so the operated-search path can
2232 /// reach the same range machinery with a target it resolved itself — the
2233 /// alternative was a second copy of the delete/yank/register logic, which
2234 /// is how the two would drift.
2235 fn apply_operator_to(&mut self, op: Operator, to: Position) {
2236 let from = self.cursor();
2237 self.apply_operator_over(
2238 op,
2239 Range {
2240 start: from,
2241 end: to,
2242 },
2243 );
2244 }
2245
2246 /// Apply `op` over an explicit range.
2247 ///
2248 /// The object path needs this: `gn`'s extent need not begin at the cursor,
2249 /// so it cannot go through the `[cursor, target)` shape the motion path
2250 /// uses. One implementation of the delete/yank/register logic, reached two
2251 /// ways.
2252 fn apply_operator_over(&mut self, op: Operator, range: Range) {
2253 let range = range.normalized();
2254 if range.is_empty() {
2255 return;
2256 }
2257 // Capture the operated text (for the register) before mutating.
2258 let text = self
2259 .buffers
2260 .get(self.active)
2261 .and_then(|buf| buf.slice(range).ok());
2262 if op.leaves_register() {
2263 if let Some(t) = &text {
2264 self.register = Some(t.clone());
2265 }
2266 }
2267 match op {
2268 // Delete + Change remove the range; Change then enters Insert so
2269 // the operator pairs with immediate typing (`ciw`, `c$`).
2270 Operator::Delete | Operator::Change => {
2271 if let Some(buf) = self.buffers.get_mut(self.active) {
2272 let _ = buf.apply(&Edit::delete(range));
2273 }
2274 self.set_cursor(range.start);
2275 if op == Operator::Change {
2276 self.modal.enter(Mode::Insert);
2277 }
2278 }
2279 // Yank copies to the register without mutating the buffer; vim
2280 // leaves the cursor at the range start.
2281 Operator::Yank => {
2282 self.set_cursor(range.start);
2283 }
2284 // Indent/Format/structural operators are not yet wired — named,
2285 // not faked (no buffer mutation, register already captured for the
2286 // register-leaving ones above).
2287 _ => {
2288 self.messages
2289 .push("operator not yet implemented".to_owned());
2290 }
2291 }
2292 }
2293
2294 /// The text last yanked or deleted into the unnamed register, if any.
2295 /// The future `p`/`P` paste reads this.
2296 #[must_use]
2297 pub fn register(&self) -> Option<&str> {
2298 self.register.as_deref()
2299 }
2300
2301 fn insert_char(&mut self, c: char) {
2302 if self.modal.mode() == Mode::Command {
2303 // A search prompt and an ex-command share Command mode (vim's
2304 // cmdline). `search.is_prompting()` is the typed discriminator —
2305 // it can only be true when `/` or `?` actually opened a prompt.
2306 if self.search.is_prompting() {
2307 // The search prompt is the SOLE store while it is open.
2308 //
2309 // This used to also `push_minibuffer(c)`, and the two stores
2310 // insert differently — `search.push` at the caret, the
2311 // minibuffer always at the end — so `/fo<Left>X` left them
2312 // reading `fXo` and `foX`. That was one of FIVE desync paths;
2313 // the caret moves, forward-delete, delete-word and
2314 // clear-to-start never touched the shadow at all.
2315 //
2316 // Deleting the write costs nothing because `status_model`
2317 // already selects the minibuffer only on the `prompt == None`
2318 // branch — the shadow is the EX-LINE's store, and while a
2319 // search prompt is open nothing reads it.
2320 self.search.push(c);
2321 self.preview_search();
2322 } else {
2323 self.modal.push_minibuffer(c);
2324 }
2325 return;
2326 }
2327 let cursor = self.cursor();
2328 let Some(buf) = self.buffers.get_mut(self.active) else {
2329 return;
2330 };
2331 let edit = Edit::insert(cursor, c.to_string());
2332 if buf.apply(&edit).is_ok() {
2333 let next = if c == '\n' {
2334 Position::new(cursor.line.saturating_add(1), 0)
2335 } else {
2336 cursor.shift_right(1)
2337 };
2338 // Route through the single cursor-mutation path so the viewport
2339 // follows the cursor (both axes) and the cursor stays clamped.
2340 self.set_cursor(next);
2341 }
2342 }
2343
2344 /// Backspace inside a prompt. Keeps the search buffer and the displayed
2345 /// minibuffer in lockstep — if only one shrank, the pattern submitted
2346 /// would differ from the text on screen.
2347 fn prompt_backspace(&mut self) -> bool {
2348 if self.modal.mode() != Mode::Command {
2349 return false;
2350 }
2351 if self.search.is_prompting() {
2352 // Backspacing past the `/` closes the prompt, as vim does. No
2353 // `pop_minibuffer` here for the same reason as `insert_char`: the
2354 // shadow is the ex-line's, and popping its TAIL when the caret is
2355 // mid-pattern was another desync path.
2356 if self.search.backspace() {
2357 self.modal.clear_minibuffer();
2358 self.modal.enter(Mode::Normal);
2359 }
2360 // Never `pop_minibuffer` on the search path: it pops the TAIL,
2361 // while `search.backspace()` removes the char before the CARET.
2362 return true;
2363 }
2364 self.modal.pop_minibuffer();
2365 true
2366 }
2367
2368 fn submit_command(&mut self) {
2369 // Read the command line BEFORE leaving Command mode — the minibuffer
2370 // exists only in the `Command` variant, so the escape must come
2371 // after the capture.
2372 let line = self.modal.minibuffer().to_string();
2373 self.modal.escape();
2374 let (name, args) = parse_command_line(&line);
2375 if name.is_empty() {
2376 return;
2377 }
2378 self.run_command(&name, &args);
2379 }
2380
2381 fn run_command(&mut self, name: &str, args: &[String]) {
2382 // Bound the command -> RunCommand slip -> command cycle. Refused and
2383 // reported, never a stack overflow: an editor that dies under the
2384 // operator loses their buffer, and a script that loops is a mistake
2385 // they should be told about, not punished for.
2386 if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2387 let mut m = String::from("command recursion too deep at `");
2388 m.push_str(name);
2389 m.push_str("` — refusing");
2390 self.messages.push(m);
2391 self.damage = self.damage.join(Damage::Viewport);
2392 self.bump_gen();
2393 return;
2394 }
2395 self.dispatch_depth += 1;
2396 self.run_command_inner(name, args);
2397 self.dispatch_depth -= 1;
2398 }
2399
2400 /// How many nested command dispatches are allowed. Deep enough that no
2401 /// legitimate script notices, shallow enough to fail fast.
2402 const MAX_DISPATCH_DEPTH: u8 = 8;
2403
2404 fn run_command_inner(&mut self, name: &str, args: &[String]) {
2405 // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2406 // gated on `Command: <name>` has its entry applied the first time
2407 // that command runs, BEFORE dispatch — so the activated plugin
2408 // can register the very command being invoked and it resolves on
2409 // this same call.
2410 if self.plugin_host.pending() > 0 {
2411 let pending = self.plugin_host.pending_for_command(name);
2412 for src in pending {
2413 self.apply_plugin_entry(&src);
2414 }
2415 }
2416 // Read through the counter, then interpret. Two immutable borrows of
2417 // `self` (the window and the registry) coexist; the `&mut` comes
2418 // afterwards, once the outcome is owned. That sequencing IS the
2419 // seam: there is no moment where a command body and `&mut self` are
2420 // live at the same time.
2421 let outcome = {
2422 let window = self.window();
2423 self.commands.run(name, &window, args)
2424 };
2425 match outcome {
2426 Ok(o) => self.interpret(o),
2427 // Reported, never fatal (Phase 0). A failed command must not
2428 // take the editor down, but it must not be invisible either.
2429 Err(e) => {
2430 self.messages.push(describe_command_failure(name, &e));
2431 self.damage = self.damage.join(Damage::Viewport);
2432 self.bump_gen();
2433 }
2434 }
2435 }
2436
2437 // ── tatara-lisp runtime bridge (imperative programmability tier) ──
2438
2439 /// Capture a read snapshot of the editor for the tatara-lisp host.
2440 /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
2441 #[must_use]
2442 pub fn snapshot(&self) -> EditorSnapshot {
2443 let current_line = self
2444 .buffers
2445 .get(self.active)
2446 .and_then(|b| b.line(self.cursor().line))
2447 .map(|s| s.trim_end_matches('\n').to_string())
2448 .unwrap_or_default();
2449 let buffer_name = self
2450 .buffers
2451 .get(self.active)
2452 .and_then(|b| b.path.as_ref())
2453 .map(|p| p.display().to_string())
2454 .unwrap_or_else(|| "[scratch]".to_string());
2455 EditorSnapshot {
2456 cursor_line: i64::from(self.cursor().line),
2457 cursor_column: i64::from(self.cursor().column),
2458 current_line,
2459 mode: self.modal.mode().as_str().to_string(),
2460 buffer_name,
2461 }
2462 }
2463
2464 /// Evaluate tatara-lisp `src` against this editor: capture a
2465 /// snapshot, run it in the embedded VM, then apply the typed effects
2466 /// the program emitted. This is the imperative programmability tier
2467 /// — live Lisp that reads state and drives the editor through the
2468 /// sandboxed effect boundary.
2469 ///
2470 /// **Snapshot semantics:** the read snapshot is captured ONCE before
2471 /// eval, and effects are applied AFTER the program returns. So within
2472 /// a single `run_lisp` call a program cannot observe its own writes —
2473 /// `(insert "x") (cursor-column)` reads the pre-insert column. This
2474 /// snapshot-isolation is deliberate (it's what makes the effect
2475 /// boundary a clean sandbox seam); a program that must read its own
2476 /// effects splits the work across calls. The VM is cached
2477 /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
2478 /// `define`s persist across calls (REPL-like).
2479 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
2480 let mut host = EscribaHost::with_snapshot(self.snapshot());
2481 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
2482 vm.eval(src, &mut host)?;
2483 let effects = host.take_effects();
2484 self.apply_host_effects(effects);
2485 Ok(())
2486 }
2487
2488 /// Apply tatara-lisp effects to live editor state.
2489 ///
2490 /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
2491 /// implementation of message-push / option-insert / insert-text beside
2492 /// the Action executor and the slip interpreter — the same duplication
2493 /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
2494 /// hands them to the one interpreter.
2495 pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
2496 self.interpret(Outcome::did(effects));
2497 }
2498
2499 /// Insert a (possibly multi-line) string at the cursor and advance
2500 /// the cursor past it. Used by the `(insert …)` effect.
2501 fn insert_text(&mut self, text: &str) {
2502 if text.is_empty() {
2503 return;
2504 }
2505 let cursor = self.cursor();
2506 let Some(buf) = self.buffers.get_mut(self.active) else {
2507 return;
2508 };
2509 let edit = Edit::insert(cursor, text.to_string());
2510 if buf.apply(&edit).is_ok() {
2511 let next = if let Some(nl) = text.rfind('\n') {
2512 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
2513 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
2514 Position::new(cursor.line + added_lines, last_line_len)
2515 } else {
2516 let n = u32::try_from(text.chars().count()).unwrap_or(0);
2517 cursor.shift_right(n)
2518 };
2519 // Route through the single cursor-mutation path so the viewport
2520 // follows the cursor (both axes) and the cursor stays clamped.
2521 self.set_cursor(next);
2522 }
2523 }
2524}
2525
2526fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
2527 let Some(text) = buf.line(line) else {
2528 return Position::new(line, 0);
2529 };
2530 let col = text
2531 .chars()
2532 .take_while(|c| c.is_whitespace() && *c != '\n')
2533 .count();
2534 Position::new(line, u32::try_from(col).unwrap_or(0))
2535}
2536
2537fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2538 let Some(text) = buf.line(pos.line) else {
2539 return pos;
2540 };
2541 let chars: Vec<char> = text.chars().collect();
2542 let start = pos.column as usize;
2543 let mut i = start;
2544 while i < chars.len() && !chars[i].is_whitespace() {
2545 i += 1;
2546 }
2547 while i < chars.len() && chars[i].is_whitespace() {
2548 i += 1;
2549 }
2550 if i >= chars.len() {
2551 // No more words on this line — jump to next line.
2552 if pos.line + 1 < buf.line_count() {
2553 return Position::new(pos.line + 1, 0);
2554 }
2555 }
2556 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
2557}
2558
2559fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2560 let Some(text) = buf.line(pos.line) else {
2561 return pos;
2562 };
2563 let chars: Vec<char> = text.chars().collect();
2564 let mut i = (pos.column as usize).min(chars.len());
2565 while i > 0 && chars[i - 1].is_whitespace() {
2566 i -= 1;
2567 }
2568 while i > 0 && !chars[i - 1].is_whitespace() {
2569 i -= 1;
2570 }
2571 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
2572}
2573
2574fn parse_command_line(line: &str) -> (String, Vec<String>) {
2575 let mut parts = line.split_whitespace();
2576 let Some(first) = parts.next() else {
2577 return (String::new(), Vec::new());
2578 };
2579 let head = first.strip_prefix(':').unwrap_or(first);
2580 let name = match head {
2581 "w" => "save",
2582 "q" => "quit",
2583 "u" => "undo",
2584 other => other,
2585 };
2586 (name.to_string(), parts.map(str::to_string).collect())
2587}
2588
2589#[cfg(test)]
2590mod tests {
2591 use super::*;
2592 use madori::event::{KeyCode, KeyEvent, Modifiers};
2593
2594 // ── search wiring (escriba-search integration) ────────────────────
2595 //
2596 // The engine is proven in escriba-search's own 61 tests. These prove the
2597 // WIRING: that keys reach it, that the cursor lands where it says, and
2598 // that a search prompt and an ex-command can share Command mode without
2599 // being confused for one another.
2600
2601 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
2602 st.apply(&Action::SearchOpen(dir));
2603 for c in pat.chars() {
2604 st.apply(&Action::InsertChar(c));
2605 }
2606 st.apply(&Action::SubmitCommand);
2607 }
2608
2609 #[test]
2610 fn slash_search_moves_the_cursor_to_the_match() {
2611 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2612 type_search(&mut st, SearchDirection::Forward, "charlie");
2613 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
2614 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
2615 assert_eq!(st.search.matches().len(), 1);
2616 }
2617
2618 #[test]
2619 // `N` is a DIFFERENT vim key from `n` — see escriba-search.
2620 #[allow(non_snake_case)]
2621 fn n_and_N_walk_matches_in_both_directions() {
2622 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
2623 type_search(&mut st, SearchDirection::Forward, "foo");
2624 let first = st.cursor().line;
2625 st.apply(&Action::SearchRepeat { reverse: false });
2626 let second = st.cursor().line;
2627 assert!(second > first, "n advances ({first} -> {second})");
2628 st.apply(&Action::SearchRepeat { reverse: true });
2629 assert_eq!(st.cursor().line, first, "N comes back");
2630 }
2631
2632 #[test]
2633 fn star_searches_the_word_under_the_cursor() {
2634 let mut st = new_state_with("needle\nhaystack\nneedle\n");
2635 st.apply(&Action::SearchWord { reverse: false });
2636 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
2637 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
2638 }
2639
2640 #[test]
2641 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
2642 let mut st = new_state_with("foo\nbar\nfoo\n");
2643 type_search(&mut st, SearchDirection::Forward, "foo");
2644 let matches_before = st.search.matches().len();
2645
2646 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2647 st.apply(&Action::InsertChar('z'));
2648 st.apply(&Action::ChangeMode(Mode::Normal));
2649
2650 assert!(!st.search.is_prompting(), "prompt gone");
2651 assert_eq!(
2652 st.search.pattern().unwrap().raw(),
2653 "foo",
2654 "old pattern survives"
2655 );
2656 assert_eq!(
2657 st.search.matches().len(),
2658 matches_before,
2659 "old highlights survive"
2660 );
2661 }
2662
2663 #[test]
2664 fn a_search_prompt_and_an_ex_command_are_not_confused() {
2665 let mut st = new_state_with("foo\n");
2666 // No `/` pressed: Command mode belongs to the ex-command line.
2667 st.apply(&Action::ChangeMode(Mode::Command));
2668 assert!(!st.search.is_prompting(), "`:` must not open a search");
2669 st.apply(&Action::InsertChar('w'));
2670 assert!(
2671 st.search.prompt().is_none(),
2672 "typed char went to the ex line"
2673 );
2674 }
2675
2676 #[test]
2677 fn a_missing_pattern_reports_instead_of_failing_silently() {
2678 let mut st = new_state_with("alpha\nbravo\n");
2679 type_search(&mut st, SearchDirection::Forward, "zzz");
2680 assert!(
2681 st.messages.iter().any(|m| m.contains("E486")),
2682 "must report not-found, got {:?}",
2683 st.messages
2684 );
2685 }
2686
2687 #[test]
2688 fn n_without_any_search_reports_rather_than_moving() {
2689 let mut st = new_state_with("alpha\nbravo\n");
2690 let before = st.cursor();
2691 st.apply(&Action::SearchRepeat { reverse: false });
2692 assert_eq!(st.cursor(), before, "cursor must not move");
2693 assert!(
2694 st.messages.iter().any(|m| m.contains("E35")),
2695 "got {:?}",
2696 st.messages
2697 );
2698 }
2699
2700 #[test]
2701 fn search_as_a_motion_composes_with_an_operator() {
2702 // The point of Motion::SearchNext: `d` + search deletes to the match.
2703 let mut st = new_state_with("alpha bravo charlie\n");
2704 type_search(&mut st, SearchDirection::Forward, "charlie");
2705 st.set_cursor(Position::new(0, 0));
2706 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
2707 assert!(target.is_some(), "search must resolve as a motion");
2708 assert_eq!(target.unwrap().column, 12, "at `charlie`");
2709 }
2710
2711 #[test]
2712 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
2713 // A silent fallback to offset 0 would make `d` + search delete to the
2714 // start of the file — the worst possible failure for an operator.
2715 let st = new_state_with("alpha bravo\n");
2716 assert!(
2717 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
2718 .is_none()
2719 );
2720 }
2721
2722 #[test]
2723 fn clear_highlight_keeps_the_pattern_usable() {
2724 let mut st = new_state_with("foo\nbar\nfoo\n");
2725 type_search(&mut st, SearchDirection::Forward, "foo");
2726 st.apply(&Action::ClearSearchHighlight);
2727 assert!(st.search.highlights().is_empty(), "nothing lit");
2728 st.apply(&Action::SearchRepeat { reverse: false });
2729 assert!(st.search.pattern().is_some(), "but n still works");
2730 }
2731
2732 #[test]
2733 fn typing_previews_incrementally_before_commit() {
2734 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2735 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2736 for c in "charlie".chars() {
2737 st.apply(&Action::InsertChar(c));
2738 }
2739 // incsearch: the cursor has already moved, with nothing committed.
2740 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
2741 assert!(st.search.pattern().is_none(), "but nothing is committed");
2742 }
2743
2744 #[test]
2745 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
2746 let mut st = new_state_with("alpha\nbravo\n");
2747 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2748 for c in "bravox".chars() {
2749 st.apply(&Action::InsertChar(c));
2750 }
2751 assert_eq!(st.search.prompt().unwrap().text(), "bravox");
2752 st.apply(&Action::PromptBackspace);
2753 assert_eq!(
2754 st.search.prompt().unwrap().text(),
2755 "bravo",
2756 "typo corrected"
2757 );
2758 assert_eq!(
2759 st.status_model().prompt_text,
2760 "bravo",
2761 "the model reads the PROMPT — the minibuffer is the ex-line's store",
2762 );
2763 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
2764 }
2765
2766 #[test]
2767 fn backspacing_past_the_slash_closes_the_prompt() {
2768 let mut st = new_state_with("alpha\n");
2769 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2770 st.apply(&Action::InsertChar('a'));
2771 st.apply(&Action::PromptBackspace);
2772 st.apply(&Action::PromptBackspace);
2773 assert!(!st.search.is_prompting(), "prompt closed");
2774 assert_eq!(st.modal.mode(), Mode::Normal);
2775 }
2776
2777 #[test]
2778 fn noh_clears_highlights_and_keeps_the_pattern() {
2779 let mut st = new_state_with("foo\nbar\nfoo\n");
2780 type_search(&mut st, SearchDirection::Forward, "foo");
2781 assert!(!st.search.highlights().is_empty());
2782 st.run_command("noh", &[]);
2783 assert!(st.search.highlights().is_empty(), ":noh turns them off");
2784 assert!(st.search.pattern().is_some(), "but n still works");
2785 }
2786
2787 #[test]
2788 fn noh_accepts_the_vim_aliases() {
2789 for name in ["noh", "nohl", "nohlsearch"] {
2790 let mut st = new_state_with("foo\nfoo\n");
2791 type_search(&mut st, SearchDirection::Forward, "foo");
2792 st.run_command(name, &[]);
2793 assert!(st.search.highlights().is_empty(), "{name} must clear");
2794 }
2795 }
2796
2797 #[test]
2798 fn backspace_on_the_ex_line_does_not_touch_search_state() {
2799 let mut st = new_state_with("foo\n");
2800 st.apply(&Action::ChangeMode(Mode::Command));
2801 st.apply(&Action::InsertChar('w'));
2802 st.apply(&Action::InsertChar('q'));
2803 st.apply(&Action::PromptBackspace);
2804 assert_eq!(st.status_model().prompt_text, "w");
2805 assert!(st.search.prompt().is_none(), "no search was involved");
2806 }
2807
2808 #[test]
2809 fn up_arrow_recalls_the_previous_search() {
2810 let mut st = new_state_with("alpha\nbravo\n");
2811 type_search(&mut st, SearchDirection::Forward, "bravo");
2812 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2813 st.apply(&Action::PromptHistory { back: true });
2814 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2815 assert_eq!(
2816 st.status_model().prompt_text,
2817 "bravo",
2818 "display follows the prompt"
2819 );
2820 }
2821
2822 #[test]
2823 fn arrowing_back_down_restores_the_half_typed_pattern() {
2824 let mut st = new_state_with("alpha\nbravo\n");
2825 type_search(&mut st, SearchDirection::Forward, "bravo");
2826 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2827 st.apply(&Action::InsertChar('a'));
2828 st.apply(&Action::PromptHistory { back: true });
2829 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2830 st.apply(&Action::PromptHistory { back: false });
2831 assert_eq!(
2832 st.search.prompt().unwrap().text(),
2833 "a",
2834 "the draft comes back"
2835 );
2836 assert_eq!(st.status_model().prompt_text, "a");
2837 }
2838
2839 #[test]
2840 fn history_arrows_do_nothing_on_the_ex_line() {
2841 let mut st = new_state_with("alpha\n");
2842 st.apply(&Action::ChangeMode(Mode::Command));
2843 st.apply(&Action::InsertChar('w'));
2844 st.apply(&Action::PromptHistory { back: true });
2845 assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
2846 }
2847
2848 fn new_state_with(text: &str) -> EditorState {
2849 let mut bufs = BufferSet::new();
2850 let id = bufs.scratch(text);
2851 EditorState::new_with_buffer(bufs, id)
2852 }
2853
2854 /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
2855 /// action advances `edit_gen` (so the renderer repaints), and merely
2856 /// reading the generation does not. This is what lets `gpu.rs` gate the
2857 /// re-highlight/re-shape on a generation change — an idle frame observes an
2858 /// unchanged generation and reuses its cached buffer.
2859 #[test]
2860 fn edit_gen_advances_on_applied_action_not_on_read() {
2861 let mut s = new_state_with("hello\nworld\n");
2862 let g0 = s.edit_gen();
2863 s.apply(&Action::InsertChar('X'));
2864 assert_ne!(
2865 s.edit_gen(),
2866 g0,
2867 "an applied action must advance the refresh generation",
2868 );
2869 // Reading the generation is not a mutation — idle frames stay put.
2870 let g1 = s.edit_gen();
2871 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
2872 }
2873
2874 /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
2875 /// `Damage` to cover exactly what changed — local for an in-place edit,
2876 /// to-end-of-document when the line count shifts — and the renderer drains
2877 /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
2878 #[test]
2879 fn damage_tracks_edit_scope_and_drains() {
2880 let mut s = new_state_with("hello\nworld\n");
2881 assert!(s.damage().is_none(), "a fresh state has no damage");
2882
2883 s.apply(&Action::InsertChar('X')); // in-place edit on line 0
2884 assert_eq!(
2885 s.damage(),
2886 Damage::Lines { from: 0, to: 0 },
2887 "a local edit damages just its line",
2888 );
2889
2890 let drained = s.take_damage();
2891 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
2892 assert!(s.damage().is_none(), "take_damage drains to None");
2893
2894 s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
2895 assert_eq!(
2896 s.damage(),
2897 Damage::Lines {
2898 from: 0,
2899 to: u32::MAX,
2900 },
2901 "a line-count change damages to end-of-document",
2902 );
2903 }
2904
2905 /// A state whose active window is a deliberately tiny viewport
2906 /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
2907 /// invariant is exercised on small inputs.
2908 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
2909 let mut s = new_state_with(text);
2910 for w in &mut s.layout.windows {
2911 w.viewport.visible_lines = vis_lines;
2912 w.viewport.visible_columns = vis_cols;
2913 }
2914 s
2915 }
2916
2917 /// The core regression invariant: the active window's viewport CONTAINS
2918 /// the cursor on BOTH axes. This is the operator's exact complaint —
2919 /// "typing past the bottom (or right) leaves the cursor off-screen" —
2920 /// made into a checkable property.
2921 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
2922 let w = s.layout.active_window().expect("active window");
2923 let v = w.viewport;
2924 let c = s.cursor();
2925 assert!(
2926 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
2927 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
2928 c.line,
2929 v.top_line,
2930 v.top_line + v.visible_lines,
2931 );
2932 assert!(
2933 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
2934 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
2935 c.column,
2936 v.left_column,
2937 v.left_column + v.visible_columns,
2938 );
2939 }
2940
2941 fn press(kc: KeyCode) -> AppEvent {
2942 AppEvent::Key(KeyEvent {
2943 key: kc,
2944 pressed: true,
2945 modifiers: Modifiers::default(),
2946 text: None,
2947 })
2948 }
2949
2950 // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
2951
2952 fn line0_len(s: &EditorState) -> u32 {
2953 s.buffers.get(s.active).unwrap().line_len_chars(0)
2954 }
2955
2956 #[test]
2957 fn delete_to_line_end_clears_line_and_fills_register() {
2958 let mut s = new_state_with("hello world");
2959 s.apply(&Action::ApplyOperator {
2960 op: Operator::Delete,
2961 motion: Motion::LineEnd,
2962 });
2963 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
2964 assert_eq!(
2965 s.register(),
2966 Some("hello world"),
2967 "delete fills the register"
2968 );
2969 assert_eq!(
2970 s.cursor(),
2971 Position::ZERO,
2972 "cursor lands at the range start"
2973 );
2974 }
2975
2976 #[test]
2977 fn delete_over_right_motion_removes_one_char() {
2978 let mut s = new_state_with("abc");
2979 s.apply(&Action::ApplyOperator {
2980 op: Operator::Delete,
2981 motion: Motion::Right,
2982 });
2983 assert_eq!(
2984 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2985 Some("bc")
2986 );
2987 assert_eq!(s.register(), Some("a"));
2988 }
2989
2990 #[test]
2991 fn change_to_line_end_deletes_and_enters_insert() {
2992 let mut s = new_state_with("hello world");
2993 assert_eq!(s.modal.mode(), Mode::Normal);
2994 s.apply(&Action::ApplyOperator {
2995 op: Operator::Change,
2996 motion: Motion::LineEnd,
2997 });
2998 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
2999 assert_eq!(
3000 s.modal.mode(),
3001 Mode::Insert,
3002 "change enters Insert to type the replacement"
3003 );
3004 assert_eq!(
3005 s.register(),
3006 Some("hello world"),
3007 "change fills the register"
3008 );
3009 }
3010
3011 #[test]
3012 fn yank_to_line_end_fills_register_without_mutating() {
3013 let mut s = new_state_with("hello world");
3014 s.apply(&Action::ApplyOperator {
3015 op: Operator::Yank,
3016 motion: Motion::LineEnd,
3017 });
3018 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
3019 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
3020 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
3021 }
3022
3023 #[test]
3024 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
3025 // The encapsulation proof: apply_motion (cursor move) and
3026 // apply_operator (range end) BOTH stand on resolve_motion — so a move
3027 // to LineEnd lands at exactly the position the operator deletes to.
3028 let mut s = new_state_with("hello world");
3029 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
3030 assert_eq!(target, Position::new(0, 11));
3031 s.apply_motion(Motion::LineEnd);
3032 assert_eq!(
3033 s.cursor(),
3034 target,
3035 "the move path resolves the same target the operator uses"
3036 );
3037 }
3038
3039 #[test]
3040 fn empty_motion_range_is_a_no_op() {
3041 // An operator over a zero-width motion (cursor already at line start)
3042 // mutates nothing and leaves the register untouched.
3043 let mut s = new_state_with("abc");
3044 s.apply(&Action::ApplyOperator {
3045 op: Operator::Delete,
3046 motion: Motion::LineStart,
3047 });
3048 assert_eq!(
3049 s.buffers.get(s.active).unwrap().line(0).as_deref(),
3050 Some("abc")
3051 );
3052 assert_eq!(s.register(), None);
3053 }
3054
3055 #[test]
3056 fn operator_then_motion_composes_through_the_pending_fsm() {
3057 // The full keymap→FSM→engine path: dispatching the `d` operator action
3058 // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
3059 // the operator key alone does nothing until the motion arrives.
3060 let mut s = new_state_with("hello world");
3061 s.apply(&Action::Operator(Operator::Delete));
3062 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
3063 s.apply(&Action::Move(Motion::LineEnd));
3064 assert_eq!(
3065 line0_len(&s),
3066 0,
3067 "d then $ composes d$ and deletes the line"
3068 );
3069 assert_eq!(s.register(), Some("hello world"));
3070 }
3071
3072 #[test]
3073 fn change_operator_through_fsm_enters_insert() {
3074 let mut s = new_state_with("hello world");
3075 s.apply(&Action::Operator(Operator::Change));
3076 s.apply(&Action::Move(Motion::LineEnd));
3077 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
3078 }
3079
3080 #[test]
3081 fn lone_motion_after_no_operator_just_moves() {
3082 // Without a preceding operator the motion passes through unchanged.
3083 let mut s = new_state_with("hello world");
3084 s.apply(&Action::Move(Motion::LineEnd));
3085 assert_eq!(s.cursor(), Position::new(0, 11));
3086 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
3087 }
3088
3089 #[test]
3090 fn counted_operator_deletes_count_times() {
3091 // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
3092 // flows through the FSM to the composed motion (the bug fix: previously
3093 // the count repeated the operator key and toggled the FSM).
3094 let mut s = new_state_with("abcdef");
3095 s.apply_counted(&Action::Operator(Operator::Delete), 3);
3096 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
3097 s.apply(&Action::Move(Motion::Right));
3098 assert_eq!(
3099 s.buffers.get(s.active).unwrap().line(0).as_deref(),
3100 Some("def")
3101 );
3102 }
3103
3104 #[test]
3105 fn operator_and_motion_counts_multiply_end_to_end() {
3106 // `2d3l` = delete 2×3 = 6 chars.
3107 let mut s = new_state_with("abcdefgh");
3108 s.apply_counted(&Action::Operator(Operator::Delete), 2);
3109 s.apply_counted(&Action::Move(Motion::Right), 3);
3110 assert_eq!(
3111 s.buffers.get(s.active).unwrap().line(0).as_deref(),
3112 Some("gh")
3113 );
3114 }
3115
3116 #[test]
3117 fn bare_counted_motion_still_repeats_no_regression() {
3118 // `3j` still moves down 3 lines — the count passes through the FSM
3119 // unchanged when no operator is pending.
3120 let mut s = new_state_with("a\nb\nc\nd\ne");
3121 s.apply_counted(&Action::Move(Motion::Down), 3);
3122 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
3123 }
3124
3125 /// A monotonic clock for the key-repeat gate in tests — each `next()`
3126 /// jumps a full second past the previous, so every press it stamps is
3127 /// well outside the 80ms debounce window and therefore an INTENTIONAL
3128 /// press (never a storm tick). Used by tests that fire the *same*
3129 /// navigation key twice and assert editor logic, not debounce timing.
3130 struct SpacedClock(std::time::Instant);
3131 impl SpacedClock {
3132 fn new() -> Self {
3133 Self(std::time::Instant::now())
3134 }
3135 fn next(&mut self) -> std::time::Instant {
3136 self.0 += std::time::Duration::from_secs(1);
3137 self.0
3138 }
3139 }
3140
3141 #[test]
3142 fn hjkl_moves_cursor() {
3143 let mut s = new_state_with("hello\nworld");
3144 s.tick(&press(KeyCode::Char('l')));
3145 assert_eq!(s.cursor().column, 1);
3146 s.tick(&press(KeyCode::Char('j')));
3147 assert_eq!(s.cursor().line, 1);
3148 s.tick(&press(KeyCode::Char('h')));
3149 assert_eq!(s.cursor().column, 0);
3150 }
3151
3152 #[test]
3153 fn insert_mode_inserts_chars() {
3154 let mut s = new_state_with("");
3155 s.tick(&press(KeyCode::Char('i')));
3156 assert_eq!(s.modal.mode(), Mode::Insert);
3157 s.tick(&press(KeyCode::Char('h')));
3158 s.tick(&press(KeyCode::Char('i')));
3159 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
3160 assert_eq!(s.cursor().column, 2);
3161 }
3162
3163 #[test]
3164 fn esc_returns_to_normal() {
3165 let mut s = new_state_with("");
3166 s.tick(&press(KeyCode::Char('i')));
3167 s.tick(&press(KeyCode::Escape));
3168 assert_eq!(s.modal.mode(), Mode::Normal);
3169 }
3170
3171 #[test]
3172 fn count_prefix_repeats_motion() {
3173 let mut s = new_state_with("abcdefghij");
3174 s.tick(&press(KeyCode::Char('5')));
3175 s.tick(&press(KeyCode::Char('l')));
3176 assert_eq!(s.cursor().column, 5);
3177 }
3178
3179 #[test]
3180 fn close_event_requests_quit() {
3181 let mut s = new_state_with("");
3182 s.tick(&AppEvent::CloseRequested);
3183 assert!(s.quit_requested);
3184 }
3185
3186 #[test]
3187 fn word_next_jumps_past_whitespace() {
3188 let mut s = new_state_with("foo bar baz");
3189 // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
3190 // the gate passes both (a real user's two taps are ≥80ms apart).
3191 let mut clk = SpacedClock::new();
3192 s.tick_at(&press(KeyCode::Char('w')), clk.next());
3193 assert_eq!(s.cursor().column, 4);
3194 s.tick_at(&press(KeyCode::Char('w')), clk.next());
3195 assert_eq!(s.cursor().column, 8);
3196 }
3197
3198 // ── Multi-key / leader pending-stroke ───────────────────────────
3199
3200 #[test]
3201 fn leader_sequence_holds_then_resolves() {
3202 let mut s = new_state_with("a\nbb\nccc");
3203 s.keymap.bind_sequence(
3204 Mode::Normal,
3205 vec![Key::Char(','), Key::Char('g')],
3206 Action::Move(Motion::DocEnd),
3207 "doc end",
3208 );
3209 // `,` begins the sequence — held pending, nothing applied yet.
3210 s.on_key(&Key::Char(','));
3211 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
3212 assert_eq!(s.cursor(), Position::ZERO);
3213 // `g` completes `<leader>g` → DocEnd; pending clears.
3214 s.on_key(&Key::Char('g'));
3215 assert!(s.pending_keys.is_empty());
3216 assert_eq!(s.cursor().line, 2);
3217 }
3218
3219 #[test]
3220 fn two_key_gg_jumps_doc_start() {
3221 let mut s = new_state_with("a\nbb\nccc");
3222 s.keymap.bind_sequence(
3223 Mode::Normal,
3224 vec![Key::Char('g'), Key::Char('g')],
3225 Action::Move(Motion::DocStart),
3226 "doc start",
3227 );
3228 let mut clk = SpacedClock::new();
3229 s.tick_at(&press(KeyCode::Char('j')), clk.next());
3230 s.tick_at(&press(KeyCode::Char('j')), clk.next());
3231 assert_eq!(s.cursor().line, 2);
3232 s.on_key(&Key::Char('g')); // pending
3233 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3234 s.on_key(&Key::Char('g')); // resolve
3235 assert_eq!(s.cursor(), Position::ZERO);
3236 }
3237
3238 #[test]
3239 fn broken_sequence_aborts_and_clears_pending() {
3240 let mut s = new_state_with("hello");
3241 s.keymap.bind_sequence(
3242 Mode::Normal,
3243 vec![Key::Char('g'), Key::Char('g')],
3244 Action::Move(Motion::DocEnd),
3245 "doc end",
3246 );
3247 s.on_key(&Key::Char('g')); // pending [g]
3248 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3249 s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
3250 assert!(s.pending_keys.is_empty());
3251 assert_eq!(s.cursor(), Position::ZERO);
3252 }
3253
3254 #[test]
3255 fn single_binding_wins_over_sequence_prefix() {
3256 // A key that is BOTH a complete single binding and the start of
3257 // a sequence fires the single binding immediately (no chord
3258 // timeout needed). Here `h` (move-left) also prefixes `hz`.
3259 let mut s = new_state_with("abcde");
3260 let mut clk = SpacedClock::new();
3261 s.tick_at(&press(KeyCode::Char('l')), clk.next());
3262 s.tick_at(&press(KeyCode::Char('l')), clk.next());
3263 assert_eq!(s.cursor().column, 2);
3264 s.keymap.bind_sequence(
3265 Mode::Normal,
3266 vec![Key::Char('h'), Key::Char('z')],
3267 Action::Move(Motion::DocEnd),
3268 "shadowed",
3269 );
3270 s.on_key(&Key::Char('h'));
3271 assert!(s.pending_keys.is_empty(), "single binding should not pend");
3272 assert_eq!(s.cursor().column, 1, "h moved left immediately");
3273 }
3274
3275 // ── tatara-lisp runtime bridge (imperative programmability) ─────
3276
3277 #[test]
3278 fn lisp_set_option_writes_live_options() {
3279 let mut s = new_state_with("");
3280 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
3281 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
3282 }
3283
3284 #[test]
3285 fn lisp_insert_modifies_buffer_and_advances_cursor() {
3286 let mut s = new_state_with("");
3287 s.run_lisp(r#"(insert "abc")"#).unwrap();
3288 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
3289 assert_eq!(s.cursor(), Position::new(0, 3));
3290 }
3291
3292 #[test]
3293 fn lisp_message_appends_to_messages() {
3294 let mut s = new_state_with("");
3295 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
3296 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
3297 }
3298
3299 #[test]
3300 fn lisp_reads_snapshot_and_branches_to_effect() {
3301 // Genuine programmability: Lisp reads the live cursor line and
3302 // an `if` decides which option to set.
3303 let mut s = new_state_with("one\ntwo\nthree");
3304 // cursor at line 0 → "top" branch
3305 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
3306 .unwrap();
3307 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
3308 }
3309
3310 #[test]
3311 fn lisp_run_command_effect_drives_registry() {
3312 // `(run-command "undo")` reaches the live command registry and
3313 // reverts a prior Lisp-driven insert — proving the RunCommand
3314 // effect dispatches through real editor commands.
3315 let mut s = new_state_with("");
3316 s.run_lisp(r#"(insert "abc")"#).unwrap();
3317 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
3318 s.run_lisp(r#"(run-command "undo")"#).unwrap();
3319 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
3320 }
3321
3322 #[test]
3323 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
3324 // The full imperative-quit path: (run-command "quit") routes
3325 // through the registry's typed `quit_requested` signal — no string
3326 // sentinel, and no minibuffer pollution (the editor stays in a
3327 // clean Normal state, which has no minibuffer at all).
3328 let mut s = new_state_with("");
3329 s.run_lisp(r#"(run-command "quit")"#).unwrap();
3330 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
3331 assert_eq!(
3332 s.modal.minibuffer(),
3333 "",
3334 "quit must not pollute any command line — Normal mode has no minibuffer",
3335 );
3336 }
3337
3338 // ── Lazy plugin activation (PluginHost) ────────────────────────
3339
3340 #[test]
3341 fn lazy_plugin_activates_on_command_trigger() {
3342 // A user plugin gated on `Command: LazyGo` has its entry applied
3343 // the first time that command runs — proving the lazy.nvim
3344 // `cmd =` model works end-to-end against live editor state.
3345 let mut s = new_state_with("");
3346 s.register_lazy_plugin(
3347 "user-lazy",
3348 vec![LazyTrigger::Command("LazyGo".into())],
3349 r#"(defoption :name "lazy-loaded" :value "yes")
3350 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
3351 );
3352 assert_eq!(s.plugin_host.pending(), 1);
3353 assert!(
3354 s.options.get("lazy-loaded").is_none(),
3355 "entry not applied yet"
3356 );
3357
3358 // Drive the command through the public imperative path.
3359 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
3360
3361 assert_eq!(
3362 s.options.get("lazy-loaded").map(String::as_str),
3363 Some("yes"),
3364 "the command trigger applied the plugin's entry",
3365 );
3366 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
3367 }
3368
3369 #[test]
3370 fn lazy_plugin_activates_on_filetype() {
3371 let mut s = new_state_with("");
3372 s.register_lazy_plugin(
3373 "user-rust",
3374 vec![LazyTrigger::FileType("rust".into())],
3375 r#"(defoption :name "rust-plugin" :value "on")"#,
3376 );
3377 let n = s.activate_filetype_plugins("rust");
3378 assert_eq!(n, 1);
3379 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
3380 // A second open of the same filetype is a no-op (one-shot).
3381 assert_eq!(s.activate_filetype_plugins("rust"), 0);
3382 }
3383
3384 #[test]
3385 fn cached_vm_serves_multiple_run_lisp_calls() {
3386 let mut s = new_state_with("");
3387 s.run_lisp(r#"(message "one")"#).unwrap();
3388 assert!(
3389 s.lisp_vm.is_some(),
3390 "VM should be cached after first run_lisp"
3391 );
3392 s.run_lisp(r#"(message "two")"#).unwrap();
3393 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
3394 }
3395
3396 #[test]
3397 fn lisp_define_persists_across_run_lisp_calls() {
3398 // The cached VM's top-level env persists across calls (REPL
3399 // semantics): a `define` in one call is visible in the next.
3400 let mut s = new_state_with("");
3401 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
3402 s.run_lisp(r#"(message greeting)"#).unwrap();
3403 assert_eq!(s.messages, vec!["hi".to_string()]);
3404 }
3405
3406 #[test]
3407 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
3408 // Within ONE call a program cannot observe its own writes — the
3409 // read snapshot is captured before eval, effects apply after. A
3410 // later call sees the refreshed snapshot.
3411 let mut s = new_state_with("");
3412 s.run_lisp(
3413 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
3414 )
3415 .unwrap();
3416 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
3417 assert_eq!(
3418 s.options.get("col").map(String::as_str),
3419 Some("stale-zero"),
3420 "cursor-column within the same call reads the pre-eval snapshot",
3421 );
3422 // After the first call the cursor advanced to column 2; the next
3423 // call's snapshot reflects it.
3424 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
3425 .unwrap();
3426 assert_eq!(
3427 s.options.get("col2").map(String::as_str),
3428 Some("live-two"),
3429 "a later call sees the refreshed snapshot",
3430 );
3431 }
3432
3433 #[test]
3434 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
3435 let mut s = new_state_with("");
3436 s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
3437 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
3438 assert_eq!(s.cursor(), Position::new(1, 3));
3439 }
3440
3441 #[test]
3442 fn visual_mode_sequence_resolves() {
3443 let mut s = new_state_with("abc");
3444 s.modal.enter(Mode::Visual);
3445 s.keymap.bind_sequence(
3446 Mode::Visual,
3447 vec![Key::Char('g'), Key::Char('e')],
3448 Action::Move(Motion::DocEnd),
3449 "ge",
3450 );
3451 s.on_key(&Key::Char('g'));
3452 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3453 s.on_key(&Key::Char('e'));
3454 assert!(s.pending_keys.is_empty());
3455 assert_eq!(
3456 s.cursor().column,
3457 3,
3458 "ge resolved to doc-end in visual mode"
3459 );
3460 }
3461
3462 #[test]
3463 fn sequence_abort_with_bound_breaking_key_redispatches() {
3464 // gg is a sequence; `l` (move-right) is a bound single key. After
3465 // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
3466 let mut s = new_state_with("abcde");
3467 s.keymap.bind_sequence(
3468 Mode::Normal,
3469 vec![Key::Char('g'), Key::Char('g')],
3470 Action::Move(Motion::DocEnd),
3471 "gg",
3472 );
3473 s.on_key(&Key::Char('g'));
3474 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3475 s.on_key(&Key::Char('l'));
3476 assert!(s.pending_keys.is_empty());
3477 assert_eq!(
3478 s.cursor().column,
3479 1,
3480 "the breaking key l should re-dispatch as move-right",
3481 );
3482 }
3483
3484 // ── Viewport-follows-cursor invariant (both axes) ───────────────
3485
3486 #[test]
3487 fn viewport_contains_cursor_after_every_op() {
3488 // Tiny window: 5 visible lines × 10 visible columns. Drive a
3489 // representative scripted sequence and assert the viewport contains
3490 // the cursor after EVERY mutating step.
3491 let mut s = new_state_small_viewport("", 5, 10);
3492 assert_cursor_in_viewport(&s, "initial");
3493
3494 // Enter insert mode and type 30 newline-separated lines — this is
3495 // the exact "type past the bottom" complaint.
3496 s.tick(&press(KeyCode::Char('i')));
3497 assert_eq!(s.modal.mode(), Mode::Insert);
3498 for line in 0..30u32 {
3499 for c in "line".chars() {
3500 s.tick(&press(KeyCode::Char(c)));
3501 assert_cursor_in_viewport(&s, "typing chars");
3502 }
3503 s.tick(&press(KeyCode::Enter));
3504 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
3505 }
3506
3507 // Type a long (200-char) line — the "type past the right edge"
3508 // complaint. The cursor must stay horizontally visible the whole way.
3509 for i in 0..200u32 {
3510 s.tick(&press(KeyCode::Char('x')));
3511 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
3512 }
3513
3514 // Multi-line insert_text effect (the `(insert …)` Lisp path).
3515 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
3516 assert_cursor_in_viewport(&s, "insert_text multiline");
3517
3518 // Back to normal mode and move in all directions / to extremes.
3519 s.tick(&press(KeyCode::Escape));
3520 assert_eq!(s.modal.mode(), Mode::Normal);
3521 for m in [
3522 Motion::DocStart,
3523 Motion::DocEnd,
3524 Motion::Down,
3525 Motion::Down,
3526 Motion::Up,
3527 Motion::Right,
3528 Motion::Right,
3529 Motion::Left,
3530 Motion::LineEnd,
3531 Motion::LineStart,
3532 Motion::GotoLine(1),
3533 Motion::GotoLine(40),
3534 Motion::PageDown,
3535 Motion::PageUp,
3536 ] {
3537 s.apply_motion(m);
3538 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
3539 }
3540
3541 // Undo many times — the buffer shrinks; the viewport must re-follow
3542 // the (now clamped) cursor.
3543 for i in 0..50u32 {
3544 s.apply(&Action::Undo);
3545 assert_cursor_in_viewport(&s, &format!("undo {i}"));
3546 }
3547 // Redo back up.
3548 for i in 0..50u32 {
3549 s.apply(&Action::Redo);
3550 assert_cursor_in_viewport(&s, &format!("redo {i}"));
3551 }
3552 }
3553
3554 #[test]
3555 fn insert_at_eof_keeps_cursor_in_bounds() {
3556 // Inserting at the end of the buffer must leave the cursor clamped
3557 // to a valid position (and inside the viewport).
3558 let mut s = new_state_small_viewport("abc", 5, 10);
3559 s.apply_motion(Motion::DocEnd);
3560 s.tick(&press(KeyCode::Char('i')));
3561 s.tick(&press(KeyCode::Char('d')));
3562 let buf = s.buffers.get(s.active).unwrap();
3563 let clamped = buf.clamp(s.cursor());
3564 assert_eq!(
3565 s.cursor(),
3566 clamped,
3567 "cursor must be clamped in-bounds at EOF"
3568 );
3569 assert_cursor_in_viewport(&s, "insert at eof");
3570 }
3571
3572 #[test]
3573 fn count_prefix_then_sequence_repeats() {
3574 // `2` then `gj` (→ move-down) repeats the resolved action twice.
3575 let mut s = new_state_with("a\nb\nc\nd\ne");
3576 s.keymap.bind_sequence(
3577 Mode::Normal,
3578 vec![Key::Char('g'), Key::Char('j')],
3579 Action::Move(Motion::Down),
3580 "gj",
3581 );
3582 s.on_key(&Key::Char('2'));
3583 s.on_key(&Key::Char('g'));
3584 s.on_key(&Key::Char('j'));
3585 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
3586 }
3587
3588 // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
3589
3590 #[test]
3591 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
3592 // The audit's exact complaint: holding `j` floods motion events
3593 // and thrashes the viewport. Simulate an OS key-repeat storm — 20
3594 // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
3595 // — and assert only the gated subset (one per 80ms window) actually
3596 // moves the cursor.
3597 let mut s = new_state_with(&"x\n".repeat(40));
3598 let t0 = std::time::Instant::now();
3599 let mut delivered = 0u32;
3600 for i in 0..20u32 {
3601 let before = s.cursor().line;
3602 s.tick_at(
3603 &press(KeyCode::Char('j')),
3604 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
3605 );
3606 if s.cursor().line != before {
3607 delivered += 1;
3608 }
3609 }
3610 // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
3611 // fewer than the 20 the ungated path would have applied.
3612 assert!(
3613 (10..=14).contains(&delivered),
3614 "expected the storm debounced to ~13 moves, got {delivered}",
3615 );
3616 assert!(
3617 delivered < 20,
3618 "the gate must drop SOME storm ticks, not pass all 20",
3619 );
3620 }
3621
3622 #[test]
3623 fn spaced_intentional_taps_all_pass() {
3624 // Intentional taps spaced past the debounce window must ALL reach
3625 // the editor — the gate filters storms, never deliberate input.
3626 let mut s = new_state_with(&"x\n".repeat(10));
3627 let t0 = std::time::Instant::now();
3628 for i in 0..5u32 {
3629 s.tick_at(
3630 &press(KeyCode::Char('j')),
3631 // 100ms apart — comfortably past the 80ms window.
3632 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
3633 );
3634 }
3635 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
3636 }
3637
3638 #[test]
3639 fn distinct_keys_have_independent_clocks() {
3640 // Holding `j` must not block a simultaneous `l` — the gate keys on
3641 // the Key, so independent keys have independent windows.
3642 let mut s = new_state_with("abc\ndef\nghi");
3643 let t = std::time::Instant::now();
3644 s.tick_at(&press(KeyCode::Char('j')), t);
3645 // `j` again within the window is dropped…
3646 s.tick_at(
3647 &press(KeyCode::Char('j')),
3648 t + std::time::Duration::from_millis(10),
3649 );
3650 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
3651 // …but `l` at the same instant passes (its own clock).
3652 s.tick_at(
3653 &press(KeyCode::Char('l')),
3654 t + std::time::Duration::from_millis(10),
3655 );
3656 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
3657 }
3658
3659 // ── Cursors newtype is the single cursor home ──────────────────────
3660
3661 #[test]
3662 fn cursor_home_preserves_single_cursor_behavior() {
3663 // The typed `Cursors` wrapper behaves exactly like the old bare
3664 // `Position` field for single-cursor editing: the read accessor
3665 // tracks every mutation routed through `set_cursor`, and there is
3666 // exactly one caret.
3667 let mut s = new_state_with("hello\nworld\nthere");
3668 assert_eq!(s.cursor(), Position::ZERO);
3669 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
3670
3671 s.apply_motion(Motion::Down);
3672 s.apply_motion(Motion::Right);
3673 s.apply_motion(Motion::Right);
3674 assert_eq!(s.cursor(), Position::new(1, 2));
3675 // Still a single caret after a sequence of motions.
3676 assert_eq!(s.cursors.count(), 1);
3677
3678 // The accessor is the SAME value the viewport-follow path read.
3679 let w = s.layout.active_window().unwrap();
3680 assert!(w.viewport.top_line <= s.cursor().line);
3681 }
3682
3683 #[test]
3684 fn insert_mode_is_ungated_so_repeat_typing_works() {
3685 // Holding a key to repeat-type a character is intended in Insert
3686 // mode — the gate must NOT suppress it. 10 rapid identical `x`
3687 // keystrokes at the same instant must all land as text.
3688 let mut s = new_state_with("");
3689 s.tick(&press(KeyCode::Char('i')));
3690 assert_eq!(s.modal.mode(), Mode::Insert);
3691 let t = std::time::Instant::now();
3692 for _ in 0..10 {
3693 s.tick_at(&press(KeyCode::Char('x')), t);
3694 }
3695 assert_eq!(
3696 s.buffers.get(s.active).unwrap().to_string(),
3697 "xxxxxxxxxx",
3698 "insert-mode repeat typing is ungated",
3699 );
3700 }
3701}