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.layout.active_window_mut() {
503            w.buffer_id = self.active;
504        }
505    }
506
507    /// Move to the next or previous buffer, wrapping.
508    fn cycle_buffer(&mut self, forward: bool) {
509        let ids = self.buffers.ids();
510        if ids.len() < 2 {
511            self.messages.push("only one buffer".to_string());
512            return;
513        }
514        let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
515        let next = if forward {
516            (at + 1) % ids.len()
517        } else {
518            (at + ids.len() - 1) % ids.len()
519        };
520        self.active = ids[next];
521        self.set_cursor(Position::ZERO);
522        if let Some(w) = self.layout.active_window_mut() {
523            w.buffer_id = self.active;
524        }
525    }
526
527    /// Re-clamp the cursor and re-contain the viewport after a buffer
528    /// mutation.
529    ///
530    /// An undo can SHRINK the buffer under a cursor that was legal a moment
531    /// ago, leaving it out of bounds and its viewport scrolled past the end.
532    /// The Action executor has always done this (`self.set_cursor(self.cursor())`
533    /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
534    /// and `:undo` did not — two implementations of one operation, already
535    /// drifted within one milestone of being written. Naming it once is the
536    /// fix; lowering the Action arms onto the same slips is what keeps it
537    /// fixed.
538    fn refollow(&mut self) {
539        self.set_cursor(self.cursor());
540    }
541
542    /// Apply one slip and record what it damaged.
543    ///
544    /// The bookkeeping wrapper. The Action executor calls
545    /// [`honour_one`](Self::honour_one) directly because it does its own,
546    /// wider bookkeeping (the dot register, the S3 damage seal) around a
547    /// whole action.
548    fn honour(&mut self, slip: Negai) {
549        let touches_text = slip.touches_text();
550        self.honour_one(slip);
551        self.damage = self.damage.join(if touches_text {
552            Damage::Full
553        } else {
554            Damage::Viewport
555        });
556        self.bump_gen();
557    }
558
559    /// Apply one slip. THE single implementation of every mutation a slip
560    /// can ask for.
561    ///
562    /// Total over `Negai`: a new request variant is a compile error here
563    /// rather than a request silently ignored — the same failure Phase 0
564    /// removed one layer up.
565    fn honour_one(&mut self, slip: Negai) {
566        match slip {
567            Negai::Edit { buffer, edit } => {
568                if let Some(b) = self.buffers.get_mut(buffer) {
569                    let _ = b.apply(&edit);
570                }
571                self.refollow();
572            }
573            Negai::SetCursor { buffer, to } => {
574                // Clamping is the interpreter's job, exactly so that no
575                // handler has to re-implement it and get it wrong.
576                let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
577                self.set_cursor(clamped);
578            }
579            Negai::EnterMode(m) => self.modal.enter(m),
580            Negai::OpenPicker(source) => self.open_picker(source),
581            Negai::SplitWindow { stacked } => {
582                let axis = if stacked {
583                    escriba_ui::shikiri::Axis::Stacked
584                } else {
585                    escriba_ui::shikiri::Axis::SideBySide
586                };
587                self.layout.split_active(axis);
588                // The new pane is narrower/shorter than the old one, so the
589                // cursor can now be outside it. Every face re-reports its
590                // frame on the next draw, but the invariant must hold NOW —
591                // an operator who splits and immediately types should not be
592                // editing off-screen.
593                self.refollow_cursor();
594                self.damage = self.damage.join(Damage::Viewport);
595            }
596            Negai::CloseWindow => {
597                let id = self.layout.active();
598                if self.layout.close(id) {
599                    self.refollow_cursor();
600                    self.damage = self.damage.join(Damage::Viewport);
601                } else {
602                    // vim's E444, and the same refusal: the last window is
603                    // the editor. Closing it would mean "quit", which is a
604                    // different verb the operator did not type.
605                    self.messages
606                        .push("E444: Cannot close last window".to_string());
607                }
608            }
609            Negai::FocusDir { dx, dy } => {
610                use escriba_ui::Dir;
611                let dir = match (dx, dy) {
612                    (d, _) if d < 0 => Dir::Left,
613                    (d, _) if d > 0 => Dir::Right,
614                    (_, d) if d < 0 => Dir::Up,
615                    _ => Dir::Down,
616                };
617                if let Some(id) = self.layout.neighbour(dir) {
618                    self.layout.focus(id);
619                    // The window we moved to has its OWN buffer; the editor's
620                    // active buffer follows focus, or the next keystroke
621                    // would edit the file we just navigated away from.
622                    if let Some(w) = self.layout.active_window() {
623                        self.active = w.buffer_id;
624                    }
625                    self.refollow_cursor();
626                    self.damage = self.damage.join(Damage::Viewport);
627                }
628                // No neighbour is not an error — it is the edge of the
629                // layout, and vim says nothing there either.
630            }
631            Negai::GrepProject { pattern } => self.grep_project(&pattern),
632            Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
633            Negai::FocusBuffer(id) => {
634                if self.buffers.get(id).is_some() {
635                    self.active = id;
636                }
637            }
638            Negai::OpenPath(path) => match self.buffers.open(&path) {
639                Ok(id) => self.active = id,
640                Err(e) => self.messages.push(e.to_string()),
641            },
642            Negai::CloseBuffer(id) => self.close_buffer(id),
643            Negai::Save { buffer } => {
644                if let Some(b) = self.buffers.get_mut(buffer) {
645                    if let Err(e) = b.save() {
646                        self.messages.push(e.to_string());
647                    }
648                }
649                self.refollow();
650            }
651            Negai::Undo { buffer } => {
652                if let Some(b) = self.buffers.get_mut(buffer) {
653                    let _ = b.undo();
654                }
655                self.refollow();
656            }
657            Negai::Redo { buffer } => {
658                if let Some(b) = self.buffers.get_mut(buffer) {
659                    let _ = b.redo();
660                }
661                self.refollow();
662            }
663            Negai::Yank { text, .. } => self.register = Some(text),
664            Negai::ClearSearchHighlight => self.search.clear_highlight(),
665            Negai::SetOption { name, value } => {
666                self.options.insert(name, value);
667            }
668            Negai::InsertText(text) => self.insert_text(&text),
669            Negai::RunCommand { name, args } => self.run_command(&name, &args),
670            Negai::PublishFindings { list, findings } => {
671                let world = self.world();
672                self.results
673                    .publish(list, escriba_shirube::ResultList::new(findings, world));
674            }
675            Negai::WalkList { list, forward } => self.walk_list(&list, forward),
676            Negai::Message(m) => self.messages.push(m),
677            Negai::Quit => self.quit_requested = true,
678            // Both suspend the dispatch and need machinery that does not
679            // exist yet — the courier (Phase 5) and the AwaitKey resume
680            // (M3). Announced, never silently dropped: a slip that vanishes
681            // is the class Phase 0 sealed.
682            Negai::Errand(_) | Negai::AwaitKey { .. } => {
683                self.messages
684                    .push("deferred work is not wired yet".to_string());
685            }
686        }
687    }
688}
689
690/// Outcome of feeding one key to the multi-key pending-stroke loop.
691enum SeqStep {
692    /// Key consumed into an in-progress sequence; wait for the next.
693    Pending,
694    /// A full bound sequence resolved — run this action.
695    Resolved(Action),
696    /// Key is not part of any sequence; hand it to single-key dispatch.
697    Passthrough,
698}
699
700/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
701/// therefore exists to debounce.
702///
703/// This is an ALLOW-LIST, and it used to be the complement — an exception list
704/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
705/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
706/// swallowed and someone noticed. The third growth is the signal that the
707/// default was backwards: almost every key in a modal editor is a discrete,
708/// deliberate press, and only a handful are ones you HOLD.
709///
710/// Inverting it makes the failure mode safe. Forgetting to list a key here now
711/// means it is ungated — one extra keypress honoured — instead of silently
712/// dropped, and a dropped key is indistinguishable from a dead one.
713///
714/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
715/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
716/// is keyed by KEY and the two presses fell inside one debounce window.
717const fn is_repeat_storm_candidate(key: &Key) -> bool {
718    matches!(
719        key,
720        // The four navigation keys a user actually holds down. `h`/`l` and
721        // `j`/`k` flood the motion path and thrash the viewport; everything
722        // else is pressed once and meant once.
723        Key::Char('h')
724            | Key::Char('j')
725            | Key::Char('k')
726            | Key::Char('l')
727            | Key::Left
728            | Key::Right
729            | Key::Up
730            | Key::Down
731    )
732}
733
734/// Turn a command failure into the sentence an operator should read.
735///
736/// The two failures mean genuinely different things and must not be reported
737/// the same way:
738///
739/// - `:flurb` — the operator typed a name that does not exist. "command not
740///   found" is exactly right; it says *you* made a typo.
741/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
742///   declares this, `--list-rc` counts it, and it is not built yet. Telling
743///   the operator "command not found" blames them for a gap we shipped.
744///
745/// The discriminator is the dotted form. `:action` takes action SYMBOLS
746/// (`picker.files`), never command names — that boundary is already pinned by
747/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
748/// dotted name that reached dispatch and resolved to nothing is a declared
749/// capability with no implementation, which is precisely what the 85 entries
750/// in `escriba/tests/action_resolution.rs` are.
751fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
752    use escriba_command::CommandError as E;
753    match e {
754        // Already the right words — the registry knew it was declared.
755        E::Unhandled(_) => e.to_string(),
756        E::NotFound(n) if n.contains('.') => {
757            let mut m = String::with_capacity(n.len() + 48);
758            m.push('`');
759            m.push_str(n);
760            m.push_str("` is declared but not implemented yet");
761            m
762        }
763        _ => {
764            let _ = name;
765            e.to_string()
766        }
767    }
768}
769
770/// What committing the open search prompt did.
771///
772/// The two commit paths — bare `/` and operated `d/` — used to own private
773/// copies of the whole sequence (read origin+skip, `accept`, three-arm
774/// match, `commit_step_skipping`), and they drifted: the operated one
775/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
776/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
777///
778/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
779/// a compile error in two places rather than a case one path quietly
780/// forgets. It does not make divergence impossible — the two paths
781/// genuinely differ at the landing step — it makes FORGETTING A CASE
782/// impossible, which is the failure that actually happened.
783enum CommitOutcome {
784    /// The prompt committed and a match was found.
785    Landed {
786        origin: usize,
787        step: escriba_search::Step,
788    },
789    /// Committed, but nothing matched. E486 already reported.
790    NotFound,
791    /// Nothing typed and no previous pattern. E35 already reported.
792    NoPrevious,
793    /// No prompt was open.
794    NoPrompt,
795}
796
797/// A replayable text change.
798#[derive(Debug, Clone)]
799struct LastChange {
800    /// The action that began the change.
801    action: Action,
802    /// How many times it ran.
803    count: u32,
804    /// Characters typed while the change held Insert mode open.
805    inserted: String,
806}
807
808impl EditorState {
809    /// Build a fresh editor with one buffer (scratch or file-backed).
810    pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
811        let window = Window {
812            id: WindowId(1),
813            buffer_id: active,
814            viewport: Viewport {
815                top_line: 0,
816                left_column: 0,
817                visible_lines: 40,
818                visible_columns: 160,
819            },
820        };
821        Self {
822            buffers: initial,
823            modal: ModalState::new(),
824            search: SearchState::new(escriba_search::CaseMode::Smart),
825            search_at: None,
826            last_change: None,
827            recording_insert: false,
828            jumps: JumpList::new(),
829            keymap: Keymap::default_vim(),
830            commands: CommandRegistry::default_set(),
831            layout: Layout::single(window),
832            active,
833            cursors: Cursors::single(Position::ZERO),
834            quit_requested: false,
835            register: None,
836            op_pending: zenmai::Stateful::new(OpState::Resting),
837            messages: Vec::new(),
838            options: HashMap::new(),
839            lisp_vm: None,
840            pending_keys: Vec::new(),
841            repeat_gate: KeyRepeatGate::new(),
842            plugin_host: PluginHost::default(),
843            edit_gen: EditGen::default(),
844            damage: Damage::None,
845            // The FLEET default until an rc says otherwise — never a
846            // hand-written theme name, so a fleet re-point lands for free.
847            dispatch_depth: 0,
848            filetypes: escriba_core::FiletypeTable::new(),
849            results: escriba_shirube::ListRegistry::new(),
850            picker: None,
851            index_rev: escriba_shirube::IndexRev::default(),
852            session_gen: escriba_shirube::SessionGen::default(),
853            theme: FleetTheme::prescribed_default(),
854            chrome: ChromePalette::prescribed(),
855            splash: None,
856        }
857    }
858
859    /// The theme this editor is set to.
860    #[must_use]
861    pub const fn theme(&self) -> FleetTheme {
862        self.theme
863    }
864
865    /// The colours every face paints with — read once per frame.
866    #[must_use]
867    pub const fn chrome(&self) -> ChromePalette {
868        self.chrome
869    }
870
871    /// Point the editor at a theme. The wiring that makes
872    /// `(deftheme :preset …)` real.
873    ///
874    /// Bumps the refresh generation, because a theme change repaints
875    /// everything: the GPU face caches its shaped buffer against that
876    /// generation and would otherwise keep the old colours until an
877    /// unrelated edit happened to invalidate it.
878    pub fn set_theme(&mut self, theme: FleetTheme) {
879        if self.theme == theme {
880            return;
881        }
882        self.theme = theme;
883        self.chrome = ChromePalette::for_theme(theme);
884        self.damage = self.damage.join(Damage::Viewport);
885        self.bump_gen();
886    }
887
888    /// The start screen, if one is up. Renderers paint this INSTEAD of the
889    /// buffer pane; `None` is the ordinary editor.
890    #[must_use]
891    pub fn splash(&self) -> Option<&Splash> {
892        self.splash.as_ref()
893    }
894
895    /// Raise the start screen. The binary calls this at boot when no file
896    /// was named; an empty splash is refused so a face never has to render
897    /// a blank screen over a perfectly good buffer.
898    pub fn set_splash(&mut self, splash: Splash) {
899        if splash.is_empty() {
900            return;
901        }
902        self.splash = Some(splash);
903        self.damage = self.damage.join(Damage::Viewport);
904        self.bump_gen();
905    }
906
907    /// Take the start screen down. Idempotent; bumps the refresh generation
908    /// only when something actually changed, so dismissing twice does not
909    /// cost a repaint.
910    pub fn dismiss_splash(&mut self) {
911        if self.splash.take().is_some() {
912            self.damage = self.damage.join(Damage::Viewport);
913            self.bump_gen();
914        }
915    }
916
917    /// Offer `key` to the start screen.
918    ///
919    /// A menu key runs its entry; ANY other key simply takes the screen
920    /// down and is then handled normally — so the first thing an operator
921    /// types is never swallowed.
922    /// The open picker, for a face to paint.
923    #[must_use]
924    pub fn picker(&self) -> Option<&escriba_ui::picker::Picker> {
925        self.picker.as_ref()
926    }
927
928    /// Give an open picker the key.
929    ///
930    /// Runs BEFORE the keymap, and before the sequence stepper: while a
931    /// picker is up it owns every key, including ones it has no meaning for.
932    /// An overlay that let unknown keys fall through would edit the file
933    /// behind itself.
934    fn consume_picker_key(&mut self, key: &Key) -> escriba_ui::picker::Consumed {
935        use escriba_ui::picker::Consumed;
936        let Some(p) = self.picker.as_mut() else {
937            return Consumed::NotShowing;
938        };
939        let outcome = p.on_key(key);
940        match &outcome {
941            Consumed::Dismissed | Consumed::Chose(_) => {
942                self.picker = None;
943                self.bump_gen();
944            }
945            Consumed::Held => self.bump_gen(),
946            Consumed::NotShowing => {}
947        }
948        outcome
949    }
950
951    /// Lower an accepted pick into the ONE interpreter.
952    ///
953    /// The whole reason `Choice` is a closed enum: a new source must decide
954    /// here, and the compiler says so.
955    fn honour_choice(&mut self, choice: escriba_ui::picker::Choice) {
956        use escriba_ui::picker::Choice;
957        let slip = match choice {
958            Choice::Buffer(id) => Negai::FocusBuffer(id),
959            Choice::Command(name) => Negai::RunCommand {
960                name,
961                args: Vec::new(),
962            },
963            Choice::OpenFile(path) => Negai::OpenPath(path),
964            Choice::Location { path, line } => {
965                // Open FIRST, then jump: the buffer may not exist yet, and
966                // `jump_to_site` needs a BufferId. Two slips, one interpret.
967                self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
968                let site = escriba_shirube::Site::in_buffer(
969                    self.active,
970                    escriba_core::Range::new(
971                        escriba_core::Position::new(line, 0),
972                        escriba_core::Position::new(line, 1),
973                    ),
974                );
975                self.jump_to_site(&site);
976                return;
977            }
978        };
979        self.interpret(Outcome::did(vec![slip]));
980    }
981
982    /// How many files a project grep will read, and how many hits it keeps.
983    ///
984    /// BOUNDED, and the bound is here rather than hidden, because this is a
985    /// SYNCHRONOUS scan on the editor's own thread. The interpreter already
986    /// does synchronous filesystem I/O (`OpenPath`, `Save`), so the posture
987    /// is not new — but those touch one file and this walks a tree, which is
988    /// the first one big enough to freeze the editor.
989    ///
990    /// The DESTINATION is the courier: an errand that scans off-thread and
991    /// delivers results as they arrive, with no ceiling at all. This bound is
992    /// the interim that ships a working grep meanwhile, and it is a real
993    /// limit — a match past the ceiling is NOT found, and the picker says so
994    /// rather than presenting a truncated list as complete.
995    const GREP_FILE_LIMIT: usize = 2_000;
996    const GREP_HIT_LIMIT: usize = 500;
997
998    /// Walk the working directory, bounded, returning `(files, truncated)`.
999    ///
1000    /// ONE walker. grep, files and project each need to enumerate the tree,
1001    /// and three copies of a bounded traversal is three places to get the
1002    /// ceiling, the skip-list, or the truncation report subtly different.
1003    ///
1004    /// Skips dotfiles, `target` and `node_modules`. That is NOT a gitignore
1005    /// implementation and does not pretend to be — a real ignore crate comes
1006    /// with the courier.
1007    fn walk_project(limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1008        Self::walk_from(std::path::Path::new("."), limit)
1009    }
1010
1011    /// The same bounded walk, from an explicit root.
1012    ///
1013    /// `walk_project` is this with `.` — one traversal, two callers, rather
1014    /// than a second copy for "browse from somewhere else".
1015    fn walk_from(root: &std::path::Path, limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1016        let mut out = Vec::new();
1017        let mut truncated = false;
1018        let mut stack = vec![root.to_path_buf()];
1019        while let Some(dir) = stack.pop() {
1020            let Ok(entries) = std::fs::read_dir(&dir) else {
1021                continue;
1022            };
1023            for entry in entries.flatten() {
1024                let name = entry.file_name();
1025                let name = name.to_string_lossy();
1026                if name.starts_with('.') || name == "target" || name == "node_modules" {
1027                    continue;
1028                }
1029                let path = entry.path();
1030                if entry.file_type().is_ok_and(|t| t.is_dir()) {
1031                    stack.push(path);
1032                    continue;
1033                }
1034                if out.len() >= limit {
1035                    truncated = true;
1036                    return (out, truncated);
1037                }
1038                out.push(path);
1039            }
1040        }
1041        (out, truncated)
1042    }
1043
1044    /// Say plainly when a bounded scan stopped short.
1045    ///
1046    /// A truncated list presented as complete is the failure this codebase
1047    /// keeps finding in itself; it does not get to ship one.
1048    fn report_truncation(&mut self, truncated: bool) {
1049        if truncated {
1050            self.messages
1051                .push("scan stopped at the limit — results are INCOMPLETE".to_string());
1052        }
1053    }
1054
1055    /// Scan the working directory for `pattern`, bounded.
1056    fn grep_project(&mut self, pattern: &str) {
1057        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1058        if pattern.is_empty() {
1059            self.messages.push("grep: empty pattern".to_string());
1060            return;
1061        }
1062        let (files, mut truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1063        let mut items: Vec<PickerItem<Choice>> = Vec::new();
1064        'outer: for path in files {
1065            let Ok(text) = std::fs::read_to_string(&path) else {
1066                continue; // binary or unreadable — not an error worth reporting
1067            };
1068            for (n, line) in text.lines().enumerate() {
1069                if !line.contains(pattern) {
1070                    continue;
1071                }
1072                if items.len() >= Self::GREP_HIT_LIMIT {
1073                    truncated = true;
1074                    break 'outer;
1075                }
1076                let Ok(n) = u32::try_from(n) else { break };
1077                let mut label = String::with_capacity(80);
1078                label.push_str(&path.to_string_lossy());
1079                label.push(':');
1080                label.push_str(&(n + 1).to_string());
1081                label.push_str("  ");
1082                label.push_str(line.trim());
1083                items.push(PickerItem::new(
1084                    Choice::Location {
1085                        path: path.clone(),
1086                        line: n,
1087                    },
1088                    label,
1089                ));
1090            }
1091        }
1092        if items.is_empty() {
1093            let mut m = String::from("grep: no matches for ");
1094            m.push_str(pattern);
1095            self.messages.push(m);
1096            return;
1097        }
1098        if truncated {
1099            // Stated, never silent. A truncated list presented as complete is
1100            // the failure this whole codebase keeps finding.
1101            self.messages
1102                .push("grep: stopped at the scan limit — results are INCOMPLETE".to_string());
1103        }
1104        self.picker = Some(Picker::open(Source::Grep, items));
1105        self.bump_gen();
1106    }
1107
1108    /// Where accepting a finding should take the operator.
1109    ///
1110    /// Returns `None` for a finding whose site names neither a path nor a
1111    /// live buffer — that is a finding about nowhere, and offering it would
1112    /// give the operator a row that does nothing when pressed.
1113    fn finding_choice(&self, f: &escriba_shirube::Finding) -> Option<escriba_ui::picker::Choice> {
1114        use escriba_ui::picker::Choice;
1115        let line = f.site.range.start.line;
1116        if let Some(p) = &f.site.path {
1117            return Some(Choice::Location {
1118                path: p.clone(),
1119                line,
1120            });
1121        }
1122        let id = f.site.buffer?;
1123        let b = self.buffers.get(id)?;
1124        // A buffer WITH a path becomes a Location, so the line survives. A
1125        // scratch buffer has no path to name, so the best available answer
1126        // is the buffer itself — and the line is lost. Stated rather than
1127        // hidden: a `Choice` that carried a BufferId AND a line would be
1128        // the fix, and it belongs with the picker, not here.
1129        b.path.as_ref().map_or(Some(Choice::Buffer(id)), |p| {
1130            Some(Choice::Location {
1131                path: p.clone(),
1132                line,
1133            })
1134        })
1135    }
1136
1137    /// How a finding's location reads in a list row.
1138    fn finding_label(&self, f: &escriba_shirube::Finding) -> String {
1139        if let Some(p) = &f.site.path {
1140            return p.to_string_lossy().into_owned();
1141        }
1142        f.site
1143            .buffer
1144            .and_then(|id| self.buffers.get(id))
1145            .and_then(|b| b.path.as_ref())
1146            .map_or_else(
1147                || String::from("[scratch]"),
1148                |p| p.to_string_lossy().into_owned(),
1149            )
1150    }
1151
1152    /// Picker rows for every file under `root`.
1153    ///
1154    /// `picker.files` and `files.open-parent` differ only in the root, so
1155    /// they share this rather than carrying two copies of the same body —
1156    /// which is what they did for one commit, and what the line-count lint
1157    /// correctly complained about.
1158    fn file_items(
1159        &mut self,
1160        root: &std::path::Path,
1161    ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1162        use escriba_ui::picker::{Choice, PickerItem};
1163        let (files, truncated) = Self::walk_from(root, Self::GREP_FILE_LIMIT);
1164        self.report_truncation(truncated);
1165        files
1166            .into_iter()
1167            .map(|p| {
1168                let label = p.to_string_lossy().into_owned();
1169                PickerItem::new(Choice::OpenFile(p), label)
1170            })
1171            .collect()
1172    }
1173
1174    /// Picker rows for the located findings the `trouble.*` verbs show.
1175    ///
1176    /// Freshness is asked of the registry, not assumed: `fresh` filters
1177    /// against the CURRENT world, so a list anchored to a revision the
1178    /// buffer has moved past contributes nothing rather than offering a
1179    /// line that has since shifted.
1180    fn finding_items(
1181        &self,
1182        workspace: bool,
1183    ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1184        use escriba_ui::picker::PickerItem;
1185        let world = self.world();
1186        let active = Some(self.active);
1187        let mut items = Vec::new();
1188        for name in self.results.names() {
1189            let Some(list) = self.results.get(name) else {
1190                continue;
1191            };
1192            for f in list.fresh(&world) {
1193                // `trouble.document` narrows to the buffer in front of the
1194                // operator. A finding that names only a path is
1195                // workspace-scoped by construction — it has no buffer to be
1196                // "this" one.
1197                if !workspace && f.site.buffer != active {
1198                    continue;
1199                }
1200                let Some(choice) = self.finding_choice(f) else {
1201                    continue;
1202                };
1203                let line = f.site.range.start.line;
1204                let mut label = String::with_capacity(64);
1205                label.push_str(f.severity.label());
1206                label.push_str("  ");
1207                label.push_str(&self.finding_label(f));
1208                label.push(':');
1209                label.push_str(&(line + 1).to_string());
1210                label.push_str("  ");
1211                label.push_str(&f.message);
1212                items.push(PickerItem::new(choice, label));
1213            }
1214        }
1215        items
1216    }
1217
1218    /// Build and open a picker over `source`.
1219    fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
1220        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1221        let (src, items) = match source {
1222            escriba_madoguchi::PickerSource::Buffers => (
1223                Source::Buffers,
1224                self.buffers
1225                    .ids()
1226                    .into_iter()
1227                    .filter_map(|id| {
1228                        let b = self.buffers.get(id)?;
1229                        let label = b.path.as_ref().map_or_else(
1230                            || String::from("[scratch]"),
1231                            |p| p.to_string_lossy().into_owned(),
1232                        );
1233                        Some(PickerItem::new(Choice::Buffer(id), label))
1234                    })
1235                    .collect::<Vec<_>>(),
1236            ),
1237            escriba_madoguchi::PickerSource::Help => (
1238                Source::Help,
1239                self.keymap
1240                    .entries_sorted()
1241                    .into_iter()
1242                    .map(|(mode, key, b)| {
1243                        // "NORMAL  gd   goto definition" — searchable by key,
1244                        // by mode, or by what it does, because a reader
1245                        // arrives from any of the three.
1246                        let mut label = String::with_capacity(48);
1247                        label.push_str(mode.as_str());
1248                        label.push_str("  ");
1249                        // `{key:?}` because there is no shared key FORMATTER
1250                        // in the fleet — awase owns the chord vocabulary but
1251                        // escriba-keymap's `Key` has no Display. That gap
1252                        // belongs to the keymap consolidation, not here, and
1253                        // inventing a fourth spelling would make it worse.
1254                        label.push_str(&format!("{key:?}"));
1255                        label.push_str("  ");
1256                        label.push_str(&b.description);
1257                        // Accepting runs the binding's action if it names a
1258                        // command; a typed Action has no name to run, so it
1259                        // reports rather than pretending.
1260                        let choice = match &b.action {
1261                            escriba_core::Action::Command { name, .. } => {
1262                                Choice::Command(name.clone())
1263                            }
1264                            other => Choice::Command(format!("{other:?}")),
1265                        };
1266                        PickerItem::new(choice, label)
1267                    })
1268                    .collect::<Vec<_>>(),
1269            ),
1270            escriba_madoguchi::PickerSource::Files => {
1271                (Source::Files, self.file_items(std::path::Path::new(".")))
1272            }
1273            escriba_madoguchi::PickerSource::Project => {
1274                // A project root is a directory carrying a marker. Derived
1275                // from the SAME walk rather than a second traversal — the
1276                // markers are files, so the walker already visited them.
1277                const MARKERS: &[&str] = &[
1278                    "Cargo.toml",
1279                    "flake.nix",
1280                    "package.json",
1281                    "go.mod",
1282                    "pyproject.toml",
1283                ];
1284                let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1285                self.report_truncation(truncated);
1286                let mut roots: Vec<std::path::PathBuf> = files
1287                    .into_iter()
1288                    .filter(|p| {
1289                        p.file_name()
1290                            .is_some_and(|n| MARKERS.contains(&n.to_string_lossy().as_ref()))
1291                    })
1292                    .filter_map(|p| p.parent().map(std::path::Path::to_path_buf))
1293                    .collect();
1294                roots.sort();
1295                roots.dedup();
1296                (
1297                    Source::Project,
1298                    roots
1299                        .into_iter()
1300                        .map(|p| {
1301                            let label = p.to_string_lossy().into_owned();
1302                            PickerItem::new(Choice::OpenFile(p), label)
1303                        })
1304                        .collect::<Vec<_>>(),
1305                )
1306            }
1307            escriba_madoguchi::PickerSource::Commands => (
1308                Source::Commands,
1309                self.commands
1310                    .names()
1311                    .into_iter()
1312                    .map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
1313                    .collect::<Vec<_>>(),
1314            ),
1315            escriba_madoguchi::PickerSource::FilesUnder(root) => {
1316                (Source::Files, self.file_items(&root))
1317            }
1318            escriba_madoguchi::PickerSource::Findings { workspace } => {
1319                (Source::Findings, self.finding_items(workspace))
1320            }
1321        };
1322        if items.is_empty() {
1323            self.messages.push("nothing to pick from".to_string());
1324            return;
1325        }
1326        self.picker = Some(Picker::open(src, items));
1327        self.bump_gen();
1328    }
1329
1330    fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
1331        let Some(splash) = self.splash.as_ref() else {
1332            return SplashKey::NotShowing;
1333        };
1334        let chosen = match key {
1335            Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
1336            _ => None,
1337        };
1338        self.dismiss_splash();
1339        chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
1340    }
1341
1342    /// The current refresh generation. A renderer caches its products against
1343    /// this; equality is the freshness test (an unchanged generation ⇒ the
1344    /// last frame is still valid, so skip the re-highlight + re-shape).
1345    #[must_use]
1346    pub fn edit_gen(&self) -> EditGen {
1347        self.edit_gen
1348    }
1349
1350    /// Advance the refresh generation (a mutation happened).
1351    fn bump_gen(&mut self) {
1352        self.edit_gen = self.edit_gen.next();
1353    }
1354
1355    /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
1356    #[must_use]
1357    pub fn damage(&self) -> Damage {
1358        self.damage
1359    }
1360
1361    /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
1362    /// renderer calls this once per frame to learn what to repaint, then the
1363    /// accumulator restarts — so damage never double-counts across frames.
1364    pub fn take_damage(&mut self) -> Damage {
1365        std::mem::replace(&mut self.damage, Damage::None)
1366    }
1367
1368    /// The line count of the active buffer (0 if none) — used to compute the
1369    /// [`Damage`] scope of a mutation.
1370    fn active_line_count(&self) -> u32 {
1371        self.buffers
1372            .get(self.active)
1373            .map_or(0, escriba_buffer::Buffer::line_count)
1374    }
1375
1376    /// Register a lazy USER plugin: its escriba entry is deferred until
1377    /// one of its `triggers` fires. Bundled defaults do NOT go through
1378    /// here — they are applied eagerly at boot. Empty `triggers` means
1379    /// the plugin never lazily activates (the binary applies eager
1380    /// plugins directly).
1381    pub fn register_lazy_plugin(
1382        &mut self,
1383        name: impl Into<String>,
1384        triggers: Vec<LazyTrigger>,
1385        entry_src: impl Into<String>,
1386    ) {
1387        self.plugin_host.register(name, triggers, entry_src);
1388    }
1389
1390    /// Apply a plugin entry's escriba-lisp to live state — the same
1391    /// keymap / command / option apply paths a user rc uses. Options are
1392    /// applied before keybinds so a plugin that sets `mapleader` resolves
1393    /// `<leader>` correctly. Returns the count of commands + keybinds it
1394    /// registered (best-effort; a malformed entry is skipped, not fatal).
1395    fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
1396        let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
1397            return 0;
1398        };
1399        let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
1400        escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
1401        if let Some(value) = self.options.get("mapleader") {
1402            if let Some(key) = escriba_lisp::parse_leader_key(value) {
1403                self.keymap.set_leader(key);
1404            }
1405        }
1406        let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
1407        (cmd.registered + km.keybinds_applied) as usize
1408    }
1409
1410    /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
1411    /// Returns the number of plugins activated. Call when a buffer of a
1412    /// known filetype is opened.
1413    pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
1414        let pending = self.plugin_host.pending_for_filetype(filetype);
1415        let n = pending.len();
1416        for src in pending {
1417            self.apply_plugin_entry(&src);
1418        }
1419        n
1420    }
1421
1422    /// Fire any lazy plugin gated on an `Event` trigger for `event`.
1423    /// Returns the number of plugins activated.
1424    pub fn activate_event_plugins(&mut self, event: &str) -> usize {
1425        let pending = self.plugin_host.pending_for_event(event);
1426        let n = pending.len();
1427        for src in pending {
1428            self.apply_plugin_entry(&src);
1429        }
1430        n
1431    }
1432
1433    /// Advance one frame's worth of state given a raw madori event.
1434    ///
1435    /// Key events pass through the [`KeyRepeatGate`] first (see
1436    /// [`Self::tick_at`]); everything else is handled directly.
1437    pub fn tick(&mut self, event: &AppEvent) {
1438        self.tick_at(event, Instant::now());
1439    }
1440
1441    /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
1442    /// lets tests drive the debounce window without depending on the
1443    /// wall clock.
1444    pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
1445        match translate_app_event(event) {
1446            InputOutcome::Key(k) => {
1447                if self.gate_key(&k, now) {
1448                    self.on_key(&k);
1449                }
1450            }
1451            InputOutcome::Resized { .. } => {
1452                // Damage only. Each face owns its own geometry: the GPU
1453                // backend derives the grid in `RenderCallback::resize`, and
1454                // the ratatui face reads its area every frame. This arm used
1455                // to write `Window.rect`, which nothing ever read — so the
1456                // resize path was already doing no real work, it just looked
1457                // like it was.
1458                self.damage = self.damage.join(Damage::Viewport);
1459                self.bump_gen();
1460            }
1461            InputOutcome::Quit => self.quit_requested = true,
1462            InputOutcome::Focus(_) | InputOutcome::None => {}
1463        }
1464    }
1465
1466    /// Decide whether `key` survives the key-repeat gate at time `now`.
1467    ///
1468    /// Returns `true` when the key should be processed, `false` when it is
1469    /// an OS key-repeat storm tick that should be dropped. Gating applies
1470    /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
1471    /// are where a held `j`/`l` floods the motion path and thrashes the
1472    /// viewport. Insert and Command modes pass every key through ungated,
1473    /// because there "hold a key to repeat the character" is the intended
1474    /// behavior, not a storm to suppress.
1475    fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
1476        match self.modal.mode() {
1477            Mode::Normal | Mode::Visual | Mode::VisualLine => {
1478                // The gate exists for HELD keys that flood the motion path and
1479                // thrash the viewport (`j`, `l`). It is wrong for the discrete
1480                // jumps: two `n` presses 10 ms apart mean two matches, and
1481                // swallowing the second is indistinguishable from a dead key —
1482                // the exact symptom the gate was added to prevent elsewhere.
1483                if is_repeat_storm_candidate(key) {
1484                    return self.repeat_gate.try_pass_at(*key, now);
1485                }
1486                true
1487            }
1488            Mode::Insert | Mode::Command => true,
1489        }
1490    }
1491
1492    /// Dispatch a single key through the keymap + apply the resulting action.
1493    pub fn on_key(&mut self, key: &Key) {
1494        // An open picker owns EVERY key while it is up — before the splash,
1495        // before the sequence stepper, before the keymap.
1496        match self.consume_picker_key(key) {
1497            escriba_ui::picker::Consumed::NotShowing => {}
1498            escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
1499            escriba_ui::picker::Consumed::Chose(c) => {
1500                self.honour_choice(c);
1501                return;
1502            }
1503        }
1504        // The start screen owns the first keypress and nothing after it.
1505        match self.consume_splash_key(key) {
1506            SplashKey::NotShowing | SplashKey::Dismissed => {}
1507            SplashKey::Ran(action) => {
1508                self.apply(&action);
1509                return;
1510            }
1511        }
1512        // Multi-key sequence resolution runs first: a key that begins or
1513        // continues a bound sequence (`<leader>ff`, `gg`) is held or
1514        // resolved here before the single-key path sees it.
1515        match self.step_sequence(key) {
1516            SeqStep::Pending => return,
1517            SeqStep::Resolved(action) => {
1518                let count = self.modal.pending_count().unwrap_or(1);
1519                self.modal.clear_count();
1520                for _ in 0..count {
1521                    self.apply(&action);
1522                    if self.quit_requested {
1523                        return;
1524                    }
1525                }
1526                return;
1527            }
1528            SeqStep::Passthrough => {}
1529        }
1530        let counted = self.keymap.dispatch(&self.modal, key);
1531        // Count prefixes accumulate into modal state.
1532        if matches!(counted.action, Action::Pending) {
1533            if let Key::Char(c) = key {
1534                if c.is_ascii_digit() {
1535                    let d = u32::from(*c as u8 - b'0');
1536                    self.modal.append_count(d);
1537                }
1538            }
1539            return;
1540        }
1541        // The count flows through the operator-pending FSM (apply_counted), which
1542        // owns repetition: a bare motion runs count× , an operator captures its
1543        // count, and an operated motion multiplies the two. No naive outer loop.
1544        self.apply_counted(&counted.action, counted.count);
1545        // After applying, reset pending count.
1546        self.modal.clear_count();
1547    }
1548
1549    /// Advance the multi-key pending-stroke state machine for `key`.
1550    ///
1551    /// Sequences only apply in normal / visual modes — insert and
1552    /// command modes treat keys as literal text. Rules:
1553    /// - Mid-sequence: extend the pending prefix. Exact match →
1554    ///   [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
1555    ///   otherwise abort the sequence and re-process this key fresh.
1556    /// - Not mid-sequence: if `key` begins a bound sequence AND is not
1557    ///   itself a complete single binding (single bindings win, so no
1558    ///   chord timeout is needed) → start pending. Otherwise
1559    ///   [`SeqStep::Passthrough`] to the single-key dispatcher.
1560    fn step_sequence(&mut self, key: &Key) -> SeqStep {
1561        let mode = self.modal.mode();
1562        if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1563            return SeqStep::Passthrough;
1564        }
1565        if !self.pending_keys.is_empty() {
1566            let mut seq = self.pending_keys.clone();
1567            seq.push(key.clone());
1568            if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1569                let action = b.action.clone();
1570                self.pending_keys.clear();
1571                return SeqStep::Resolved(action);
1572            }
1573            if self.keymap.is_sequence_prefix(mode, &seq) {
1574                self.pending_keys = seq;
1575                return SeqStep::Pending;
1576            }
1577            // The key broke the in-progress sequence — abort it and let
1578            // the key be re-processed as a fresh stroke below.
1579            self.pending_keys.clear();
1580        }
1581        let start = [key.clone()];
1582        if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1583            self.pending_keys = start.to_vec();
1584            return SeqStep::Pending;
1585        }
1586        SeqStep::Passthrough
1587    }
1588
1589    /// The primary cursor position. The single read accessor — every
1590    /// renderer + motion path goes through it, so the underlying
1591    /// representation (today a single-cursor [`Cursors`]) can grow to
1592    /// multi-caret without changing read sites.
1593    #[must_use]
1594    pub fn cursor(&self) -> Position {
1595        self.cursors.primary()
1596    }
1597
1598    /// The **single** cursor-mutation path. Clamp the requested position to
1599    /// the active buffer's bounds, then scroll the active window's viewport
1600    /// to contain it on BOTH axes. Routing every cursor change through this
1601    /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1602    /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1603    /// typed home — there is no code path that advances the cursor without
1604    /// re-deriving the viewport from it, and no second `Position` field to
1605    /// fall out of sync.
1606    /// Re-assert the cursor-visibility invariant against the CURRENT
1607    /// viewport.
1608    ///
1609    /// A resize changes how much a face can show without moving the cursor,
1610    /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
1611    /// would sit off-screen until the operator happened to move it. Every
1612    /// face calls this after telling the runtime its new size.
1613    pub fn refollow_cursor(&mut self) {
1614        self.set_cursor(self.cursors.primary());
1615    }
1616
1617    fn set_cursor(&mut self, pos: Position) {
1618        let clamped = if let Some(buf) = self.buffers.get(self.active) {
1619            buf.clamp(pos)
1620        } else {
1621            pos
1622        };
1623        self.cursors.set_primary(clamped);
1624        if let Some(w) = self.layout.active_window_mut() {
1625            w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1626        }
1627    }
1628
1629    /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1630    fn apply(&mut self, action: &Action) {
1631        self.apply_counted(action, 1);
1632    }
1633
1634    /// Dispatch one resolved action with its count. Routes `(action, count)`
1635    /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1636    /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1637    /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1638    /// operator-then-motion pair is rewritten into a counted
1639    /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1640    /// composition — there is no naive outer repeat loop.
1641    fn apply_counted(&mut self, action: &Action, count: u32) {
1642        // An uncompilable pattern must not reach the operator machine.
1643        //
1644        // `SearchState::accept` puts the prompt BACK on a compile error so the
1645        // typed text is not lost — but the FSM had already transitioned out of
1646        // `AwaitingSearch` on the way in, so the prompt survived and the
1647        // OPERATOR did not, with nothing said about it. The `d` was simply
1648        // gone, and the corrected pattern then ran as a bare search.
1649        //
1650        // The machine is a pure `(State, Event) -> (State, effects)` and
1651        // cannot observe the result of an effect, so it cannot decide this
1652        // itself. The fix is to stop handing it an event it has no business
1653        // deciding: the runtime classifies the submit first, from state it
1654        // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1655        // the bare-`/<CR>` reuse path is untouched.
1656        //
1657        // Tier-honest: parse-rejected at the boundary, not
1658        // truly-unrepresentable.
1659        if matches!(action, Action::SubmitCommand) {
1660            if let Some(e) = self.search.prompt_error() {
1661                let mut m = String::from("E383: Invalid search string: ");
1662                m.push_str(&e.to_string());
1663                self.messages.push(m);
1664                return;
1665            }
1666        }
1667
1668        for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1669            for _ in 0..times {
1670                self.apply_resolved(&resolved);
1671                if self.quit_requested {
1672                    return;
1673                }
1674            }
1675        }
1676    }
1677
1678    /// The active buffer's text. Search is a pure function of it.
1679    /// The active buffer's text revision — the token an offset measured
1680    /// against it should carry.
1681    #[must_use]
1682    fn text_rev(&self) -> TextRev {
1683        self.buffers
1684            .get(self.active)
1685            .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1686    }
1687
1688    fn active_text(&self) -> String {
1689        self.buffers
1690            .get(self.active)
1691            .map(escriba_buffer::Buffer::to_string)
1692            .unwrap_or_default()
1693    }
1694
1695    /// The cursor as a char offset — the coordinate search speaks.
1696    fn cursor_char(&self) -> usize {
1697        self.buffers
1698            .get(self.active)
1699            .and_then(|b| b.position_to_char(self.cursor()).ok())
1700            .unwrap_or(0)
1701    }
1702
1703    /// Move the cursor onto a match and report a wrap the way vim does.
1704    /// The status line as data — what every face draws.
1705    ///
1706    /// One model, so the two faces can only disagree about styling. Before
1707    /// this existed the GPU face built its own line from a fixed `format!()`
1708    /// and drew neither the prompt nor any message, which made a fully
1709    /// working `/` look like a dead key on escriba's default renderer.
1710    #[must_use]
1711    pub fn status_model(&self) -> StatusModel<'_> {
1712        let cursor = self.cursor();
1713        let prompt = self.search.prompt();
1714
1715        let kind = match prompt.map(|p| p.direction) {
1716            Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1717            Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1718            // Command mode with no search prompt open is an ex-command; the
1719            // typed `Option<Prompt>` is the discriminator, never a mode flag.
1720            None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1721            None => PromptKind::None,
1722        };
1723
1724        StatusModel {
1725            mode: self.modal.mode(),
1726            line: cursor.line.saturating_add(1) as usize,
1727            column: cursor.column.saturating_add(1) as usize,
1728            prompt: kind,
1729            prompt_text: prompt
1730                .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1731            prompt_caret: prompt.map_or_else(
1732                || self.modal.minibuffer_caret(),
1733                escriba_search::Prompt::caret,
1734            ),
1735            count: self.match_count(),
1736            message: self.messages.last().map(String::as_str),
1737        }
1738    }
1739
1740    /// `[3/17]` for the current pattern.
1741    ///
1742    /// While a prompt is open the count describes the PREVIEW — the answer to
1743    /// "what would Enter do", which is the question being asked mid-typing.
1744    /// Once committed it describes where the cursor actually is.
1745    #[must_use]
1746    fn match_count(&self) -> MatchCount {
1747        if self.search.is_prompting() {
1748            let text = self.active_text();
1749            // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1750            // the same `None`, so a half-typed character class reported
1751            // `[0/0]` — telling the user their pattern matches nothing while
1752            // they are still writing it.
1753            return match self.search.preview(&text) {
1754                escriba_search::Preview::Landed { step, total } => {
1755                    MatchCount::new(step.index, total)
1756                }
1757                escriba_search::Preview::NoMatch => MatchCount::None,
1758                escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1759                    MatchCount::Idle
1760                }
1761            };
1762        }
1763        if self.search.pattern().is_none() {
1764            return MatchCount::Idle;
1765        }
1766        let total = self.search.matches().len();
1767        // Read THROUGH the anchor: an ordinal computed against text that has
1768        // since changed reads as absent, so a stale count cannot be displayed.
1769        let rev = self.text_rev();
1770        self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1771            if total == 0 {
1772                MatchCount::None
1773            } else {
1774                MatchCount::Idle
1775            },
1776            |&i| MatchCount::new(i, total),
1777        )
1778    }
1779
1780    /// `.` — replay the last change at the cursor.
1781    ///
1782    /// Two steps, because a change can be two: run the action, then re-type
1783    /// whatever followed it. `cgn` + `.` is exactly this — change the next
1784    /// match, then repeat that whole gesture on the one after.
1785    fn repeat_last_change(&mut self) {
1786        let Some(change) = self.last_change.clone() else {
1787            self.messages
1788                .push("E32: No previous change to repeat".to_string());
1789            return;
1790        };
1791
1792        for _ in 0..change.count.max(1) {
1793            self.apply_resolved(&change.action);
1794        }
1795        for c in change.inserted.chars() {
1796            self.apply_resolved(&Action::InsertChar(c));
1797        }
1798        if self.modal.mode() == Mode::Insert {
1799            // A replayed change must not leave the editor in Insert — the
1800            // original ended with an Esc the recording deliberately does not
1801            // store, since it is punctuation rather than part of the change.
1802            self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1803        }
1804        // The replay wrote through `apply_resolved`, which re-records
1805        // `last_change` from the inner action. Put the ORIGINAL back so a
1806        // second `.` repeats the same change rather than a fragment of it.
1807        self.last_change = Some(change);
1808        self.recording_insert = false;
1809    }
1810
1811    /// Resolve a text object to the range it names.
1812    ///
1813    /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1814    /// match operates on THAT match rather than skipping to the next — which
1815    /// is what makes `cgn` then `.` walk matches one at a time instead of
1816    /// every other one.
1817    /// `dd` — the current line INCLUDING its terminator.
1818    ///
1819    /// Taking the newline is what makes `dd` remove a line rather than blank
1820    /// it. On the last line there is no following newline to take, so it
1821    /// falls back to the preceding one — otherwise `dd` on the final line
1822    /// leaves an empty line behind, which is the one case a naive
1823    /// "start-of-line to start-of-next-line" range gets wrong.
1824    fn object_line(&self) -> Option<Range> {
1825        let buf = self.buffers.get(self.active)?;
1826        let line = self.cursor().line;
1827        let last = buf.line_count().saturating_sub(1);
1828        if line < last {
1829            Some(Range::new(
1830                Position::new(line, 0),
1831                Position::new(line + 1, 0),
1832            ))
1833        } else if line > 0 {
1834            // Final line: swallow the PRECEDING newline instead.
1835            Some(Range::new(
1836                Position::new(line - 1, buf.line_len_chars(line - 1)),
1837                Position::new(line, buf.line_len_chars(line)),
1838            ))
1839        } else {
1840            // The only line in the buffer: clear it, keep the line itself.
1841            Some(Range::new(
1842                Position::new(0, 0),
1843                Position::new(0, buf.line_len_chars(0)),
1844            ))
1845        }
1846    }
1847
1848    /// `iw` / `aw` — the word under the cursor.
1849    ///
1850    /// vim's `w` classes are word / punctuation / whitespace, and a text
1851    /// object never crosses a line. `around` additionally takes the trailing
1852    /// whitespace run, falling back to LEADING whitespace when there is none
1853    /// after — which is what vim does at end of line.
1854    fn object_word(&self, around: bool) -> Option<Range> {
1855        let buf = self.buffers.get(self.active)?;
1856        let pos = self.cursor();
1857        let text: Vec<char> = buf.line(pos.line)?.chars().collect();
1858        if text.is_empty() {
1859            return None;
1860        }
1861        let col = (pos.column as usize).min(text.len().saturating_sub(1));
1862
1863        #[derive(PartialEq, Clone, Copy)]
1864        enum Class {
1865            Word,
1866            Punct,
1867            Space,
1868        }
1869        let class = |c: char| {
1870            if c.is_alphanumeric() || c == '_' {
1871                Class::Word
1872            } else if c.is_whitespace() {
1873                Class::Space
1874            } else {
1875                Class::Punct
1876            }
1877        };
1878
1879        let here = class(text[col]);
1880        let mut start = col;
1881        while start > 0 && class(text[start - 1]) == here {
1882            start -= 1;
1883        }
1884        let mut end = col + 1;
1885        while end < text.len() && class(text[end]) == here {
1886            end += 1;
1887        }
1888
1889        if around {
1890            let after = end;
1891            while end < text.len() && class(text[end]) == Class::Space {
1892                end += 1;
1893            }
1894            // No trailing run: take the leading one instead, as vim does.
1895            if end == after {
1896                while start > 0 && class(text[start - 1]) == Class::Space {
1897                    start -= 1;
1898                }
1899            }
1900        }
1901
1902        Some(Range::new(
1903            Position::new(pos.line, start as u32),
1904            Position::new(pos.line, end as u32),
1905        ))
1906    }
1907
1908    /// `i(` / `a"` … — the region between a matched pair, on one line.
1909    ///
1910    /// Brackets NEST and quotes do not, and that is the only difference:
1911    /// with `open == close` the scan cannot count depth, so it takes the
1912    /// nearest delimiter on each side instead.
1913    fn object_delimited(&self, open: char, close: char, around: bool) -> Option<Range> {
1914        let buf = self.buffers.get(self.active)?;
1915        let pos = self.cursor();
1916        let text: Vec<char> = buf.line(pos.line)?.chars().collect();
1917        if text.is_empty() {
1918            return None;
1919        }
1920        let col = (pos.column as usize).min(text.len().saturating_sub(1));
1921
1922        let (l, r) = if open == close {
1923            // Quotes: nearest on each side, no nesting to track.
1924            let l = (0..=col).rev().find(|&i| text[i] == open)?;
1925            let r = ((col.max(l) + 1)..text.len()).find(|&i| text[i] == close)?;
1926            (l, r)
1927        } else {
1928            // Brackets: walk out counting depth, so an inner pair does not
1929            // terminate the search for the enclosing one.
1930            let mut depth = 0i32;
1931            let l = (0..=col).rev().find(|&i| {
1932                if text[i] == close && i != col {
1933                    depth += 1;
1934                    false
1935                } else if text[i] == open {
1936                    if depth == 0 {
1937                        true
1938                    } else {
1939                        depth -= 1;
1940                        false
1941                    }
1942                } else {
1943                    false
1944                }
1945            })?;
1946            depth = 0;
1947            let r = ((l + 1)..text.len()).find(|&i| {
1948                if text[i] == open {
1949                    depth += 1;
1950                    false
1951                } else if text[i] == close {
1952                    if depth == 0 {
1953                        true
1954                    } else {
1955                        depth -= 1;
1956                        false
1957                    }
1958                } else {
1959                    false
1960                }
1961            })?;
1962            (l, r)
1963        };
1964
1965        // `i` is strictly between the delimiters; `a` includes them.
1966        let (s, e) = if around { (l, r + 1) } else { (l + 1, r) };
1967        Some(Range::new(
1968            Position::new(pos.line, s as u32),
1969            Position::new(pos.line, e as u32),
1970        ))
1971    }
1972
1973    fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
1974        use escriba_core::TextObject as O;
1975
1976        // The text-scanning objects resolve against the BUFFER; the two
1977        // search objects resolve against the match set. Splitting here keeps
1978        // the search logic below exactly as it was rather than threading a
1979        // second concern through it.
1980        match object {
1981            O::Line => return self.object_line(),
1982            O::Word { around } => return self.object_word(around),
1983            O::Delimited {
1984                open,
1985                close,
1986                around,
1987            } => return self.object_delimited(open, close, around),
1988            O::NextMatch | O::PrevMatch => {}
1989        }
1990
1991        let at = self.cursor_char();
1992        let matches = self.search.matches();
1993
1994        // A match CONTAINING the cursor wins outright, whichever direction the
1995        // object names.
1996        //
1997        // Comparing only against `m.start` — which is what a `starts`-vector
1998        // plus `Bound::Inclusive` does — is right only when the cursor sits on
1999        // a match's FIRST character. One column further in, `start < at` and
2000        // the match is rejected, so `cgn` skipped the very instance the
2001        // operator was standing in and the rename silently missed it. vim
2002        // operates on the containing match from every interior column, and the
2003        // `starts`-only comparison cannot express "contains" because it never
2004        // looks at `m.end`.
2005        let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
2006            let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
2007            match object {
2008                O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
2009                // Every other variant returned above; `NextMatch` is the only
2010                // one that can reach here besides `PrevMatch`.
2011                _ => Bound::Inclusive.first_matching(&starts, at, true),
2012            }
2013        })?;
2014
2015        let m = matches.get(idx)?;
2016        let buf = self.buffers.get(self.active)?;
2017        Some(Range {
2018            start: buf.char_to_position(m.start),
2019            end: buf.char_to_position(m.end),
2020        })
2021    }
2022
2023    fn land_on(&mut self, step: escriba_search::Step) {
2024        if let Some(buf) = self.buffers.get(self.active) {
2025            let pos = buf.char_to_position(step.target.start);
2026            self.set_cursor(pos);
2027        }
2028        // The `[3/17]` numerator. `Step` has carried this index since the
2029        // engine was written — `engine.rs` even names the counter as the
2030        // reason it exists — and every consumer discarded it until now.
2031        self.search_at = Some(Anchored::new(step.index, self.text_rev()));
2032    }
2033
2034    /// vim's "search hit BOTTOM, continuing at TOP".
2035    ///
2036    /// One reporter, called by the two places a search can wrap: the shared
2037    /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
2038    /// commit would say it twice.
2039    fn report_wrap(&mut self, step: &escriba_search::Step) {
2040        if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
2041            self.messages.push(msg.to_string());
2042        }
2043    }
2044
2045    /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
2046    /// than failing silently — a search that appears to do nothing is
2047    /// indistinguishable from a dropped keystroke.
2048    fn jump_search(&mut self, reverse: bool) {
2049        // Using the matches re-lights them: `n` after an auto-clear shows you
2050        // what you are walking through.
2051        self.search.relight();
2052        // `n` is a far jump — record where we leave from so `<C-o>` works.
2053        self.jumps.push(self.spot());
2054        let at = self.cursor_char();
2055        match self.search.repeat(at, reverse) {
2056            Some(step) => {
2057                // `n` wrapping the file says so, same as a commit does.
2058                self.report_wrap(&step);
2059                self.land_on(step);
2060            }
2061            None => {
2062                let msg = self.search.pattern().map_or_else(
2063                    || "E35: No previous regular expression".to_string(),
2064                    |p| {
2065                        let mut m = String::from("E486: Pattern not found: ");
2066                        m.push_str(p.raw());
2067                        m
2068                    },
2069                );
2070                self.messages.push(msg);
2071            }
2072        }
2073    }
2074
2075    /// Move the cursor to where the in-progress pattern would land, without
2076    /// committing anything. vim's `incsearch`.
2077    ///
2078    /// A pattern that does not compile yet (`/a[`, mid-typing) previews
2079    /// nothing and reports nothing — an error toast on every keystroke of a
2080    /// character class would be unusable.
2081    fn preview_search(&mut self) {
2082        let text = self.active_text();
2083        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
2084            return;
2085        };
2086        let target = match self.search.preview(&text) {
2087            escriba_search::Preview::Landed { step, .. } => step.target.start,
2088            // Nothing to show: back to where the search started. Covers a
2089            // half-typed pattern and a pattern that finds nothing alike —
2090            // both mean "there is no match to preview".
2091            escriba_search::Preview::Idle
2092            | escriba_search::Preview::Incomplete
2093            | escriba_search::Preview::NoMatch => origin,
2094        };
2095        // A pattern that STOPS matching returns the cursor to the origin.
2096        //
2097        // Preview used to only ever move forward, so typing `ch` (a match) and
2098        // then `chz` (none) left the cursor parked on the `ch` match — a
2099        // preview showing a position the pattern no longer justifies, while
2100        // the count beside it read `[0/0]`. Restoring is also what makes
2101        // Escape's promise legible: at every keystroke the cursor is either on
2102        // a real match or back where you started, never on a stale one.
2103        if let Some(buf) = self.buffers.get(self.active) {
2104            let pos = buf.char_to_position(target);
2105            self.set_cursor(pos);
2106        }
2107    }
2108
2109    /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
2110    /// where the search lands, as ONE action.
2111    ///
2112    /// Split from [`Self::submit_search`] rather than sharing it because the
2113    /// two want opposite things from the commit: the bare `/` MOVES the cursor
2114    /// to the match, and an operated `/` must NOT — the cursor is the
2115    /// operator's start point, and moving it first would leave the operator
2116    /// with a zero-width range.
2117    /// Commit the open search prompt. The ONE copy of the sequence.
2118    ///
2119    /// Reports its own failures (E486 / E35) so neither caller has to carry a
2120    /// third copy of the message strings. `Accepted::Invalid` cannot reach
2121    /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
2122    /// boundary before the FSM or this method ever sees the submit.
2123    fn commit_search_prompt(&mut self) -> CommitOutcome {
2124        let text = self.active_text();
2125        let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
2126        else {
2127            return CommitOutcome::NoPrompt;
2128        };
2129
2130        match self.search.accept(&text) {
2131            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
2132                self.modal.clear_minibuffer();
2133                self.modal.enter(Mode::Normal);
2134                match self.search.commit_step_skipping(origin, skip) {
2135                    Some(step) => {
2136                        // The wrap notice belongs HERE, once, for both commit
2137                        // paths. Reporting it in each caller is what let the
2138                        // operated path lose it in the first place — and my
2139                        // first attempt at this refactor duplicated it again
2140                        // rather than moving it, which the red proof caught.
2141                        self.report_wrap(&step);
2142                        CommitOutcome::Landed { origin, step }
2143                    }
2144                    None => {
2145                        self.report_pattern_not_found();
2146                        CommitOutcome::NotFound
2147                    }
2148                }
2149            }
2150            escriba_search::Accepted::NothingToRepeat => {
2151                self.modal.clear_minibuffer();
2152                self.modal.enter(Mode::Normal);
2153                self.messages
2154                    .push("E35: No previous regular expression".to_string());
2155                CommitOutcome::NoPrevious
2156            }
2157            // Unreachable: the boundary guard in `apply_counted` returns early
2158            // on an uncompilable pattern, leaving the prompt open. Reported
2159            // rather than `unreachable!()` — a panic in the editor's commit
2160            // path is a worse failure than a duplicate message.
2161            escriba_search::Accepted::Invalid(e) => {
2162                let mut m = String::from("E383: Invalid search string: ");
2163                m.push_str(&e.to_string());
2164                self.messages.push(m);
2165                CommitOutcome::NoPrompt
2166            }
2167        }
2168    }
2169
2170    /// vim's E486, with the pattern named. One place, so every path that fails
2171    /// to find reports identically.
2172    fn report_pattern_not_found(&mut self) {
2173        let mut m = String::from("E486: Pattern not found");
2174        if let Some(p) = self.search.pattern() {
2175            m.push_str(": ");
2176            m.push_str(p.raw());
2177        }
2178        self.messages.push(m);
2179    }
2180
2181    /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
2182    ///
2183    /// The only difference from the operated path is that this one lands;
2184    /// everything else lives in `commit_search_prompt`.
2185    fn submit_search(&mut self) {
2186        match self.commit_search_prompt() {
2187            CommitOutcome::Landed { origin, step } => {
2188                if let Some(buf) = self.buffers.get(self.active) {
2189                    let from = buf.char_to_position(origin);
2190                    self.jumps.push(escriba_core::Spot::new(self.active, from));
2191                }
2192                self.land_on(step);
2193            }
2194            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
2195        }
2196    }
2197
2198    /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
2199    /// search lands, as ONE action.
2200    ///
2201    /// The cursor must NOT move to the match first: it is the operator's start
2202    /// point. That is the whole reason this differs from the bare path, and
2203    /// now the only reason.
2204    fn submit_search_operated(&mut self, op: Operator) {
2205        match self.commit_search_prompt() {
2206            CommitOutcome::Landed { origin, step } => {
2207                if let Some(buf) = self.buffers.get(self.active) {
2208                    let from = buf.char_to_position(origin);
2209                    let target = buf.char_to_position(step.target.start);
2210                    // Operating over a search is itself a far jump.
2211                    self.jumps.push(escriba_core::Spot::new(self.active, from));
2212                    self.set_cursor(from);
2213                    self.apply_operator_to(op, target);
2214                }
2215            }
2216            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
2217        }
2218    }
2219
2220    fn apply_resolved(&mut self, action: &Action) {
2221        // Snapshot the scope inputs before the mutation so the resulting
2222        // Damage covers the changed region (the S3 seal — conservative widen).
2223        let lines_before = self.active_line_count();
2224        // Snapshot for the dot register: the only reliable witness that this
2225        // action changed text is that the buffer's revision moved.
2226        let rev_before = self.text_rev();
2227        let cline_before = self.cursor().line;
2228        match action {
2229            // Every action with an exact slip equivalent goes through the
2230            // interpreter, so "undo" has ONE implementation rather than one
2231            // per entry point. These had already drifted: the executor
2232            // re-followed the viewport after undo and the M1 interpreter did
2233            // not, so `u` and `:undo` behaved differently within a milestone
2234            // of each other.
2235            // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
2236            // guard: a guard arm does not count toward exhaustiveness, so the
2237            // guarded form silently gave up the total match — the compiler
2238            // said so, and it was right. `lowering_and_dispatch_agree` pins
2239            // that this list and `lower` stay the same set.
2240            Action::Quit
2241            | Action::ClearSearchHighlight
2242            | Action::Save
2243            | Action::Undo
2244            | Action::Redo
2245            | Action::Edit(_) => {
2246                for slip in Self::lower(action, self.active).unwrap_or_default() {
2247                    self.honour_one(slip);
2248                }
2249            }
2250            Action::Move(m) => self.apply_motion(*m),
2251            Action::SearchOpen(dir) => {
2252                // vim's `/` is the command-line with a different prompt char,
2253                // so we reuse Command mode; `search.prompt` is what tells a
2254                // later <CR> this is a search and not an ex-command.
2255                let origin = self.cursor_char();
2256                self.search.open(*dir, origin);
2257                self.modal.enter(Mode::Command);
2258            }
2259            Action::SearchRepeat { reverse } => self.jump_search(*reverse),
2260            Action::SearchWord { reverse } => {
2261                let dir = if *reverse {
2262                    SearchDirection::Backward
2263                } else {
2264                    SearchDirection::Forward
2265                };
2266                let (text, at) = (self.active_text(), self.cursor_char());
2267                // `*` jumps, so it records too.
2268                self.jumps.push(self.spot());
2269                match self.search.search_word(&text, at, dir) {
2270                    Some(step) => self.land_on(step),
2271                    // vim beeps and stays put when there is no word under the
2272                    // cursor; a silent no-op would look like a broken key.
2273                    None => self
2274                        .messages
2275                        .push("E348: No string under cursor".to_string()),
2276                }
2277            }
2278            Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
2279            Action::TextObject(object) => {
2280                // Bare `gn` moves onto the match. vim additionally starts a
2281                // Visual selection of it; escriba's Visual plumbing does not
2282                // carry a selection an operator can consume yet, so this
2283                // stops at the jump rather than faking a selection that
2284                // nothing would honour.
2285                if let Some(range) = self.resolve_object(*object) {
2286                    self.jumps.push(self.spot());
2287                    self.set_cursor(range.start);
2288                } else {
2289                    self.report_pattern_not_found();
2290                }
2291            }
2292            Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
2293                Some(range) => self.apply_operator_over(*op, range),
2294                None => self.report_pattern_not_found(),
2295            },
2296            Action::RepeatLastChange => self.repeat_last_change(),
2297            Action::JumpBack => {
2298                let here = self.spot();
2299                if let Some(spot) = self.jumps.back(here) {
2300                    self.goto_spot(spot);
2301                } else {
2302                    self.messages
2303                        .push("E662: At start of changelist".to_string());
2304                }
2305            }
2306            Action::JumpForward => {
2307                if let Some(spot) = self.jumps.forward() {
2308                    self.goto_spot(spot);
2309                } else {
2310                    self.messages.push("E663: At end of changelist".to_string());
2311                }
2312            }
2313            Action::ChangeMode(m) => {
2314                // Leaving the cmdline abandons any open search prompt and
2315                // returns the cursor home. The COMMITTED pattern survives —
2316                // cancelling a new search must not erase the old highlights.
2317                if *m == Mode::Normal && self.search.is_prompting() {
2318                    if let Some(origin) = self.search.cancel() {
2319                        if let Some(buf) = self.buffers.get(self.active) {
2320                            let pos = buf.char_to_position(origin);
2321                            self.set_cursor(pos);
2322                        }
2323                    }
2324                }
2325                self.modal.enter(*m);
2326            }
2327            Action::InsertChar(c) => self.insert_char(*c),
2328
2329            Action::SubmitCommand => {
2330                if self.search.is_prompting() {
2331                    self.submit_search();
2332                } else {
2333                    self.submit_command();
2334                }
2335            }
2336            Action::Command { name, args } => self.run_command(name, args),
2337            Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
2338            // The operator-pending FSM consumes Operator keys (begins pending);
2339            // they never reach the executor. Defensive no-op for exhaustiveness.
2340            Action::Operator(_) => {}
2341            Action::PromptCaret { to } => {
2342                // Both prompts have a caret now, and the same keys move it.
2343                if self.search.is_prompting() {
2344                    self.search.move_caret(*to);
2345                } else {
2346                    self.modal.move_minibuffer_caret(*to);
2347                }
2348            }
2349            Action::SearchPreviewStep { forward } => {
2350                if self.search.is_prompting() {
2351                    self.search.preview_step(*forward);
2352                    self.preview_search();
2353                }
2354            }
2355            Action::PromptDelete => {
2356                if self.search.is_prompting() {
2357                    self.search.delete_at_caret();
2358                    self.preview_search();
2359                } else {
2360                    self.modal.delete_minibuffer_at_caret();
2361                }
2362            }
2363            Action::PromptDeleteWord => {
2364                if self.search.is_prompting() {
2365                    self.search.delete_word_before_caret();
2366                    self.preview_search();
2367                }
2368            }
2369            Action::PromptClearToStart => {
2370                if self.search.is_prompting() {
2371                    self.search.clear_before_caret();
2372                    self.preview_search();
2373                }
2374            }
2375            Action::PromptBackspace => {
2376                self.prompt_backspace();
2377                // Shortening the pattern changes which matches exist, so the
2378                // preview must re-run — otherwise the cursor sits on a match
2379                // of a pattern that is no longer typed.
2380                if self.search.is_prompting() {
2381                    self.preview_search();
2382                }
2383            }
2384            Action::PromptHistory { back } => {
2385                if self.search.is_prompting() {
2386                    self.search.history_step(*back);
2387                    // No minibuffer resync: the shadow is the ex-line's store
2388                    // and nothing reads it while a search prompt is open, so
2389                    // rewriting it here was maintaining a copy for no reader.
2390                    self.preview_search();
2391                }
2392            }
2393            Action::Pending => {}
2394        }
2395        // Widen the dirty region by what this action touched (M1). Content
2396        // mutations that changed the line count run to end-of-document (every
2397        // line below shifted); an in-place edit or a cursor move is local;
2398        // arbitrary commands are conservatively Full. Never narrows.
2399        let lines_after = self.active_line_count();
2400        let cline_after = self.cursor().line;
2401        let d = match action {
2402            // A search repaints every highlight in the viewport, not just the
2403            // line the cursor left — so it must widen to Full. Treating it as a
2404            // cursor move would leave stale highlights on untouched lines.
2405            Action::SearchOpen(_)
2406            | Action::PromptHistory { .. }
2407            | Action::PromptBackspace
2408            | Action::PromptCaret { .. }
2409            | Action::SearchPreviewStep { .. }
2410            | Action::PromptDelete
2411            | Action::PromptDeleteWord
2412            | Action::PromptClearToStart
2413            | Action::SearchRepeat { .. }
2414            | Action::SearchWord { .. }
2415            | Action::ClearSearchHighlight
2416            | Action::SearchSubmitOperated { .. }
2417            // A replayed change can edit anywhere the original could, and a
2418            // match object can be anywhere in the document.
2419            | Action::RepeatLastChange
2420            | Action::TextObject(_)
2421            | Action::ApplyOperatorObject { .. }
2422            // A jump can land anywhere, so the viewport may scroll wholesale.
2423            | Action::JumpBack
2424            | Action::JumpForward => Damage::Full,
2425            Action::InsertChar(_)
2426            | Action::Edit(_)
2427            | Action::Undo
2428            | Action::Redo
2429            | Action::ApplyOperator { .. } => {
2430                if lines_after == lines_before {
2431                    Damage::span(cline_before, cline_after)
2432                } else {
2433                    Damage::Lines {
2434                        from: cline_before.min(cline_after),
2435                        to: u32::MAX,
2436                    }
2437                }
2438            }
2439            Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
2440            Action::Save => Damage::Viewport,
2441            Action::Command { .. } | Action::SubmitCommand => Damage::Full,
2442            Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
2443        };
2444        self.damage = self.damage.join(d);
2445        // Remember this change for `.`.
2446        //
2447        // Recorded from an OBSERVED MUTATION, not from the action's variant.
2448        // `text_effect()` is the wrong predicate here even though it looks
2449        // like the right one: it exists to decide cache invalidation, where
2450        // OVER-reporting is the safe direction, and the dot register needs the
2451        // opposite bias. Leaning on it meant `last_change` was set by actions
2452        // that changed no text at all, with two measured consequences:
2453        //
2454        //   `iZ<Esc>` then `/a<CR>` then `.`  — did nothing; the register held
2455        //       `SubmitCommand`, whose replay reads an already-cleared
2456        //       minibuffer.
2457        //   `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
2458        //       abandoned prompt left the register holding `InsertChar('q')`,
2459        //       and `.` in Normal mode routes that to the text. A corrupting
2460        //       register, not merely a lost one.
2461        //
2462        // Comparing the buffer's `TextRev` across the action answers the only
2463        // question that matters — did this actually change the text — and gets
2464        // the failed-operator case (`dgn` with no pattern) right for free.
2465        if self.recording_insert {
2466            match action {
2467                Action::InsertChar(c) => {
2468                    if let Some(lc) = self.last_change.as_mut() {
2469                        lc.inserted.push(*c);
2470                    }
2471                }
2472                // Leaving Insert ends the session; the change is now whole.
2473                Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
2474                _ => {}
2475            }
2476        } else if self.text_rev() != rev_before
2477            && !matches!(
2478                action,
2479                Action::RepeatLastChange | Action::Undo | Action::Redo
2480            )
2481        {
2482            self.last_change = Some(LastChange {
2483                action: action.clone(),
2484                count: 1,
2485                inserted: String::new(),
2486            });
2487            self.recording_insert = self.modal.mode() == Mode::Insert;
2488        }
2489
2490        // The search is over the moment you move on or edit — clear the
2491        // highlight rather than leaving the buffer as confetti until an
2492        // explicit `:noh`, which is the remap nearly every vimrc carries.
2493        // Clearing suppresses without forgetting, so `n` still works.
2494        if action.highlight_effect() == HighlightEffect::Clear {
2495            self.search.clear_highlight();
2496        }
2497        // Text changed ⇒ every match offset cached against the old text is
2498        // wrong. `SearchState::refresh` existed for exactly this and had ZERO
2499        // callers, so inserting four characters left both renderers painting
2500        // the highlight four columns off.
2501        //
2502        // Gated on the typed classifier rather than on `bump_gen` (which fires
2503        // for pure cursor moves too): re-scanning the document on every `j`
2504        // would be a per-keystroke full pass for no reason.
2505        if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
2506            let text = self.active_text();
2507            self.search.refresh(&text);
2508            // NO manual invalidation of `search_at` here, deliberately. It is
2509            // `Anchored` to the text revision, so an ordinal computed against
2510            // the old text now reads as `None` on its own. This is the line
2511            // that used to have to be remembered.
2512        }
2513        // An action reached the executor ⇒ visible state may have changed.
2514        // Advance the refresh generation so the renderer repaints (and
2515        // re-highlights) exactly once. A gated-out key never reaches here, so
2516        // a key-repeat storm does not spin the renderer.
2517        self.bump_gen();
2518    }
2519
2520    /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
2521    /// active buffer — **pure**: no cursor mutation, no side effects. This is
2522    /// the single motion-resolution source of truth that both [`apply_motion`]
2523    /// (move the cursor *to* the target) and [`apply_operator`] (use the target
2524    /// as the *other end* of an operated range) stand on. `None` only if there
2525    /// is no active buffer.
2526    ///
2527    /// [`apply_motion`]: Self::apply_motion
2528    /// [`apply_operator`]: Self::apply_operator
2529    fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
2530        let buf = self.buffers.get(self.active)?;
2531        let pos = from;
2532        Some(match motion {
2533            // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
2534            // against the committed match list, so it is `None` (motion fails,
2535            // operator aborts, buffer untouched) when nothing is committed —
2536            // never a silent move to 0, which would delete to the file start.
2537            Motion::SearchNext | Motion::SearchPrev => {
2538                let at = buf.position_to_char(pos).ok()?;
2539                let step = self
2540                    .search
2541                    .repeat(at, matches!(motion, Motion::SearchPrev))?;
2542                buf.char_to_position(step.target.start)
2543            }
2544            Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
2545            Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
2546            Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
2547            Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
2548            Motion::LineStart => Position::new(pos.line, 0),
2549            Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
2550            Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
2551            Motion::DocStart => Position::ZERO,
2552            Motion::DocEnd => Position::new(
2553                buf.line_count().saturating_sub(1),
2554                buf.line_len_chars(buf.line_count().saturating_sub(1)),
2555            ),
2556            Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
2557            Motion::WordStartPrev => word_prev(buf, pos),
2558            Motion::PageDown | Motion::HalfPageDown => {
2559                Position::new(pos.line.saturating_add(10), pos.column)
2560            }
2561            Motion::PageUp | Motion::HalfPageUp => {
2562                Position::new(pos.line.saturating_sub(10), pos.column)
2563            }
2564            Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
2565            // Structural Lisp motions — stubs for phase 1.B; full paredit
2566            // semantics land when caixa-ast is wired to the active buffer.
2567            Motion::ForwardSexp
2568            | Motion::BackwardSexp
2569            | Motion::UpList
2570            | Motion::DownList
2571            | Motion::BeginningOfDefun
2572            | Motion::EndOfDefun
2573            | Motion::BeginningOfSexp
2574            | Motion::EndOfSexp => pos,
2575        })
2576    }
2577
2578    fn apply_motion(&mut self, motion: Motion) {
2579        // A bare search motion is a FAR JUMP and it REPORTS — it records into
2580        // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
2581        // when nothing matches. `resolve_motion` can do none of that: it is
2582        // deliberately pure because the OPERATOR path calls it to find a range
2583        // without moving the cursor. So `n` routes to the one executor that
2584        // owns those side effects, and `Action::SearchRepeat` routes to the
2585        // same place — one code path, two spellings.
2586        if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2587            self.jump_search(matches!(motion, Motion::SearchPrev));
2588            return;
2589        }
2590        let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
2591            return;
2592        };
2593        // The single cursor-mutation path clamps to the buffer and scrolls
2594        // the viewport to contain the cursor on both axes.
2595        self.set_cursor(pos);
2596    }
2597
2598    /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
2599    /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
2600    /// Composition is explicit: the motion resolves a target via
2601    /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
2602    /// `[cursor, target)` range. Register-leaving operators
2603    /// ([`Operator::leaves_register`]) capture the text first.
2604    fn apply_operator(&mut self, op: Operator, motion: Motion) {
2605        let from = self.cursor();
2606        let Some(to) = self.resolve_motion(from, motion) else {
2607            // A motion that cannot resolve aborts the operator with the buffer
2608            // untouched. A search motion says WHY — `dn` with no pattern armed
2609            // is otherwise indistinguishable from a dropped keystroke, which
2610            // is the same complaint that motivated E486 on the bare path.
2611            if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2612                if self.search.pattern().is_none() {
2613                    self.messages
2614                        .push("E35: No previous regular expression".to_string());
2615                } else {
2616                    self.report_pattern_not_found();
2617                }
2618            }
2619            return;
2620        };
2621        self.apply_operator_to(op, to);
2622    }
2623
2624    /// Apply `op` over `[cursor, to)`.
2625    ///
2626    /// Split out of [`Self::apply_operator`] so the operated-search path can
2627    /// reach the same range machinery with a target it resolved itself — the
2628    /// alternative was a second copy of the delete/yank/register logic, which
2629    /// is how the two would drift.
2630    fn apply_operator_to(&mut self, op: Operator, to: Position) {
2631        let from = self.cursor();
2632        self.apply_operator_over(
2633            op,
2634            Range {
2635                start: from,
2636                end: to,
2637            },
2638        );
2639    }
2640
2641    /// Apply `op` over an explicit range.
2642    ///
2643    /// The object path needs this: `gn`'s extent need not begin at the cursor,
2644    /// so it cannot go through the `[cursor, target)` shape the motion path
2645    /// uses. One implementation of the delete/yank/register logic, reached two
2646    /// ways.
2647    fn apply_operator_over(&mut self, op: Operator, range: Range) {
2648        let range = range.normalized();
2649        if range.is_empty() {
2650            return;
2651        }
2652        // Capture the operated text (for the register) before mutating.
2653        let text = self
2654            .buffers
2655            .get(self.active)
2656            .and_then(|buf| buf.slice(range).ok());
2657        if op.leaves_register() {
2658            if let Some(t) = &text {
2659                self.register = Some(t.clone());
2660            }
2661        }
2662        match op {
2663            // Delete + Change remove the range; Change then enters Insert so
2664            // the operator pairs with immediate typing (`ciw`, `c$`).
2665            Operator::Delete | Operator::Change => {
2666                if let Some(buf) = self.buffers.get_mut(self.active) {
2667                    let _ = buf.apply(&Edit::delete(range));
2668                }
2669                self.set_cursor(range.start);
2670                if op == Operator::Change {
2671                    self.modal.enter(Mode::Insert);
2672                }
2673            }
2674            // Yank copies to the register without mutating the buffer; vim
2675            // leaves the cursor at the range start.
2676            Operator::Yank => {
2677                self.set_cursor(range.start);
2678            }
2679            // Indent/Format/structural operators are not yet wired — named,
2680            // not faked (no buffer mutation, register already captured for the
2681            // register-leaving ones above).
2682            _ => {
2683                self.messages
2684                    .push("operator not yet implemented".to_owned());
2685            }
2686        }
2687    }
2688
2689    /// The text last yanked or deleted into the unnamed register, if any.
2690    /// The future `p`/`P` paste reads this.
2691    #[must_use]
2692    pub fn register(&self) -> Option<&str> {
2693        self.register.as_deref()
2694    }
2695
2696    fn insert_char(&mut self, c: char) {
2697        if self.modal.mode() == Mode::Command {
2698            // A search prompt and an ex-command share Command mode (vim's
2699            // cmdline). `search.is_prompting()` is the typed discriminator —
2700            // it can only be true when `/` or `?` actually opened a prompt.
2701            if self.search.is_prompting() {
2702                // The search prompt is the SOLE store while it is open.
2703                //
2704                // This used to also `push_minibuffer(c)`, and the two stores
2705                // insert differently — `search.push` at the caret, the
2706                // minibuffer always at the end — so `/fo<Left>X` left them
2707                // reading `fXo` and `foX`. That was one of FIVE desync paths;
2708                // the caret moves, forward-delete, delete-word and
2709                // clear-to-start never touched the shadow at all.
2710                //
2711                // Deleting the write costs nothing because `status_model`
2712                // already selects the minibuffer only on the `prompt == None`
2713                // branch — the shadow is the EX-LINE's store, and while a
2714                // search prompt is open nothing reads it.
2715                self.search.push(c);
2716                self.preview_search();
2717            } else {
2718                self.modal.push_minibuffer(c);
2719            }
2720            return;
2721        }
2722        let cursor = self.cursor();
2723        let Some(buf) = self.buffers.get_mut(self.active) else {
2724            return;
2725        };
2726        let edit = Edit::insert(cursor, c.to_string());
2727        if buf.apply(&edit).is_ok() {
2728            let next = if c == '\n' {
2729                Position::new(cursor.line.saturating_add(1), 0)
2730            } else {
2731                cursor.shift_right(1)
2732            };
2733            // Route through the single cursor-mutation path so the viewport
2734            // follows the cursor (both axes) and the cursor stays clamped.
2735            self.set_cursor(next);
2736        }
2737    }
2738
2739    /// Backspace inside a prompt. Keeps the search buffer and the displayed
2740    /// minibuffer in lockstep — if only one shrank, the pattern submitted
2741    /// would differ from the text on screen.
2742    fn prompt_backspace(&mut self) -> bool {
2743        if self.modal.mode() != Mode::Command {
2744            return false;
2745        }
2746        if self.search.is_prompting() {
2747            // Backspacing past the `/` closes the prompt, as vim does. No
2748            // `pop_minibuffer` here for the same reason as `insert_char`: the
2749            // shadow is the ex-line's, and popping its TAIL when the caret is
2750            // mid-pattern was another desync path.
2751            if self.search.backspace() {
2752                self.modal.clear_minibuffer();
2753                self.modal.enter(Mode::Normal);
2754            }
2755            // Never `pop_minibuffer` on the search path: it pops the TAIL,
2756            // while `search.backspace()` removes the char before the CARET.
2757            return true;
2758        }
2759        self.modal.pop_minibuffer();
2760        true
2761    }
2762
2763    fn submit_command(&mut self) {
2764        // Read the command line BEFORE leaving Command mode — the minibuffer
2765        // exists only in the `Command` variant, so the escape must come
2766        // after the capture.
2767        let line = self.modal.minibuffer().to_string();
2768        self.modal.escape();
2769        let (name, args) = parse_command_line(&line);
2770        if name.is_empty() {
2771            return;
2772        }
2773        self.run_command(&name, &args);
2774    }
2775
2776    fn run_command(&mut self, name: &str, args: &[String]) {
2777        // Bound the command -> RunCommand slip -> command cycle. Refused and
2778        // reported, never a stack overflow: an editor that dies under the
2779        // operator loses their buffer, and a script that loops is a mistake
2780        // they should be told about, not punished for.
2781        if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2782            let mut m = String::from("command recursion too deep at `");
2783            m.push_str(name);
2784            m.push_str("` — refusing");
2785            self.messages.push(m);
2786            self.damage = self.damage.join(Damage::Viewport);
2787            self.bump_gen();
2788            return;
2789        }
2790        self.dispatch_depth += 1;
2791        self.run_command_inner(name, args);
2792        self.dispatch_depth -= 1;
2793    }
2794
2795    /// How many nested command dispatches are allowed. Deep enough that no
2796    /// legitimate script notices, shallow enough to fail fast.
2797    const MAX_DISPATCH_DEPTH: u8 = 8;
2798
2799    fn run_command_inner(&mut self, name: &str, args: &[String]) {
2800        // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2801        // gated on `Command: <name>` has its entry applied the first time
2802        // that command runs, BEFORE dispatch — so the activated plugin
2803        // can register the very command being invoked and it resolves on
2804        // this same call.
2805        if self.plugin_host.pending() > 0 {
2806            let pending = self.plugin_host.pending_for_command(name);
2807            for src in pending {
2808                self.apply_plugin_entry(&src);
2809            }
2810        }
2811        // Read through the counter, then interpret. Two immutable borrows of
2812        // `self` (the window and the registry) coexist; the `&mut` comes
2813        // afterwards, once the outcome is owned. That sequencing IS the
2814        // seam: there is no moment where a command body and `&mut self` are
2815        // live at the same time.
2816        let outcome = {
2817            let window = self.window();
2818            self.commands.run(name, &window, args)
2819        };
2820        match outcome {
2821            Ok(o) => self.interpret(o),
2822            // Reported, never fatal (Phase 0). A failed command must not
2823            // take the editor down, but it must not be invisible either.
2824            Err(e) => {
2825                self.messages.push(describe_command_failure(name, &e));
2826                self.damage = self.damage.join(Damage::Viewport);
2827                self.bump_gen();
2828            }
2829        }
2830    }
2831
2832    // ── tatara-lisp runtime bridge (imperative programmability tier) ──
2833
2834    /// Capture a read snapshot of the editor for the tatara-lisp host.
2835    /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
2836    #[must_use]
2837    pub fn snapshot(&self) -> EditorSnapshot {
2838        let current_line = self
2839            .buffers
2840            .get(self.active)
2841            .and_then(|b| b.line(self.cursor().line))
2842            .map(|s| s.trim_end_matches('\n').to_string())
2843            .unwrap_or_default();
2844        let buffer_name = self
2845            .buffers
2846            .get(self.active)
2847            .and_then(|b| b.path.as_ref())
2848            .map(|p| p.display().to_string())
2849            .unwrap_or_else(|| "[scratch]".to_string());
2850        EditorSnapshot {
2851            cursor_line: i64::from(self.cursor().line),
2852            cursor_column: i64::from(self.cursor().column),
2853            current_line,
2854            mode: self.modal.mode().as_str().to_string(),
2855            buffer_name,
2856        }
2857    }
2858
2859    /// Evaluate tatara-lisp `src` against this editor: capture a
2860    /// snapshot, run it in the embedded VM, then apply the typed effects
2861    /// the program emitted. This is the imperative programmability tier
2862    /// — live Lisp that reads state and drives the editor through the
2863    /// sandboxed effect boundary.
2864    ///
2865    /// **Snapshot semantics:** the read snapshot is captured ONCE before
2866    /// eval, and effects are applied AFTER the program returns. So within
2867    /// a single `run_lisp` call a program cannot observe its own writes —
2868    /// `(insert "x") (cursor-column)` reads the pre-insert column. This
2869    /// snapshot-isolation is deliberate (it's what makes the effect
2870    /// boundary a clean sandbox seam); a program that must read its own
2871    /// effects splits the work across calls. The VM is cached
2872    /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
2873    /// `define`s persist across calls (REPL-like).
2874    pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
2875        let mut host = EscribaHost::with_snapshot(self.snapshot());
2876        let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
2877        vm.eval(src, &mut host)?;
2878        let effects = host.take_effects();
2879        self.apply_host_effects(effects);
2880        Ok(())
2881    }
2882
2883    /// Apply tatara-lisp effects to live editor state.
2884    ///
2885    /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
2886    /// implementation of message-push / option-insert / insert-text beside
2887    /// the Action executor and the slip interpreter — the same duplication
2888    /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
2889    /// hands them to the one interpreter.
2890    pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
2891        self.interpret(Outcome::did(effects));
2892    }
2893
2894    /// Insert a (possibly multi-line) string at the cursor and advance
2895    /// the cursor past it. Used by the `(insert …)` effect.
2896    fn insert_text(&mut self, text: &str) {
2897        if text.is_empty() {
2898            return;
2899        }
2900        let cursor = self.cursor();
2901        let Some(buf) = self.buffers.get_mut(self.active) else {
2902            return;
2903        };
2904        let edit = Edit::insert(cursor, text.to_string());
2905        if buf.apply(&edit).is_ok() {
2906            let next = if let Some(nl) = text.rfind('\n') {
2907                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
2908                let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
2909                Position::new(cursor.line + added_lines, last_line_len)
2910            } else {
2911                let n = u32::try_from(text.chars().count()).unwrap_or(0);
2912                cursor.shift_right(n)
2913            };
2914            // Route through the single cursor-mutation path so the viewport
2915            // follows the cursor (both axes) and the cursor stays clamped.
2916            self.set_cursor(next);
2917        }
2918    }
2919}
2920
2921fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
2922    let Some(text) = buf.line(line) else {
2923        return Position::new(line, 0);
2924    };
2925    let col = text
2926        .chars()
2927        .take_while(|c| c.is_whitespace() && *c != '\n')
2928        .count();
2929    Position::new(line, u32::try_from(col).unwrap_or(0))
2930}
2931
2932fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2933    let Some(text) = buf.line(pos.line) else {
2934        return pos;
2935    };
2936    let chars: Vec<char> = text.chars().collect();
2937    let start = pos.column as usize;
2938    let mut i = start;
2939    while i < chars.len() && !chars[i].is_whitespace() {
2940        i += 1;
2941    }
2942    while i < chars.len() && chars[i].is_whitespace() {
2943        i += 1;
2944    }
2945    if i >= chars.len() {
2946        // No more words on this line — jump to next line.
2947        if pos.line + 1 < buf.line_count() {
2948            return Position::new(pos.line + 1, 0);
2949        }
2950    }
2951    Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
2952}
2953
2954fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2955    let Some(text) = buf.line(pos.line) else {
2956        return pos;
2957    };
2958    let chars: Vec<char> = text.chars().collect();
2959    let mut i = (pos.column as usize).min(chars.len());
2960    while i > 0 && chars[i - 1].is_whitespace() {
2961        i -= 1;
2962    }
2963    while i > 0 && !chars[i - 1].is_whitespace() {
2964        i -= 1;
2965    }
2966    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
2967}
2968
2969fn parse_command_line(line: &str) -> (String, Vec<String>) {
2970    let mut parts = line.split_whitespace();
2971    let Some(first) = parts.next() else {
2972        return (String::new(), Vec::new());
2973    };
2974    let head = first.strip_prefix(':').unwrap_or(first);
2975    let name = match head {
2976        "w" => "save",
2977        "q" => "quit",
2978        "u" => "undo",
2979        other => other,
2980    };
2981    (name.to_string(), parts.map(str::to_string).collect())
2982}
2983
2984#[cfg(test)]
2985mod tests {
2986    use super::*;
2987    use madori::event::{KeyCode, KeyEvent, Modifiers};
2988
2989    // ── search wiring (escriba-search integration) ────────────────────
2990    //
2991    // The engine is proven in escriba-search's own 61 tests. These prove the
2992    // WIRING: that keys reach it, that the cursor lands where it says, and
2993    // that a search prompt and an ex-command can share Command mode without
2994    // being confused for one another.
2995
2996    fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
2997        st.apply(&Action::SearchOpen(dir));
2998        for c in pat.chars() {
2999            st.apply(&Action::InsertChar(c));
3000        }
3001        st.apply(&Action::SubmitCommand);
3002    }
3003
3004    #[test]
3005    fn slash_search_moves_the_cursor_to_the_match() {
3006        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
3007        type_search(&mut st, SearchDirection::Forward, "charlie");
3008        assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
3009        assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
3010        assert_eq!(st.search.matches().len(), 1);
3011    }
3012
3013    #[test]
3014    // `N` is a DIFFERENT vim key from `n` — see escriba-search.
3015    #[allow(non_snake_case)]
3016    fn n_and_N_walk_matches_in_both_directions() {
3017        let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
3018        type_search(&mut st, SearchDirection::Forward, "foo");
3019        let first = st.cursor().line;
3020        st.apply(&Action::SearchRepeat { reverse: false });
3021        let second = st.cursor().line;
3022        assert!(second > first, "n advances ({first} -> {second})");
3023        st.apply(&Action::SearchRepeat { reverse: true });
3024        assert_eq!(st.cursor().line, first, "N comes back");
3025    }
3026
3027    #[test]
3028    fn star_searches_the_word_under_the_cursor() {
3029        let mut st = new_state_with("needle\nhaystack\nneedle\n");
3030        st.apply(&Action::SearchWord { reverse: false });
3031        assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
3032        assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
3033    }
3034
3035    #[test]
3036    fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
3037        let mut st = new_state_with("foo\nbar\nfoo\n");
3038        type_search(&mut st, SearchDirection::Forward, "foo");
3039        let matches_before = st.search.matches().len();
3040
3041        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3042        st.apply(&Action::InsertChar('z'));
3043        st.apply(&Action::ChangeMode(Mode::Normal));
3044
3045        assert!(!st.search.is_prompting(), "prompt gone");
3046        assert_eq!(
3047            st.search.pattern().unwrap().raw(),
3048            "foo",
3049            "old pattern survives"
3050        );
3051        assert_eq!(
3052            st.search.matches().len(),
3053            matches_before,
3054            "old highlights survive"
3055        );
3056    }
3057
3058    #[test]
3059    fn a_search_prompt_and_an_ex_command_are_not_confused() {
3060        let mut st = new_state_with("foo\n");
3061        // No `/` pressed: Command mode belongs to the ex-command line.
3062        st.apply(&Action::ChangeMode(Mode::Command));
3063        assert!(!st.search.is_prompting(), "`:` must not open a search");
3064        st.apply(&Action::InsertChar('w'));
3065        assert!(
3066            st.search.prompt().is_none(),
3067            "typed char went to the ex line"
3068        );
3069    }
3070
3071    #[test]
3072    fn a_missing_pattern_reports_instead_of_failing_silently() {
3073        let mut st = new_state_with("alpha\nbravo\n");
3074        type_search(&mut st, SearchDirection::Forward, "zzz");
3075        assert!(
3076            st.messages.iter().any(|m| m.contains("E486")),
3077            "must report not-found, got {:?}",
3078            st.messages
3079        );
3080    }
3081
3082    #[test]
3083    fn n_without_any_search_reports_rather_than_moving() {
3084        let mut st = new_state_with("alpha\nbravo\n");
3085        let before = st.cursor();
3086        st.apply(&Action::SearchRepeat { reverse: false });
3087        assert_eq!(st.cursor(), before, "cursor must not move");
3088        assert!(
3089            st.messages.iter().any(|m| m.contains("E35")),
3090            "got {:?}",
3091            st.messages
3092        );
3093    }
3094
3095    #[test]
3096    fn search_as_a_motion_composes_with_an_operator() {
3097        // The point of Motion::SearchNext: `d` + search deletes to the match.
3098        let mut st = new_state_with("alpha bravo charlie\n");
3099        type_search(&mut st, SearchDirection::Forward, "charlie");
3100        st.set_cursor(Position::new(0, 0));
3101        let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
3102        assert!(target.is_some(), "search must resolve as a motion");
3103        assert_eq!(target.unwrap().column, 12, "at `charlie`");
3104    }
3105
3106    #[test]
3107    fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
3108        // A silent fallback to offset 0 would make `d` + search delete to the
3109        // start of the file — the worst possible failure for an operator.
3110        let st = new_state_with("alpha bravo\n");
3111        assert!(
3112            st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
3113                .is_none()
3114        );
3115    }
3116
3117    #[test]
3118    fn clear_highlight_keeps_the_pattern_usable() {
3119        let mut st = new_state_with("foo\nbar\nfoo\n");
3120        type_search(&mut st, SearchDirection::Forward, "foo");
3121        st.apply(&Action::ClearSearchHighlight);
3122        assert!(st.search.highlights().is_empty(), "nothing lit");
3123        st.apply(&Action::SearchRepeat { reverse: false });
3124        assert!(st.search.pattern().is_some(), "but n still works");
3125    }
3126
3127    #[test]
3128    fn typing_previews_incrementally_before_commit() {
3129        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
3130        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3131        for c in "charlie".chars() {
3132            st.apply(&Action::InsertChar(c));
3133        }
3134        // incsearch: the cursor has already moved, with nothing committed.
3135        assert_eq!(st.cursor().line, 2, "preview moved the cursor");
3136        assert!(st.search.pattern().is_none(), "but nothing is committed");
3137    }
3138
3139    #[test]
3140    fn backspace_corrects_the_prompt_and_reruns_the_preview() {
3141        let mut st = new_state_with("alpha\nbravo\n");
3142        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3143        for c in "bravox".chars() {
3144            st.apply(&Action::InsertChar(c));
3145        }
3146        assert_eq!(st.search.prompt().unwrap().text(), "bravox");
3147        st.apply(&Action::PromptBackspace);
3148        assert_eq!(
3149            st.search.prompt().unwrap().text(),
3150            "bravo",
3151            "typo corrected"
3152        );
3153        assert_eq!(
3154            st.status_model().prompt_text,
3155            "bravo",
3156            "the model reads the PROMPT — the minibuffer is the ex-line's store",
3157        );
3158        assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
3159    }
3160
3161    #[test]
3162    fn backspacing_past_the_slash_closes_the_prompt() {
3163        let mut st = new_state_with("alpha\n");
3164        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3165        st.apply(&Action::InsertChar('a'));
3166        st.apply(&Action::PromptBackspace);
3167        st.apply(&Action::PromptBackspace);
3168        assert!(!st.search.is_prompting(), "prompt closed");
3169        assert_eq!(st.modal.mode(), Mode::Normal);
3170    }
3171
3172    #[test]
3173    fn noh_clears_highlights_and_keeps_the_pattern() {
3174        let mut st = new_state_with("foo\nbar\nfoo\n");
3175        type_search(&mut st, SearchDirection::Forward, "foo");
3176        assert!(!st.search.highlights().is_empty());
3177        st.run_command("noh", &[]);
3178        assert!(st.search.highlights().is_empty(), ":noh turns them off");
3179        assert!(st.search.pattern().is_some(), "but n still works");
3180    }
3181
3182    #[test]
3183    fn noh_accepts_the_vim_aliases() {
3184        for name in ["noh", "nohl", "nohlsearch"] {
3185            let mut st = new_state_with("foo\nfoo\n");
3186            type_search(&mut st, SearchDirection::Forward, "foo");
3187            st.run_command(name, &[]);
3188            assert!(st.search.highlights().is_empty(), "{name} must clear");
3189        }
3190    }
3191
3192    #[test]
3193    fn backspace_on_the_ex_line_does_not_touch_search_state() {
3194        let mut st = new_state_with("foo\n");
3195        st.apply(&Action::ChangeMode(Mode::Command));
3196        st.apply(&Action::InsertChar('w'));
3197        st.apply(&Action::InsertChar('q'));
3198        st.apply(&Action::PromptBackspace);
3199        assert_eq!(st.status_model().prompt_text, "w");
3200        assert!(st.search.prompt().is_none(), "no search was involved");
3201    }
3202
3203    #[test]
3204    fn up_arrow_recalls_the_previous_search() {
3205        let mut st = new_state_with("alpha\nbravo\n");
3206        type_search(&mut st, SearchDirection::Forward, "bravo");
3207        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3208        st.apply(&Action::PromptHistory { back: true });
3209        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
3210        assert_eq!(
3211            st.status_model().prompt_text,
3212            "bravo",
3213            "display follows the prompt"
3214        );
3215    }
3216
3217    #[test]
3218    fn arrowing_back_down_restores_the_half_typed_pattern() {
3219        let mut st = new_state_with("alpha\nbravo\n");
3220        type_search(&mut st, SearchDirection::Forward, "bravo");
3221        st.apply(&Action::SearchOpen(SearchDirection::Forward));
3222        st.apply(&Action::InsertChar('a'));
3223        st.apply(&Action::PromptHistory { back: true });
3224        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
3225        st.apply(&Action::PromptHistory { back: false });
3226        assert_eq!(
3227            st.search.prompt().unwrap().text(),
3228            "a",
3229            "the draft comes back"
3230        );
3231        assert_eq!(st.status_model().prompt_text, "a");
3232    }
3233
3234    #[test]
3235    fn history_arrows_do_nothing_on_the_ex_line() {
3236        let mut st = new_state_with("alpha\n");
3237        st.apply(&Action::ChangeMode(Mode::Command));
3238        st.apply(&Action::InsertChar('w'));
3239        st.apply(&Action::PromptHistory { back: true });
3240        assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
3241    }
3242
3243    // ── trouble.* — the findings view ────────────────────────────────
3244    //
3245    // These assert on the ROWS the picker would be built from, not on the
3246    // registry: the registry is already tested, and what could be wrong
3247    // here is the projection — scoping, freshness, and whether a row goes
3248    // anywhere when pressed.
3249
3250    fn finding_at(buffer: BufferId, line: u32, msg: &str) -> escriba_shirube::Finding {
3251        use escriba_core::{Position, Range};
3252        escriba_shirube::Finding::new(
3253            escriba_shirube::Site::in_buffer(
3254                buffer,
3255                Range::new(Position::new(line, 0), Position::new(line, 1)),
3256            ),
3257            escriba_shirube::Severity::Error,
3258            msg.to_string(),
3259            escriba_shirube::Origin::Text("test"),
3260        )
3261    }
3262
3263    #[test]
3264    fn published_findings_become_picker_rows() {
3265        let mut st = new_state_with("a\nb\nc\n");
3266        let world = st.world();
3267        st.results.publish(
3268            "test",
3269            escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
3270        );
3271        let rows = st.finding_items(true);
3272        assert_eq!(rows.len(), 1, "the published finding produces a row");
3273        // The row must SAY something an operator can act on: severity,
3274        // 1-based line, and the message.
3275        let label = &rows[0].label;
3276        assert!(label.contains("ERROR"), "{label}");
3277        assert!(label.contains(":2"), "lines are 1-based on screen: {label}");
3278        assert!(label.contains("boom"), "{label}");
3279    }
3280
3281    #[test]
3282    fn a_stale_list_contributes_no_rows() {
3283        // THE load-bearing one. A list anchored to a revision the buffer has
3284        // moved past must vanish from the view rather than offer a line that
3285        // has since shifted — which is the whole reason findings carry an
3286        // anchor instead of just a position.
3287        let mut st = new_state_with("a\nb\nc\n");
3288        let world = st.world();
3289        st.results.publish(
3290            "test",
3291            escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
3292        );
3293        assert_eq!(st.finding_items(true).len(), 1, "fresh to begin with");
3294
3295        st.apply(&Action::InsertChar('x'));
3296        assert!(
3297            st.finding_items(true).is_empty(),
3298            "an edit moved the text on; the list is stale and must not be shown"
3299        );
3300    }
3301
3302    #[test]
3303    fn document_scope_excludes_another_buffer() {
3304        // `trouble.document` vs `trouble.workspace` is one bool, so this is
3305        // the only thing that can distinguish them.
3306        let mut st = new_state_with("a\nb\n");
3307        let other = st.buffers.scratch("z\n");
3308        let world = st.world();
3309        st.results.publish(
3310            "test",
3311            escriba_shirube::ResultList::new(
3312                vec![
3313                    finding_at(st.active, 0, "mine"),
3314                    finding_at(other, 0, "theirs"),
3315                ],
3316                world,
3317            ),
3318        );
3319        let ws = st.finding_items(true);
3320        assert_eq!(ws.len(), 2, "workspace scope shows both");
3321        let doc = st.finding_items(false);
3322        assert_eq!(doc.len(), 1, "document scope shows only the active buffer");
3323        assert!(doc[0].label.contains("mine"), "{}", doc[0].label);
3324    }
3325
3326    #[test]
3327    fn files_under_a_root_produces_rows() {
3328        // `files.open-parent` differs from `files.open` only in the root, so
3329        // what must hold is that a root is actually honoured.
3330        let mut st = new_state_with("");
3331        let rows = st.file_items(std::path::Path::new("."));
3332        assert!(!rows.is_empty(), "the working directory has files");
3333    }
3334
3335    // ── vim text objects ─────────────────────────────────────────────
3336    //
3337    // Asserted through `apply` on real buffer text, so a wrong RANGE shows
3338    // up as wrong text rather than as a range that merely looks plausible.
3339
3340    fn after(text: &str, line: u32, col: u32, act: Action) -> String {
3341        let mut st = new_state_with(text);
3342        st.set_cursor(Position::new(line, col));
3343        st.apply(&act);
3344        st.buffers
3345            .get(st.active)
3346            .map(|b| b.to_string())
3347            .unwrap_or_default()
3348    }
3349
3350    fn del_obj(o: escriba_core::TextObject) -> Action {
3351        Action::ApplyOperatorObject {
3352            op: escriba_core::Operator::Delete,
3353            object: o,
3354        }
3355    }
3356
3357    #[test]
3358    fn dd_removes_the_line_not_just_its_contents() {
3359        // The distinction the newline makes: without it, `dd` blanks a line
3360        // and leaves it behind.
3361        let got = after("a\nb\nc\n", 1, 0, del_obj(escriba_core::TextObject::Line));
3362        assert_eq!(got, "a\nc\n");
3363    }
3364
3365    #[test]
3366    fn dd_on_the_last_line_leaves_no_blank_behind() {
3367        // The case a naive start-of-line..start-of-next range gets wrong:
3368        // there is no following newline to take, so it must take the
3369        // preceding one.
3370        let got = after("a\nb\nc\n", 2, 0, del_obj(escriba_core::TextObject::Line));
3371        assert_eq!(got, "a\nb\n", "no trailing empty line: {got:?}");
3372    }
3373
3374    #[test]
3375    fn dd_on_the_only_line_clears_it_but_keeps_the_line() {
3376        let got = after("solo\n", 0, 2, del_obj(escriba_core::TextObject::Line));
3377        assert!(got.starts_with('\n') || got.is_empty(), "{got:?}");
3378    }
3379
3380    #[test]
3381    fn diw_takes_the_word_and_daw_takes_its_trailing_space() {
3382        let inner = after(
3383            "one two three\n",
3384            0,
3385            5,
3386            del_obj(escriba_core::TextObject::Word { around: false }),
3387        );
3388        assert_eq!(inner, "one  three\n", "iw leaves both spaces");
3389        let around = after(
3390            "one two three\n",
3391            0,
3392            5,
3393            del_obj(escriba_core::TextObject::Word { around: true }),
3394        );
3395        assert_eq!(around, "one three\n", "aw takes the trailing space");
3396    }
3397
3398    #[test]
3399    fn iw_from_any_column_inside_the_word_takes_the_whole_word() {
3400        for col in 4..=6 {
3401            let got = after(
3402                "one two three\n",
3403                0,
3404                col,
3405                del_obj(escriba_core::TextObject::Word { around: false }),
3406            );
3407            assert_eq!(got, "one  three\n", "from column {col}");
3408        }
3409    }
3410
3411    #[test]
3412    fn iw_on_punctuation_takes_the_punctuation_run() {
3413        // vim's three classes: word / punctuation / whitespace. A `::` is a
3414        // run of punctuation, not part of either identifier.
3415        let got = after(
3416            "foo::bar\n",
3417            0,
3418            3,
3419            del_obj(escriba_core::TextObject::Word { around: false }),
3420        );
3421        assert_eq!(got, "foobar\n");
3422    }
3423
3424    #[test]
3425    fn i_paren_takes_the_inside_and_a_paren_takes_the_brackets_too() {
3426        let inner = after(
3427            "f(a, b)\n",
3428            0,
3429            3,
3430            del_obj(escriba_core::TextObject::Delimited {
3431                open: '(',
3432                close: ')',
3433                around: false,
3434            }),
3435        );
3436        assert_eq!(inner, "f()\n");
3437        let around = after(
3438            "f(a, b)\n",
3439            0,
3440            3,
3441            del_obj(escriba_core::TextObject::Delimited {
3442                open: '(',
3443                close: ')',
3444                around: true,
3445            }),
3446        );
3447        assert_eq!(around, "f\n");
3448    }
3449
3450    #[test]
3451    fn nested_brackets_resolve_to_the_enclosing_pair() {
3452        // THE reason the bracket scan counts depth: an inner pair must not
3453        // terminate the search for the one the cursor is actually inside.
3454        let got = after(
3455            "f(g(x), y)\n",
3456            0,
3457            8,
3458            del_obj(escriba_core::TextObject::Delimited {
3459                open: '(',
3460                close: ')',
3461                around: false,
3462            }),
3463        );
3464        assert_eq!(got, "f()\n", "took the outer pair");
3465    }
3466
3467    #[test]
3468    fn quotes_do_not_nest_so_the_nearest_pair_wins() {
3469        let got = after(
3470            r#"say "hi there" ok"#,
3471            0,
3472            7,
3473            del_obj(escriba_core::TextObject::Delimited {
3474                open: '"',
3475                close: '"',
3476                around: false,
3477            }),
3478        );
3479        assert_eq!(got, "say \"\" ok");
3480    }
3481
3482    #[test]
3483    fn an_unmatched_delimiter_resolves_to_nothing_rather_than_guessing() {
3484        let mut st = new_state_with("f(a, b\n");
3485        st.set_cursor(Position::new(0, 3));
3486        let before = st
3487            .buffers
3488            .get(st.active)
3489            .map(|b| b.to_string())
3490            .unwrap_or_default();
3491        st.apply(&del_obj(escriba_core::TextObject::Delimited {
3492            open: '(',
3493            close: ')',
3494            around: false,
3495        }));
3496        let got_after = st
3497            .buffers
3498            .get(st.active)
3499            .map(|b| b.to_string())
3500            .unwrap_or_default();
3501        assert_eq!(got_after, before, "no closing bracket: change nothing");
3502    }
3503
3504    fn new_state_with(text: &str) -> EditorState {
3505        let mut bufs = BufferSet::new();
3506        let id = bufs.scratch(text);
3507        EditorState::new_with_buffer(bufs, id)
3508    }
3509
3510    /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
3511    /// action advances `edit_gen` (so the renderer repaints), and merely
3512    /// reading the generation does not. This is what lets `gpu.rs` gate the
3513    /// re-highlight/re-shape on a generation change — an idle frame observes an
3514    /// unchanged generation and reuses its cached buffer.
3515    #[test]
3516    fn edit_gen_advances_on_applied_action_not_on_read() {
3517        let mut s = new_state_with("hello\nworld\n");
3518        let g0 = s.edit_gen();
3519        s.apply(&Action::InsertChar('X'));
3520        assert_ne!(
3521            s.edit_gen(),
3522            g0,
3523            "an applied action must advance the refresh generation",
3524        );
3525        // Reading the generation is not a mutation — idle frames stay put.
3526        let g1 = s.edit_gen();
3527        assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
3528    }
3529
3530    /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
3531    /// `Damage` to cover exactly what changed — local for an in-place edit,
3532    /// to-end-of-document when the line count shifts — and the renderer drains
3533    /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
3534    #[test]
3535    fn damage_tracks_edit_scope_and_drains() {
3536        let mut s = new_state_with("hello\nworld\n");
3537        assert!(s.damage().is_none(), "a fresh state has no damage");
3538
3539        s.apply(&Action::InsertChar('X')); // in-place edit on line 0
3540        assert_eq!(
3541            s.damage(),
3542            Damage::Lines { from: 0, to: 0 },
3543            "a local edit damages just its line",
3544        );
3545
3546        let drained = s.take_damage();
3547        assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
3548        assert!(s.damage().is_none(), "take_damage drains to None");
3549
3550        s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
3551        assert_eq!(
3552            s.damage(),
3553            Damage::Lines {
3554                from: 0,
3555                to: u32::MAX,
3556            },
3557            "a line-count change damages to end-of-document",
3558        );
3559    }
3560
3561    /// A state whose active window is a deliberately tiny viewport
3562    /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
3563    /// invariant is exercised on small inputs.
3564    fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
3565        let mut s = new_state_with(text);
3566        for w in s.layout.windows_mut() {
3567            w.viewport.visible_lines = vis_lines;
3568            w.viewport.visible_columns = vis_cols;
3569        }
3570        s
3571    }
3572
3573    /// The core regression invariant: the active window's viewport CONTAINS
3574    /// the cursor on BOTH axes. This is the operator's exact complaint —
3575    /// "typing past the bottom (or right) leaves the cursor off-screen" —
3576    /// made into a checkable property.
3577    fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
3578        let w = s.layout.active_window().expect("active window");
3579        let v = w.viewport;
3580        let c = s.cursor();
3581        assert!(
3582            v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
3583            "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
3584            c.line,
3585            v.top_line,
3586            v.top_line + v.visible_lines,
3587        );
3588        assert!(
3589            v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
3590            "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
3591            c.column,
3592            v.left_column,
3593            v.left_column + v.visible_columns,
3594        );
3595    }
3596
3597    /// `dd` from the KEYBOARD, not from a synthesized action.
3598    ///
3599    /// The FSM composition is unit-tested, but what an operator actually
3600    /// does is press `d` twice — and that path goes through the keymap and
3601    /// the sequence stepper, either of which could swallow the second `d`.
3602    #[test]
3603    fn pressing_d_twice_deletes_the_line() {
3604        let mut st = new_state_with("alpha\nbeta\ngamma\n");
3605        st.set_cursor(Position::new(1, 0));
3606        st.tick(&press(KeyCode::Char('d')));
3607        st.tick(&press(KeyCode::Char('d')));
3608        let got = st
3609            .buffers
3610            .get(st.active)
3611            .map(|b| b.to_string())
3612            .unwrap_or_default();
3613        assert_eq!(got, "alpha\ngamma\n", "dd from the keyboard");
3614    }
3615
3616    #[test]
3617    fn pressing_2_d_d_deletes_two_lines() {
3618        let mut st = new_state_with("a\nb\nc\nd\n");
3619        st.set_cursor(Position::new(0, 0));
3620        for k in ['2', 'd', 'd'] {
3621            st.tick(&press(KeyCode::Char(k)));
3622        }
3623        let got = st
3624            .buffers
3625            .get(st.active)
3626            .map(|b| b.to_string())
3627            .unwrap_or_default();
3628        assert_eq!(got, "c\nd\n", "count applies to the doubled operator");
3629    }
3630
3631    fn press(kc: KeyCode) -> AppEvent {
3632        AppEvent::Key(KeyEvent {
3633            key: kc,
3634            pressed: true,
3635            modifiers: Modifiers::default(),
3636            text: None,
3637        })
3638    }
3639
3640    // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
3641
3642    fn line0_len(s: &EditorState) -> u32 {
3643        s.buffers.get(s.active).unwrap().line_len_chars(0)
3644    }
3645
3646    #[test]
3647    fn delete_to_line_end_clears_line_and_fills_register() {
3648        let mut s = new_state_with("hello world");
3649        s.apply(&Action::ApplyOperator {
3650            op: Operator::Delete,
3651            motion: Motion::LineEnd,
3652        });
3653        assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
3654        assert_eq!(
3655            s.register(),
3656            Some("hello world"),
3657            "delete fills the register"
3658        );
3659        assert_eq!(
3660            s.cursor(),
3661            Position::ZERO,
3662            "cursor lands at the range start"
3663        );
3664    }
3665
3666    #[test]
3667    fn delete_over_right_motion_removes_one_char() {
3668        let mut s = new_state_with("abc");
3669        s.apply(&Action::ApplyOperator {
3670            op: Operator::Delete,
3671            motion: Motion::Right,
3672        });
3673        assert_eq!(
3674            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3675            Some("bc")
3676        );
3677        assert_eq!(s.register(), Some("a"));
3678    }
3679
3680    #[test]
3681    fn change_to_line_end_deletes_and_enters_insert() {
3682        let mut s = new_state_with("hello world");
3683        assert_eq!(s.modal.mode(), Mode::Normal);
3684        s.apply(&Action::ApplyOperator {
3685            op: Operator::Change,
3686            motion: Motion::LineEnd,
3687        });
3688        assert_eq!(line0_len(&s), 0, "c$ deletes the range");
3689        assert_eq!(
3690            s.modal.mode(),
3691            Mode::Insert,
3692            "change enters Insert to type the replacement"
3693        );
3694        assert_eq!(
3695            s.register(),
3696            Some("hello world"),
3697            "change fills the register"
3698        );
3699    }
3700
3701    #[test]
3702    fn yank_to_line_end_fills_register_without_mutating() {
3703        let mut s = new_state_with("hello world");
3704        s.apply(&Action::ApplyOperator {
3705            op: Operator::Yank,
3706            motion: Motion::LineEnd,
3707        });
3708        assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
3709        assert_eq!(s.register(), Some("hello world"), "yank fills the register");
3710        assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
3711    }
3712
3713    #[test]
3714    fn resolve_motion_is_the_shared_target_for_move_and_operator() {
3715        // The encapsulation proof: apply_motion (cursor move) and
3716        // apply_operator (range end) BOTH stand on resolve_motion — so a move
3717        // to LineEnd lands at exactly the position the operator deletes to.
3718        let mut s = new_state_with("hello world");
3719        let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
3720        assert_eq!(target, Position::new(0, 11));
3721        s.apply_motion(Motion::LineEnd);
3722        assert_eq!(
3723            s.cursor(),
3724            target,
3725            "the move path resolves the same target the operator uses"
3726        );
3727    }
3728
3729    #[test]
3730    fn empty_motion_range_is_a_no_op() {
3731        // An operator over a zero-width motion (cursor already at line start)
3732        // mutates nothing and leaves the register untouched.
3733        let mut s = new_state_with("abc");
3734        s.apply(&Action::ApplyOperator {
3735            op: Operator::Delete,
3736            motion: Motion::LineStart,
3737        });
3738        assert_eq!(
3739            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3740            Some("abc")
3741        );
3742        assert_eq!(s.register(), None);
3743    }
3744
3745    #[test]
3746    fn operator_then_motion_composes_through_the_pending_fsm() {
3747        // The full keymap→FSM→engine path: dispatching the `d` operator action
3748        // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
3749        // the operator key alone does nothing until the motion arrives.
3750        let mut s = new_state_with("hello world");
3751        s.apply(&Action::Operator(Operator::Delete));
3752        assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
3753        s.apply(&Action::Move(Motion::LineEnd));
3754        assert_eq!(
3755            line0_len(&s),
3756            0,
3757            "d then $ composes d$ and deletes the line"
3758        );
3759        assert_eq!(s.register(), Some("hello world"));
3760    }
3761
3762    #[test]
3763    fn change_operator_through_fsm_enters_insert() {
3764        let mut s = new_state_with("hello world");
3765        s.apply(&Action::Operator(Operator::Change));
3766        s.apply(&Action::Move(Motion::LineEnd));
3767        assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
3768    }
3769
3770    #[test]
3771    fn lone_motion_after_no_operator_just_moves() {
3772        // Without a preceding operator the motion passes through unchanged.
3773        let mut s = new_state_with("hello world");
3774        s.apply(&Action::Move(Motion::LineEnd));
3775        assert_eq!(s.cursor(), Position::new(0, 11));
3776        assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
3777    }
3778
3779    #[test]
3780    fn counted_operator_deletes_count_times() {
3781        // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
3782        // flows through the FSM to the composed motion (the bug fix: previously
3783        // the count repeated the operator key and toggled the FSM).
3784        let mut s = new_state_with("abcdef");
3785        s.apply_counted(&Action::Operator(Operator::Delete), 3);
3786        assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
3787        s.apply(&Action::Move(Motion::Right));
3788        assert_eq!(
3789            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3790            Some("def")
3791        );
3792    }
3793
3794    #[test]
3795    fn operator_and_motion_counts_multiply_end_to_end() {
3796        // `2d3l` = delete 2×3 = 6 chars.
3797        let mut s = new_state_with("abcdefgh");
3798        s.apply_counted(&Action::Operator(Operator::Delete), 2);
3799        s.apply_counted(&Action::Move(Motion::Right), 3);
3800        assert_eq!(
3801            s.buffers.get(s.active).unwrap().line(0).as_deref(),
3802            Some("gh")
3803        );
3804    }
3805
3806    #[test]
3807    fn bare_counted_motion_still_repeats_no_regression() {
3808        // `3j` still moves down 3 lines — the count passes through the FSM
3809        // unchanged when no operator is pending.
3810        let mut s = new_state_with("a\nb\nc\nd\ne");
3811        s.apply_counted(&Action::Move(Motion::Down), 3);
3812        assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
3813    }
3814
3815    /// A monotonic clock for the key-repeat gate in tests — each `next()`
3816    /// jumps a full second past the previous, so every press it stamps is
3817    /// well outside the 80ms debounce window and therefore an INTENTIONAL
3818    /// press (never a storm tick). Used by tests that fire the *same*
3819    /// navigation key twice and assert editor logic, not debounce timing.
3820    struct SpacedClock(std::time::Instant);
3821    impl SpacedClock {
3822        fn new() -> Self {
3823            Self(std::time::Instant::now())
3824        }
3825        fn next(&mut self) -> std::time::Instant {
3826            self.0 += std::time::Duration::from_secs(1);
3827            self.0
3828        }
3829    }
3830
3831    #[test]
3832    fn hjkl_moves_cursor() {
3833        let mut s = new_state_with("hello\nworld");
3834        s.tick(&press(KeyCode::Char('l')));
3835        assert_eq!(s.cursor().column, 1);
3836        s.tick(&press(KeyCode::Char('j')));
3837        assert_eq!(s.cursor().line, 1);
3838        s.tick(&press(KeyCode::Char('h')));
3839        assert_eq!(s.cursor().column, 0);
3840    }
3841
3842    #[test]
3843    fn insert_mode_inserts_chars() {
3844        let mut s = new_state_with("");
3845        s.tick(&press(KeyCode::Char('i')));
3846        assert_eq!(s.modal.mode(), Mode::Insert);
3847        s.tick(&press(KeyCode::Char('h')));
3848        s.tick(&press(KeyCode::Char('i')));
3849        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
3850        assert_eq!(s.cursor().column, 2);
3851    }
3852
3853    #[test]
3854    fn esc_returns_to_normal() {
3855        let mut s = new_state_with("");
3856        s.tick(&press(KeyCode::Char('i')));
3857        s.tick(&press(KeyCode::Escape));
3858        assert_eq!(s.modal.mode(), Mode::Normal);
3859    }
3860
3861    #[test]
3862    fn count_prefix_repeats_motion() {
3863        let mut s = new_state_with("abcdefghij");
3864        s.tick(&press(KeyCode::Char('5')));
3865        s.tick(&press(KeyCode::Char('l')));
3866        assert_eq!(s.cursor().column, 5);
3867    }
3868
3869    #[test]
3870    fn close_event_requests_quit() {
3871        let mut s = new_state_with("");
3872        s.tick(&AppEvent::CloseRequested);
3873        assert!(s.quit_requested);
3874    }
3875
3876    #[test]
3877    fn word_next_jumps_past_whitespace() {
3878        let mut s = new_state_with("foo bar baz");
3879        // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
3880        // the gate passes both (a real user's two taps are ≥80ms apart).
3881        let mut clk = SpacedClock::new();
3882        s.tick_at(&press(KeyCode::Char('w')), clk.next());
3883        assert_eq!(s.cursor().column, 4);
3884        s.tick_at(&press(KeyCode::Char('w')), clk.next());
3885        assert_eq!(s.cursor().column, 8);
3886    }
3887
3888    // ── Multi-key / leader pending-stroke ───────────────────────────
3889
3890    #[test]
3891    fn leader_sequence_holds_then_resolves() {
3892        let mut s = new_state_with("a\nbb\nccc");
3893        s.keymap.bind_sequence(
3894            Mode::Normal,
3895            vec![Key::Char(','), Key::Char('g')],
3896            Action::Move(Motion::DocEnd),
3897            "doc end",
3898        );
3899        // `,` begins the sequence — held pending, nothing applied yet.
3900        s.on_key(&Key::Char(','));
3901        assert_eq!(s.pending_keys, vec![Key::Char(',')]);
3902        assert_eq!(s.cursor(), Position::ZERO);
3903        // `g` completes `<leader>g` → DocEnd; pending clears.
3904        s.on_key(&Key::Char('g'));
3905        assert!(s.pending_keys.is_empty());
3906        assert_eq!(s.cursor().line, 2);
3907    }
3908
3909    #[test]
3910    fn two_key_gg_jumps_doc_start() {
3911        let mut s = new_state_with("a\nbb\nccc");
3912        s.keymap.bind_sequence(
3913            Mode::Normal,
3914            vec![Key::Char('g'), Key::Char('g')],
3915            Action::Move(Motion::DocStart),
3916            "doc start",
3917        );
3918        let mut clk = SpacedClock::new();
3919        s.tick_at(&press(KeyCode::Char('j')), clk.next());
3920        s.tick_at(&press(KeyCode::Char('j')), clk.next());
3921        assert_eq!(s.cursor().line, 2);
3922        s.on_key(&Key::Char('g')); // pending
3923        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3924        s.on_key(&Key::Char('g')); // resolve
3925        assert_eq!(s.cursor(), Position::ZERO);
3926    }
3927
3928    #[test]
3929    fn broken_sequence_aborts_and_clears_pending() {
3930        let mut s = new_state_with("hello");
3931        s.keymap.bind_sequence(
3932            Mode::Normal,
3933            vec![Key::Char('g'), Key::Char('g')],
3934            Action::Move(Motion::DocEnd),
3935            "doc end",
3936        );
3937        s.on_key(&Key::Char('g')); // pending [g]
3938        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3939        s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
3940        assert!(s.pending_keys.is_empty());
3941        assert_eq!(s.cursor(), Position::ZERO);
3942    }
3943
3944    #[test]
3945    fn single_binding_wins_over_sequence_prefix() {
3946        // A key that is BOTH a complete single binding and the start of
3947        // a sequence fires the single binding immediately (no chord
3948        // timeout needed). Here `h` (move-left) also prefixes `hz`.
3949        let mut s = new_state_with("abcde");
3950        let mut clk = SpacedClock::new();
3951        s.tick_at(&press(KeyCode::Char('l')), clk.next());
3952        s.tick_at(&press(KeyCode::Char('l')), clk.next());
3953        assert_eq!(s.cursor().column, 2);
3954        s.keymap.bind_sequence(
3955            Mode::Normal,
3956            vec![Key::Char('h'), Key::Char('z')],
3957            Action::Move(Motion::DocEnd),
3958            "shadowed",
3959        );
3960        s.on_key(&Key::Char('h'));
3961        assert!(s.pending_keys.is_empty(), "single binding should not pend");
3962        assert_eq!(s.cursor().column, 1, "h moved left immediately");
3963    }
3964
3965    // ── tatara-lisp runtime bridge (imperative programmability) ─────
3966
3967    #[test]
3968    fn lisp_set_option_writes_live_options() {
3969        let mut s = new_state_with("");
3970        s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
3971        assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
3972    }
3973
3974    #[test]
3975    fn lisp_insert_modifies_buffer_and_advances_cursor() {
3976        let mut s = new_state_with("");
3977        s.run_lisp(r#"(insert "abc")"#).unwrap();
3978        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
3979        assert_eq!(s.cursor(), Position::new(0, 3));
3980    }
3981
3982    #[test]
3983    fn lisp_message_appends_to_messages() {
3984        let mut s = new_state_with("");
3985        s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
3986        assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
3987    }
3988
3989    #[test]
3990    fn lisp_reads_snapshot_and_branches_to_effect() {
3991        // Genuine programmability: Lisp reads the live cursor line and
3992        // an `if` decides which option to set.
3993        let mut s = new_state_with("one\ntwo\nthree");
3994        // cursor at line 0 → "top" branch
3995        s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
3996            .unwrap();
3997        assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
3998    }
3999
4000    #[test]
4001    fn lisp_run_command_effect_drives_registry() {
4002        // `(run-command "undo")` reaches the live command registry and
4003        // reverts a prior Lisp-driven insert — proving the RunCommand
4004        // effect dispatches through real editor commands.
4005        let mut s = new_state_with("");
4006        s.run_lisp(r#"(insert "abc")"#).unwrap();
4007        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
4008        s.run_lisp(r#"(run-command "undo")"#).unwrap();
4009        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
4010    }
4011
4012    #[test]
4013    fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
4014        // The full imperative-quit path: (run-command "quit") routes
4015        // through the registry's typed `quit_requested` signal — no string
4016        // sentinel, and no minibuffer pollution (the editor stays in a
4017        // clean Normal state, which has no minibuffer at all).
4018        let mut s = new_state_with("");
4019        s.run_lisp(r#"(run-command "quit")"#).unwrap();
4020        assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
4021        assert_eq!(
4022            s.modal.minibuffer(),
4023            "",
4024            "quit must not pollute any command line — Normal mode has no minibuffer",
4025        );
4026    }
4027
4028    // ── Lazy plugin activation (PluginHost) ────────────────────────
4029
4030    #[test]
4031    fn lazy_plugin_activates_on_command_trigger() {
4032        // A user plugin gated on `Command: LazyGo` has its entry applied
4033        // the first time that command runs — proving the lazy.nvim
4034        // `cmd =` model works end-to-end against live editor state.
4035        let mut s = new_state_with("");
4036        s.register_lazy_plugin(
4037            "user-lazy",
4038            vec![LazyTrigger::Command("LazyGo".into())],
4039            r#"(defoption :name "lazy-loaded" :value "yes")
4040               (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
4041        );
4042        assert_eq!(s.plugin_host.pending(), 1);
4043        assert!(
4044            s.options.get("lazy-loaded").is_none(),
4045            "entry not applied yet"
4046        );
4047
4048        // Drive the command through the public imperative path.
4049        s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
4050
4051        assert_eq!(
4052            s.options.get("lazy-loaded").map(String::as_str),
4053            Some("yes"),
4054            "the command trigger applied the plugin's entry",
4055        );
4056        assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
4057    }
4058
4059    #[test]
4060    fn lazy_plugin_activates_on_filetype() {
4061        let mut s = new_state_with("");
4062        s.register_lazy_plugin(
4063            "user-rust",
4064            vec![LazyTrigger::FileType("rust".into())],
4065            r#"(defoption :name "rust-plugin" :value "on")"#,
4066        );
4067        let n = s.activate_filetype_plugins("rust");
4068        assert_eq!(n, 1);
4069        assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
4070        // A second open of the same filetype is a no-op (one-shot).
4071        assert_eq!(s.activate_filetype_plugins("rust"), 0);
4072    }
4073
4074    #[test]
4075    fn cached_vm_serves_multiple_run_lisp_calls() {
4076        let mut s = new_state_with("");
4077        s.run_lisp(r#"(message "one")"#).unwrap();
4078        assert!(
4079            s.lisp_vm.is_some(),
4080            "VM should be cached after first run_lisp"
4081        );
4082        s.run_lisp(r#"(message "two")"#).unwrap();
4083        assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
4084    }
4085
4086    #[test]
4087    fn lisp_define_persists_across_run_lisp_calls() {
4088        // The cached VM's top-level env persists across calls (REPL
4089        // semantics): a `define` in one call is visible in the next.
4090        let mut s = new_state_with("");
4091        s.run_lisp(r#"(define greeting "hi")"#).unwrap();
4092        s.run_lisp(r#"(message greeting)"#).unwrap();
4093        assert_eq!(s.messages, vec!["hi".to_string()]);
4094    }
4095
4096    #[test]
4097    fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
4098        // Within ONE call a program cannot observe its own writes — the
4099        // read snapshot is captured before eval, effects apply after. A
4100        // later call sees the refreshed snapshot.
4101        let mut s = new_state_with("");
4102        s.run_lisp(
4103            r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
4104        )
4105        .unwrap();
4106        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
4107        assert_eq!(
4108            s.options.get("col").map(String::as_str),
4109            Some("stale-zero"),
4110            "cursor-column within the same call reads the pre-eval snapshot",
4111        );
4112        // After the first call the cursor advanced to column 2; the next
4113        // call's snapshot reflects it.
4114        s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
4115            .unwrap();
4116        assert_eq!(
4117            s.options.get("col2").map(String::as_str),
4118            Some("live-two"),
4119            "a later call sees the refreshed snapshot",
4120        );
4121    }
4122
4123    #[test]
4124    fn insert_text_effect_multiline_lands_cursor_on_last_line() {
4125        let mut s = new_state_with("");
4126        s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
4127        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
4128        assert_eq!(s.cursor(), Position::new(1, 3));
4129    }
4130
4131    #[test]
4132    fn visual_mode_sequence_resolves() {
4133        let mut s = new_state_with("abc");
4134        s.modal.enter(Mode::Visual);
4135        s.keymap.bind_sequence(
4136            Mode::Visual,
4137            vec![Key::Char('g'), Key::Char('e')],
4138            Action::Move(Motion::DocEnd),
4139            "ge",
4140        );
4141        s.on_key(&Key::Char('g'));
4142        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4143        s.on_key(&Key::Char('e'));
4144        assert!(s.pending_keys.is_empty());
4145        assert_eq!(
4146            s.cursor().column,
4147            3,
4148            "ge resolved to doc-end in visual mode"
4149        );
4150    }
4151
4152    #[test]
4153    fn sequence_abort_with_bound_breaking_key_redispatches() {
4154        // gg is a sequence; `l` (move-right) is a bound single key. After
4155        // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
4156        let mut s = new_state_with("abcde");
4157        s.keymap.bind_sequence(
4158            Mode::Normal,
4159            vec![Key::Char('g'), Key::Char('g')],
4160            Action::Move(Motion::DocEnd),
4161            "gg",
4162        );
4163        s.on_key(&Key::Char('g'));
4164        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4165        s.on_key(&Key::Char('l'));
4166        assert!(s.pending_keys.is_empty());
4167        assert_eq!(
4168            s.cursor().column,
4169            1,
4170            "the breaking key l should re-dispatch as move-right",
4171        );
4172    }
4173
4174    // ── Viewport-follows-cursor invariant (both axes) ───────────────
4175
4176    #[test]
4177    fn viewport_contains_cursor_after_every_op() {
4178        // Tiny window: 5 visible lines × 10 visible columns. Drive a
4179        // representative scripted sequence and assert the viewport contains
4180        // the cursor after EVERY mutating step.
4181        let mut s = new_state_small_viewport("", 5, 10);
4182        assert_cursor_in_viewport(&s, "initial");
4183
4184        // Enter insert mode and type 30 newline-separated lines — this is
4185        // the exact "type past the bottom" complaint.
4186        s.tick(&press(KeyCode::Char('i')));
4187        assert_eq!(s.modal.mode(), Mode::Insert);
4188        for line in 0..30u32 {
4189            for c in "line".chars() {
4190                s.tick(&press(KeyCode::Char(c)));
4191                assert_cursor_in_viewport(&s, "typing chars");
4192            }
4193            s.tick(&press(KeyCode::Enter));
4194            assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
4195        }
4196
4197        // Type a long (200-char) line — the "type past the right edge"
4198        // complaint. The cursor must stay horizontally visible the whole way.
4199        for i in 0..200u32 {
4200            s.tick(&press(KeyCode::Char('x')));
4201            assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
4202        }
4203
4204        // Multi-line insert_text effect (the `(insert …)` Lisp path).
4205        s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
4206        assert_cursor_in_viewport(&s, "insert_text multiline");
4207
4208        // Back to normal mode and move in all directions / to extremes.
4209        s.tick(&press(KeyCode::Escape));
4210        assert_eq!(s.modal.mode(), Mode::Normal);
4211        for m in [
4212            Motion::DocStart,
4213            Motion::DocEnd,
4214            Motion::Down,
4215            Motion::Down,
4216            Motion::Up,
4217            Motion::Right,
4218            Motion::Right,
4219            Motion::Left,
4220            Motion::LineEnd,
4221            Motion::LineStart,
4222            Motion::GotoLine(1),
4223            Motion::GotoLine(40),
4224            Motion::PageDown,
4225            Motion::PageUp,
4226        ] {
4227            s.apply_motion(m);
4228            assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
4229        }
4230
4231        // Undo many times — the buffer shrinks; the viewport must re-follow
4232        // the (now clamped) cursor.
4233        for i in 0..50u32 {
4234            s.apply(&Action::Undo);
4235            assert_cursor_in_viewport(&s, &format!("undo {i}"));
4236        }
4237        // Redo back up.
4238        for i in 0..50u32 {
4239            s.apply(&Action::Redo);
4240            assert_cursor_in_viewport(&s, &format!("redo {i}"));
4241        }
4242    }
4243
4244    #[test]
4245    fn insert_at_eof_keeps_cursor_in_bounds() {
4246        // Inserting at the end of the buffer must leave the cursor clamped
4247        // to a valid position (and inside the viewport).
4248        let mut s = new_state_small_viewport("abc", 5, 10);
4249        s.apply_motion(Motion::DocEnd);
4250        s.tick(&press(KeyCode::Char('i')));
4251        s.tick(&press(KeyCode::Char('d')));
4252        let buf = s.buffers.get(s.active).unwrap();
4253        let clamped = buf.clamp(s.cursor());
4254        assert_eq!(
4255            s.cursor(),
4256            clamped,
4257            "cursor must be clamped in-bounds at EOF"
4258        );
4259        assert_cursor_in_viewport(&s, "insert at eof");
4260    }
4261
4262    #[test]
4263    fn count_prefix_then_sequence_repeats() {
4264        // `2` then `gj` (→ move-down) repeats the resolved action twice.
4265        let mut s = new_state_with("a\nb\nc\nd\ne");
4266        s.keymap.bind_sequence(
4267            Mode::Normal,
4268            vec![Key::Char('g'), Key::Char('j')],
4269            Action::Move(Motion::Down),
4270            "gj",
4271        );
4272        s.on_key(&Key::Char('2'));
4273        s.on_key(&Key::Char('g'));
4274        s.on_key(&Key::Char('j'));
4275        assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
4276    }
4277
4278    // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
4279
4280    #[test]
4281    fn held_key_repeat_storm_is_debounced_in_normal_mode() {
4282        // The audit's exact complaint: holding `j` floods motion events
4283        // and thrashes the viewport. Simulate an OS key-repeat storm — 20
4284        // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
4285        // — and assert only the gated subset (one per 80ms window) actually
4286        // moves the cursor.
4287        let mut s = new_state_with(&"x\n".repeat(40));
4288        let t0 = std::time::Instant::now();
4289        let mut delivered = 0u32;
4290        for i in 0..20u32 {
4291            let before = s.cursor().line;
4292            s.tick_at(
4293                &press(KeyCode::Char('j')),
4294                t0 + std::time::Duration::from_millis(u64::from(i) * 50),
4295            );
4296            if s.cursor().line != before {
4297                delivered += 1;
4298            }
4299        }
4300        // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
4301        // fewer than the 20 the ungated path would have applied.
4302        assert!(
4303            (10..=14).contains(&delivered),
4304            "expected the storm debounced to ~13 moves, got {delivered}",
4305        );
4306        assert!(
4307            delivered < 20,
4308            "the gate must drop SOME storm ticks, not pass all 20",
4309        );
4310    }
4311
4312    #[test]
4313    fn spaced_intentional_taps_all_pass() {
4314        // Intentional taps spaced past the debounce window must ALL reach
4315        // the editor — the gate filters storms, never deliberate input.
4316        let mut s = new_state_with(&"x\n".repeat(10));
4317        let t0 = std::time::Instant::now();
4318        for i in 0..5u32 {
4319            s.tick_at(
4320                &press(KeyCode::Char('j')),
4321                // 100ms apart — comfortably past the 80ms window.
4322                t0 + std::time::Duration::from_millis(u64::from(i) * 100),
4323            );
4324        }
4325        assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
4326    }
4327
4328    #[test]
4329    fn distinct_keys_have_independent_clocks() {
4330        // Holding `j` must not block a simultaneous `l` — the gate keys on
4331        // the Key, so independent keys have independent windows.
4332        let mut s = new_state_with("abc\ndef\nghi");
4333        let t = std::time::Instant::now();
4334        s.tick_at(&press(KeyCode::Char('j')), t);
4335        // `j` again within the window is dropped…
4336        s.tick_at(
4337            &press(KeyCode::Char('j')),
4338            t + std::time::Duration::from_millis(10),
4339        );
4340        assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
4341        // …but `l` at the same instant passes (its own clock).
4342        s.tick_at(
4343            &press(KeyCode::Char('l')),
4344            t + std::time::Duration::from_millis(10),
4345        );
4346        assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
4347    }
4348
4349    // ── Cursors newtype is the single cursor home ──────────────────────
4350
4351    #[test]
4352    fn cursor_home_preserves_single_cursor_behavior() {
4353        // The typed `Cursors` wrapper behaves exactly like the old bare
4354        // `Position` field for single-cursor editing: the read accessor
4355        // tracks every mutation routed through `set_cursor`, and there is
4356        // exactly one caret.
4357        let mut s = new_state_with("hello\nworld\nthere");
4358        assert_eq!(s.cursor(), Position::ZERO);
4359        assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
4360
4361        s.apply_motion(Motion::Down);
4362        s.apply_motion(Motion::Right);
4363        s.apply_motion(Motion::Right);
4364        assert_eq!(s.cursor(), Position::new(1, 2));
4365        // Still a single caret after a sequence of motions.
4366        assert_eq!(s.cursors.count(), 1);
4367
4368        // The accessor is the SAME value the viewport-follow path read.
4369        let w = s.layout.active_window().unwrap();
4370        assert!(w.viewport.top_line <= s.cursor().line);
4371    }
4372
4373    #[test]
4374    fn insert_mode_is_ungated_so_repeat_typing_works() {
4375        // Holding a key to repeat-type a character is intended in Insert
4376        // mode — the gate must NOT suppress it. 10 rapid identical `x`
4377        // keystrokes at the same instant must all land as text.
4378        let mut s = new_state_with("");
4379        s.tick(&press(KeyCode::Char('i')));
4380        assert_eq!(s.modal.mode(), Mode::Insert);
4381        let t = std::time::Instant::now();
4382        for _ in 0..10 {
4383            s.tick_at(&press(KeyCode::Char('x')), t);
4384        }
4385        assert_eq!(
4386            s.buffers.get(s.active).unwrap().to_string(),
4387            "xxxxxxxxxx",
4388            "insert-mode repeat typing is ungated",
4389        );
4390    }
4391}