Skip to main content

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::OpenFile(path) => Negai::OpenPath(path),
924            Choice::Location { path, line } => {
925                // Open FIRST, then jump: the buffer may not exist yet, and
926                // `jump_to_site` needs a BufferId. Two slips, one interpret.
927                self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
928                let site = escriba_shirube::Site::in_buffer(
929                    self.active,
930                    escriba_core::Range::new(
931                        escriba_core::Position::new(line, 0),
932                        escriba_core::Position::new(line, 1),
933                    ),
934                );
935                self.jump_to_site(&site);
936                return;
937            }
938        };
939        self.interpret(Outcome::did(vec![slip]));
940    }
941
942    /// How many files a project grep will read, and how many hits it keeps.
943    ///
944    /// BOUNDED, and the bound is here rather than hidden, because this is a
945    /// SYNCHRONOUS scan on the editor's own thread. The interpreter already
946    /// does synchronous filesystem I/O (`OpenPath`, `Save`), so the posture
947    /// is not new — but those touch one file and this walks a tree, which is
948    /// the first one big enough to freeze the editor.
949    ///
950    /// The DESTINATION is the courier: an errand that scans off-thread and
951    /// delivers results as they arrive, with no ceiling at all. This bound is
952    /// the interim that ships a working grep meanwhile, and it is a real
953    /// limit — a match past the ceiling is NOT found, and the picker says so
954    /// rather than presenting a truncated list as complete.
955    const GREP_FILE_LIMIT: usize = 2_000;
956    const GREP_HIT_LIMIT: usize = 500;
957
958    /// Walk the working directory, bounded, returning `(files, truncated)`.
959    ///
960    /// ONE walker. grep, files and project each need to enumerate the tree,
961    /// and three copies of a bounded traversal is three places to get the
962    /// ceiling, the skip-list, or the truncation report subtly different.
963    ///
964    /// Skips dotfiles, `target` and `node_modules`. That is NOT a gitignore
965    /// implementation and does not pretend to be — a real ignore crate comes
966    /// with the courier.
967    fn walk_project(limit: usize) -> (Vec<std::path::PathBuf>, bool) {
968        let mut out = Vec::new();
969        let mut truncated = false;
970        let mut stack = vec![std::path::PathBuf::from(".")];
971        while let Some(dir) = stack.pop() {
972            let Ok(entries) = std::fs::read_dir(&dir) else {
973                continue;
974            };
975            for entry in entries.flatten() {
976                let name = entry.file_name();
977                let name = name.to_string_lossy();
978                if name.starts_with('.') || name == "target" || name == "node_modules" {
979                    continue;
980                }
981                let path = entry.path();
982                if entry.file_type().is_ok_and(|t| t.is_dir()) {
983                    stack.push(path);
984                    continue;
985                }
986                if out.len() >= limit {
987                    truncated = true;
988                    return (out, truncated);
989                }
990                out.push(path);
991            }
992        }
993        (out, truncated)
994    }
995
996    /// Say plainly when a bounded scan stopped short.
997    ///
998    /// A truncated list presented as complete is the failure this codebase
999    /// keeps finding in itself; it does not get to ship one.
1000    fn report_truncation(&mut self, truncated: bool) {
1001        if truncated {
1002            self.messages
1003                .push("scan stopped at the limit — results are INCOMPLETE".to_string());
1004        }
1005    }
1006
1007    /// Scan the working directory for `pattern`, bounded.
1008    fn grep_project(&mut self, pattern: &str) {
1009        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1010        if pattern.is_empty() {
1011            self.messages.push("grep: empty pattern".to_string());
1012            return;
1013        }
1014        let (files, mut truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1015        let mut items: Vec<PickerItem<Choice>> = Vec::new();
1016        'outer: for path in files {
1017            let Ok(text) = std::fs::read_to_string(&path) else {
1018                continue; // binary or unreadable — not an error worth reporting
1019            };
1020            for (n, line) in text.lines().enumerate() {
1021                if !line.contains(pattern) {
1022                    continue;
1023                }
1024                if items.len() >= Self::GREP_HIT_LIMIT {
1025                    truncated = true;
1026                    break 'outer;
1027                }
1028                let Ok(n) = u32::try_from(n) else { break };
1029                let mut label = String::with_capacity(80);
1030                label.push_str(&path.to_string_lossy());
1031                label.push(':');
1032                label.push_str(&(n + 1).to_string());
1033                label.push_str("  ");
1034                label.push_str(line.trim());
1035                items.push(PickerItem::new(
1036                    Choice::Location {
1037                        path: path.clone(),
1038                        line: n,
1039                    },
1040                    label,
1041                ));
1042            }
1043        }
1044        if items.is_empty() {
1045            let mut m = String::from("grep: no matches for ");
1046            m.push_str(pattern);
1047            self.messages.push(m);
1048            return;
1049        }
1050        if truncated {
1051            // Stated, never silent. A truncated list presented as complete is
1052            // the failure this whole codebase keeps finding.
1053            self.messages
1054                .push("grep: stopped at the scan limit — results are INCOMPLETE".to_string());
1055        }
1056        self.picker = Some(Picker::open(Source::Grep, items));
1057        self.bump_gen();
1058    }
1059
1060    /// Build and open a picker over `source`.
1061    fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
1062        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1063        let (src, items) = match source {
1064            escriba_madoguchi::PickerSource::Buffers => (
1065                Source::Buffers,
1066                self.buffers
1067                    .ids()
1068                    .into_iter()
1069                    .filter_map(|id| {
1070                        let b = self.buffers.get(id)?;
1071                        let label = b.path.as_ref().map_or_else(
1072                            || String::from("[scratch]"),
1073                            |p| p.to_string_lossy().into_owned(),
1074                        );
1075                        Some(PickerItem::new(Choice::Buffer(id), label))
1076                    })
1077                    .collect::<Vec<_>>(),
1078            ),
1079            escriba_madoguchi::PickerSource::Help => (
1080                Source::Help,
1081                self.keymap
1082                    .entries_sorted()
1083                    .into_iter()
1084                    .map(|(mode, key, b)| {
1085                        // "NORMAL  gd   goto definition" — searchable by key,
1086                        // by mode, or by what it does, because a reader
1087                        // arrives from any of the three.
1088                        let mut label = String::with_capacity(48);
1089                        label.push_str(mode.as_str());
1090                        label.push_str("  ");
1091                        // `{key:?}` because there is no shared key FORMATTER
1092                        // in the fleet — awase owns the chord vocabulary but
1093                        // escriba-keymap's `Key` has no Display. That gap
1094                        // belongs to the keymap consolidation, not here, and
1095                        // inventing a fourth spelling would make it worse.
1096                        label.push_str(&format!("{key:?}"));
1097                        label.push_str("  ");
1098                        label.push_str(&b.description);
1099                        // Accepting runs the binding's action if it names a
1100                        // command; a typed Action has no name to run, so it
1101                        // reports rather than pretending.
1102                        let choice = match &b.action {
1103                            escriba_core::Action::Command { name, .. } => {
1104                                Choice::Command(name.clone())
1105                            }
1106                            other => Choice::Command(format!("{other:?}")),
1107                        };
1108                        PickerItem::new(choice, label)
1109                    })
1110                    .collect::<Vec<_>>(),
1111            ),
1112            escriba_madoguchi::PickerSource::Files => {
1113                let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1114                self.report_truncation(truncated);
1115                (
1116                    Source::Files,
1117                    files
1118                        .into_iter()
1119                        .map(|p| {
1120                            let label = p.to_string_lossy().into_owned();
1121                            PickerItem::new(Choice::OpenFile(p), label)
1122                        })
1123                        .collect::<Vec<_>>(),
1124                )
1125            }
1126            escriba_madoguchi::PickerSource::Project => {
1127                // A project root is a directory carrying a marker. Derived
1128                // from the SAME walk rather than a second traversal — the
1129                // markers are files, so the walker already visited them.
1130                const MARKERS: &[&str] = &[
1131                    "Cargo.toml",
1132                    "flake.nix",
1133                    "package.json",
1134                    "go.mod",
1135                    "pyproject.toml",
1136                ];
1137                let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1138                self.report_truncation(truncated);
1139                let mut roots: Vec<std::path::PathBuf> = files
1140                    .into_iter()
1141                    .filter(|p| {
1142                        p.file_name()
1143                            .is_some_and(|n| MARKERS.contains(&n.to_string_lossy().as_ref()))
1144                    })
1145                    .filter_map(|p| p.parent().map(std::path::Path::to_path_buf))
1146                    .collect();
1147                roots.sort();
1148                roots.dedup();
1149                (
1150                    Source::Project,
1151                    roots
1152                        .into_iter()
1153                        .map(|p| {
1154                            let label = p.to_string_lossy().into_owned();
1155                            PickerItem::new(Choice::OpenFile(p), label)
1156                        })
1157                        .collect::<Vec<_>>(),
1158                )
1159            }
1160            escriba_madoguchi::PickerSource::Commands => (
1161                Source::Commands,
1162                self.commands
1163                    .names()
1164                    .into_iter()
1165                    .map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
1166                    .collect::<Vec<_>>(),
1167            ),
1168        };
1169        if items.is_empty() {
1170            self.messages.push("nothing to pick from".to_string());
1171            return;
1172        }
1173        self.picker = Some(Picker::open(src, items));
1174        self.bump_gen();
1175    }
1176
1177    fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
1178        let Some(splash) = self.splash.as_ref() else {
1179            return SplashKey::NotShowing;
1180        };
1181        let chosen = match key {
1182            Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
1183            _ => None,
1184        };
1185        self.dismiss_splash();
1186        chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
1187    }
1188
1189    /// The current refresh generation. A renderer caches its products against
1190    /// this; equality is the freshness test (an unchanged generation ⇒ the
1191    /// last frame is still valid, so skip the re-highlight + re-shape).
1192    #[must_use]
1193    pub fn edit_gen(&self) -> EditGen {
1194        self.edit_gen
1195    }
1196
1197    /// Advance the refresh generation (a mutation happened).
1198    fn bump_gen(&mut self) {
1199        self.edit_gen = self.edit_gen.next();
1200    }
1201
1202    /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
1203    #[must_use]
1204    pub fn damage(&self) -> Damage {
1205        self.damage
1206    }
1207
1208    /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
1209    /// renderer calls this once per frame to learn what to repaint, then the
1210    /// accumulator restarts — so damage never double-counts across frames.
1211    pub fn take_damage(&mut self) -> Damage {
1212        std::mem::replace(&mut self.damage, Damage::None)
1213    }
1214
1215    /// The line count of the active buffer (0 if none) — used to compute the
1216    /// [`Damage`] scope of a mutation.
1217    fn active_line_count(&self) -> u32 {
1218        self.buffers
1219            .get(self.active)
1220            .map_or(0, escriba_buffer::Buffer::line_count)
1221    }
1222
1223    /// Register a lazy USER plugin: its escriba entry is deferred until
1224    /// one of its `triggers` fires. Bundled defaults do NOT go through
1225    /// here — they are applied eagerly at boot. Empty `triggers` means
1226    /// the plugin never lazily activates (the binary applies eager
1227    /// plugins directly).
1228    pub fn register_lazy_plugin(
1229        &mut self,
1230        name: impl Into<String>,
1231        triggers: Vec<LazyTrigger>,
1232        entry_src: impl Into<String>,
1233    ) {
1234        self.plugin_host.register(name, triggers, entry_src);
1235    }
1236
1237    /// Apply a plugin entry's escriba-lisp to live state — the same
1238    /// keymap / command / option apply paths a user rc uses. Options are
1239    /// applied before keybinds so a plugin that sets `mapleader` resolves
1240    /// `<leader>` correctly. Returns the count of commands + keybinds it
1241    /// registered (best-effort; a malformed entry is skipped, not fatal).
1242    fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
1243        let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
1244            return 0;
1245        };
1246        let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
1247        escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
1248        if let Some(value) = self.options.get("mapleader") {
1249            if let Some(key) = escriba_lisp::parse_leader_key(value) {
1250                self.keymap.set_leader(key);
1251            }
1252        }
1253        let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
1254        (cmd.registered + km.keybinds_applied) as usize
1255    }
1256
1257    /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
1258    /// Returns the number of plugins activated. Call when a buffer of a
1259    /// known filetype is opened.
1260    pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
1261        let pending = self.plugin_host.pending_for_filetype(filetype);
1262        let n = pending.len();
1263        for src in pending {
1264            self.apply_plugin_entry(&src);
1265        }
1266        n
1267    }
1268
1269    /// Fire any lazy plugin gated on an `Event` trigger for `event`.
1270    /// Returns the number of plugins activated.
1271    pub fn activate_event_plugins(&mut self, event: &str) -> usize {
1272        let pending = self.plugin_host.pending_for_event(event);
1273        let n = pending.len();
1274        for src in pending {
1275            self.apply_plugin_entry(&src);
1276        }
1277        n
1278    }
1279
1280    /// Advance one frame's worth of state given a raw madori event.
1281    ///
1282    /// Key events pass through the [`KeyRepeatGate`] first (see
1283    /// [`Self::tick_at`]); everything else is handled directly.
1284    pub fn tick(&mut self, event: &AppEvent) {
1285        self.tick_at(event, Instant::now());
1286    }
1287
1288    /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
1289    /// lets tests drive the debounce window without depending on the
1290    /// wall clock.
1291    pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
1292        match translate_app_event(event) {
1293            InputOutcome::Key(k) => {
1294                if self.gate_key(&k, now) {
1295                    self.on_key(&k);
1296                }
1297            }
1298            InputOutcome::Resized { .. } => {
1299                // Damage only. Each face owns its own geometry: the GPU
1300                // backend derives the grid in `RenderCallback::resize`, and
1301                // the ratatui face reads its area every frame. This arm used
1302                // to write `Window.rect`, which nothing ever read — so the
1303                // resize path was already doing no real work, it just looked
1304                // like it was.
1305                self.damage = self.damage.join(Damage::Viewport);
1306                self.bump_gen();
1307            }
1308            InputOutcome::Quit => self.quit_requested = true,
1309            InputOutcome::Focus(_) | InputOutcome::None => {}
1310        }
1311    }
1312
1313    /// Decide whether `key` survives the key-repeat gate at time `now`.
1314    ///
1315    /// Returns `true` when the key should be processed, `false` when it is
1316    /// an OS key-repeat storm tick that should be dropped. Gating applies
1317    /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
1318    /// are where a held `j`/`l` floods the motion path and thrashes the
1319    /// viewport. Insert and Command modes pass every key through ungated,
1320    /// because there "hold a key to repeat the character" is the intended
1321    /// behavior, not a storm to suppress.
1322    fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
1323        match self.modal.mode() {
1324            Mode::Normal | Mode::Visual | Mode::VisualLine => {
1325                // The gate exists for HELD keys that flood the motion path and
1326                // thrash the viewport (`j`, `l`). It is wrong for the discrete
1327                // jumps: two `n` presses 10 ms apart mean two matches, and
1328                // swallowing the second is indistinguishable from a dead key —
1329                // the exact symptom the gate was added to prevent elsewhere.
1330                if is_repeat_storm_candidate(key) {
1331                    return self.repeat_gate.try_pass_at(*key, now);
1332                }
1333                true
1334            }
1335            Mode::Insert | Mode::Command => true,
1336        }
1337    }
1338
1339    /// Dispatch a single key through the keymap + apply the resulting action.
1340    pub fn on_key(&mut self, key: &Key) {
1341        // An open picker owns EVERY key while it is up — before the splash,
1342        // before the sequence stepper, before the keymap.
1343        match self.consume_picker_key(key) {
1344            escriba_ui::picker::Consumed::NotShowing => {}
1345            escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
1346            escriba_ui::picker::Consumed::Chose(c) => {
1347                self.honour_choice(c);
1348                return;
1349            }
1350        }
1351        // The start screen owns the first keypress and nothing after it.
1352        match self.consume_splash_key(key) {
1353            SplashKey::NotShowing | SplashKey::Dismissed => {}
1354            SplashKey::Ran(action) => {
1355                self.apply(&action);
1356                return;
1357            }
1358        }
1359        // Multi-key sequence resolution runs first: a key that begins or
1360        // continues a bound sequence (`<leader>ff`, `gg`) is held or
1361        // resolved here before the single-key path sees it.
1362        match self.step_sequence(key) {
1363            SeqStep::Pending => return,
1364            SeqStep::Resolved(action) => {
1365                let count = self.modal.pending_count().unwrap_or(1);
1366                self.modal.clear_count();
1367                for _ in 0..count {
1368                    self.apply(&action);
1369                    if self.quit_requested {
1370                        return;
1371                    }
1372                }
1373                return;
1374            }
1375            SeqStep::Passthrough => {}
1376        }
1377        let counted = self.keymap.dispatch(&self.modal, key);
1378        // Count prefixes accumulate into modal state.
1379        if matches!(counted.action, Action::Pending) {
1380            if let Key::Char(c) = key {
1381                if c.is_ascii_digit() {
1382                    let d = u32::from(*c as u8 - b'0');
1383                    self.modal.append_count(d);
1384                }
1385            }
1386            return;
1387        }
1388        // The count flows through the operator-pending FSM (apply_counted), which
1389        // owns repetition: a bare motion runs count× , an operator captures its
1390        // count, and an operated motion multiplies the two. No naive outer loop.
1391        self.apply_counted(&counted.action, counted.count);
1392        // After applying, reset pending count.
1393        self.modal.clear_count();
1394    }
1395
1396    /// Advance the multi-key pending-stroke state machine for `key`.
1397    ///
1398    /// Sequences only apply in normal / visual modes — insert and
1399    /// command modes treat keys as literal text. Rules:
1400    /// - Mid-sequence: extend the pending prefix. Exact match →
1401    ///   [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
1402    ///   otherwise abort the sequence and re-process this key fresh.
1403    /// - Not mid-sequence: if `key` begins a bound sequence AND is not
1404    ///   itself a complete single binding (single bindings win, so no
1405    ///   chord timeout is needed) → start pending. Otherwise
1406    ///   [`SeqStep::Passthrough`] to the single-key dispatcher.
1407    fn step_sequence(&mut self, key: &Key) -> SeqStep {
1408        let mode = self.modal.mode();
1409        if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1410            return SeqStep::Passthrough;
1411        }
1412        if !self.pending_keys.is_empty() {
1413            let mut seq = self.pending_keys.clone();
1414            seq.push(key.clone());
1415            if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1416                let action = b.action.clone();
1417                self.pending_keys.clear();
1418                return SeqStep::Resolved(action);
1419            }
1420            if self.keymap.is_sequence_prefix(mode, &seq) {
1421                self.pending_keys = seq;
1422                return SeqStep::Pending;
1423            }
1424            // The key broke the in-progress sequence — abort it and let
1425            // the key be re-processed as a fresh stroke below.
1426            self.pending_keys.clear();
1427        }
1428        let start = [key.clone()];
1429        if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1430            self.pending_keys = start.to_vec();
1431            return SeqStep::Pending;
1432        }
1433        SeqStep::Passthrough
1434    }
1435
1436    /// The primary cursor position. The single read accessor — every
1437    /// renderer + motion path goes through it, so the underlying
1438    /// representation (today a single-cursor [`Cursors`]) can grow to
1439    /// multi-caret without changing read sites.
1440    #[must_use]
1441    pub fn cursor(&self) -> Position {
1442        self.cursors.primary()
1443    }
1444
1445    /// The **single** cursor-mutation path. Clamp the requested position to
1446    /// the active buffer's bounds, then scroll the active window's viewport
1447    /// to contain it on BOTH axes. Routing every cursor change through this
1448    /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1449    /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1450    /// typed home — there is no code path that advances the cursor without
1451    /// re-deriving the viewport from it, and no second `Position` field to
1452    /// fall out of sync.
1453    /// Re-assert the cursor-visibility invariant against the CURRENT
1454    /// viewport.
1455    ///
1456    /// A resize changes how much a face can show without moving the cursor,
1457    /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
1458    /// would sit off-screen until the operator happened to move it. Every
1459    /// face calls this after telling the runtime its new size.
1460    pub fn refollow_cursor(&mut self) {
1461        self.set_cursor(self.cursors.primary());
1462    }
1463
1464    fn set_cursor(&mut self, pos: Position) {
1465        let clamped = if let Some(buf) = self.buffers.get(self.active) {
1466            buf.clamp(pos)
1467        } else {
1468            pos
1469        };
1470        self.cursors.set_primary(clamped);
1471        if let Some(w) = self
1472            .layout
1473            .windows
1474            .iter_mut()
1475            .find(|w| w.id == self.layout.active)
1476        {
1477            w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1478        }
1479    }
1480
1481    /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1482    fn apply(&mut self, action: &Action) {
1483        self.apply_counted(action, 1);
1484    }
1485
1486    /// Dispatch one resolved action with its count. Routes `(action, count)`
1487    /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1488    /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1489    /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1490    /// operator-then-motion pair is rewritten into a counted
1491    /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1492    /// composition — there is no naive outer repeat loop.
1493    fn apply_counted(&mut self, action: &Action, count: u32) {
1494        // An uncompilable pattern must not reach the operator machine.
1495        //
1496        // `SearchState::accept` puts the prompt BACK on a compile error so the
1497        // typed text is not lost — but the FSM had already transitioned out of
1498        // `AwaitingSearch` on the way in, so the prompt survived and the
1499        // OPERATOR did not, with nothing said about it. The `d` was simply
1500        // gone, and the corrected pattern then ran as a bare search.
1501        //
1502        // The machine is a pure `(State, Event) -> (State, effects)` and
1503        // cannot observe the result of an effect, so it cannot decide this
1504        // itself. The fix is to stop handing it an event it has no business
1505        // deciding: the runtime classifies the submit first, from state it
1506        // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1507        // the bare-`/<CR>` reuse path is untouched.
1508        //
1509        // Tier-honest: parse-rejected at the boundary, not
1510        // truly-unrepresentable.
1511        if matches!(action, Action::SubmitCommand) {
1512            if let Some(e) = self.search.prompt_error() {
1513                let mut m = String::from("E383: Invalid search string: ");
1514                m.push_str(&e.to_string());
1515                self.messages.push(m);
1516                return;
1517            }
1518        }
1519
1520        for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1521            for _ in 0..times {
1522                self.apply_resolved(&resolved);
1523                if self.quit_requested {
1524                    return;
1525                }
1526            }
1527        }
1528    }
1529
1530    /// The active buffer's text. Search is a pure function of it.
1531    /// The active buffer's text revision — the token an offset measured
1532    /// against it should carry.
1533    #[must_use]
1534    fn text_rev(&self) -> TextRev {
1535        self.buffers
1536            .get(self.active)
1537            .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1538    }
1539
1540    fn active_text(&self) -> String {
1541        self.buffers
1542            .get(self.active)
1543            .map(escriba_buffer::Buffer::to_string)
1544            .unwrap_or_default()
1545    }
1546
1547    /// The cursor as a char offset — the coordinate search speaks.
1548    fn cursor_char(&self) -> usize {
1549        self.buffers
1550            .get(self.active)
1551            .and_then(|b| b.position_to_char(self.cursor()).ok())
1552            .unwrap_or(0)
1553    }
1554
1555    /// Move the cursor onto a match and report a wrap the way vim does.
1556    /// The status line as data — what every face draws.
1557    ///
1558    /// One model, so the two faces can only disagree about styling. Before
1559    /// this existed the GPU face built its own line from a fixed `format!()`
1560    /// and drew neither the prompt nor any message, which made a fully
1561    /// working `/` look like a dead key on escriba's default renderer.
1562    #[must_use]
1563    pub fn status_model(&self) -> StatusModel<'_> {
1564        let cursor = self.cursor();
1565        let prompt = self.search.prompt();
1566
1567        let kind = match prompt.map(|p| p.direction) {
1568            Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1569            Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1570            // Command mode with no search prompt open is an ex-command; the
1571            // typed `Option<Prompt>` is the discriminator, never a mode flag.
1572            None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1573            None => PromptKind::None,
1574        };
1575
1576        StatusModel {
1577            mode: self.modal.mode(),
1578            line: cursor.line.saturating_add(1) as usize,
1579            column: cursor.column.saturating_add(1) as usize,
1580            prompt: kind,
1581            prompt_text: prompt
1582                .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1583            prompt_caret: prompt.map_or_else(
1584                || self.modal.minibuffer_caret(),
1585                escriba_search::Prompt::caret,
1586            ),
1587            count: self.match_count(),
1588            message: self.messages.last().map(String::as_str),
1589        }
1590    }
1591
1592    /// `[3/17]` for the current pattern.
1593    ///
1594    /// While a prompt is open the count describes the PREVIEW — the answer to
1595    /// "what would Enter do", which is the question being asked mid-typing.
1596    /// Once committed it describes where the cursor actually is.
1597    #[must_use]
1598    fn match_count(&self) -> MatchCount {
1599        if self.search.is_prompting() {
1600            let text = self.active_text();
1601            // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1602            // the same `None`, so a half-typed character class reported
1603            // `[0/0]` — telling the user their pattern matches nothing while
1604            // they are still writing it.
1605            return match self.search.preview(&text) {
1606                escriba_search::Preview::Landed { step, total } => {
1607                    MatchCount::new(step.index, total)
1608                }
1609                escriba_search::Preview::NoMatch => MatchCount::None,
1610                escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1611                    MatchCount::Idle
1612                }
1613            };
1614        }
1615        if self.search.pattern().is_none() {
1616            return MatchCount::Idle;
1617        }
1618        let total = self.search.matches().len();
1619        // Read THROUGH the anchor: an ordinal computed against text that has
1620        // since changed reads as absent, so a stale count cannot be displayed.
1621        let rev = self.text_rev();
1622        self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1623            if total == 0 {
1624                MatchCount::None
1625            } else {
1626                MatchCount::Idle
1627            },
1628            |&i| MatchCount::new(i, total),
1629        )
1630    }
1631
1632    /// `.` — replay the last change at the cursor.
1633    ///
1634    /// Two steps, because a change can be two: run the action, then re-type
1635    /// whatever followed it. `cgn` + `.` is exactly this — change the next
1636    /// match, then repeat that whole gesture on the one after.
1637    fn repeat_last_change(&mut self) {
1638        let Some(change) = self.last_change.clone() else {
1639            self.messages
1640                .push("E32: No previous change to repeat".to_string());
1641            return;
1642        };
1643
1644        for _ in 0..change.count.max(1) {
1645            self.apply_resolved(&change.action);
1646        }
1647        for c in change.inserted.chars() {
1648            self.apply_resolved(&Action::InsertChar(c));
1649        }
1650        if self.modal.mode() == Mode::Insert {
1651            // A replayed change must not leave the editor in Insert — the
1652            // original ended with an Esc the recording deliberately does not
1653            // store, since it is punctuation rather than part of the change.
1654            self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1655        }
1656        // The replay wrote through `apply_resolved`, which re-records
1657        // `last_change` from the inner action. Put the ORIGINAL back so a
1658        // second `.` repeats the same change rather than a fragment of it.
1659        self.last_change = Some(change);
1660        self.recording_insert = false;
1661    }
1662
1663    /// Resolve a text object to the range it names.
1664    ///
1665    /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1666    /// match operates on THAT match rather than skipping to the next — which
1667    /// is what makes `cgn` then `.` walk matches one at a time instead of
1668    /// every other one.
1669    fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
1670        use escriba_core::TextObject as O;
1671        let at = self.cursor_char();
1672        let matches = self.search.matches();
1673
1674        // A match CONTAINING the cursor wins outright, whichever direction the
1675        // object names.
1676        //
1677        // Comparing only against `m.start` — which is what a `starts`-vector
1678        // plus `Bound::Inclusive` does — is right only when the cursor sits on
1679        // a match's FIRST character. One column further in, `start < at` and
1680        // the match is rejected, so `cgn` skipped the very instance the
1681        // operator was standing in and the rename silently missed it. vim
1682        // operates on the containing match from every interior column, and the
1683        // `starts`-only comparison cannot express "contains" because it never
1684        // looks at `m.end`.
1685        let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
1686            let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
1687            match object {
1688                O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
1689                O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
1690            }
1691        })?;
1692
1693        let m = matches.get(idx)?;
1694        let buf = self.buffers.get(self.active)?;
1695        Some(Range {
1696            start: buf.char_to_position(m.start),
1697            end: buf.char_to_position(m.end),
1698        })
1699    }
1700
1701    fn land_on(&mut self, step: escriba_search::Step) {
1702        if let Some(buf) = self.buffers.get(self.active) {
1703            let pos = buf.char_to_position(step.target.start);
1704            self.set_cursor(pos);
1705        }
1706        // The `[3/17]` numerator. `Step` has carried this index since the
1707        // engine was written — `engine.rs` even names the counter as the
1708        // reason it exists — and every consumer discarded it until now.
1709        self.search_at = Some(Anchored::new(step.index, self.text_rev()));
1710    }
1711
1712    /// vim's "search hit BOTTOM, continuing at TOP".
1713    ///
1714    /// One reporter, called by the two places a search can wrap: the shared
1715    /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
1716    /// commit would say it twice.
1717    fn report_wrap(&mut self, step: &escriba_search::Step) {
1718        if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
1719            self.messages.push(msg.to_string());
1720        }
1721    }
1722
1723    /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
1724    /// than failing silently — a search that appears to do nothing is
1725    /// indistinguishable from a dropped keystroke.
1726    fn jump_search(&mut self, reverse: bool) {
1727        // Using the matches re-lights them: `n` after an auto-clear shows you
1728        // what you are walking through.
1729        self.search.relight();
1730        // `n` is a far jump — record where we leave from so `<C-o>` works.
1731        self.jumps.push(self.spot());
1732        let at = self.cursor_char();
1733        match self.search.repeat(at, reverse) {
1734            Some(step) => {
1735                // `n` wrapping the file says so, same as a commit does.
1736                self.report_wrap(&step);
1737                self.land_on(step);
1738            }
1739            None => {
1740                let msg = self.search.pattern().map_or_else(
1741                    || "E35: No previous regular expression".to_string(),
1742                    |p| {
1743                        let mut m = String::from("E486: Pattern not found: ");
1744                        m.push_str(p.raw());
1745                        m
1746                    },
1747                );
1748                self.messages.push(msg);
1749            }
1750        }
1751    }
1752
1753    /// Move the cursor to where the in-progress pattern would land, without
1754    /// committing anything. vim's `incsearch`.
1755    ///
1756    /// A pattern that does not compile yet (`/a[`, mid-typing) previews
1757    /// nothing and reports nothing — an error toast on every keystroke of a
1758    /// character class would be unusable.
1759    fn preview_search(&mut self) {
1760        let text = self.active_text();
1761        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
1762            return;
1763        };
1764        let target = match self.search.preview(&text) {
1765            escriba_search::Preview::Landed { step, .. } => step.target.start,
1766            // Nothing to show: back to where the search started. Covers a
1767            // half-typed pattern and a pattern that finds nothing alike —
1768            // both mean "there is no match to preview".
1769            escriba_search::Preview::Idle
1770            | escriba_search::Preview::Incomplete
1771            | escriba_search::Preview::NoMatch => origin,
1772        };
1773        // A pattern that STOPS matching returns the cursor to the origin.
1774        //
1775        // Preview used to only ever move forward, so typing `ch` (a match) and
1776        // then `chz` (none) left the cursor parked on the `ch` match — a
1777        // preview showing a position the pattern no longer justifies, while
1778        // the count beside it read `[0/0]`. Restoring is also what makes
1779        // Escape's promise legible: at every keystroke the cursor is either on
1780        // a real match or back where you started, never on a stale one.
1781        if let Some(buf) = self.buffers.get(self.active) {
1782            let pos = buf.char_to_position(target);
1783            self.set_cursor(pos);
1784        }
1785    }
1786
1787    /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
1788    /// where the search lands, as ONE action.
1789    ///
1790    /// Split from [`Self::submit_search`] rather than sharing it because the
1791    /// two want opposite things from the commit: the bare `/` MOVES the cursor
1792    /// to the match, and an operated `/` must NOT — the cursor is the
1793    /// operator's start point, and moving it first would leave the operator
1794    /// with a zero-width range.
1795    /// Commit the open search prompt. The ONE copy of the sequence.
1796    ///
1797    /// Reports its own failures (E486 / E35) so neither caller has to carry a
1798    /// third copy of the message strings. `Accepted::Invalid` cannot reach
1799    /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
1800    /// boundary before the FSM or this method ever sees the submit.
1801    fn commit_search_prompt(&mut self) -> CommitOutcome {
1802        let text = self.active_text();
1803        let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
1804        else {
1805            return CommitOutcome::NoPrompt;
1806        };
1807
1808        match self.search.accept(&text) {
1809            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
1810                self.modal.clear_minibuffer();
1811                self.modal.enter(Mode::Normal);
1812                match self.search.commit_step_skipping(origin, skip) {
1813                    Some(step) => {
1814                        // The wrap notice belongs HERE, once, for both commit
1815                        // paths. Reporting it in each caller is what let the
1816                        // operated path lose it in the first place — and my
1817                        // first attempt at this refactor duplicated it again
1818                        // rather than moving it, which the red proof caught.
1819                        self.report_wrap(&step);
1820                        CommitOutcome::Landed { origin, step }
1821                    }
1822                    None => {
1823                        self.report_pattern_not_found();
1824                        CommitOutcome::NotFound
1825                    }
1826                }
1827            }
1828            escriba_search::Accepted::NothingToRepeat => {
1829                self.modal.clear_minibuffer();
1830                self.modal.enter(Mode::Normal);
1831                self.messages
1832                    .push("E35: No previous regular expression".to_string());
1833                CommitOutcome::NoPrevious
1834            }
1835            // Unreachable: the boundary guard in `apply_counted` returns early
1836            // on an uncompilable pattern, leaving the prompt open. Reported
1837            // rather than `unreachable!()` — a panic in the editor's commit
1838            // path is a worse failure than a duplicate message.
1839            escriba_search::Accepted::Invalid(e) => {
1840                let mut m = String::from("E383: Invalid search string: ");
1841                m.push_str(&e.to_string());
1842                self.messages.push(m);
1843                CommitOutcome::NoPrompt
1844            }
1845        }
1846    }
1847
1848    /// vim's E486, with the pattern named. One place, so every path that fails
1849    /// to find reports identically.
1850    fn report_pattern_not_found(&mut self) {
1851        let mut m = String::from("E486: Pattern not found");
1852        if let Some(p) = self.search.pattern() {
1853            m.push_str(": ");
1854            m.push_str(p.raw());
1855        }
1856        self.messages.push(m);
1857    }
1858
1859    /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
1860    ///
1861    /// The only difference from the operated path is that this one lands;
1862    /// everything else lives in `commit_search_prompt`.
1863    fn submit_search(&mut self) {
1864        match self.commit_search_prompt() {
1865            CommitOutcome::Landed { origin, step } => {
1866                if let Some(buf) = self.buffers.get(self.active) {
1867                    let from = buf.char_to_position(origin);
1868                    self.jumps.push(escriba_core::Spot::new(self.active, from));
1869                }
1870                self.land_on(step);
1871            }
1872            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1873        }
1874    }
1875
1876    /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
1877    /// search lands, as ONE action.
1878    ///
1879    /// The cursor must NOT move to the match first: it is the operator's start
1880    /// point. That is the whole reason this differs from the bare path, and
1881    /// now the only reason.
1882    fn submit_search_operated(&mut self, op: Operator) {
1883        match self.commit_search_prompt() {
1884            CommitOutcome::Landed { origin, step } => {
1885                if let Some(buf) = self.buffers.get(self.active) {
1886                    let from = buf.char_to_position(origin);
1887                    let target = buf.char_to_position(step.target.start);
1888                    // Operating over a search is itself a far jump.
1889                    self.jumps.push(escriba_core::Spot::new(self.active, from));
1890                    self.set_cursor(from);
1891                    self.apply_operator_to(op, target);
1892                }
1893            }
1894            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1895        }
1896    }
1897
1898    fn apply_resolved(&mut self, action: &Action) {
1899        // Snapshot the scope inputs before the mutation so the resulting
1900        // Damage covers the changed region (the S3 seal — conservative widen).
1901        let lines_before = self.active_line_count();
1902        // Snapshot for the dot register: the only reliable witness that this
1903        // action changed text is that the buffer's revision moved.
1904        let rev_before = self.text_rev();
1905        let cline_before = self.cursor().line;
1906        match action {
1907            // Every action with an exact slip equivalent goes through the
1908            // interpreter, so "undo" has ONE implementation rather than one
1909            // per entry point. These had already drifted: the executor
1910            // re-followed the viewport after undo and the M1 interpreter did
1911            // not, so `u` and `:undo` behaved differently within a milestone
1912            // of each other.
1913            // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
1914            // guard: a guard arm does not count toward exhaustiveness, so the
1915            // guarded form silently gave up the total match — the compiler
1916            // said so, and it was right. `lowering_and_dispatch_agree` pins
1917            // that this list and `lower` stay the same set.
1918            Action::Quit
1919            | Action::ClearSearchHighlight
1920            | Action::Save
1921            | Action::Undo
1922            | Action::Redo
1923            | Action::Edit(_) => {
1924                for slip in Self::lower(action, self.active).unwrap_or_default() {
1925                    self.honour_one(slip);
1926                }
1927            }
1928            Action::Move(m) => self.apply_motion(*m),
1929            Action::SearchOpen(dir) => {
1930                // vim's `/` is the command-line with a different prompt char,
1931                // so we reuse Command mode; `search.prompt` is what tells a
1932                // later <CR> this is a search and not an ex-command.
1933                let origin = self.cursor_char();
1934                self.search.open(*dir, origin);
1935                self.modal.enter(Mode::Command);
1936            }
1937            Action::SearchRepeat { reverse } => self.jump_search(*reverse),
1938            Action::SearchWord { reverse } => {
1939                let dir = if *reverse {
1940                    SearchDirection::Backward
1941                } else {
1942                    SearchDirection::Forward
1943                };
1944                let (text, at) = (self.active_text(), self.cursor_char());
1945                // `*` jumps, so it records too.
1946                self.jumps.push(self.spot());
1947                match self.search.search_word(&text, at, dir) {
1948                    Some(step) => self.land_on(step),
1949                    // vim beeps and stays put when there is no word under the
1950                    // cursor; a silent no-op would look like a broken key.
1951                    None => self
1952                        .messages
1953                        .push("E348: No string under cursor".to_string()),
1954                }
1955            }
1956            Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
1957            Action::TextObject(object) => {
1958                // Bare `gn` moves onto the match. vim additionally starts a
1959                // Visual selection of it; escriba's Visual plumbing does not
1960                // carry a selection an operator can consume yet, so this
1961                // stops at the jump rather than faking a selection that
1962                // nothing would honour.
1963                if let Some(range) = self.resolve_object(*object) {
1964                    self.jumps.push(self.spot());
1965                    self.set_cursor(range.start);
1966                } else {
1967                    self.report_pattern_not_found();
1968                }
1969            }
1970            Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
1971                Some(range) => self.apply_operator_over(*op, range),
1972                None => self.report_pattern_not_found(),
1973            },
1974            Action::RepeatLastChange => self.repeat_last_change(),
1975            Action::JumpBack => {
1976                let here = self.spot();
1977                if let Some(spot) = self.jumps.back(here) {
1978                    self.goto_spot(spot);
1979                } else {
1980                    self.messages
1981                        .push("E662: At start of changelist".to_string());
1982                }
1983            }
1984            Action::JumpForward => {
1985                if let Some(spot) = self.jumps.forward() {
1986                    self.goto_spot(spot);
1987                } else {
1988                    self.messages.push("E663: At end of changelist".to_string());
1989                }
1990            }
1991            Action::ChangeMode(m) => {
1992                // Leaving the cmdline abandons any open search prompt and
1993                // returns the cursor home. The COMMITTED pattern survives —
1994                // cancelling a new search must not erase the old highlights.
1995                if *m == Mode::Normal && self.search.is_prompting() {
1996                    if let Some(origin) = self.search.cancel() {
1997                        if let Some(buf) = self.buffers.get(self.active) {
1998                            let pos = buf.char_to_position(origin);
1999                            self.set_cursor(pos);
2000                        }
2001                    }
2002                }
2003                self.modal.enter(*m);
2004            }
2005            Action::InsertChar(c) => self.insert_char(*c),
2006
2007            Action::SubmitCommand => {
2008                if self.search.is_prompting() {
2009                    self.submit_search();
2010                } else {
2011                    self.submit_command();
2012                }
2013            }
2014            Action::Command { name, args } => self.run_command(name, args),
2015            Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
2016            // The operator-pending FSM consumes Operator keys (begins pending);
2017            // they never reach the executor. Defensive no-op for exhaustiveness.
2018            Action::Operator(_) => {}
2019            Action::PromptCaret { to } => {
2020                // Both prompts have a caret now, and the same keys move it.
2021                if self.search.is_prompting() {
2022                    self.search.move_caret(*to);
2023                } else {
2024                    self.modal.move_minibuffer_caret(*to);
2025                }
2026            }
2027            Action::SearchPreviewStep { forward } => {
2028                if self.search.is_prompting() {
2029                    self.search.preview_step(*forward);
2030                    self.preview_search();
2031                }
2032            }
2033            Action::PromptDelete => {
2034                if self.search.is_prompting() {
2035                    self.search.delete_at_caret();
2036                    self.preview_search();
2037                } else {
2038                    self.modal.delete_minibuffer_at_caret();
2039                }
2040            }
2041            Action::PromptDeleteWord => {
2042                if self.search.is_prompting() {
2043                    self.search.delete_word_before_caret();
2044                    self.preview_search();
2045                }
2046            }
2047            Action::PromptClearToStart => {
2048                if self.search.is_prompting() {
2049                    self.search.clear_before_caret();
2050                    self.preview_search();
2051                }
2052            }
2053            Action::PromptBackspace => {
2054                self.prompt_backspace();
2055                // Shortening the pattern changes which matches exist, so the
2056                // preview must re-run — otherwise the cursor sits on a match
2057                // of a pattern that is no longer typed.
2058                if self.search.is_prompting() {
2059                    self.preview_search();
2060                }
2061            }
2062            Action::PromptHistory { back } => {
2063                if self.search.is_prompting() {
2064                    self.search.history_step(*back);
2065                    // No minibuffer resync: the shadow is the ex-line's store
2066                    // and nothing reads it while a search prompt is open, so
2067                    // rewriting it here was maintaining a copy for no reader.
2068                    self.preview_search();
2069                }
2070            }
2071            Action::Pending => {}
2072        }
2073        // Widen the dirty region by what this action touched (M1). Content
2074        // mutations that changed the line count run to end-of-document (every
2075        // line below shifted); an in-place edit or a cursor move is local;
2076        // arbitrary commands are conservatively Full. Never narrows.
2077        let lines_after = self.active_line_count();
2078        let cline_after = self.cursor().line;
2079        let d = match action {
2080            // A search repaints every highlight in the viewport, not just the
2081            // line the cursor left — so it must widen to Full. Treating it as a
2082            // cursor move would leave stale highlights on untouched lines.
2083            Action::SearchOpen(_)
2084            | Action::PromptHistory { .. }
2085            | Action::PromptBackspace
2086            | Action::PromptCaret { .. }
2087            | Action::SearchPreviewStep { .. }
2088            | Action::PromptDelete
2089            | Action::PromptDeleteWord
2090            | Action::PromptClearToStart
2091            | Action::SearchRepeat { .. }
2092            | Action::SearchWord { .. }
2093            | Action::ClearSearchHighlight
2094            | Action::SearchSubmitOperated { .. }
2095            // A replayed change can edit anywhere the original could, and a
2096            // match object can be anywhere in the document.
2097            | Action::RepeatLastChange
2098            | Action::TextObject(_)
2099            | Action::ApplyOperatorObject { .. }
2100            // A jump can land anywhere, so the viewport may scroll wholesale.
2101            | Action::JumpBack
2102            | Action::JumpForward => Damage::Full,
2103            Action::InsertChar(_)
2104            | Action::Edit(_)
2105            | Action::Undo
2106            | Action::Redo
2107            | Action::ApplyOperator { .. } => {
2108                if lines_after == lines_before {
2109                    Damage::span(cline_before, cline_after)
2110                } else {
2111                    Damage::Lines {
2112                        from: cline_before.min(cline_after),
2113                        to: u32::MAX,
2114                    }
2115                }
2116            }
2117            Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
2118            Action::Save => Damage::Viewport,
2119            Action::Command { .. } | Action::SubmitCommand => Damage::Full,
2120            Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
2121        };
2122        self.damage = self.damage.join(d);
2123        // Remember this change for `.`.
2124        //
2125        // Recorded from an OBSERVED MUTATION, not from the action's variant.
2126        // `text_effect()` is the wrong predicate here even though it looks
2127        // like the right one: it exists to decide cache invalidation, where
2128        // OVER-reporting is the safe direction, and the dot register needs the
2129        // opposite bias. Leaning on it meant `last_change` was set by actions
2130        // that changed no text at all, with two measured consequences:
2131        //
2132        //   `iZ<Esc>` then `/a<CR>` then `.`  — did nothing; the register held
2133        //       `SubmitCommand`, whose replay reads an already-cleared
2134        //       minibuffer.
2135        //   `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
2136        //       abandoned prompt left the register holding `InsertChar('q')`,
2137        //       and `.` in Normal mode routes that to the text. A corrupting
2138        //       register, not merely a lost one.
2139        //
2140        // Comparing the buffer's `TextRev` across the action answers the only
2141        // question that matters — did this actually change the text — and gets
2142        // the failed-operator case (`dgn` with no pattern) right for free.
2143        if self.recording_insert {
2144            match action {
2145                Action::InsertChar(c) => {
2146                    if let Some(lc) = self.last_change.as_mut() {
2147                        lc.inserted.push(*c);
2148                    }
2149                }
2150                // Leaving Insert ends the session; the change is now whole.
2151                Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
2152                _ => {}
2153            }
2154        } else if self.text_rev() != rev_before
2155            && !matches!(
2156                action,
2157                Action::RepeatLastChange | Action::Undo | Action::Redo
2158            )
2159        {
2160            self.last_change = Some(LastChange {
2161                action: action.clone(),
2162                count: 1,
2163                inserted: String::new(),
2164            });
2165            self.recording_insert = self.modal.mode() == Mode::Insert;
2166        }
2167
2168        // The search is over the moment you move on or edit — clear the
2169        // highlight rather than leaving the buffer as confetti until an
2170        // explicit `:noh`, which is the remap nearly every vimrc carries.
2171        // Clearing suppresses without forgetting, so `n` still works.
2172        if action.highlight_effect() == HighlightEffect::Clear {
2173            self.search.clear_highlight();
2174        }
2175        // Text changed ⇒ every match offset cached against the old text is
2176        // wrong. `SearchState::refresh` existed for exactly this and had ZERO
2177        // callers, so inserting four characters left both renderers painting
2178        // the highlight four columns off.
2179        //
2180        // Gated on the typed classifier rather than on `bump_gen` (which fires
2181        // for pure cursor moves too): re-scanning the document on every `j`
2182        // would be a per-keystroke full pass for no reason.
2183        if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
2184            let text = self.active_text();
2185            self.search.refresh(&text);
2186            // NO manual invalidation of `search_at` here, deliberately. It is
2187            // `Anchored` to the text revision, so an ordinal computed against
2188            // the old text now reads as `None` on its own. This is the line
2189            // that used to have to be remembered.
2190        }
2191        // An action reached the executor ⇒ visible state may have changed.
2192        // Advance the refresh generation so the renderer repaints (and
2193        // re-highlights) exactly once. A gated-out key never reaches here, so
2194        // a key-repeat storm does not spin the renderer.
2195        self.bump_gen();
2196    }
2197
2198    /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
2199    /// active buffer — **pure**: no cursor mutation, no side effects. This is
2200    /// the single motion-resolution source of truth that both [`apply_motion`]
2201    /// (move the cursor *to* the target) and [`apply_operator`] (use the target
2202    /// as the *other end* of an operated range) stand on. `None` only if there
2203    /// is no active buffer.
2204    ///
2205    /// [`apply_motion`]: Self::apply_motion
2206    /// [`apply_operator`]: Self::apply_operator
2207    fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
2208        let buf = self.buffers.get(self.active)?;
2209        let pos = from;
2210        Some(match motion {
2211            // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
2212            // against the committed match list, so it is `None` (motion fails,
2213            // operator aborts, buffer untouched) when nothing is committed —
2214            // never a silent move to 0, which would delete to the file start.
2215            Motion::SearchNext | Motion::SearchPrev => {
2216                let at = buf.position_to_char(pos).ok()?;
2217                let step = self
2218                    .search
2219                    .repeat(at, matches!(motion, Motion::SearchPrev))?;
2220                buf.char_to_position(step.target.start)
2221            }
2222            Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
2223            Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
2224            Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
2225            Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
2226            Motion::LineStart => Position::new(pos.line, 0),
2227            Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
2228            Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
2229            Motion::DocStart => Position::ZERO,
2230            Motion::DocEnd => Position::new(
2231                buf.line_count().saturating_sub(1),
2232                buf.line_len_chars(buf.line_count().saturating_sub(1)),
2233            ),
2234            Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
2235            Motion::WordStartPrev => word_prev(buf, pos),
2236            Motion::PageDown | Motion::HalfPageDown => {
2237                Position::new(pos.line.saturating_add(10), pos.column)
2238            }
2239            Motion::PageUp | Motion::HalfPageUp => {
2240                Position::new(pos.line.saturating_sub(10), pos.column)
2241            }
2242            Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
2243            // Structural Lisp motions — stubs for phase 1.B; full paredit
2244            // semantics land when caixa-ast is wired to the active buffer.
2245            Motion::ForwardSexp
2246            | Motion::BackwardSexp
2247            | Motion::UpList
2248            | Motion::DownList
2249            | Motion::BeginningOfDefun
2250            | Motion::EndOfDefun
2251            | Motion::BeginningOfSexp
2252            | Motion::EndOfSexp => pos,
2253        })
2254    }
2255
2256    fn apply_motion(&mut self, motion: Motion) {
2257        // A bare search motion is a FAR JUMP and it REPORTS — it records into
2258        // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
2259        // when nothing matches. `resolve_motion` can do none of that: it is
2260        // deliberately pure because the OPERATOR path calls it to find a range
2261        // without moving the cursor. So `n` routes to the one executor that
2262        // owns those side effects, and `Action::SearchRepeat` routes to the
2263        // same place — one code path, two spellings.
2264        if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2265            self.jump_search(matches!(motion, Motion::SearchPrev));
2266            return;
2267        }
2268        let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
2269            return;
2270        };
2271        // The single cursor-mutation path clamps to the buffer and scrolls
2272        // the viewport to contain the cursor on both axes.
2273        self.set_cursor(pos);
2274    }
2275
2276    /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
2277    /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
2278    /// Composition is explicit: the motion resolves a target via
2279    /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
2280    /// `[cursor, target)` range. Register-leaving operators
2281    /// ([`Operator::leaves_register`]) capture the text first.
2282    fn apply_operator(&mut self, op: Operator, motion: Motion) {
2283        let from = self.cursor();
2284        let Some(to) = self.resolve_motion(from, motion) else {
2285            // A motion that cannot resolve aborts the operator with the buffer
2286            // untouched. A search motion says WHY — `dn` with no pattern armed
2287            // is otherwise indistinguishable from a dropped keystroke, which
2288            // is the same complaint that motivated E486 on the bare path.
2289            if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2290                if self.search.pattern().is_none() {
2291                    self.messages
2292                        .push("E35: No previous regular expression".to_string());
2293                } else {
2294                    self.report_pattern_not_found();
2295                }
2296            }
2297            return;
2298        };
2299        self.apply_operator_to(op, to);
2300    }
2301
2302    /// Apply `op` over `[cursor, to)`.
2303    ///
2304    /// Split out of [`Self::apply_operator`] so the operated-search path can
2305    /// reach the same range machinery with a target it resolved itself — the
2306    /// alternative was a second copy of the delete/yank/register logic, which
2307    /// is how the two would drift.
2308    fn apply_operator_to(&mut self, op: Operator, to: Position) {
2309        let from = self.cursor();
2310        self.apply_operator_over(
2311            op,
2312            Range {
2313                start: from,
2314                end: to,
2315            },
2316        );
2317    }
2318
2319    /// Apply `op` over an explicit range.
2320    ///
2321    /// The object path needs this: `gn`'s extent need not begin at the cursor,
2322    /// so it cannot go through the `[cursor, target)` shape the motion path
2323    /// uses. One implementation of the delete/yank/register logic, reached two
2324    /// ways.
2325    fn apply_operator_over(&mut self, op: Operator, range: Range) {
2326        let range = range.normalized();
2327        if range.is_empty() {
2328            return;
2329        }
2330        // Capture the operated text (for the register) before mutating.
2331        let text = self
2332            .buffers
2333            .get(self.active)
2334            .and_then(|buf| buf.slice(range).ok());
2335        if op.leaves_register() {
2336            if let Some(t) = &text {
2337                self.register = Some(t.clone());
2338            }
2339        }
2340        match op {
2341            // Delete + Change remove the range; Change then enters Insert so
2342            // the operator pairs with immediate typing (`ciw`, `c$`).
2343            Operator::Delete | Operator::Change => {
2344                if let Some(buf) = self.buffers.get_mut(self.active) {
2345                    let _ = buf.apply(&Edit::delete(range));
2346                }
2347                self.set_cursor(range.start);
2348                if op == Operator::Change {
2349                    self.modal.enter(Mode::Insert);
2350                }
2351            }
2352            // Yank copies to the register without mutating the buffer; vim
2353            // leaves the cursor at the range start.
2354            Operator::Yank => {
2355                self.set_cursor(range.start);
2356            }
2357            // Indent/Format/structural operators are not yet wired — named,
2358            // not faked (no buffer mutation, register already captured for the
2359            // register-leaving ones above).
2360            _ => {
2361                self.messages
2362                    .push("operator not yet implemented".to_owned());
2363            }
2364        }
2365    }
2366
2367    /// The text last yanked or deleted into the unnamed register, if any.
2368    /// The future `p`/`P` paste reads this.
2369    #[must_use]
2370    pub fn register(&self) -> Option<&str> {
2371        self.register.as_deref()
2372    }
2373
2374    fn insert_char(&mut self, c: char) {
2375        if self.modal.mode() == Mode::Command {
2376            // A search prompt and an ex-command share Command mode (vim's
2377            // cmdline). `search.is_prompting()` is the typed discriminator —
2378            // it can only be true when `/` or `?` actually opened a prompt.
2379            if self.search.is_prompting() {
2380                // The search prompt is the SOLE store while it is open.
2381                //
2382                // This used to also `push_minibuffer(c)`, and the two stores
2383                // insert differently — `search.push` at the caret, the
2384                // minibuffer always at the end — so `/fo<Left>X` left them
2385                // reading `fXo` and `foX`. That was one of FIVE desync paths;
2386                // the caret moves, forward-delete, delete-word and
2387                // clear-to-start never touched the shadow at all.
2388                //
2389                // Deleting the write costs nothing because `status_model`
2390                // already selects the minibuffer only on the `prompt == None`
2391                // branch — the shadow is the EX-LINE's store, and while a
2392                // search prompt is open nothing reads it.
2393                self.search.push(c);
2394                self.preview_search();
2395            } else {
2396                self.modal.push_minibuffer(c);
2397            }
2398            return;
2399        }
2400        let cursor = self.cursor();
2401        let Some(buf) = self.buffers.get_mut(self.active) else {
2402            return;
2403        };
2404        let edit = Edit::insert(cursor, c.to_string());
2405        if buf.apply(&edit).is_ok() {
2406            let next = if c == '\n' {
2407                Position::new(cursor.line.saturating_add(1), 0)
2408            } else {
2409                cursor.shift_right(1)
2410            };
2411            // Route through the single cursor-mutation path so the viewport
2412            // follows the cursor (both axes) and the cursor stays clamped.
2413            self.set_cursor(next);
2414        }
2415    }
2416
2417    /// Backspace inside a prompt. Keeps the search buffer and the displayed
2418    /// minibuffer in lockstep — if only one shrank, the pattern submitted
2419    /// would differ from the text on screen.
2420    fn prompt_backspace(&mut self) -> bool {
2421        if self.modal.mode() != Mode::Command {
2422            return false;
2423        }
2424        if self.search.is_prompting() {
2425            // Backspacing past the `/` closes the prompt, as vim does. No
2426            // `pop_minibuffer` here for the same reason as `insert_char`: the
2427            // shadow is the ex-line's, and popping its TAIL when the caret is
2428            // mid-pattern was another desync path.
2429            if self.search.backspace() {
2430                self.modal.clear_minibuffer();
2431                self.modal.enter(Mode::Normal);
2432            }
2433            // Never `pop_minibuffer` on the search path: it pops the TAIL,
2434            // while `search.backspace()` removes the char before the CARET.
2435            return true;
2436        }
2437        self.modal.pop_minibuffer();
2438        true
2439    }
2440
2441    fn submit_command(&mut self) {
2442        // Read the command line BEFORE leaving Command mode — the minibuffer
2443        // exists only in the `Command` variant, so the escape must come
2444        // after the capture.
2445        let line = self.modal.minibuffer().to_string();
2446        self.modal.escape();
2447        let (name, args) = parse_command_line(&line);
2448        if name.is_empty() {
2449            return;
2450        }
2451        self.run_command(&name, &args);
2452    }
2453
2454    fn run_command(&mut self, name: &str, args: &[String]) {
2455        // Bound the command -> RunCommand slip -> command cycle. Refused and
2456        // reported, never a stack overflow: an editor that dies under the
2457        // operator loses their buffer, and a script that loops is a mistake
2458        // they should be told about, not punished for.
2459        if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2460            let mut m = String::from("command recursion too deep at `");
2461            m.push_str(name);
2462            m.push_str("` — refusing");
2463            self.messages.push(m);
2464            self.damage = self.damage.join(Damage::Viewport);
2465            self.bump_gen();
2466            return;
2467        }
2468        self.dispatch_depth += 1;
2469        self.run_command_inner(name, args);
2470        self.dispatch_depth -= 1;
2471    }
2472
2473    /// How many nested command dispatches are allowed. Deep enough that no
2474    /// legitimate script notices, shallow enough to fail fast.
2475    const MAX_DISPATCH_DEPTH: u8 = 8;
2476
2477    fn run_command_inner(&mut self, name: &str, args: &[String]) {
2478        // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2479        // gated on `Command: <name>` has its entry applied the first time
2480        // that command runs, BEFORE dispatch — so the activated plugin
2481        // can register the very command being invoked and it resolves on
2482        // this same call.
2483        if self.plugin_host.pending() > 0 {
2484            let pending = self.plugin_host.pending_for_command(name);
2485            for src in pending {
2486                self.apply_plugin_entry(&src);
2487            }
2488        }
2489        // Read through the counter, then interpret. Two immutable borrows of
2490        // `self` (the window and the registry) coexist; the `&mut` comes
2491        // afterwards, once the outcome is owned. That sequencing IS the
2492        // seam: there is no moment where a command body and `&mut self` are
2493        // live at the same time.
2494        let outcome = {
2495            let window = self.window();
2496            self.commands.run(name, &window, args)
2497        };
2498        match outcome {
2499            Ok(o) => self.interpret(o),
2500            // Reported, never fatal (Phase 0). A failed command must not
2501            // take the editor down, but it must not be invisible either.
2502            Err(e) => {
2503                self.messages.push(describe_command_failure(name, &e));
2504                self.damage = self.damage.join(Damage::Viewport);
2505                self.bump_gen();
2506            }
2507        }
2508    }
2509
2510    // ── tatara-lisp runtime bridge (imperative programmability tier) ──
2511
2512    /// Capture a read snapshot of the editor for the tatara-lisp host.
2513    /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
2514    #[must_use]
2515    pub fn snapshot(&self) -> EditorSnapshot {
2516        let current_line = self
2517            .buffers
2518            .get(self.active)
2519            .and_then(|b| b.line(self.cursor().line))
2520            .map(|s| s.trim_end_matches('\n').to_string())
2521            .unwrap_or_default();
2522        let buffer_name = self
2523            .buffers
2524            .get(self.active)
2525            .and_then(|b| b.path.as_ref())
2526            .map(|p| p.display().to_string())
2527            .unwrap_or_else(|| "[scratch]".to_string());
2528        EditorSnapshot {
2529            cursor_line: i64::from(self.cursor().line),
2530            cursor_column: i64::from(self.cursor().column),
2531            current_line,
2532            mode: self.modal.mode().as_str().to_string(),
2533            buffer_name,
2534        }
2535    }
2536
2537    /// Evaluate tatara-lisp `src` against this editor: capture a
2538    /// snapshot, run it in the embedded VM, then apply the typed effects
2539    /// the program emitted. This is the imperative programmability tier
2540    /// — live Lisp that reads state and drives the editor through the
2541    /// sandboxed effect boundary.
2542    ///
2543    /// **Snapshot semantics:** the read snapshot is captured ONCE before
2544    /// eval, and effects are applied AFTER the program returns. So within
2545    /// a single `run_lisp` call a program cannot observe its own writes —
2546    /// `(insert "x") (cursor-column)` reads the pre-insert column. This
2547    /// snapshot-isolation is deliberate (it's what makes the effect
2548    /// boundary a clean sandbox seam); a program that must read its own
2549    /// effects splits the work across calls. The VM is cached
2550    /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
2551    /// `define`s persist across calls (REPL-like).
2552    pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
2553        let mut host = EscribaHost::with_snapshot(self.snapshot());
2554        let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
2555        vm.eval(src, &mut host)?;
2556        let effects = host.take_effects();
2557        self.apply_host_effects(effects);
2558        Ok(())
2559    }
2560
2561    /// Apply tatara-lisp effects to live editor state.
2562    ///
2563    /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
2564    /// implementation of message-push / option-insert / insert-text beside
2565    /// the Action executor and the slip interpreter — the same duplication
2566    /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
2567    /// hands them to the one interpreter.
2568    pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
2569        self.interpret(Outcome::did(effects));
2570    }
2571
2572    /// Insert a (possibly multi-line) string at the cursor and advance
2573    /// the cursor past it. Used by the `(insert …)` effect.
2574    fn insert_text(&mut self, text: &str) {
2575        if text.is_empty() {
2576            return;
2577        }
2578        let cursor = self.cursor();
2579        let Some(buf) = self.buffers.get_mut(self.active) else {
2580            return;
2581        };
2582        let edit = Edit::insert(cursor, text.to_string());
2583        if buf.apply(&edit).is_ok() {
2584            let next = if let Some(nl) = text.rfind('\n') {
2585                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
2586                let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
2587                Position::new(cursor.line + added_lines, last_line_len)
2588            } else {
2589                let n = u32::try_from(text.chars().count()).unwrap_or(0);
2590                cursor.shift_right(n)
2591            };
2592            // Route through the single cursor-mutation path so the viewport
2593            // follows the cursor (both axes) and the cursor stays clamped.
2594            self.set_cursor(next);
2595        }
2596    }
2597}
2598
2599fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
2600    let Some(text) = buf.line(line) else {
2601        return Position::new(line, 0);
2602    };
2603    let col = text
2604        .chars()
2605        .take_while(|c| c.is_whitespace() && *c != '\n')
2606        .count();
2607    Position::new(line, u32::try_from(col).unwrap_or(0))
2608}
2609
2610fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2611    let Some(text) = buf.line(pos.line) else {
2612        return pos;
2613    };
2614    let chars: Vec<char> = text.chars().collect();
2615    let start = pos.column as usize;
2616    let mut i = start;
2617    while i < chars.len() && !chars[i].is_whitespace() {
2618        i += 1;
2619    }
2620    while i < chars.len() && chars[i].is_whitespace() {
2621        i += 1;
2622    }
2623    if i >= chars.len() {
2624        // No more words on this line — jump to next line.
2625        if pos.line + 1 < buf.line_count() {
2626            return Position::new(pos.line + 1, 0);
2627        }
2628    }
2629    Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
2630}
2631
2632fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2633    let Some(text) = buf.line(pos.line) else {
2634        return pos;
2635    };
2636    let chars: Vec<char> = text.chars().collect();
2637    let mut i = (pos.column as usize).min(chars.len());
2638    while i > 0 && chars[i - 1].is_whitespace() {
2639        i -= 1;
2640    }
2641    while i > 0 && !chars[i - 1].is_whitespace() {
2642        i -= 1;
2643    }
2644    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
2645}
2646
2647fn parse_command_line(line: &str) -> (String, Vec<String>) {
2648    let mut parts = line.split_whitespace();
2649    let Some(first) = parts.next() else {
2650        return (String::new(), Vec::new());
2651    };
2652    let head = first.strip_prefix(':').unwrap_or(first);
2653    let name = match head {
2654        "w" => "save",
2655        "q" => "quit",
2656        "u" => "undo",
2657        other => other,
2658    };
2659    (name.to_string(), parts.map(str::to_string).collect())
2660}
2661
2662#[cfg(test)]
2663mod tests {
2664    use super::*;
2665    use madori::event::{KeyCode, KeyEvent, Modifiers};
2666
2667    // ── search wiring (escriba-search integration) ────────────────────
2668    //
2669    // The engine is proven in escriba-search's own 61 tests. These prove the
2670    // WIRING: that keys reach it, that the cursor lands where it says, and
2671    // that a search prompt and an ex-command can share Command mode without
2672    // being confused for one another.
2673
2674    fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
2675        st.apply(&Action::SearchOpen(dir));
2676        for c in pat.chars() {
2677            st.apply(&Action::InsertChar(c));
2678        }
2679        st.apply(&Action::SubmitCommand);
2680    }
2681
2682    #[test]
2683    fn slash_search_moves_the_cursor_to_the_match() {
2684        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2685        type_search(&mut st, SearchDirection::Forward, "charlie");
2686        assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
2687        assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
2688        assert_eq!(st.search.matches().len(), 1);
2689    }
2690
2691    #[test]
2692    // `N` is a DIFFERENT vim key from `n` — see escriba-search.
2693    #[allow(non_snake_case)]
2694    fn n_and_N_walk_matches_in_both_directions() {
2695        let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
2696        type_search(&mut st, SearchDirection::Forward, "foo");
2697        let first = st.cursor().line;
2698        st.apply(&Action::SearchRepeat { reverse: false });
2699        let second = st.cursor().line;
2700        assert!(second > first, "n advances ({first} -> {second})");
2701        st.apply(&Action::SearchRepeat { reverse: true });
2702        assert_eq!(st.cursor().line, first, "N comes back");
2703    }
2704
2705    #[test]
2706    fn star_searches_the_word_under_the_cursor() {
2707        let mut st = new_state_with("needle\nhaystack\nneedle\n");
2708        st.apply(&Action::SearchWord { reverse: false });
2709        assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
2710        assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
2711    }
2712
2713    #[test]
2714    fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
2715        let mut st = new_state_with("foo\nbar\nfoo\n");
2716        type_search(&mut st, SearchDirection::Forward, "foo");
2717        let matches_before = st.search.matches().len();
2718
2719        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2720        st.apply(&Action::InsertChar('z'));
2721        st.apply(&Action::ChangeMode(Mode::Normal));
2722
2723        assert!(!st.search.is_prompting(), "prompt gone");
2724        assert_eq!(
2725            st.search.pattern().unwrap().raw(),
2726            "foo",
2727            "old pattern survives"
2728        );
2729        assert_eq!(
2730            st.search.matches().len(),
2731            matches_before,
2732            "old highlights survive"
2733        );
2734    }
2735
2736    #[test]
2737    fn a_search_prompt_and_an_ex_command_are_not_confused() {
2738        let mut st = new_state_with("foo\n");
2739        // No `/` pressed: Command mode belongs to the ex-command line.
2740        st.apply(&Action::ChangeMode(Mode::Command));
2741        assert!(!st.search.is_prompting(), "`:` must not open a search");
2742        st.apply(&Action::InsertChar('w'));
2743        assert!(
2744            st.search.prompt().is_none(),
2745            "typed char went to the ex line"
2746        );
2747    }
2748
2749    #[test]
2750    fn a_missing_pattern_reports_instead_of_failing_silently() {
2751        let mut st = new_state_with("alpha\nbravo\n");
2752        type_search(&mut st, SearchDirection::Forward, "zzz");
2753        assert!(
2754            st.messages.iter().any(|m| m.contains("E486")),
2755            "must report not-found, got {:?}",
2756            st.messages
2757        );
2758    }
2759
2760    #[test]
2761    fn n_without_any_search_reports_rather_than_moving() {
2762        let mut st = new_state_with("alpha\nbravo\n");
2763        let before = st.cursor();
2764        st.apply(&Action::SearchRepeat { reverse: false });
2765        assert_eq!(st.cursor(), before, "cursor must not move");
2766        assert!(
2767            st.messages.iter().any(|m| m.contains("E35")),
2768            "got {:?}",
2769            st.messages
2770        );
2771    }
2772
2773    #[test]
2774    fn search_as_a_motion_composes_with_an_operator() {
2775        // The point of Motion::SearchNext: `d` + search deletes to the match.
2776        let mut st = new_state_with("alpha bravo charlie\n");
2777        type_search(&mut st, SearchDirection::Forward, "charlie");
2778        st.set_cursor(Position::new(0, 0));
2779        let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
2780        assert!(target.is_some(), "search must resolve as a motion");
2781        assert_eq!(target.unwrap().column, 12, "at `charlie`");
2782    }
2783
2784    #[test]
2785    fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
2786        // A silent fallback to offset 0 would make `d` + search delete to the
2787        // start of the file — the worst possible failure for an operator.
2788        let st = new_state_with("alpha bravo\n");
2789        assert!(
2790            st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
2791                .is_none()
2792        );
2793    }
2794
2795    #[test]
2796    fn clear_highlight_keeps_the_pattern_usable() {
2797        let mut st = new_state_with("foo\nbar\nfoo\n");
2798        type_search(&mut st, SearchDirection::Forward, "foo");
2799        st.apply(&Action::ClearSearchHighlight);
2800        assert!(st.search.highlights().is_empty(), "nothing lit");
2801        st.apply(&Action::SearchRepeat { reverse: false });
2802        assert!(st.search.pattern().is_some(), "but n still works");
2803    }
2804
2805    #[test]
2806    fn typing_previews_incrementally_before_commit() {
2807        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2808        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2809        for c in "charlie".chars() {
2810            st.apply(&Action::InsertChar(c));
2811        }
2812        // incsearch: the cursor has already moved, with nothing committed.
2813        assert_eq!(st.cursor().line, 2, "preview moved the cursor");
2814        assert!(st.search.pattern().is_none(), "but nothing is committed");
2815    }
2816
2817    #[test]
2818    fn backspace_corrects_the_prompt_and_reruns_the_preview() {
2819        let mut st = new_state_with("alpha\nbravo\n");
2820        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2821        for c in "bravox".chars() {
2822            st.apply(&Action::InsertChar(c));
2823        }
2824        assert_eq!(st.search.prompt().unwrap().text(), "bravox");
2825        st.apply(&Action::PromptBackspace);
2826        assert_eq!(
2827            st.search.prompt().unwrap().text(),
2828            "bravo",
2829            "typo corrected"
2830        );
2831        assert_eq!(
2832            st.status_model().prompt_text,
2833            "bravo",
2834            "the model reads the PROMPT — the minibuffer is the ex-line's store",
2835        );
2836        assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
2837    }
2838
2839    #[test]
2840    fn backspacing_past_the_slash_closes_the_prompt() {
2841        let mut st = new_state_with("alpha\n");
2842        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2843        st.apply(&Action::InsertChar('a'));
2844        st.apply(&Action::PromptBackspace);
2845        st.apply(&Action::PromptBackspace);
2846        assert!(!st.search.is_prompting(), "prompt closed");
2847        assert_eq!(st.modal.mode(), Mode::Normal);
2848    }
2849
2850    #[test]
2851    fn noh_clears_highlights_and_keeps_the_pattern() {
2852        let mut st = new_state_with("foo\nbar\nfoo\n");
2853        type_search(&mut st, SearchDirection::Forward, "foo");
2854        assert!(!st.search.highlights().is_empty());
2855        st.run_command("noh", &[]);
2856        assert!(st.search.highlights().is_empty(), ":noh turns them off");
2857        assert!(st.search.pattern().is_some(), "but n still works");
2858    }
2859
2860    #[test]
2861    fn noh_accepts_the_vim_aliases() {
2862        for name in ["noh", "nohl", "nohlsearch"] {
2863            let mut st = new_state_with("foo\nfoo\n");
2864            type_search(&mut st, SearchDirection::Forward, "foo");
2865            st.run_command(name, &[]);
2866            assert!(st.search.highlights().is_empty(), "{name} must clear");
2867        }
2868    }
2869
2870    #[test]
2871    fn backspace_on_the_ex_line_does_not_touch_search_state() {
2872        let mut st = new_state_with("foo\n");
2873        st.apply(&Action::ChangeMode(Mode::Command));
2874        st.apply(&Action::InsertChar('w'));
2875        st.apply(&Action::InsertChar('q'));
2876        st.apply(&Action::PromptBackspace);
2877        assert_eq!(st.status_model().prompt_text, "w");
2878        assert!(st.search.prompt().is_none(), "no search was involved");
2879    }
2880
2881    #[test]
2882    fn up_arrow_recalls_the_previous_search() {
2883        let mut st = new_state_with("alpha\nbravo\n");
2884        type_search(&mut st, SearchDirection::Forward, "bravo");
2885        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2886        st.apply(&Action::PromptHistory { back: true });
2887        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2888        assert_eq!(
2889            st.status_model().prompt_text,
2890            "bravo",
2891            "display follows the prompt"
2892        );
2893    }
2894
2895    #[test]
2896    fn arrowing_back_down_restores_the_half_typed_pattern() {
2897        let mut st = new_state_with("alpha\nbravo\n");
2898        type_search(&mut st, SearchDirection::Forward, "bravo");
2899        st.apply(&Action::SearchOpen(SearchDirection::Forward));
2900        st.apply(&Action::InsertChar('a'));
2901        st.apply(&Action::PromptHistory { back: true });
2902        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2903        st.apply(&Action::PromptHistory { back: false });
2904        assert_eq!(
2905            st.search.prompt().unwrap().text(),
2906            "a",
2907            "the draft comes back"
2908        );
2909        assert_eq!(st.status_model().prompt_text, "a");
2910    }
2911
2912    #[test]
2913    fn history_arrows_do_nothing_on_the_ex_line() {
2914        let mut st = new_state_with("alpha\n");
2915        st.apply(&Action::ChangeMode(Mode::Command));
2916        st.apply(&Action::InsertChar('w'));
2917        st.apply(&Action::PromptHistory { back: true });
2918        assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
2919    }
2920
2921    fn new_state_with(text: &str) -> EditorState {
2922        let mut bufs = BufferSet::new();
2923        let id = bufs.scratch(text);
2924        EditorState::new_with_buffer(bufs, id)
2925    }
2926
2927    /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
2928    /// action advances `edit_gen` (so the renderer repaints), and merely
2929    /// reading the generation does not. This is what lets `gpu.rs` gate the
2930    /// re-highlight/re-shape on a generation change — an idle frame observes an
2931    /// unchanged generation and reuses its cached buffer.
2932    #[test]
2933    fn edit_gen_advances_on_applied_action_not_on_read() {
2934        let mut s = new_state_with("hello\nworld\n");
2935        let g0 = s.edit_gen();
2936        s.apply(&Action::InsertChar('X'));
2937        assert_ne!(
2938            s.edit_gen(),
2939            g0,
2940            "an applied action must advance the refresh generation",
2941        );
2942        // Reading the generation is not a mutation — idle frames stay put.
2943        let g1 = s.edit_gen();
2944        assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
2945    }
2946
2947    /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
2948    /// `Damage` to cover exactly what changed — local for an in-place edit,
2949    /// to-end-of-document when the line count shifts — and the renderer drains
2950    /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
2951    #[test]
2952    fn damage_tracks_edit_scope_and_drains() {
2953        let mut s = new_state_with("hello\nworld\n");
2954        assert!(s.damage().is_none(), "a fresh state has no damage");
2955
2956        s.apply(&Action::InsertChar('X')); // in-place edit on line 0
2957        assert_eq!(
2958            s.damage(),
2959            Damage::Lines { from: 0, to: 0 },
2960            "a local edit damages just its line",
2961        );
2962
2963        let drained = s.take_damage();
2964        assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
2965        assert!(s.damage().is_none(), "take_damage drains to None");
2966
2967        s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
2968        assert_eq!(
2969            s.damage(),
2970            Damage::Lines {
2971                from: 0,
2972                to: u32::MAX,
2973            },
2974            "a line-count change damages to end-of-document",
2975        );
2976    }
2977
2978    /// A state whose active window is a deliberately tiny viewport
2979    /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
2980    /// invariant is exercised on small inputs.
2981    fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
2982        let mut s = new_state_with(text);
2983        for w in &mut s.layout.windows {
2984            w.viewport.visible_lines = vis_lines;
2985            w.viewport.visible_columns = vis_cols;
2986        }
2987        s
2988    }
2989
2990    /// The core regression invariant: the active window's viewport CONTAINS
2991    /// the cursor on BOTH axes. This is the operator's exact complaint —
2992    /// "typing past the bottom (or right) leaves the cursor off-screen" —
2993    /// made into a checkable property.
2994    fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
2995        let w = s.layout.active_window().expect("active window");
2996        let v = w.viewport;
2997        let c = s.cursor();
2998        assert!(
2999            v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
3000            "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
3001            c.line,
3002            v.top_line,
3003            v.top_line + v.visible_lines,
3004        );
3005        assert!(
3006            v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
3007            "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
3008            c.column,
3009            v.left_column,
3010            v.left_column + v.visible_columns,
3011        );
3012    }
3013
3014    fn press(kc: KeyCode) -> AppEvent {
3015        AppEvent::Key(KeyEvent {
3016            key: kc,
3017            pressed: true,
3018            modifiers: Modifiers::default(),
3019            text: None,
3020        })
3021    }
3022
3023    // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
3024
3025    fn line0_len(s: &EditorState) -> u32 {
3026        s.buffers.get(s.active).unwrap().line_len_chars(0)
3027    }
3028
3029    #[test]
3030    fn delete_to_line_end_clears_line_and_fills_register() {
3031        let mut s = new_state_with("hello world");
3032        s.apply(&Action::ApplyOperator {
3033            op: Operator::Delete,
3034            motion: Motion::LineEnd,
3035        });
3036        assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
3037        assert_eq!(
3038            s.register(),
3039            Some("hello world"),
3040            "delete fills the register"
3041        );
3042        assert_eq!(
3043            s.cursor(),
3044            Position::ZERO,
3045            "cursor lands at the range start"
3046        );
3047    }
3048
3049    #[test]
3050    fn delete_over_right_motion_removes_one_char() {
3051        let mut s = new_state_with("abc");
3052        s.apply(&Action::ApplyOperator {
3053            op: Operator::Delete,
3054            motion: Motion::Right,
3055        });
3056        assert_eq!(
3057            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3058            Some("bc")
3059        );
3060        assert_eq!(s.register(), Some("a"));
3061    }
3062
3063    #[test]
3064    fn change_to_line_end_deletes_and_enters_insert() {
3065        let mut s = new_state_with("hello world");
3066        assert_eq!(s.modal.mode(), Mode::Normal);
3067        s.apply(&Action::ApplyOperator {
3068            op: Operator::Change,
3069            motion: Motion::LineEnd,
3070        });
3071        assert_eq!(line0_len(&s), 0, "c$ deletes the range");
3072        assert_eq!(
3073            s.modal.mode(),
3074            Mode::Insert,
3075            "change enters Insert to type the replacement"
3076        );
3077        assert_eq!(
3078            s.register(),
3079            Some("hello world"),
3080            "change fills the register"
3081        );
3082    }
3083
3084    #[test]
3085    fn yank_to_line_end_fills_register_without_mutating() {
3086        let mut s = new_state_with("hello world");
3087        s.apply(&Action::ApplyOperator {
3088            op: Operator::Yank,
3089            motion: Motion::LineEnd,
3090        });
3091        assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
3092        assert_eq!(s.register(), Some("hello world"), "yank fills the register");
3093        assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
3094    }
3095
3096    #[test]
3097    fn resolve_motion_is_the_shared_target_for_move_and_operator() {
3098        // The encapsulation proof: apply_motion (cursor move) and
3099        // apply_operator (range end) BOTH stand on resolve_motion — so a move
3100        // to LineEnd lands at exactly the position the operator deletes to.
3101        let mut s = new_state_with("hello world");
3102        let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
3103        assert_eq!(target, Position::new(0, 11));
3104        s.apply_motion(Motion::LineEnd);
3105        assert_eq!(
3106            s.cursor(),
3107            target,
3108            "the move path resolves the same target the operator uses"
3109        );
3110    }
3111
3112    #[test]
3113    fn empty_motion_range_is_a_no_op() {
3114        // An operator over a zero-width motion (cursor already at line start)
3115        // mutates nothing and leaves the register untouched.
3116        let mut s = new_state_with("abc");
3117        s.apply(&Action::ApplyOperator {
3118            op: Operator::Delete,
3119            motion: Motion::LineStart,
3120        });
3121        assert_eq!(
3122            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3123            Some("abc")
3124        );
3125        assert_eq!(s.register(), None);
3126    }
3127
3128    #[test]
3129    fn operator_then_motion_composes_through_the_pending_fsm() {
3130        // The full keymap→FSM→engine path: dispatching the `d` operator action
3131        // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
3132        // the operator key alone does nothing until the motion arrives.
3133        let mut s = new_state_with("hello world");
3134        s.apply(&Action::Operator(Operator::Delete));
3135        assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
3136        s.apply(&Action::Move(Motion::LineEnd));
3137        assert_eq!(
3138            line0_len(&s),
3139            0,
3140            "d then $ composes d$ and deletes the line"
3141        );
3142        assert_eq!(s.register(), Some("hello world"));
3143    }
3144
3145    #[test]
3146    fn change_operator_through_fsm_enters_insert() {
3147        let mut s = new_state_with("hello world");
3148        s.apply(&Action::Operator(Operator::Change));
3149        s.apply(&Action::Move(Motion::LineEnd));
3150        assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
3151    }
3152
3153    #[test]
3154    fn lone_motion_after_no_operator_just_moves() {
3155        // Without a preceding operator the motion passes through unchanged.
3156        let mut s = new_state_with("hello world");
3157        s.apply(&Action::Move(Motion::LineEnd));
3158        assert_eq!(s.cursor(), Position::new(0, 11));
3159        assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
3160    }
3161
3162    #[test]
3163    fn counted_operator_deletes_count_times() {
3164        // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
3165        // flows through the FSM to the composed motion (the bug fix: previously
3166        // the count repeated the operator key and toggled the FSM).
3167        let mut s = new_state_with("abcdef");
3168        s.apply_counted(&Action::Operator(Operator::Delete), 3);
3169        assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
3170        s.apply(&Action::Move(Motion::Right));
3171        assert_eq!(
3172            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3173            Some("def")
3174        );
3175    }
3176
3177    #[test]
3178    fn operator_and_motion_counts_multiply_end_to_end() {
3179        // `2d3l` = delete 2×3 = 6 chars.
3180        let mut s = new_state_with("abcdefgh");
3181        s.apply_counted(&Action::Operator(Operator::Delete), 2);
3182        s.apply_counted(&Action::Move(Motion::Right), 3);
3183        assert_eq!(
3184            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3185            Some("gh")
3186        );
3187    }
3188
3189    #[test]
3190    fn bare_counted_motion_still_repeats_no_regression() {
3191        // `3j` still moves down 3 lines — the count passes through the FSM
3192        // unchanged when no operator is pending.
3193        let mut s = new_state_with("a\nb\nc\nd\ne");
3194        s.apply_counted(&Action::Move(Motion::Down), 3);
3195        assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
3196    }
3197
3198    /// A monotonic clock for the key-repeat gate in tests — each `next()`
3199    /// jumps a full second past the previous, so every press it stamps is
3200    /// well outside the 80ms debounce window and therefore an INTENTIONAL
3201    /// press (never a storm tick). Used by tests that fire the *same*
3202    /// navigation key twice and assert editor logic, not debounce timing.
3203    struct SpacedClock(std::time::Instant);
3204    impl SpacedClock {
3205        fn new() -> Self {
3206            Self(std::time::Instant::now())
3207        }
3208        fn next(&mut self) -> std::time::Instant {
3209            self.0 += std::time::Duration::from_secs(1);
3210            self.0
3211        }
3212    }
3213
3214    #[test]
3215    fn hjkl_moves_cursor() {
3216        let mut s = new_state_with("hello\nworld");
3217        s.tick(&press(KeyCode::Char('l')));
3218        assert_eq!(s.cursor().column, 1);
3219        s.tick(&press(KeyCode::Char('j')));
3220        assert_eq!(s.cursor().line, 1);
3221        s.tick(&press(KeyCode::Char('h')));
3222        assert_eq!(s.cursor().column, 0);
3223    }
3224
3225    #[test]
3226    fn insert_mode_inserts_chars() {
3227        let mut s = new_state_with("");
3228        s.tick(&press(KeyCode::Char('i')));
3229        assert_eq!(s.modal.mode(), Mode::Insert);
3230        s.tick(&press(KeyCode::Char('h')));
3231        s.tick(&press(KeyCode::Char('i')));
3232        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
3233        assert_eq!(s.cursor().column, 2);
3234    }
3235
3236    #[test]
3237    fn esc_returns_to_normal() {
3238        let mut s = new_state_with("");
3239        s.tick(&press(KeyCode::Char('i')));
3240        s.tick(&press(KeyCode::Escape));
3241        assert_eq!(s.modal.mode(), Mode::Normal);
3242    }
3243
3244    #[test]
3245    fn count_prefix_repeats_motion() {
3246        let mut s = new_state_with("abcdefghij");
3247        s.tick(&press(KeyCode::Char('5')));
3248        s.tick(&press(KeyCode::Char('l')));
3249        assert_eq!(s.cursor().column, 5);
3250    }
3251
3252    #[test]
3253    fn close_event_requests_quit() {
3254        let mut s = new_state_with("");
3255        s.tick(&AppEvent::CloseRequested);
3256        assert!(s.quit_requested);
3257    }
3258
3259    #[test]
3260    fn word_next_jumps_past_whitespace() {
3261        let mut s = new_state_with("foo bar baz");
3262        // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
3263        // the gate passes both (a real user's two taps are ≥80ms apart).
3264        let mut clk = SpacedClock::new();
3265        s.tick_at(&press(KeyCode::Char('w')), clk.next());
3266        assert_eq!(s.cursor().column, 4);
3267        s.tick_at(&press(KeyCode::Char('w')), clk.next());
3268        assert_eq!(s.cursor().column, 8);
3269    }
3270
3271    // ── Multi-key / leader pending-stroke ───────────────────────────
3272
3273    #[test]
3274    fn leader_sequence_holds_then_resolves() {
3275        let mut s = new_state_with("a\nbb\nccc");
3276        s.keymap.bind_sequence(
3277            Mode::Normal,
3278            vec![Key::Char(','), Key::Char('g')],
3279            Action::Move(Motion::DocEnd),
3280            "doc end",
3281        );
3282        // `,` begins the sequence — held pending, nothing applied yet.
3283        s.on_key(&Key::Char(','));
3284        assert_eq!(s.pending_keys, vec![Key::Char(',')]);
3285        assert_eq!(s.cursor(), Position::ZERO);
3286        // `g` completes `<leader>g` → DocEnd; pending clears.
3287        s.on_key(&Key::Char('g'));
3288        assert!(s.pending_keys.is_empty());
3289        assert_eq!(s.cursor().line, 2);
3290    }
3291
3292    #[test]
3293    fn two_key_gg_jumps_doc_start() {
3294        let mut s = new_state_with("a\nbb\nccc");
3295        s.keymap.bind_sequence(
3296            Mode::Normal,
3297            vec![Key::Char('g'), Key::Char('g')],
3298            Action::Move(Motion::DocStart),
3299            "doc start",
3300        );
3301        let mut clk = SpacedClock::new();
3302        s.tick_at(&press(KeyCode::Char('j')), clk.next());
3303        s.tick_at(&press(KeyCode::Char('j')), clk.next());
3304        assert_eq!(s.cursor().line, 2);
3305        s.on_key(&Key::Char('g')); // pending
3306        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3307        s.on_key(&Key::Char('g')); // resolve
3308        assert_eq!(s.cursor(), Position::ZERO);
3309    }
3310
3311    #[test]
3312    fn broken_sequence_aborts_and_clears_pending() {
3313        let mut s = new_state_with("hello");
3314        s.keymap.bind_sequence(
3315            Mode::Normal,
3316            vec![Key::Char('g'), Key::Char('g')],
3317            Action::Move(Motion::DocEnd),
3318            "doc end",
3319        );
3320        s.on_key(&Key::Char('g')); // pending [g]
3321        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3322        s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
3323        assert!(s.pending_keys.is_empty());
3324        assert_eq!(s.cursor(), Position::ZERO);
3325    }
3326
3327    #[test]
3328    fn single_binding_wins_over_sequence_prefix() {
3329        // A key that is BOTH a complete single binding and the start of
3330        // a sequence fires the single binding immediately (no chord
3331        // timeout needed). Here `h` (move-left) also prefixes `hz`.
3332        let mut s = new_state_with("abcde");
3333        let mut clk = SpacedClock::new();
3334        s.tick_at(&press(KeyCode::Char('l')), clk.next());
3335        s.tick_at(&press(KeyCode::Char('l')), clk.next());
3336        assert_eq!(s.cursor().column, 2);
3337        s.keymap.bind_sequence(
3338            Mode::Normal,
3339            vec![Key::Char('h'), Key::Char('z')],
3340            Action::Move(Motion::DocEnd),
3341            "shadowed",
3342        );
3343        s.on_key(&Key::Char('h'));
3344        assert!(s.pending_keys.is_empty(), "single binding should not pend");
3345        assert_eq!(s.cursor().column, 1, "h moved left immediately");
3346    }
3347
3348    // ── tatara-lisp runtime bridge (imperative programmability) ─────
3349
3350    #[test]
3351    fn lisp_set_option_writes_live_options() {
3352        let mut s = new_state_with("");
3353        s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
3354        assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
3355    }
3356
3357    #[test]
3358    fn lisp_insert_modifies_buffer_and_advances_cursor() {
3359        let mut s = new_state_with("");
3360        s.run_lisp(r#"(insert "abc")"#).unwrap();
3361        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
3362        assert_eq!(s.cursor(), Position::new(0, 3));
3363    }
3364
3365    #[test]
3366    fn lisp_message_appends_to_messages() {
3367        let mut s = new_state_with("");
3368        s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
3369        assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
3370    }
3371
3372    #[test]
3373    fn lisp_reads_snapshot_and_branches_to_effect() {
3374        // Genuine programmability: Lisp reads the live cursor line and
3375        // an `if` decides which option to set.
3376        let mut s = new_state_with("one\ntwo\nthree");
3377        // cursor at line 0 → "top" branch
3378        s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
3379            .unwrap();
3380        assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
3381    }
3382
3383    #[test]
3384    fn lisp_run_command_effect_drives_registry() {
3385        // `(run-command "undo")` reaches the live command registry and
3386        // reverts a prior Lisp-driven insert — proving the RunCommand
3387        // effect dispatches through real editor commands.
3388        let mut s = new_state_with("");
3389        s.run_lisp(r#"(insert "abc")"#).unwrap();
3390        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
3391        s.run_lisp(r#"(run-command "undo")"#).unwrap();
3392        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
3393    }
3394
3395    #[test]
3396    fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
3397        // The full imperative-quit path: (run-command "quit") routes
3398        // through the registry's typed `quit_requested` signal — no string
3399        // sentinel, and no minibuffer pollution (the editor stays in a
3400        // clean Normal state, which has no minibuffer at all).
3401        let mut s = new_state_with("");
3402        s.run_lisp(r#"(run-command "quit")"#).unwrap();
3403        assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
3404        assert_eq!(
3405            s.modal.minibuffer(),
3406            "",
3407            "quit must not pollute any command line — Normal mode has no minibuffer",
3408        );
3409    }
3410
3411    // ── Lazy plugin activation (PluginHost) ────────────────────────
3412
3413    #[test]
3414    fn lazy_plugin_activates_on_command_trigger() {
3415        // A user plugin gated on `Command: LazyGo` has its entry applied
3416        // the first time that command runs — proving the lazy.nvim
3417        // `cmd =` model works end-to-end against live editor state.
3418        let mut s = new_state_with("");
3419        s.register_lazy_plugin(
3420            "user-lazy",
3421            vec![LazyTrigger::Command("LazyGo".into())],
3422            r#"(defoption :name "lazy-loaded" :value "yes")
3423               (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
3424        );
3425        assert_eq!(s.plugin_host.pending(), 1);
3426        assert!(
3427            s.options.get("lazy-loaded").is_none(),
3428            "entry not applied yet"
3429        );
3430
3431        // Drive the command through the public imperative path.
3432        s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
3433
3434        assert_eq!(
3435            s.options.get("lazy-loaded").map(String::as_str),
3436            Some("yes"),
3437            "the command trigger applied the plugin's entry",
3438        );
3439        assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
3440    }
3441
3442    #[test]
3443    fn lazy_plugin_activates_on_filetype() {
3444        let mut s = new_state_with("");
3445        s.register_lazy_plugin(
3446            "user-rust",
3447            vec![LazyTrigger::FileType("rust".into())],
3448            r#"(defoption :name "rust-plugin" :value "on")"#,
3449        );
3450        let n = s.activate_filetype_plugins("rust");
3451        assert_eq!(n, 1);
3452        assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
3453        // A second open of the same filetype is a no-op (one-shot).
3454        assert_eq!(s.activate_filetype_plugins("rust"), 0);
3455    }
3456
3457    #[test]
3458    fn cached_vm_serves_multiple_run_lisp_calls() {
3459        let mut s = new_state_with("");
3460        s.run_lisp(r#"(message "one")"#).unwrap();
3461        assert!(
3462            s.lisp_vm.is_some(),
3463            "VM should be cached after first run_lisp"
3464        );
3465        s.run_lisp(r#"(message "two")"#).unwrap();
3466        assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
3467    }
3468
3469    #[test]
3470    fn lisp_define_persists_across_run_lisp_calls() {
3471        // The cached VM's top-level env persists across calls (REPL
3472        // semantics): a `define` in one call is visible in the next.
3473        let mut s = new_state_with("");
3474        s.run_lisp(r#"(define greeting "hi")"#).unwrap();
3475        s.run_lisp(r#"(message greeting)"#).unwrap();
3476        assert_eq!(s.messages, vec!["hi".to_string()]);
3477    }
3478
3479    #[test]
3480    fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
3481        // Within ONE call a program cannot observe its own writes — the
3482        // read snapshot is captured before eval, effects apply after. A
3483        // later call sees the refreshed snapshot.
3484        let mut s = new_state_with("");
3485        s.run_lisp(
3486            r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
3487        )
3488        .unwrap();
3489        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
3490        assert_eq!(
3491            s.options.get("col").map(String::as_str),
3492            Some("stale-zero"),
3493            "cursor-column within the same call reads the pre-eval snapshot",
3494        );
3495        // After the first call the cursor advanced to column 2; the next
3496        // call's snapshot reflects it.
3497        s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
3498            .unwrap();
3499        assert_eq!(
3500            s.options.get("col2").map(String::as_str),
3501            Some("live-two"),
3502            "a later call sees the refreshed snapshot",
3503        );
3504    }
3505
3506    #[test]
3507    fn insert_text_effect_multiline_lands_cursor_on_last_line() {
3508        let mut s = new_state_with("");
3509        s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
3510        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
3511        assert_eq!(s.cursor(), Position::new(1, 3));
3512    }
3513
3514    #[test]
3515    fn visual_mode_sequence_resolves() {
3516        let mut s = new_state_with("abc");
3517        s.modal.enter(Mode::Visual);
3518        s.keymap.bind_sequence(
3519            Mode::Visual,
3520            vec![Key::Char('g'), Key::Char('e')],
3521            Action::Move(Motion::DocEnd),
3522            "ge",
3523        );
3524        s.on_key(&Key::Char('g'));
3525        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3526        s.on_key(&Key::Char('e'));
3527        assert!(s.pending_keys.is_empty());
3528        assert_eq!(
3529            s.cursor().column,
3530            3,
3531            "ge resolved to doc-end in visual mode"
3532        );
3533    }
3534
3535    #[test]
3536    fn sequence_abort_with_bound_breaking_key_redispatches() {
3537        // gg is a sequence; `l` (move-right) is a bound single key. After
3538        // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
3539        let mut s = new_state_with("abcde");
3540        s.keymap.bind_sequence(
3541            Mode::Normal,
3542            vec![Key::Char('g'), Key::Char('g')],
3543            Action::Move(Motion::DocEnd),
3544            "gg",
3545        );
3546        s.on_key(&Key::Char('g'));
3547        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3548        s.on_key(&Key::Char('l'));
3549        assert!(s.pending_keys.is_empty());
3550        assert_eq!(
3551            s.cursor().column,
3552            1,
3553            "the breaking key l should re-dispatch as move-right",
3554        );
3555    }
3556
3557    // ── Viewport-follows-cursor invariant (both axes) ───────────────
3558
3559    #[test]
3560    fn viewport_contains_cursor_after_every_op() {
3561        // Tiny window: 5 visible lines × 10 visible columns. Drive a
3562        // representative scripted sequence and assert the viewport contains
3563        // the cursor after EVERY mutating step.
3564        let mut s = new_state_small_viewport("", 5, 10);
3565        assert_cursor_in_viewport(&s, "initial");
3566
3567        // Enter insert mode and type 30 newline-separated lines — this is
3568        // the exact "type past the bottom" complaint.
3569        s.tick(&press(KeyCode::Char('i')));
3570        assert_eq!(s.modal.mode(), Mode::Insert);
3571        for line in 0..30u32 {
3572            for c in "line".chars() {
3573                s.tick(&press(KeyCode::Char(c)));
3574                assert_cursor_in_viewport(&s, "typing chars");
3575            }
3576            s.tick(&press(KeyCode::Enter));
3577            assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
3578        }
3579
3580        // Type a long (200-char) line — the "type past the right edge"
3581        // complaint. The cursor must stay horizontally visible the whole way.
3582        for i in 0..200u32 {
3583            s.tick(&press(KeyCode::Char('x')));
3584            assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
3585        }
3586
3587        // Multi-line insert_text effect (the `(insert …)` Lisp path).
3588        s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
3589        assert_cursor_in_viewport(&s, "insert_text multiline");
3590
3591        // Back to normal mode and move in all directions / to extremes.
3592        s.tick(&press(KeyCode::Escape));
3593        assert_eq!(s.modal.mode(), Mode::Normal);
3594        for m in [
3595            Motion::DocStart,
3596            Motion::DocEnd,
3597            Motion::Down,
3598            Motion::Down,
3599            Motion::Up,
3600            Motion::Right,
3601            Motion::Right,
3602            Motion::Left,
3603            Motion::LineEnd,
3604            Motion::LineStart,
3605            Motion::GotoLine(1),
3606            Motion::GotoLine(40),
3607            Motion::PageDown,
3608            Motion::PageUp,
3609        ] {
3610            s.apply_motion(m);
3611            assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
3612        }
3613
3614        // Undo many times — the buffer shrinks; the viewport must re-follow
3615        // the (now clamped) cursor.
3616        for i in 0..50u32 {
3617            s.apply(&Action::Undo);
3618            assert_cursor_in_viewport(&s, &format!("undo {i}"));
3619        }
3620        // Redo back up.
3621        for i in 0..50u32 {
3622            s.apply(&Action::Redo);
3623            assert_cursor_in_viewport(&s, &format!("redo {i}"));
3624        }
3625    }
3626
3627    #[test]
3628    fn insert_at_eof_keeps_cursor_in_bounds() {
3629        // Inserting at the end of the buffer must leave the cursor clamped
3630        // to a valid position (and inside the viewport).
3631        let mut s = new_state_small_viewport("abc", 5, 10);
3632        s.apply_motion(Motion::DocEnd);
3633        s.tick(&press(KeyCode::Char('i')));
3634        s.tick(&press(KeyCode::Char('d')));
3635        let buf = s.buffers.get(s.active).unwrap();
3636        let clamped = buf.clamp(s.cursor());
3637        assert_eq!(
3638            s.cursor(),
3639            clamped,
3640            "cursor must be clamped in-bounds at EOF"
3641        );
3642        assert_cursor_in_viewport(&s, "insert at eof");
3643    }
3644
3645    #[test]
3646    fn count_prefix_then_sequence_repeats() {
3647        // `2` then `gj` (→ move-down) repeats the resolved action twice.
3648        let mut s = new_state_with("a\nb\nc\nd\ne");
3649        s.keymap.bind_sequence(
3650            Mode::Normal,
3651            vec![Key::Char('g'), Key::Char('j')],
3652            Action::Move(Motion::Down),
3653            "gj",
3654        );
3655        s.on_key(&Key::Char('2'));
3656        s.on_key(&Key::Char('g'));
3657        s.on_key(&Key::Char('j'));
3658        assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
3659    }
3660
3661    // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
3662
3663    #[test]
3664    fn held_key_repeat_storm_is_debounced_in_normal_mode() {
3665        // The audit's exact complaint: holding `j` floods motion events
3666        // and thrashes the viewport. Simulate an OS key-repeat storm — 20
3667        // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
3668        // — and assert only the gated subset (one per 80ms window) actually
3669        // moves the cursor.
3670        let mut s = new_state_with(&"x\n".repeat(40));
3671        let t0 = std::time::Instant::now();
3672        let mut delivered = 0u32;
3673        for i in 0..20u32 {
3674            let before = s.cursor().line;
3675            s.tick_at(
3676                &press(KeyCode::Char('j')),
3677                t0 + std::time::Duration::from_millis(u64::from(i) * 50),
3678            );
3679            if s.cursor().line != before {
3680                delivered += 1;
3681            }
3682        }
3683        // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
3684        // fewer than the 20 the ungated path would have applied.
3685        assert!(
3686            (10..=14).contains(&delivered),
3687            "expected the storm debounced to ~13 moves, got {delivered}",
3688        );
3689        assert!(
3690            delivered < 20,
3691            "the gate must drop SOME storm ticks, not pass all 20",
3692        );
3693    }
3694
3695    #[test]
3696    fn spaced_intentional_taps_all_pass() {
3697        // Intentional taps spaced past the debounce window must ALL reach
3698        // the editor — the gate filters storms, never deliberate input.
3699        let mut s = new_state_with(&"x\n".repeat(10));
3700        let t0 = std::time::Instant::now();
3701        for i in 0..5u32 {
3702            s.tick_at(
3703                &press(KeyCode::Char('j')),
3704                // 100ms apart — comfortably past the 80ms window.
3705                t0 + std::time::Duration::from_millis(u64::from(i) * 100),
3706            );
3707        }
3708        assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
3709    }
3710
3711    #[test]
3712    fn distinct_keys_have_independent_clocks() {
3713        // Holding `j` must not block a simultaneous `l` — the gate keys on
3714        // the Key, so independent keys have independent windows.
3715        let mut s = new_state_with("abc\ndef\nghi");
3716        let t = std::time::Instant::now();
3717        s.tick_at(&press(KeyCode::Char('j')), t);
3718        // `j` again within the window is dropped…
3719        s.tick_at(
3720            &press(KeyCode::Char('j')),
3721            t + std::time::Duration::from_millis(10),
3722        );
3723        assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
3724        // …but `l` at the same instant passes (its own clock).
3725        s.tick_at(
3726            &press(KeyCode::Char('l')),
3727            t + std::time::Duration::from_millis(10),
3728        );
3729        assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
3730    }
3731
3732    // ── Cursors newtype is the single cursor home ──────────────────────
3733
3734    #[test]
3735    fn cursor_home_preserves_single_cursor_behavior() {
3736        // The typed `Cursors` wrapper behaves exactly like the old bare
3737        // `Position` field for single-cursor editing: the read accessor
3738        // tracks every mutation routed through `set_cursor`, and there is
3739        // exactly one caret.
3740        let mut s = new_state_with("hello\nworld\nthere");
3741        assert_eq!(s.cursor(), Position::ZERO);
3742        assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
3743
3744        s.apply_motion(Motion::Down);
3745        s.apply_motion(Motion::Right);
3746        s.apply_motion(Motion::Right);
3747        assert_eq!(s.cursor(), Position::new(1, 2));
3748        // Still a single caret after a sequence of motions.
3749        assert_eq!(s.cursors.count(), 1);
3750
3751        // The accessor is the SAME value the viewport-follow path read.
3752        let w = s.layout.active_window().unwrap();
3753        assert!(w.viewport.top_line <= s.cursor().line);
3754    }
3755
3756    #[test]
3757    fn insert_mode_is_ungated_so_repeat_typing_works() {
3758        // Holding a key to repeat-type a character is intended in Insert
3759        // mode — the gate must NOT suppress it. 10 rapid identical `x`
3760        // keystrokes at the same instant must all land as text.
3761        let mut s = new_state_with("");
3762        s.tick(&press(KeyCode::Char('i')));
3763        assert_eq!(s.modal.mode(), Mode::Insert);
3764        let t = std::time::Instant::now();
3765        for _ in 0..10 {
3766            s.tick_at(&press(KeyCode::Char('x')), t);
3767        }
3768        assert_eq!(
3769            s.buffers.get(s.active).unwrap().to_string(),
3770            "xxxxxxxxxx",
3771            "insert-mode repeat typing is ungated",
3772        );
3773    }
3774}