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, EditContext};
25use escriba_core::{
26    Action, Anchored, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList, Mode,
27    Motion, Operator, Position, Range, TextEffect, WindowId,
28};
29use escriba_input::{InputOutcome, translate_app_event};
30use escriba_keymap::{Key, Keymap};
31use escriba_mode::ModalState;
32use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
33use escriba_ui::{Layout, Rect, Viewport, Window};
34use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, HostEffect, VmError};
35use madori::AppEvent;
36use std::time::Instant;
37
38/// Full editor state — the single Rust value the binary hands to the
39/// renderer each frame.
40pub struct EditorState {
41    pub buffers: BufferSet,
42    pub modal: ModalState,
43    /// Search session — the committed pattern, its matches, the live `/`
44    /// prompt and history. Owns no buffer or cursor; it answers questions
45    /// about text and this runtime applies the answers.
46    pub search: SearchState,
47    pub keymap: Keymap,
48    pub commands: CommandRegistry,
49    pub layout: Layout,
50    pub active: BufferId,
51    /// The single typed home for cursor state. Phase-1 holds one primary
52    /// [`Position`]; reads go through [`Self::cursor`], writes through
53    /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
54    /// `Position` field beside an unused multi-caret type to desync.
55    cursors: Cursors,
56    pub quit_requested: bool,
57    /// Messages surfaced to the user (status line / `:messages`) — the
58    /// sink for the tatara-lisp `(message …)` effect and other feedback.
59    pub messages: Vec<String>,
60    /// Which match the cursor last landed on (0-based) — the `[3/17]`
61    /// numerator, ANCHORED to the text revision it was computed against.
62    ///
63    /// The anchor is what removes the manual invalidation this field used to
64    /// need. An ordinal indexes a match set; when the text changes the set
65    /// changes underneath it and the number silently means something else.
66    /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
67    /// forgetting to clear is no longer a thing that can be forgotten.
68    search_at: Option<Anchored<usize, TextRev>>,
69    /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
70    /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
71    /// makes a search a place you can come back from.
72    pub jumps: JumpList,
73    /// Generic editor option store (name → value). Written by the
74    /// tatara-lisp `(set-option …)` effect and the declarative
75    /// `defoption` apply path; typed accessors layer on top later.
76    pub options: HashMap<String, String>,
77    /// Cached embedded tatara-lisp runtime, built lazily on first
78    /// `run_lisp`. Caching avoids re-installing the ~175-definition full
79    /// stdlib on every call; the interpreter's top-level env also
80    /// persists across calls, giving REPL-like session semantics (an
81    /// earlier `(define …)` is visible to a later `run_lisp`).
82    lisp_vm: Option<EscribaVm>,
83    /// Keys accumulated for an in-progress multi-key sequence — e.g.
84    /// holding `[,, f]` while waiting for the final key of
85    /// `<leader>ff`. Empty when not mid-sequence. Lives on
86    /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
87    /// depend on `escriba-keymap`'s `Key`.
88    pub pending_keys: Vec<Key>,
89    /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
90    /// the windowing system deliver one `KeyDown` per repeat tick
91    /// (~30-50ms); without a gate those flood the motion path and thrash
92    /// the viewport. The gate lets ONE event per `min_interval` (80ms
93    /// default — ~12 intentional taps/sec still pass) reach the editor in
94    /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
95    /// the same one mado uses) is reused — not reinvented.
96    repeat_gate: KeyRepeatGate<Key>,
97    /// Runtime lazy-activation host for USER plugin caixas (the bundled
98    /// default catalog is applied eagerly at boot, not through here).
99    /// A command / filetype-open / event fires the matching plugins'
100    /// entries through the escriba-lisp apply paths. See [`PluginHost`].
101    pub plugin_host: PluginHost,
102    /// The unnamed register — the home for text an operator yanks or
103    /// deletes (`Operator::leaves_register`). `None` until the first
104    /// register-leaving operator runs. Phase-1 holds the single unnamed
105    /// register; named registers (`"ay`) layer on later.
106    register: Option<String>,
107    /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
108    /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
109    /// action passes through it; only an operator-then-motion pair is rewritten
110    /// into an [`Action::ApplyOperator`].
111    op_pending: zenmai::Stateful<OperatorPending>,
112    /// Monotonic refresh-generation stamp — the root of the sealed refresh
113    /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
114    /// action + resize; the renderer gates on it so an idle frame does zero
115    /// re-highlight / re-shape, and a stale frame is unreachable.
116    edit_gen: EditGen,
117    /// The accumulated dirty region since the renderer last drained it (M1).
118    /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
119    /// always covers the changed region (`Damage ⊇ changed`); the renderer
120    /// drains it with [`take_damage`](Self::take_damage) to scope its work.
121    damage: Damage,
122}
123
124/// Outcome of feeding one key to the multi-key pending-stroke loop.
125enum SeqStep {
126    /// Key consumed into an in-progress sequence; wait for the next.
127    Pending,
128    /// A full bound sequence resolved — run this action.
129    Resolved(Action),
130    /// Key is not part of any sequence; hand it to single-key dispatch.
131    Passthrough,
132}
133
134/// Keys whose repeat is a deliberate second press, never an OS key-repeat
135/// storm.
136///
137/// Each of these relocates the cursor somewhere far and specific, so a user
138/// pressing one twice quickly means it twice. Held `j`/`l` is the opposite:
139/// the repeats are the OS, and honouring all of them thrashes the viewport.
140const fn is_discrete_jump(key: &Key) -> bool {
141    matches!(
142        key,
143        Key::Char('n')
144            | Key::Char('N')
145            | Key::Char('*')
146            | Key::Char('#')
147            | Key::Ctrl('o')
148            | Key::Ctrl('i')
149    )
150}
151
152impl EditorState {
153    /// Build a fresh editor with one buffer (scratch or file-backed).
154    pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
155        let window = Window {
156            id: WindowId(1),
157            buffer_id: active,
158            viewport: Viewport {
159                top_line: 0,
160                left_column: 0,
161                visible_lines: 40,
162                visible_columns: 160,
163            },
164            rect: Rect {
165                x: 0,
166                y: 0,
167                width: 1200,
168                height: 800,
169            },
170        };
171        Self {
172            buffers: initial,
173            modal: ModalState::new(),
174            search: SearchState::new(escriba_search::CaseMode::Smart),
175            search_at: None,
176            jumps: JumpList::new(),
177            keymap: Keymap::default_vim(),
178            commands: CommandRegistry::default_set(),
179            layout: Layout::single(window),
180            active,
181            cursors: Cursors::single(Position::ZERO),
182            quit_requested: false,
183            register: None,
184            op_pending: zenmai::Stateful::new(OpState::Resting),
185            messages: Vec::new(),
186            options: HashMap::new(),
187            lisp_vm: None,
188            pending_keys: Vec::new(),
189            repeat_gate: KeyRepeatGate::new(),
190            plugin_host: PluginHost::default(),
191            edit_gen: EditGen::default(),
192            damage: Damage::None,
193        }
194    }
195
196    /// The current refresh generation. A renderer caches its products against
197    /// this; equality is the freshness test (an unchanged generation ⇒ the
198    /// last frame is still valid, so skip the re-highlight + re-shape).
199    #[must_use]
200    pub fn edit_gen(&self) -> EditGen {
201        self.edit_gen
202    }
203
204    /// Advance the refresh generation (a mutation happened).
205    fn bump_gen(&mut self) {
206        self.edit_gen = self.edit_gen.next();
207    }
208
209    /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
210    #[must_use]
211    pub fn damage(&self) -> Damage {
212        self.damage
213    }
214
215    /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
216    /// renderer calls this once per frame to learn what to repaint, then the
217    /// accumulator restarts — so damage never double-counts across frames.
218    pub fn take_damage(&mut self) -> Damage {
219        std::mem::replace(&mut self.damage, Damage::None)
220    }
221
222    /// The line count of the active buffer (0 if none) — used to compute the
223    /// [`Damage`] scope of a mutation.
224    fn active_line_count(&self) -> u32 {
225        self.buffers
226            .get(self.active)
227            .map_or(0, escriba_buffer::Buffer::line_count)
228    }
229
230    /// Register a lazy USER plugin: its escriba entry is deferred until
231    /// one of its `triggers` fires. Bundled defaults do NOT go through
232    /// here — they are applied eagerly at boot. Empty `triggers` means
233    /// the plugin never lazily activates (the binary applies eager
234    /// plugins directly).
235    pub fn register_lazy_plugin(
236        &mut self,
237        name: impl Into<String>,
238        triggers: Vec<LazyTrigger>,
239        entry_src: impl Into<String>,
240    ) {
241        self.plugin_host.register(name, triggers, entry_src);
242    }
243
244    /// Apply a plugin entry's escriba-lisp to live state — the same
245    /// keymap / command / option apply paths a user rc uses. Options are
246    /// applied before keybinds so a plugin that sets `mapleader` resolves
247    /// `<leader>` correctly. Returns the count of commands + keybinds it
248    /// registered (best-effort; a malformed entry is skipped, not fatal).
249    fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
250        let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
251            return 0;
252        };
253        let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
254        escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
255        if let Some(value) = self.options.get("mapleader") {
256            if let Some(key) = escriba_lisp::parse_leader_key(value) {
257                self.keymap.set_leader(key);
258            }
259        }
260        let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
261        (cmd.registered + km.keybinds_applied) as usize
262    }
263
264    /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
265    /// Returns the number of plugins activated. Call when a buffer of a
266    /// known filetype is opened.
267    pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
268        let pending = self.plugin_host.pending_for_filetype(filetype);
269        let n = pending.len();
270        for src in pending {
271            self.apply_plugin_entry(&src);
272        }
273        n
274    }
275
276    /// Fire any lazy plugin gated on an `Event` trigger for `event`.
277    /// Returns the number of plugins activated.
278    pub fn activate_event_plugins(&mut self, event: &str) -> usize {
279        let pending = self.plugin_host.pending_for_event(event);
280        let n = pending.len();
281        for src in pending {
282            self.apply_plugin_entry(&src);
283        }
284        n
285    }
286
287    /// Advance one frame's worth of state given a raw madori event.
288    ///
289    /// Key events pass through the [`KeyRepeatGate`] first (see
290    /// [`Self::tick_at`]); everything else is handled directly.
291    pub fn tick(&mut self, event: &AppEvent) {
292        self.tick_at(event, Instant::now());
293    }
294
295    /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
296    /// lets tests drive the debounce window without depending on the
297    /// wall clock.
298    pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
299        match translate_app_event(event) {
300            InputOutcome::Key(k) => {
301                if self.gate_key(&k, now) {
302                    self.on_key(&k);
303                }
304            }
305            InputOutcome::Resized { width, height } => {
306                if let Some(w) = self
307                    .layout
308                    .windows
309                    .iter_mut()
310                    .find(|w| w.id == self.layout.active)
311                {
312                    w.rect.width = width;
313                    w.rect.height = height;
314                }
315                self.damage = self.damage.join(Damage::Viewport);
316                self.bump_gen();
317            }
318            InputOutcome::Quit => self.quit_requested = true,
319            InputOutcome::Focus(_) | InputOutcome::None => {}
320        }
321    }
322
323    /// Decide whether `key` survives the key-repeat gate at time `now`.
324    ///
325    /// Returns `true` when the key should be processed, `false` when it is
326    /// an OS key-repeat storm tick that should be dropped. Gating applies
327    /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
328    /// are where a held `j`/`l` floods the motion path and thrashes the
329    /// viewport. Insert and Command modes pass every key through ungated,
330    /// because there "hold a key to repeat the character" is the intended
331    /// behavior, not a storm to suppress.
332    fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
333        match self.modal.mode() {
334            Mode::Normal | Mode::Visual | Mode::VisualLine => {
335                // The gate exists for HELD keys that flood the motion path and
336                // thrash the viewport (`j`, `l`). It is wrong for the discrete
337                // jumps: two `n` presses 10 ms apart mean two matches, and
338                // swallowing the second is indistinguishable from a dead key —
339                // the exact symptom the gate was added to prevent elsewhere.
340                if is_discrete_jump(key) {
341                    return true;
342                }
343                self.repeat_gate.try_pass_at(*key, now)
344            }
345            Mode::Insert | Mode::Command => true,
346        }
347    }
348
349    /// Dispatch a single key through the keymap + apply the resulting action.
350    pub fn on_key(&mut self, key: &Key) {
351        // Multi-key sequence resolution runs first: a key that begins or
352        // continues a bound sequence (`<leader>ff`, `gg`) is held or
353        // resolved here before the single-key path sees it.
354        match self.step_sequence(key) {
355            SeqStep::Pending => return,
356            SeqStep::Resolved(action) => {
357                let count = self.modal.pending_count().unwrap_or(1);
358                self.modal.clear_count();
359                for _ in 0..count {
360                    self.apply(&action);
361                    if self.quit_requested {
362                        return;
363                    }
364                }
365                return;
366            }
367            SeqStep::Passthrough => {}
368        }
369        let counted = self.keymap.dispatch(&self.modal, key);
370        // Count prefixes accumulate into modal state.
371        if matches!(counted.action, Action::Pending) {
372            if let Key::Char(c) = key {
373                if c.is_ascii_digit() {
374                    let d = u32::from(*c as u8 - b'0');
375                    self.modal.append_count(d);
376                }
377            }
378            return;
379        }
380        // The count flows through the operator-pending FSM (apply_counted), which
381        // owns repetition: a bare motion runs count× , an operator captures its
382        // count, and an operated motion multiplies the two. No naive outer loop.
383        self.apply_counted(&counted.action, counted.count);
384        // After applying, reset pending count.
385        self.modal.clear_count();
386    }
387
388    /// Advance the multi-key pending-stroke state machine for `key`.
389    ///
390    /// Sequences only apply in normal / visual modes — insert and
391    /// command modes treat keys as literal text. Rules:
392    /// - Mid-sequence: extend the pending prefix. Exact match →
393    ///   [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
394    ///   otherwise abort the sequence and re-process this key fresh.
395    /// - Not mid-sequence: if `key` begins a bound sequence AND is not
396    ///   itself a complete single binding (single bindings win, so no
397    ///   chord timeout is needed) → start pending. Otherwise
398    ///   [`SeqStep::Passthrough`] to the single-key dispatcher.
399    fn step_sequence(&mut self, key: &Key) -> SeqStep {
400        let mode = self.modal.mode();
401        if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
402            return SeqStep::Passthrough;
403        }
404        if !self.pending_keys.is_empty() {
405            let mut seq = self.pending_keys.clone();
406            seq.push(key.clone());
407            if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
408                let action = b.action.clone();
409                self.pending_keys.clear();
410                return SeqStep::Resolved(action);
411            }
412            if self.keymap.is_sequence_prefix(mode, &seq) {
413                self.pending_keys = seq;
414                return SeqStep::Pending;
415            }
416            // The key broke the in-progress sequence — abort it and let
417            // the key be re-processed as a fresh stroke below.
418            self.pending_keys.clear();
419        }
420        let start = [key.clone()];
421        if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
422            self.pending_keys = start.to_vec();
423            return SeqStep::Pending;
424        }
425        SeqStep::Passthrough
426    }
427
428    /// The primary cursor position. The single read accessor — every
429    /// renderer + motion path goes through it, so the underlying
430    /// representation (today a single-cursor [`Cursors`]) can grow to
431    /// multi-caret without changing read sites.
432    #[must_use]
433    pub fn cursor(&self) -> Position {
434        self.cursors.primary()
435    }
436
437    /// The **single** cursor-mutation path. Clamp the requested position to
438    /// the active buffer's bounds, then scroll the active window's viewport
439    /// to contain it on BOTH axes. Routing every cursor change through this
440    /// (and through [`Cursors::set_primary`]) makes "cursor outside its
441    /// viewport" an unrepresentable state, AND keeps cursor state in ONE
442    /// typed home — there is no code path that advances the cursor without
443    /// re-deriving the viewport from it, and no second `Position` field to
444    /// fall out of sync.
445    fn set_cursor(&mut self, pos: Position) {
446        let clamped = if let Some(buf) = self.buffers.get(self.active) {
447            buf.clamp(pos)
448        } else {
449            pos
450        };
451        self.cursors.set_primary(clamped);
452        if let Some(w) = self
453            .layout
454            .windows
455            .iter_mut()
456            .find(|w| w.id == self.layout.active)
457        {
458            w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
459        }
460    }
461
462    /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
463    fn apply(&mut self, action: &Action) {
464        self.apply_counted(action, 1);
465    }
466
467    /// Dispatch one resolved action with its count. Routes `(action, count)`
468    /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
469    /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
470    /// their count (so `5j` runs the motion 5×), an operator key is held, and an
471    /// operator-then-motion pair is rewritten into a counted
472    /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
473    /// composition — there is no naive outer repeat loop.
474    fn apply_counted(&mut self, action: &Action, count: u32) {
475        for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
476            for _ in 0..times {
477                self.apply_resolved(&resolved);
478                if self.quit_requested {
479                    return;
480                }
481            }
482        }
483    }
484
485    /// The active buffer's text. Search is a pure function of it.
486    /// The active buffer's text revision — the token an offset measured
487    /// against it should carry.
488    #[must_use]
489    fn text_rev(&self) -> TextRev {
490        self.buffers
491            .get(self.active)
492            .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
493    }
494
495    fn active_text(&self) -> String {
496        self.buffers
497            .get(self.active)
498            .map(escriba_buffer::Buffer::to_string)
499            .unwrap_or_default()
500    }
501
502    /// The cursor as a char offset — the coordinate search speaks.
503    fn cursor_char(&self) -> usize {
504        self.buffers
505            .get(self.active)
506            .and_then(|b| b.position_to_char(self.cursor()).ok())
507            .unwrap_or(0)
508    }
509
510    /// Move the cursor onto a match and report a wrap the way vim does.
511    /// The status line as data — what every face draws.
512    ///
513    /// One model, so the two faces can only disagree about styling. Before
514    /// this existed the GPU face built its own line from a fixed `format!()`
515    /// and drew neither the prompt nor any message, which made a fully
516    /// working `/` look like a dead key on escriba's default renderer.
517    #[must_use]
518    pub fn status_model(&self) -> StatusModel<'_> {
519        let cursor = self.cursor();
520        let prompt = self.search.prompt();
521
522        let kind = match prompt.map(|p| p.direction) {
523            Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
524            Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
525            // Command mode with no search prompt open is an ex-command; the
526            // typed `Option<Prompt>` is the discriminator, never a mode flag.
527            None if self.modal.mode() == Mode::Command => PromptKind::Ex,
528            None => PromptKind::None,
529        };
530
531        StatusModel {
532            mode: self.modal.mode(),
533            line: cursor.line.saturating_add(1) as usize,
534            column: cursor.column.saturating_add(1) as usize,
535            prompt: kind,
536            prompt_text: prompt.map_or_else(|| self.modal.minibuffer(), |p| p.text.as_str()),
537            // The ex-line has no caret of its own yet, so it reports the end —
538            // which is where an append-only line's caret always is. Stated
539            // rather than defaulted silently: giving the ex-line real caret
540            // editing is the same change applied to `ModalState`.
541            prompt_caret: prompt.map_or_else(
542                || self.modal.minibuffer().chars().count(),
543                escriba_search::Prompt::caret,
544            ),
545            count: self.match_count(),
546            message: self.messages.last().map(String::as_str),
547        }
548    }
549
550    /// `[3/17]` for the current pattern.
551    ///
552    /// While a prompt is open the count describes the PREVIEW — the answer to
553    /// "what would Enter do", which is the question being asked mid-typing.
554    /// Once committed it describes where the cursor actually is.
555    #[must_use]
556    fn match_count(&self) -> MatchCount {
557        if self.search.is_prompting() {
558            let text = self.active_text();
559            let total = self.search.preview_total(&text);
560            return match self.search.preview(&text) {
561                Some(step) => MatchCount::new(step.index, total),
562                // A pattern that is mid-typing (`a[`) has nothing to report;
563                // one that compiles and finds nothing reports `[0/0]`, which
564                // is the useful half — it says so BEFORE Enter.
565                None if total == 0 && !self.search.prompt_is_empty() => MatchCount::None,
566                None => MatchCount::Idle,
567            };
568        }
569        if self.search.pattern().is_none() {
570            return MatchCount::Idle;
571        }
572        let total = self.search.matches().len();
573        // Read THROUGH the anchor: an ordinal computed against text that has
574        // since changed reads as absent, so a stale count cannot be displayed.
575        let rev = self.text_rev();
576        self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
577            if total == 0 {
578                MatchCount::None
579            } else {
580                MatchCount::Idle
581            },
582            |&i| MatchCount::new(i, total),
583        )
584    }
585
586    fn land_on(&mut self, step: escriba_search::Step) {
587        if let Some(buf) = self.buffers.get(self.active) {
588            let pos = buf.char_to_position(step.target.start);
589            self.set_cursor(pos);
590        }
591        // The `[3/17]` numerator. `Step` has carried this index since the
592        // engine was written — `engine.rs` even names the counter as the
593        // reason it exists — and every consumer discarded it until now.
594        self.search_at = Some(Anchored::new(step.index, self.text_rev()));
595        if let Some(msg) = step.wrapped.message() {
596            self.messages.push(msg.to_string());
597        }
598    }
599
600    /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
601    /// than failing silently — a search that appears to do nothing is
602    /// indistinguishable from a dropped keystroke.
603    fn jump_search(&mut self, reverse: bool) {
604        // Using the matches re-lights them: `n` after an auto-clear shows you
605        // what you are walking through.
606        self.search.relight();
607        // `n` is a far jump — record where we leave from so `<C-o>` works.
608        self.jumps.push(self.cursor());
609        let at = self.cursor_char();
610        match self.search.repeat(at, reverse) {
611            Some(step) => self.land_on(step),
612            None => {
613                let msg = self.search.pattern().map_or_else(
614                    || "E35: No previous regular expression".to_string(),
615                    |p| {
616                        let mut m = String::from("E486: Pattern not found: ");
617                        m.push_str(p.raw());
618                        m
619                    },
620                );
621                self.messages.push(msg);
622            }
623        }
624    }
625
626    /// Move the cursor to where the in-progress pattern would land, without
627    /// committing anything. vim's `incsearch`.
628    ///
629    /// A pattern that does not compile yet (`/a[`, mid-typing) previews
630    /// nothing and reports nothing — an error toast on every keystroke of a
631    /// character class would be unusable.
632    fn preview_search(&mut self) {
633        let text = self.active_text();
634        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
635            return;
636        };
637        let target = self
638            .search
639            .preview(&text)
640            .map_or(origin, |s| s.target.start);
641        // A pattern that STOPS matching returns the cursor to the origin.
642        //
643        // Preview used to only ever move forward, so typing `ch` (a match) and
644        // then `chz` (none) left the cursor parked on the `ch` match — a
645        // preview showing a position the pattern no longer justifies, while
646        // the count beside it read `[0/0]`. Restoring is also what makes
647        // Escape's promise legible: at every keystroke the cursor is either on
648        // a real match or back where you started, never on a stale one.
649        if let Some(buf) = self.buffers.get(self.active) {
650            let pos = buf.char_to_position(target);
651            self.set_cursor(pos);
652        }
653    }
654
655    /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
656    /// where the search lands, as ONE action.
657    ///
658    /// Split from [`Self::submit_search`] rather than sharing it because the
659    /// two want opposite things from the commit: the bare `/` MOVES the cursor
660    /// to the match, and an operated `/` must NOT — the cursor is the
661    /// operator's start point, and moving it first would leave the operator
662    /// with a zero-width range.
663    fn submit_search_operated(&mut self, op: Operator) {
664        let text = self.active_text();
665        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
666            return;
667        };
668
669        match self.search.accept(&text) {
670            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
671                self.modal.clear_minibuffer();
672                self.modal.enter(Mode::Normal);
673                match self.search.commit_step(origin) {
674                    Some(step) => {
675                        // Operating over a search is itself a far jump.
676                        if let Some(buf) = self.buffers.get(self.active) {
677                            let from = buf.char_to_position(origin);
678                            self.jumps.push(from);
679                            let target = buf.char_to_position(step.target.start);
680                            self.set_cursor(from);
681                            self.search_at = Some(Anchored::new(step.index, self.text_rev()));
682                            self.apply_operator_to(op, target);
683                        }
684                    }
685                    None => self.report_pattern_not_found(),
686                }
687            }
688            escriba_search::Accepted::NothingToRepeat => {
689                self.modal.clear_minibuffer();
690                self.modal.enter(Mode::Normal);
691                self.messages
692                    .push("E35: No previous regular expression".to_string());
693            }
694            escriba_search::Accepted::Invalid(e) => {
695                let mut m = String::from("E383: Invalid search string: ");
696                m.push_str(&e.to_string());
697                self.messages.push(m);
698            }
699        }
700    }
701
702    /// vim's E486, with the pattern named. One place, so both the bare and the
703    /// operated search paths report identically.
704    fn report_pattern_not_found(&mut self) {
705        let mut m = String::from("E486: Pattern not found");
706        if let Some(p) = self.search.pattern() {
707            m.push_str(": ");
708            m.push_str(p.raw());
709        }
710        self.messages.push(m);
711    }
712
713    /// Commit the `/` prompt: compile, search, jump, and report like vim.
714    fn submit_search(&mut self) {
715        let text = self.active_text();
716        // Step from the prompt's ORIGIN, never from the live cursor.
717        //
718        // Incremental preview has already parked the cursor on the previewed
719        // match, so stepping from `cursor_char()` searches from the answer
720        // rather than from the question. Two measured consequences of that:
721        // `/foo<CR>` committed to the match AFTER the one the preview showed
722        // (the exclusive step skipped past it), and `?foo` previewed one match
723        // and committed to a different one. Anchoring to the origin makes
724        // "what the preview showed is where I land" true by construction —
725        // which is the entire promise of incremental search.
726        //
727        // `commit_step` then uses the INCLUSIVE step, the same one the
728        // preview uses, so a match sitting on the origin is included rather
729        // than stepped past.
730        let at = self
731            .search
732            .prompt()
733            .map_or_else(|| self.cursor_char(), |p| p.origin);
734        let outcome = self.search.accept(&text);
735        match outcome {
736            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
737                self.modal.clear_minibuffer();
738                self.modal.enter(Mode::Normal);
739                // Record the jump from the PROMPT ORIGIN, not the live cursor:
740                // preview has already moved the cursor onto the match, so
741                // `<C-o>` would otherwise return you to the match you jumped
742                // to rather than the place you searched from.
743                if let Some(buf) = self.buffers.get(self.active) {
744                    let origin = buf.char_to_position(at);
745                    self.jumps.push(origin);
746                }
747                match self.search.commit_step(at) {
748                    Some(step) => self.land_on(step),
749                    None => {
750                        let mut m = String::from("E486: Pattern not found");
751                        if let Some(p) = self.search.pattern() {
752                            m.push_str(": ");
753                            m.push_str(p.raw());
754                        }
755                        self.messages.push(m);
756                    }
757                }
758            }
759            escriba_search::Accepted::NothingToRepeat => {
760                self.modal.clear_minibuffer();
761                self.modal.enter(Mode::Normal);
762                self.messages
763                    .push("E35: No previous regular expression".to_string());
764            }
765            // The prompt stays OPEN so the typed pattern is not lost; the user
766            // fixes the regex instead of retyping it.
767            escriba_search::Accepted::Invalid(e) => {
768                let mut m = String::from("E383: Invalid search string: ");
769                m.push_str(&e.to_string());
770                self.messages.push(m);
771            }
772        }
773    }
774
775    fn apply_resolved(&mut self, action: &Action) {
776        // Snapshot the scope inputs before the mutation so the resulting
777        // Damage covers the changed region (the S3 seal — conservative widen).
778        let lines_before = self.active_line_count();
779        let cline_before = self.cursor().line;
780        match action {
781            Action::Move(m) => self.apply_motion(*m),
782            Action::SearchOpen(dir) => {
783                // vim's `/` is the command-line with a different prompt char,
784                // so we reuse Command mode; `search.prompt` is what tells a
785                // later <CR> this is a search and not an ex-command.
786                let origin = self.cursor_char();
787                self.search.open(*dir, origin);
788                self.modal.enter(Mode::Command);
789            }
790            Action::SearchRepeat { reverse } => self.jump_search(*reverse),
791            Action::SearchWord { reverse } => {
792                let dir = if *reverse {
793                    SearchDirection::Backward
794                } else {
795                    SearchDirection::Forward
796                };
797                let (text, at) = (self.active_text(), self.cursor_char());
798                // `*` jumps, so it records too.
799                self.jumps.push(self.cursor());
800                match self.search.search_word(&text, at, dir) {
801                    Some(step) => self.land_on(step),
802                    // vim beeps and stays put when there is no word under the
803                    // cursor; a silent no-op would look like a broken key.
804                    None => self
805                        .messages
806                        .push("E348: No string under cursor".to_string()),
807                }
808            }
809            Action::ClearSearchHighlight => self.search.clear_highlight(),
810            Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
811            Action::JumpBack => {
812                let here = self.cursor();
813                if let Some(pos) = self.jumps.back(here) {
814                    self.set_cursor(pos);
815                } else {
816                    self.messages
817                        .push("E662: At start of changelist".to_string());
818                }
819            }
820            Action::JumpForward => {
821                if let Some(pos) = self.jumps.forward() {
822                    self.set_cursor(pos);
823                } else {
824                    self.messages.push("E663: At end of changelist".to_string());
825                }
826            }
827            Action::ChangeMode(m) => {
828                // Leaving the cmdline abandons any open search prompt and
829                // returns the cursor home. The COMMITTED pattern survives —
830                // cancelling a new search must not erase the old highlights.
831                if *m == Mode::Normal && self.search.is_prompting() {
832                    if let Some(origin) = self.search.cancel() {
833                        if let Some(buf) = self.buffers.get(self.active) {
834                            let pos = buf.char_to_position(origin);
835                            self.set_cursor(pos);
836                        }
837                    }
838                }
839                self.modal.enter(*m);
840            }
841            Action::InsertChar(c) => self.insert_char(*c),
842            Action::Edit(edit) => self.apply_edit(edit),
843            Action::Undo => {
844                if let Some(buf) = self.buffers.get_mut(self.active) {
845                    let _ = buf.undo();
846                }
847                // The buffer may have shrunk — re-follow so the viewport
848                // re-contains a now-out-of-bounds cursor.
849                self.set_cursor(self.cursor());
850            }
851            Action::Redo => {
852                if let Some(buf) = self.buffers.get_mut(self.active) {
853                    let _ = buf.redo();
854                }
855                self.set_cursor(self.cursor());
856            }
857            Action::Save => {
858                if let Some(buf) = self.buffers.get_mut(self.active) {
859                    let _ = buf.save();
860                }
861                self.set_cursor(self.cursor());
862            }
863            Action::Quit => self.quit_requested = true,
864            Action::SubmitCommand => {
865                if self.search.is_prompting() {
866                    self.submit_search();
867                } else {
868                    self.submit_command();
869                }
870            }
871            Action::Command { name, args } => self.run_command(name, args),
872            Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
873            // The operator-pending FSM consumes Operator keys (begins pending);
874            // they never reach the executor. Defensive no-op for exhaustiveness.
875            Action::Operator(_) => {}
876            Action::PromptCaret { to } => {
877                if self.search.is_prompting() {
878                    self.search.move_caret(*to);
879                }
880            }
881            Action::PromptDelete => {
882                if self.search.is_prompting() {
883                    self.search.delete_at_caret();
884                    self.preview_search();
885                }
886            }
887            Action::PromptDeleteWord => {
888                if self.search.is_prompting() {
889                    self.search.delete_word_before_caret();
890                    self.preview_search();
891                }
892            }
893            Action::PromptClearToStart => {
894                if self.search.is_prompting() {
895                    self.search.clear_before_caret();
896                    self.preview_search();
897                }
898            }
899            Action::PromptBackspace => {
900                self.prompt_backspace();
901                // Shortening the pattern changes which matches exist, so the
902                // preview must re-run — otherwise the cursor sits on a match
903                // of a pattern that is no longer typed.
904                if self.search.is_prompting() {
905                    self.preview_search();
906                }
907            }
908            Action::PromptHistory { back } => {
909                if self.search.is_prompting() {
910                    self.search.history_step(*back);
911                    // The minibuffer is a separate display buffer, so it must
912                    // be rewritten from the prompt rather than left showing the
913                    // pattern history just replaced.
914                    self.modal.clear_minibuffer();
915                    if let Some(text) = self.search.prompt().map(|p| p.text.clone()) {
916                        self.modal.push_minibuffer_str(&text);
917                    }
918                    self.preview_search();
919                }
920            }
921            Action::Pending => {}
922        }
923        // Widen the dirty region by what this action touched (M1). Content
924        // mutations that changed the line count run to end-of-document (every
925        // line below shifted); an in-place edit or a cursor move is local;
926        // arbitrary commands are conservatively Full. Never narrows.
927        let lines_after = self.active_line_count();
928        let cline_after = self.cursor().line;
929        let d = match action {
930            // A search repaints every highlight in the viewport, not just the
931            // line the cursor left — so it must widen to Full. Treating it as a
932            // cursor move would leave stale highlights on untouched lines.
933            Action::SearchOpen(_)
934            | Action::PromptHistory { .. }
935            | Action::PromptBackspace
936            | Action::PromptCaret { .. }
937            | Action::PromptDelete
938            | Action::PromptDeleteWord
939            | Action::PromptClearToStart
940            | Action::SearchRepeat { .. }
941            | Action::SearchWord { .. }
942            | Action::ClearSearchHighlight
943            | Action::SearchSubmitOperated { .. }
944            // A jump can land anywhere, so the viewport may scroll wholesale.
945            | Action::JumpBack
946            | Action::JumpForward => Damage::Full,
947            Action::InsertChar(_)
948            | Action::Edit(_)
949            | Action::Undo
950            | Action::Redo
951            | Action::ApplyOperator { .. } => {
952                if lines_after == lines_before {
953                    Damage::span(cline_before, cline_after)
954                } else {
955                    Damage::Lines {
956                        from: cline_before.min(cline_after),
957                        to: u32::MAX,
958                    }
959                }
960            }
961            Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
962            Action::Save => Damage::Viewport,
963            Action::Command { .. } | Action::SubmitCommand => Damage::Full,
964            Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
965        };
966        self.damage = self.damage.join(d);
967        // The search is over the moment you move on or edit — clear the
968        // highlight rather than leaving the buffer as confetti until an
969        // explicit `:noh`, which is the remap nearly every vimrc carries.
970        // Clearing suppresses without forgetting, so `n` still works.
971        if action.highlight_effect() == HighlightEffect::Clear {
972            self.search.clear_highlight();
973        }
974        // Text changed ⇒ every match offset cached against the old text is
975        // wrong. `SearchState::refresh` existed for exactly this and had ZERO
976        // callers, so inserting four characters left both renderers painting
977        // the highlight four columns off.
978        //
979        // Gated on the typed classifier rather than on `bump_gen` (which fires
980        // for pure cursor moves too): re-scanning the document on every `j`
981        // would be a per-keystroke full pass for no reason.
982        if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
983            let text = self.active_text();
984            self.search.refresh(&text);
985            // NO manual invalidation of `search_at` here, deliberately. It is
986            // `Anchored` to the text revision, so an ordinal computed against
987            // the old text now reads as `None` on its own. This is the line
988            // that used to have to be remembered.
989        }
990        // An action reached the executor ⇒ visible state may have changed.
991        // Advance the refresh generation so the renderer repaints (and
992        // re-highlights) exactly once. A gated-out key never reaches here, so
993        // a key-repeat storm does not spin the renderer.
994        self.bump_gen();
995    }
996
997    /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
998    /// active buffer — **pure**: no cursor mutation, no side effects. This is
999    /// the single motion-resolution source of truth that both [`apply_motion`]
1000    /// (move the cursor *to* the target) and [`apply_operator`] (use the target
1001    /// as the *other end* of an operated range) stand on. `None` only if there
1002    /// is no active buffer.
1003    ///
1004    /// [`apply_motion`]: Self::apply_motion
1005    /// [`apply_operator`]: Self::apply_operator
1006    fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1007        let buf = self.buffers.get(self.active)?;
1008        let pos = from;
1009        Some(match motion {
1010            // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
1011            // against the committed match list, so it is `None` (motion fails,
1012            // operator aborts, buffer untouched) when nothing is committed —
1013            // never a silent move to 0, which would delete to the file start.
1014            Motion::SearchNext | Motion::SearchPrev => {
1015                let at = buf.position_to_char(pos).ok()?;
1016                let step = self
1017                    .search
1018                    .repeat(at, matches!(motion, Motion::SearchPrev))?;
1019                buf.char_to_position(step.target.start)
1020            }
1021            Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1022            Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1023            Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1024            Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1025            Motion::LineStart => Position::new(pos.line, 0),
1026            Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1027            Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1028            Motion::DocStart => Position::ZERO,
1029            Motion::DocEnd => Position::new(
1030                buf.line_count().saturating_sub(1),
1031                buf.line_len_chars(buf.line_count().saturating_sub(1)),
1032            ),
1033            Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1034            Motion::WordStartPrev => word_prev(buf, pos),
1035            Motion::PageDown | Motion::HalfPageDown => {
1036                Position::new(pos.line.saturating_add(10), pos.column)
1037            }
1038            Motion::PageUp | Motion::HalfPageUp => {
1039                Position::new(pos.line.saturating_sub(10), pos.column)
1040            }
1041            Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1042            // Structural Lisp motions — stubs for phase 1.B; full paredit
1043            // semantics land when caixa-ast is wired to the active buffer.
1044            Motion::ForwardSexp
1045            | Motion::BackwardSexp
1046            | Motion::UpList
1047            | Motion::DownList
1048            | Motion::BeginningOfDefun
1049            | Motion::EndOfDefun
1050            | Motion::BeginningOfSexp
1051            | Motion::EndOfSexp => pos,
1052        })
1053    }
1054
1055    fn apply_motion(&mut self, motion: Motion) {
1056        // A bare search motion is a FAR JUMP and it REPORTS — it records into
1057        // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
1058        // when nothing matches. `resolve_motion` can do none of that: it is
1059        // deliberately pure because the OPERATOR path calls it to find a range
1060        // without moving the cursor. So `n` routes to the one executor that
1061        // owns those side effects, and `Action::SearchRepeat` routes to the
1062        // same place — one code path, two spellings.
1063        if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1064            self.jump_search(matches!(motion, Motion::SearchPrev));
1065            return;
1066        }
1067        let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1068            return;
1069        };
1070        // The single cursor-mutation path clamps to the buffer and scrolls
1071        // the viewport to contain the cursor on both axes.
1072        self.set_cursor(pos);
1073    }
1074
1075    /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
1076    /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
1077    /// Composition is explicit: the motion resolves a target via
1078    /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
1079    /// `[cursor, target)` range. Register-leaving operators
1080    /// ([`Operator::leaves_register`]) capture the text first.
1081    fn apply_operator(&mut self, op: Operator, motion: Motion) {
1082        let from = self.cursor();
1083        let Some(to) = self.resolve_motion(from, motion) else {
1084            // A motion that cannot resolve aborts the operator with the buffer
1085            // untouched. A search motion says WHY — `dn` with no pattern armed
1086            // is otherwise indistinguishable from a dropped keystroke, which
1087            // is the same complaint that motivated E486 on the bare path.
1088            if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1089                if self.search.pattern().is_none() {
1090                    self.messages
1091                        .push("E35: No previous regular expression".to_string());
1092                } else {
1093                    self.report_pattern_not_found();
1094                }
1095            }
1096            return;
1097        };
1098        self.apply_operator_to(op, to);
1099    }
1100
1101    /// Apply `op` over `[cursor, to)`.
1102    ///
1103    /// Split out of [`Self::apply_operator`] so the operated-search path can
1104    /// reach the same range machinery with a target it resolved itself — the
1105    /// alternative was a second copy of the delete/yank/register logic, which
1106    /// is how the two would drift.
1107    fn apply_operator_to(&mut self, op: Operator, to: Position) {
1108        let from = self.cursor();
1109        let range = Range {
1110            start: from,
1111            end: to,
1112        }
1113        .normalized();
1114        if range.is_empty() {
1115            return;
1116        }
1117        // Capture the operated text (for the register) before mutating.
1118        let text = self
1119            .buffers
1120            .get(self.active)
1121            .and_then(|buf| buf.slice(range).ok());
1122        if op.leaves_register() {
1123            if let Some(t) = &text {
1124                self.register = Some(t.clone());
1125            }
1126        }
1127        match op {
1128            // Delete + Change remove the range; Change then enters Insert so
1129            // the operator pairs with immediate typing (`ciw`, `c$`).
1130            Operator::Delete | Operator::Change => {
1131                if let Some(buf) = self.buffers.get_mut(self.active) {
1132                    let _ = buf.apply(&Edit::delete(range));
1133                }
1134                self.set_cursor(range.start);
1135                if op == Operator::Change {
1136                    self.modal.enter(Mode::Insert);
1137                }
1138            }
1139            // Yank copies to the register without mutating the buffer; vim
1140            // leaves the cursor at the range start.
1141            Operator::Yank => {
1142                self.set_cursor(range.start);
1143            }
1144            // Indent/Format/structural operators are not yet wired — named,
1145            // not faked (no buffer mutation, register already captured for the
1146            // register-leaving ones above).
1147            _ => {
1148                self.messages
1149                    .push("operator not yet implemented".to_owned());
1150            }
1151        }
1152    }
1153
1154    /// The text last yanked or deleted into the unnamed register, if any.
1155    /// The future `p`/`P` paste reads this.
1156    #[must_use]
1157    pub fn register(&self) -> Option<&str> {
1158        self.register.as_deref()
1159    }
1160
1161    fn insert_char(&mut self, c: char) {
1162        if self.modal.mode() == Mode::Command {
1163            // A search prompt and an ex-command share Command mode (vim's
1164            // cmdline). `search.is_prompting()` is the typed discriminator —
1165            // it can only be true when `/` or `?` actually opened a prompt.
1166            if self.search.is_prompting() {
1167                self.search.push(c);
1168                self.modal.push_minibuffer(c);
1169                self.preview_search();
1170            } else {
1171                self.modal.push_minibuffer(c);
1172            }
1173            return;
1174        }
1175        let cursor = self.cursor();
1176        let Some(buf) = self.buffers.get_mut(self.active) else {
1177            return;
1178        };
1179        let edit = Edit::insert(cursor, c.to_string());
1180        if buf.apply(&edit).is_ok() {
1181            let next = if c == '\n' {
1182                Position::new(cursor.line.saturating_add(1), 0)
1183            } else {
1184                cursor.shift_right(1)
1185            };
1186            // Route through the single cursor-mutation path so the viewport
1187            // follows the cursor (both axes) and the cursor stays clamped.
1188            self.set_cursor(next);
1189        }
1190    }
1191
1192    /// Backspace inside a prompt. Keeps the search buffer and the displayed
1193    /// minibuffer in lockstep — if only one shrank, the pattern submitted
1194    /// would differ from the text on screen.
1195    fn prompt_backspace(&mut self) -> bool {
1196        if self.modal.mode() != Mode::Command {
1197            return false;
1198        }
1199        if self.search.is_prompting() {
1200            // Backspacing past the `/` closes the prompt, as vim does.
1201            if self.search.backspace() {
1202                self.modal.clear_minibuffer();
1203                self.modal.enter(Mode::Normal);
1204                return true;
1205            }
1206        }
1207        self.modal.pop_minibuffer();
1208        true
1209    }
1210
1211    fn apply_edit(&mut self, _edit: &Edit) {
1212        // Phase 2: actually apply arbitrary edits from the keymap. For now
1213        // the only keymap-originated edits are InsertChar (handled above)
1214        // and the Backspace sentinel that escriba-keymap emits.
1215    }
1216
1217    fn submit_command(&mut self) {
1218        // Read the command line BEFORE leaving Command mode — the minibuffer
1219        // exists only in the `Command` variant, so the escape must come
1220        // after the capture.
1221        let line = self.modal.minibuffer().to_string();
1222        self.modal.escape();
1223        let (name, args) = parse_command_line(&line);
1224        if name.is_empty() {
1225            return;
1226        }
1227        self.run_command(&name, &args);
1228    }
1229
1230    fn run_command(&mut self, name: &str, args: &[String]) {
1231        // `:noh` is handled here rather than in the command registry because
1232        // it mutates SearchState, which EditContext does not expose (and
1233        // should not — the registry's contract is buffers + modal state).
1234        // Without it there is no way to turn highlights off, which makes
1235        // hlsearch actively unpleasant rather than useful.
1236        if matches!(name, "noh" | "nohl" | "nohlsearch") {
1237            self.search.clear_highlight();
1238            return;
1239        }
1240        // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
1241        // gated on `Command: <name>` has its entry applied the first time
1242        // that command runs, BEFORE dispatch — so the activated plugin
1243        // can register the very command being invoked and it resolves on
1244        // this same call.
1245        if self.plugin_host.pending() > 0 {
1246            let pending = self.plugin_host.pending_for_command(name);
1247            for src in pending {
1248                self.apply_plugin_entry(&src);
1249            }
1250        }
1251        let active = Some(self.active);
1252        let mut quit = false;
1253        {
1254            let mut ctx = EditContext {
1255                buffers: &mut self.buffers,
1256                active,
1257                state: &mut self.modal,
1258                quit_requested: &mut quit,
1259            };
1260            let _ = self.commands.run(name, &mut ctx, args);
1261        }
1262        // The command's typed quit signal — no string sentinel, no
1263        // mode-specific buffer to clear.
1264        if quit {
1265            self.quit_requested = true;
1266        }
1267    }
1268
1269    // ── tatara-lisp runtime bridge (imperative programmability tier) ──
1270
1271    /// Capture a read snapshot of the editor for the tatara-lisp host.
1272    /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
1273    #[must_use]
1274    pub fn snapshot(&self) -> EditorSnapshot {
1275        let current_line = self
1276            .buffers
1277            .get(self.active)
1278            .and_then(|b| b.line(self.cursor().line))
1279            .map(|s| s.trim_end_matches('\n').to_string())
1280            .unwrap_or_default();
1281        let buffer_name = self
1282            .buffers
1283            .get(self.active)
1284            .and_then(|b| b.path.as_ref())
1285            .map(|p| p.display().to_string())
1286            .unwrap_or_else(|| "[scratch]".to_string());
1287        EditorSnapshot {
1288            cursor_line: i64::from(self.cursor().line),
1289            cursor_column: i64::from(self.cursor().column),
1290            current_line,
1291            mode: self.modal.mode().as_str().to_string(),
1292            buffer_name,
1293        }
1294    }
1295
1296    /// Evaluate tatara-lisp `src` against this editor: capture a
1297    /// snapshot, run it in the embedded VM, then apply the typed effects
1298    /// the program emitted. This is the imperative programmability tier
1299    /// — live Lisp that reads state and drives the editor through the
1300    /// sandboxed effect boundary.
1301    ///
1302    /// **Snapshot semantics:** the read snapshot is captured ONCE before
1303    /// eval, and effects are applied AFTER the program returns. So within
1304    /// a single `run_lisp` call a program cannot observe its own writes —
1305    /// `(insert "x") (cursor-column)` reads the pre-insert column. This
1306    /// snapshot-isolation is deliberate (it's what makes the effect
1307    /// boundary a clean sandbox seam); a program that must read its own
1308    /// effects splits the work across calls. The VM is cached
1309    /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
1310    /// `define`s persist across calls (REPL-like).
1311    pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
1312        let mut host = EscribaHost::with_snapshot(self.snapshot());
1313        let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
1314        vm.eval(src, &mut host)?;
1315        let effects = host.take_effects();
1316        self.apply_host_effects(effects);
1317        Ok(())
1318    }
1319
1320    /// Apply tatara-lisp [`HostEffect`]s to live editor state. The
1321    /// single seam where Lisp-requested mutations land — extend here +
1322    /// in `escriba-vm` to add a capability.
1323    pub fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1324        for eff in effects {
1325            match eff {
1326                HostEffect::Message(m) => self.messages.push(m),
1327                HostEffect::RunCommand { name, args } => self.run_command(&name, &args),
1328                HostEffect::SetOption { name, value } => {
1329                    self.options.insert(name, value);
1330                }
1331                HostEffect::InsertText(text) => self.insert_text(&text),
1332            }
1333        }
1334    }
1335
1336    /// Insert a (possibly multi-line) string at the cursor and advance
1337    /// the cursor past it. Used by the `(insert …)` effect.
1338    fn insert_text(&mut self, text: &str) {
1339        if text.is_empty() {
1340            return;
1341        }
1342        let cursor = self.cursor();
1343        let Some(buf) = self.buffers.get_mut(self.active) else {
1344            return;
1345        };
1346        let edit = Edit::insert(cursor, text.to_string());
1347        if buf.apply(&edit).is_ok() {
1348            let next = if let Some(nl) = text.rfind('\n') {
1349                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
1350                let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
1351                Position::new(cursor.line + added_lines, last_line_len)
1352            } else {
1353                let n = u32::try_from(text.chars().count()).unwrap_or(0);
1354                cursor.shift_right(n)
1355            };
1356            // Route through the single cursor-mutation path so the viewport
1357            // follows the cursor (both axes) and the cursor stays clamped.
1358            self.set_cursor(next);
1359        }
1360    }
1361}
1362
1363fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
1364    let Some(text) = buf.line(line) else {
1365        return Position::new(line, 0);
1366    };
1367    let col = text
1368        .chars()
1369        .take_while(|c| c.is_whitespace() && *c != '\n')
1370        .count();
1371    Position::new(line, u32::try_from(col).unwrap_or(0))
1372}
1373
1374fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1375    let Some(text) = buf.line(pos.line) else {
1376        return pos;
1377    };
1378    let chars: Vec<char> = text.chars().collect();
1379    let start = pos.column as usize;
1380    let mut i = start;
1381    while i < chars.len() && !chars[i].is_whitespace() {
1382        i += 1;
1383    }
1384    while i < chars.len() && chars[i].is_whitespace() {
1385        i += 1;
1386    }
1387    if i >= chars.len() {
1388        // No more words on this line — jump to next line.
1389        if pos.line + 1 < buf.line_count() {
1390            return Position::new(pos.line + 1, 0);
1391        }
1392    }
1393    Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
1394}
1395
1396fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
1397    let Some(text) = buf.line(pos.line) else {
1398        return pos;
1399    };
1400    let chars: Vec<char> = text.chars().collect();
1401    let mut i = (pos.column as usize).min(chars.len());
1402    while i > 0 && chars[i - 1].is_whitespace() {
1403        i -= 1;
1404    }
1405    while i > 0 && !chars[i - 1].is_whitespace() {
1406        i -= 1;
1407    }
1408    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
1409}
1410
1411fn parse_command_line(line: &str) -> (String, Vec<String>) {
1412    let mut parts = line.split_whitespace();
1413    let Some(first) = parts.next() else {
1414        return (String::new(), Vec::new());
1415    };
1416    let head = first.strip_prefix(':').unwrap_or(first);
1417    let name = match head {
1418        "w" => "save",
1419        "q" => "quit",
1420        "u" => "undo",
1421        other => other,
1422    };
1423    (name.to_string(), parts.map(str::to_string).collect())
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429    use madori::event::{KeyCode, KeyEvent, Modifiers};
1430
1431    // ── search wiring (escriba-search integration) ────────────────────
1432    //
1433    // The engine is proven in escriba-search's own 61 tests. These prove the
1434    // WIRING: that keys reach it, that the cursor lands where it says, and
1435    // that a search prompt and an ex-command can share Command mode without
1436    // being confused for one another.
1437
1438    fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
1439        st.apply(&Action::SearchOpen(dir));
1440        for c in pat.chars() {
1441            st.apply(&Action::InsertChar(c));
1442        }
1443        st.apply(&Action::SubmitCommand);
1444    }
1445
1446    #[test]
1447    fn slash_search_moves_the_cursor_to_the_match() {
1448        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1449        type_search(&mut st, SearchDirection::Forward, "charlie");
1450        assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
1451        assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
1452        assert_eq!(st.search.matches().len(), 1);
1453    }
1454
1455    #[test]
1456    fn n_and_N_walk_matches_in_both_directions() {
1457        let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
1458        type_search(&mut st, SearchDirection::Forward, "foo");
1459        let first = st.cursor().line;
1460        st.apply(&Action::SearchRepeat { reverse: false });
1461        let second = st.cursor().line;
1462        assert!(second > first, "n advances ({first} -> {second})");
1463        st.apply(&Action::SearchRepeat { reverse: true });
1464        assert_eq!(st.cursor().line, first, "N comes back");
1465    }
1466
1467    #[test]
1468    fn star_searches_the_word_under_the_cursor() {
1469        let mut st = new_state_with("needle\nhaystack\nneedle\n");
1470        st.apply(&Action::SearchWord { reverse: false });
1471        assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
1472        assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
1473    }
1474
1475    #[test]
1476    fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
1477        let mut st = new_state_with("foo\nbar\nfoo\n");
1478        type_search(&mut st, SearchDirection::Forward, "foo");
1479        let matches_before = st.search.matches().len();
1480
1481        st.apply(&Action::SearchOpen(SearchDirection::Forward));
1482        st.apply(&Action::InsertChar('z'));
1483        st.apply(&Action::ChangeMode(Mode::Normal));
1484
1485        assert!(!st.search.is_prompting(), "prompt gone");
1486        assert_eq!(
1487            st.search.pattern().unwrap().raw(),
1488            "foo",
1489            "old pattern survives"
1490        );
1491        assert_eq!(
1492            st.search.matches().len(),
1493            matches_before,
1494            "old highlights survive"
1495        );
1496    }
1497
1498    #[test]
1499    fn a_search_prompt_and_an_ex_command_are_not_confused() {
1500        let mut st = new_state_with("foo\n");
1501        // No `/` pressed: Command mode belongs to the ex-command line.
1502        st.apply(&Action::ChangeMode(Mode::Command));
1503        assert!(!st.search.is_prompting(), "`:` must not open a search");
1504        st.apply(&Action::InsertChar('w'));
1505        assert!(
1506            st.search.prompt().is_none(),
1507            "typed char went to the ex line"
1508        );
1509    }
1510
1511    #[test]
1512    fn a_missing_pattern_reports_instead_of_failing_silently() {
1513        let mut st = new_state_with("alpha\nbravo\n");
1514        type_search(&mut st, SearchDirection::Forward, "zzz");
1515        assert!(
1516            st.messages.iter().any(|m| m.contains("E486")),
1517            "must report not-found, got {:?}",
1518            st.messages
1519        );
1520    }
1521
1522    #[test]
1523    fn n_without_any_search_reports_rather_than_moving() {
1524        let mut st = new_state_with("alpha\nbravo\n");
1525        let before = st.cursor();
1526        st.apply(&Action::SearchRepeat { reverse: false });
1527        assert_eq!(st.cursor(), before, "cursor must not move");
1528        assert!(
1529            st.messages.iter().any(|m| m.contains("E35")),
1530            "got {:?}",
1531            st.messages
1532        );
1533    }
1534
1535    #[test]
1536    fn search_as_a_motion_composes_with_an_operator() {
1537        // The point of Motion::SearchNext: `d` + search deletes to the match.
1538        let mut st = new_state_with("alpha bravo charlie\n");
1539        type_search(&mut st, SearchDirection::Forward, "charlie");
1540        st.set_cursor(Position::new(0, 0));
1541        let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
1542        assert!(target.is_some(), "search must resolve as a motion");
1543        assert_eq!(target.unwrap().column, 12, "at `charlie`");
1544    }
1545
1546    #[test]
1547    fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
1548        // A silent fallback to offset 0 would make `d` + search delete to the
1549        // start of the file — the worst possible failure for an operator.
1550        let st = new_state_with("alpha bravo\n");
1551        assert!(
1552            st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
1553                .is_none()
1554        );
1555    }
1556
1557    #[test]
1558    fn clear_highlight_keeps_the_pattern_usable() {
1559        let mut st = new_state_with("foo\nbar\nfoo\n");
1560        type_search(&mut st, SearchDirection::Forward, "foo");
1561        st.apply(&Action::ClearSearchHighlight);
1562        assert!(st.search.highlights().is_empty(), "nothing lit");
1563        st.apply(&Action::SearchRepeat { reverse: false });
1564        assert!(st.search.pattern().is_some(), "but n still works");
1565    }
1566
1567    #[test]
1568    fn typing_previews_incrementally_before_commit() {
1569        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
1570        st.apply(&Action::SearchOpen(SearchDirection::Forward));
1571        for c in "charlie".chars() {
1572            st.apply(&Action::InsertChar(c));
1573        }
1574        // incsearch: the cursor has already moved, with nothing committed.
1575        assert_eq!(st.cursor().line, 2, "preview moved the cursor");
1576        assert!(st.search.pattern().is_none(), "but nothing is committed");
1577    }
1578
1579    #[test]
1580    fn backspace_corrects_the_prompt_and_reruns_the_preview() {
1581        let mut st = new_state_with("alpha\nbravo\n");
1582        st.apply(&Action::SearchOpen(SearchDirection::Forward));
1583        for c in "bravox".chars() {
1584            st.apply(&Action::InsertChar(c));
1585        }
1586        assert_eq!(st.search.prompt().unwrap().text, "bravox");
1587        st.apply(&Action::PromptBackspace);
1588        assert_eq!(st.search.prompt().unwrap().text, "bravo", "typo corrected");
1589        assert_eq!(st.modal.minibuffer(), "bravo", "display stays in lockstep");
1590        assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
1591    }
1592
1593    #[test]
1594    fn backspacing_past_the_slash_closes_the_prompt() {
1595        let mut st = new_state_with("alpha\n");
1596        st.apply(&Action::SearchOpen(SearchDirection::Forward));
1597        st.apply(&Action::InsertChar('a'));
1598        st.apply(&Action::PromptBackspace);
1599        st.apply(&Action::PromptBackspace);
1600        assert!(!st.search.is_prompting(), "prompt closed");
1601        assert_eq!(st.modal.mode(), Mode::Normal);
1602    }
1603
1604    #[test]
1605    fn noh_clears_highlights_and_keeps_the_pattern() {
1606        let mut st = new_state_with("foo\nbar\nfoo\n");
1607        type_search(&mut st, SearchDirection::Forward, "foo");
1608        assert!(!st.search.highlights().is_empty());
1609        st.run_command("noh", &[]);
1610        assert!(st.search.highlights().is_empty(), ":noh turns them off");
1611        assert!(st.search.pattern().is_some(), "but n still works");
1612    }
1613
1614    #[test]
1615    fn noh_accepts_the_vim_aliases() {
1616        for name in ["noh", "nohl", "nohlsearch"] {
1617            let mut st = new_state_with("foo\nfoo\n");
1618            type_search(&mut st, SearchDirection::Forward, "foo");
1619            st.run_command(name, &[]);
1620            assert!(st.search.highlights().is_empty(), "{name} must clear");
1621        }
1622    }
1623
1624    #[test]
1625    fn backspace_on_the_ex_line_does_not_touch_search_state() {
1626        let mut st = new_state_with("foo\n");
1627        st.apply(&Action::ChangeMode(Mode::Command));
1628        st.apply(&Action::InsertChar('w'));
1629        st.apply(&Action::InsertChar('q'));
1630        st.apply(&Action::PromptBackspace);
1631        assert_eq!(st.modal.minibuffer(), "w");
1632        assert!(st.search.prompt().is_none(), "no search was involved");
1633    }
1634
1635    #[test]
1636    fn up_arrow_recalls_the_previous_search() {
1637        let mut st = new_state_with("alpha\nbravo\n");
1638        type_search(&mut st, SearchDirection::Forward, "bravo");
1639        st.apply(&Action::SearchOpen(SearchDirection::Forward));
1640        st.apply(&Action::PromptHistory { back: true });
1641        assert_eq!(st.search.prompt().unwrap().text, "bravo");
1642        assert_eq!(st.modal.minibuffer(), "bravo", "display follows the prompt");
1643    }
1644
1645    #[test]
1646    fn arrowing_back_down_restores_the_half_typed_pattern() {
1647        let mut st = new_state_with("alpha\nbravo\n");
1648        type_search(&mut st, SearchDirection::Forward, "bravo");
1649        st.apply(&Action::SearchOpen(SearchDirection::Forward));
1650        st.apply(&Action::InsertChar('a'));
1651        st.apply(&Action::PromptHistory { back: true });
1652        assert_eq!(st.search.prompt().unwrap().text, "bravo");
1653        st.apply(&Action::PromptHistory { back: false });
1654        assert_eq!(
1655            st.search.prompt().unwrap().text,
1656            "a",
1657            "the draft comes back"
1658        );
1659        assert_eq!(st.modal.minibuffer(), "a");
1660    }
1661
1662    #[test]
1663    fn history_arrows_do_nothing_on_the_ex_line() {
1664        let mut st = new_state_with("alpha\n");
1665        st.apply(&Action::ChangeMode(Mode::Command));
1666        st.apply(&Action::InsertChar('w'));
1667        st.apply(&Action::PromptHistory { back: true });
1668        assert_eq!(st.modal.minibuffer(), "w", "ex line untouched");
1669    }
1670
1671    fn new_state_with(text: &str) -> EditorState {
1672        let mut bufs = BufferSet::new();
1673        let id = bufs.scratch(text);
1674        EditorState::new_with_buffer(bufs, id)
1675    }
1676
1677    /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
1678    /// action advances `edit_gen` (so the renderer repaints), and merely
1679    /// reading the generation does not. This is what lets `gpu.rs` gate the
1680    /// re-highlight/re-shape on a generation change — an idle frame observes an
1681    /// unchanged generation and reuses its cached buffer.
1682    #[test]
1683    fn edit_gen_advances_on_applied_action_not_on_read() {
1684        let mut s = new_state_with("hello\nworld\n");
1685        let g0 = s.edit_gen();
1686        s.apply(&Action::InsertChar('X'));
1687        assert_ne!(
1688            s.edit_gen(),
1689            g0,
1690            "an applied action must advance the refresh generation",
1691        );
1692        // Reading the generation is not a mutation — idle frames stay put.
1693        let g1 = s.edit_gen();
1694        assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
1695    }
1696
1697    /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
1698    /// `Damage` to cover exactly what changed — local for an in-place edit,
1699    /// to-end-of-document when the line count shifts — and the renderer drains
1700    /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
1701    #[test]
1702    fn damage_tracks_edit_scope_and_drains() {
1703        let mut s = new_state_with("hello\nworld\n");
1704        assert!(s.damage().is_none(), "a fresh state has no damage");
1705
1706        s.apply(&Action::InsertChar('X')); // in-place edit on line 0
1707        assert_eq!(
1708            s.damage(),
1709            Damage::Lines { from: 0, to: 0 },
1710            "a local edit damages just its line",
1711        );
1712
1713        let drained = s.take_damage();
1714        assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
1715        assert!(s.damage().is_none(), "take_damage drains to None");
1716
1717        s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
1718        assert_eq!(
1719            s.damage(),
1720            Damage::Lines {
1721                from: 0,
1722                to: u32::MAX,
1723            },
1724            "a line-count change damages to end-of-document",
1725        );
1726    }
1727
1728    /// A state whose active window is a deliberately tiny viewport
1729    /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
1730    /// invariant is exercised on small inputs.
1731    fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
1732        let mut s = new_state_with(text);
1733        for w in &mut s.layout.windows {
1734            w.viewport.visible_lines = vis_lines;
1735            w.viewport.visible_columns = vis_cols;
1736        }
1737        s
1738    }
1739
1740    /// The core regression invariant: the active window's viewport CONTAINS
1741    /// the cursor on BOTH axes. This is the operator's exact complaint —
1742    /// "typing past the bottom (or right) leaves the cursor off-screen" —
1743    /// made into a checkable property.
1744    fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
1745        let w = s.layout.active_window().expect("active window");
1746        let v = w.viewport;
1747        let c = s.cursor();
1748        assert!(
1749            v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
1750            "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
1751            c.line,
1752            v.top_line,
1753            v.top_line + v.visible_lines,
1754        );
1755        assert!(
1756            v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
1757            "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
1758            c.column,
1759            v.left_column,
1760            v.left_column + v.visible_columns,
1761        );
1762    }
1763
1764    fn press(kc: KeyCode) -> AppEvent {
1765        AppEvent::Key(KeyEvent {
1766            key: kc,
1767            pressed: true,
1768            modifiers: Modifiers::default(),
1769            text: None,
1770        })
1771    }
1772
1773    // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
1774
1775    fn line0_len(s: &EditorState) -> u32 {
1776        s.buffers.get(s.active).unwrap().line_len_chars(0)
1777    }
1778
1779    #[test]
1780    fn delete_to_line_end_clears_line_and_fills_register() {
1781        let mut s = new_state_with("hello world");
1782        s.apply(&Action::ApplyOperator {
1783            op: Operator::Delete,
1784            motion: Motion::LineEnd,
1785        });
1786        assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
1787        assert_eq!(
1788            s.register(),
1789            Some("hello world"),
1790            "delete fills the register"
1791        );
1792        assert_eq!(
1793            s.cursor(),
1794            Position::ZERO,
1795            "cursor lands at the range start"
1796        );
1797    }
1798
1799    #[test]
1800    fn delete_over_right_motion_removes_one_char() {
1801        let mut s = new_state_with("abc");
1802        s.apply(&Action::ApplyOperator {
1803            op: Operator::Delete,
1804            motion: Motion::Right,
1805        });
1806        assert_eq!(
1807            s.buffers.get(s.active).unwrap().line(0).as_deref(),
1808            Some("bc")
1809        );
1810        assert_eq!(s.register(), Some("a"));
1811    }
1812
1813    #[test]
1814    fn change_to_line_end_deletes_and_enters_insert() {
1815        let mut s = new_state_with("hello world");
1816        assert_eq!(s.modal.mode(), Mode::Normal);
1817        s.apply(&Action::ApplyOperator {
1818            op: Operator::Change,
1819            motion: Motion::LineEnd,
1820        });
1821        assert_eq!(line0_len(&s), 0, "c$ deletes the range");
1822        assert_eq!(
1823            s.modal.mode(),
1824            Mode::Insert,
1825            "change enters Insert to type the replacement"
1826        );
1827        assert_eq!(
1828            s.register(),
1829            Some("hello world"),
1830            "change fills the register"
1831        );
1832    }
1833
1834    #[test]
1835    fn yank_to_line_end_fills_register_without_mutating() {
1836        let mut s = new_state_with("hello world");
1837        s.apply(&Action::ApplyOperator {
1838            op: Operator::Yank,
1839            motion: Motion::LineEnd,
1840        });
1841        assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
1842        assert_eq!(s.register(), Some("hello world"), "yank fills the register");
1843        assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
1844    }
1845
1846    #[test]
1847    fn resolve_motion_is_the_shared_target_for_move_and_operator() {
1848        // The encapsulation proof: apply_motion (cursor move) and
1849        // apply_operator (range end) BOTH stand on resolve_motion — so a move
1850        // to LineEnd lands at exactly the position the operator deletes to.
1851        let mut s = new_state_with("hello world");
1852        let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
1853        assert_eq!(target, Position::new(0, 11));
1854        s.apply_motion(Motion::LineEnd);
1855        assert_eq!(
1856            s.cursor(),
1857            target,
1858            "the move path resolves the same target the operator uses"
1859        );
1860    }
1861
1862    #[test]
1863    fn empty_motion_range_is_a_no_op() {
1864        // An operator over a zero-width motion (cursor already at line start)
1865        // mutates nothing and leaves the register untouched.
1866        let mut s = new_state_with("abc");
1867        s.apply(&Action::ApplyOperator {
1868            op: Operator::Delete,
1869            motion: Motion::LineStart,
1870        });
1871        assert_eq!(
1872            s.buffers.get(s.active).unwrap().line(0).as_deref(),
1873            Some("abc")
1874        );
1875        assert_eq!(s.register(), None);
1876    }
1877
1878    #[test]
1879    fn operator_then_motion_composes_through_the_pending_fsm() {
1880        // The full keymap→FSM→engine path: dispatching the `d` operator action
1881        // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
1882        // the operator key alone does nothing until the motion arrives.
1883        let mut s = new_state_with("hello world");
1884        s.apply(&Action::Operator(Operator::Delete));
1885        assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
1886        s.apply(&Action::Move(Motion::LineEnd));
1887        assert_eq!(
1888            line0_len(&s),
1889            0,
1890            "d then $ composes d$ and deletes the line"
1891        );
1892        assert_eq!(s.register(), Some("hello world"));
1893    }
1894
1895    #[test]
1896    fn change_operator_through_fsm_enters_insert() {
1897        let mut s = new_state_with("hello world");
1898        s.apply(&Action::Operator(Operator::Change));
1899        s.apply(&Action::Move(Motion::LineEnd));
1900        assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
1901    }
1902
1903    #[test]
1904    fn lone_motion_after_no_operator_just_moves() {
1905        // Without a preceding operator the motion passes through unchanged.
1906        let mut s = new_state_with("hello world");
1907        s.apply(&Action::Move(Motion::LineEnd));
1908        assert_eq!(s.cursor(), Position::new(0, 11));
1909        assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
1910    }
1911
1912    #[test]
1913    fn counted_operator_deletes_count_times() {
1914        // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
1915        // flows through the FSM to the composed motion (the bug fix: previously
1916        // the count repeated the operator key and toggled the FSM).
1917        let mut s = new_state_with("abcdef");
1918        s.apply_counted(&Action::Operator(Operator::Delete), 3);
1919        assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
1920        s.apply(&Action::Move(Motion::Right));
1921        assert_eq!(
1922            s.buffers.get(s.active).unwrap().line(0).as_deref(),
1923            Some("def")
1924        );
1925    }
1926
1927    #[test]
1928    fn operator_and_motion_counts_multiply_end_to_end() {
1929        // `2d3l` = delete 2×3 = 6 chars.
1930        let mut s = new_state_with("abcdefgh");
1931        s.apply_counted(&Action::Operator(Operator::Delete), 2);
1932        s.apply_counted(&Action::Move(Motion::Right), 3);
1933        assert_eq!(
1934            s.buffers.get(s.active).unwrap().line(0).as_deref(),
1935            Some("gh")
1936        );
1937    }
1938
1939    #[test]
1940    fn bare_counted_motion_still_repeats_no_regression() {
1941        // `3j` still moves down 3 lines — the count passes through the FSM
1942        // unchanged when no operator is pending.
1943        let mut s = new_state_with("a\nb\nc\nd\ne");
1944        s.apply_counted(&Action::Move(Motion::Down), 3);
1945        assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
1946    }
1947
1948    /// A monotonic clock for the key-repeat gate in tests — each `next()`
1949    /// jumps a full second past the previous, so every press it stamps is
1950    /// well outside the 80ms debounce window and therefore an INTENTIONAL
1951    /// press (never a storm tick). Used by tests that fire the *same*
1952    /// navigation key twice and assert editor logic, not debounce timing.
1953    struct SpacedClock(std::time::Instant);
1954    impl SpacedClock {
1955        fn new() -> Self {
1956            Self(std::time::Instant::now())
1957        }
1958        fn next(&mut self) -> std::time::Instant {
1959            self.0 += std::time::Duration::from_secs(1);
1960            self.0
1961        }
1962    }
1963
1964    #[test]
1965    fn hjkl_moves_cursor() {
1966        let mut s = new_state_with("hello\nworld");
1967        s.tick(&press(KeyCode::Char('l')));
1968        assert_eq!(s.cursor().column, 1);
1969        s.tick(&press(KeyCode::Char('j')));
1970        assert_eq!(s.cursor().line, 1);
1971        s.tick(&press(KeyCode::Char('h')));
1972        assert_eq!(s.cursor().column, 0);
1973    }
1974
1975    #[test]
1976    fn insert_mode_inserts_chars() {
1977        let mut s = new_state_with("");
1978        s.tick(&press(KeyCode::Char('i')));
1979        assert_eq!(s.modal.mode(), Mode::Insert);
1980        s.tick(&press(KeyCode::Char('h')));
1981        s.tick(&press(KeyCode::Char('i')));
1982        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
1983        assert_eq!(s.cursor().column, 2);
1984    }
1985
1986    #[test]
1987    fn esc_returns_to_normal() {
1988        let mut s = new_state_with("");
1989        s.tick(&press(KeyCode::Char('i')));
1990        s.tick(&press(KeyCode::Escape));
1991        assert_eq!(s.modal.mode(), Mode::Normal);
1992    }
1993
1994    #[test]
1995    fn count_prefix_repeats_motion() {
1996        let mut s = new_state_with("abcdefghij");
1997        s.tick(&press(KeyCode::Char('5')));
1998        s.tick(&press(KeyCode::Char('l')));
1999        assert_eq!(s.cursor().column, 5);
2000    }
2001
2002    #[test]
2003    fn close_event_requests_quit() {
2004        let mut s = new_state_with("");
2005        s.tick(&AppEvent::CloseRequested);
2006        assert!(s.quit_requested);
2007    }
2008
2009    #[test]
2010    fn word_next_jumps_past_whitespace() {
2011        let mut s = new_state_with("foo bar baz");
2012        // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
2013        // the gate passes both (a real user's two taps are ≥80ms apart).
2014        let mut clk = SpacedClock::new();
2015        s.tick_at(&press(KeyCode::Char('w')), clk.next());
2016        assert_eq!(s.cursor().column, 4);
2017        s.tick_at(&press(KeyCode::Char('w')), clk.next());
2018        assert_eq!(s.cursor().column, 8);
2019    }
2020
2021    // ── Multi-key / leader pending-stroke ───────────────────────────
2022
2023    #[test]
2024    fn leader_sequence_holds_then_resolves() {
2025        let mut s = new_state_with("a\nbb\nccc");
2026        s.keymap.bind_sequence(
2027            Mode::Normal,
2028            vec![Key::Char(','), Key::Char('g')],
2029            Action::Move(Motion::DocEnd),
2030            "doc end",
2031        );
2032        // `,` begins the sequence — held pending, nothing applied yet.
2033        s.on_key(&Key::Char(','));
2034        assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2035        assert_eq!(s.cursor(), Position::ZERO);
2036        // `g` completes `<leader>g` → DocEnd; pending clears.
2037        s.on_key(&Key::Char('g'));
2038        assert!(s.pending_keys.is_empty());
2039        assert_eq!(s.cursor().line, 2);
2040    }
2041
2042    #[test]
2043    fn two_key_gg_jumps_doc_start() {
2044        let mut s = new_state_with("a\nbb\nccc");
2045        s.keymap.bind_sequence(
2046            Mode::Normal,
2047            vec![Key::Char('g'), Key::Char('g')],
2048            Action::Move(Motion::DocStart),
2049            "doc start",
2050        );
2051        let mut clk = SpacedClock::new();
2052        s.tick_at(&press(KeyCode::Char('j')), clk.next());
2053        s.tick_at(&press(KeyCode::Char('j')), clk.next());
2054        assert_eq!(s.cursor().line, 2);
2055        s.on_key(&Key::Char('g')); // pending
2056        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2057        s.on_key(&Key::Char('g')); // resolve
2058        assert_eq!(s.cursor(), Position::ZERO);
2059    }
2060
2061    #[test]
2062    fn broken_sequence_aborts_and_clears_pending() {
2063        let mut s = new_state_with("hello");
2064        s.keymap.bind_sequence(
2065            Mode::Normal,
2066            vec![Key::Char('g'), Key::Char('g')],
2067            Action::Move(Motion::DocEnd),
2068            "doc end",
2069        );
2070        s.on_key(&Key::Char('g')); // pending [g]
2071        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2072        s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
2073        assert!(s.pending_keys.is_empty());
2074        assert_eq!(s.cursor(), Position::ZERO);
2075    }
2076
2077    #[test]
2078    fn single_binding_wins_over_sequence_prefix() {
2079        // A key that is BOTH a complete single binding and the start of
2080        // a sequence fires the single binding immediately (no chord
2081        // timeout needed). Here `h` (move-left) also prefixes `hz`.
2082        let mut s = new_state_with("abcde");
2083        let mut clk = SpacedClock::new();
2084        s.tick_at(&press(KeyCode::Char('l')), clk.next());
2085        s.tick_at(&press(KeyCode::Char('l')), clk.next());
2086        assert_eq!(s.cursor().column, 2);
2087        s.keymap.bind_sequence(
2088            Mode::Normal,
2089            vec![Key::Char('h'), Key::Char('z')],
2090            Action::Move(Motion::DocEnd),
2091            "shadowed",
2092        );
2093        s.on_key(&Key::Char('h'));
2094        assert!(s.pending_keys.is_empty(), "single binding should not pend");
2095        assert_eq!(s.cursor().column, 1, "h moved left immediately");
2096    }
2097
2098    // ── tatara-lisp runtime bridge (imperative programmability) ─────
2099
2100    #[test]
2101    fn lisp_set_option_writes_live_options() {
2102        let mut s = new_state_with("");
2103        s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2104        assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2105    }
2106
2107    #[test]
2108    fn lisp_insert_modifies_buffer_and_advances_cursor() {
2109        let mut s = new_state_with("");
2110        s.run_lisp(r#"(insert "abc")"#).unwrap();
2111        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2112        assert_eq!(s.cursor(), Position::new(0, 3));
2113    }
2114
2115    #[test]
2116    fn lisp_message_appends_to_messages() {
2117        let mut s = new_state_with("");
2118        s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2119        assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2120    }
2121
2122    #[test]
2123    fn lisp_reads_snapshot_and_branches_to_effect() {
2124        // Genuine programmability: Lisp reads the live cursor line and
2125        // an `if` decides which option to set.
2126        let mut s = new_state_with("one\ntwo\nthree");
2127        // cursor at line 0 → "top" branch
2128        s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2129            .unwrap();
2130        assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2131    }
2132
2133    #[test]
2134    fn lisp_run_command_effect_drives_registry() {
2135        // `(run-command "undo")` reaches the live command registry and
2136        // reverts a prior Lisp-driven insert — proving the RunCommand
2137        // effect dispatches through real editor commands.
2138        let mut s = new_state_with("");
2139        s.run_lisp(r#"(insert "abc")"#).unwrap();
2140        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2141        s.run_lisp(r#"(run-command "undo")"#).unwrap();
2142        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2143    }
2144
2145    #[test]
2146    fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2147        // The full imperative-quit path: (run-command "quit") routes
2148        // through the registry's typed `quit_requested` signal — no string
2149        // sentinel, and no minibuffer pollution (the editor stays in a
2150        // clean Normal state, which has no minibuffer at all).
2151        let mut s = new_state_with("");
2152        s.run_lisp(r#"(run-command "quit")"#).unwrap();
2153        assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2154        assert_eq!(
2155            s.modal.minibuffer(),
2156            "",
2157            "quit must not pollute any command line — Normal mode has no minibuffer",
2158        );
2159    }
2160
2161    // ── Lazy plugin activation (PluginHost) ────────────────────────
2162
2163    #[test]
2164    fn lazy_plugin_activates_on_command_trigger() {
2165        // A user plugin gated on `Command: LazyGo` has its entry applied
2166        // the first time that command runs — proving the lazy.nvim
2167        // `cmd =` model works end-to-end against live editor state.
2168        let mut s = new_state_with("");
2169        s.register_lazy_plugin(
2170            "user-lazy",
2171            vec![LazyTrigger::Command("LazyGo".into())],
2172            r#"(defoption :name "lazy-loaded" :value "yes")
2173               (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
2174        );
2175        assert_eq!(s.plugin_host.pending(), 1);
2176        assert!(
2177            s.options.get("lazy-loaded").is_none(),
2178            "entry not applied yet"
2179        );
2180
2181        // Drive the command through the public imperative path.
2182        s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
2183
2184        assert_eq!(
2185            s.options.get("lazy-loaded").map(String::as_str),
2186            Some("yes"),
2187            "the command trigger applied the plugin's entry",
2188        );
2189        assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
2190    }
2191
2192    #[test]
2193    fn lazy_plugin_activates_on_filetype() {
2194        let mut s = new_state_with("");
2195        s.register_lazy_plugin(
2196            "user-rust",
2197            vec![LazyTrigger::FileType("rust".into())],
2198            r#"(defoption :name "rust-plugin" :value "on")"#,
2199        );
2200        let n = s.activate_filetype_plugins("rust");
2201        assert_eq!(n, 1);
2202        assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
2203        // A second open of the same filetype is a no-op (one-shot).
2204        assert_eq!(s.activate_filetype_plugins("rust"), 0);
2205    }
2206
2207    #[test]
2208    fn cached_vm_serves_multiple_run_lisp_calls() {
2209        let mut s = new_state_with("");
2210        s.run_lisp(r#"(message "one")"#).unwrap();
2211        assert!(
2212            s.lisp_vm.is_some(),
2213            "VM should be cached after first run_lisp"
2214        );
2215        s.run_lisp(r#"(message "two")"#).unwrap();
2216        assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
2217    }
2218
2219    #[test]
2220    fn lisp_define_persists_across_run_lisp_calls() {
2221        // The cached VM's top-level env persists across calls (REPL
2222        // semantics): a `define` in one call is visible in the next.
2223        let mut s = new_state_with("");
2224        s.run_lisp(r#"(define greeting "hi")"#).unwrap();
2225        s.run_lisp(r#"(message greeting)"#).unwrap();
2226        assert_eq!(s.messages, vec!["hi".to_string()]);
2227    }
2228
2229    #[test]
2230    fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
2231        // Within ONE call a program cannot observe its own writes — the
2232        // read snapshot is captured before eval, effects apply after. A
2233        // later call sees the refreshed snapshot.
2234        let mut s = new_state_with("");
2235        s.run_lisp(
2236            r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
2237        )
2238        .unwrap();
2239        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
2240        assert_eq!(
2241            s.options.get("col").map(String::as_str),
2242            Some("stale-zero"),
2243            "cursor-column within the same call reads the pre-eval snapshot",
2244        );
2245        // After the first call the cursor advanced to column 2; the next
2246        // call's snapshot reflects it.
2247        s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
2248            .unwrap();
2249        assert_eq!(
2250            s.options.get("col2").map(String::as_str),
2251            Some("live-two"),
2252            "a later call sees the refreshed snapshot",
2253        );
2254    }
2255
2256    #[test]
2257    fn insert_text_effect_multiline_lands_cursor_on_last_line() {
2258        let mut s = new_state_with("");
2259        s.apply_host_effects(vec![HostEffect::InsertText("foo\nbar".to_string())]);
2260        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
2261        assert_eq!(s.cursor(), Position::new(1, 3));
2262    }
2263
2264    #[test]
2265    fn visual_mode_sequence_resolves() {
2266        let mut s = new_state_with("abc");
2267        s.modal.enter(Mode::Visual);
2268        s.keymap.bind_sequence(
2269            Mode::Visual,
2270            vec![Key::Char('g'), Key::Char('e')],
2271            Action::Move(Motion::DocEnd),
2272            "ge",
2273        );
2274        s.on_key(&Key::Char('g'));
2275        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2276        s.on_key(&Key::Char('e'));
2277        assert!(s.pending_keys.is_empty());
2278        assert_eq!(
2279            s.cursor().column,
2280            3,
2281            "ge resolved to doc-end in visual mode"
2282        );
2283    }
2284
2285    #[test]
2286    fn sequence_abort_with_bound_breaking_key_redispatches() {
2287        // gg is a sequence; `l` (move-right) is a bound single key. After
2288        // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
2289        let mut s = new_state_with("abcde");
2290        s.keymap.bind_sequence(
2291            Mode::Normal,
2292            vec![Key::Char('g'), Key::Char('g')],
2293            Action::Move(Motion::DocEnd),
2294            "gg",
2295        );
2296        s.on_key(&Key::Char('g'));
2297        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2298        s.on_key(&Key::Char('l'));
2299        assert!(s.pending_keys.is_empty());
2300        assert_eq!(
2301            s.cursor().column,
2302            1,
2303            "the breaking key l should re-dispatch as move-right",
2304        );
2305    }
2306
2307    // ── Viewport-follows-cursor invariant (both axes) ───────────────
2308
2309    #[test]
2310    fn viewport_contains_cursor_after_every_op() {
2311        // Tiny window: 5 visible lines × 10 visible columns. Drive a
2312        // representative scripted sequence and assert the viewport contains
2313        // the cursor after EVERY mutating step.
2314        let mut s = new_state_small_viewport("", 5, 10);
2315        assert_cursor_in_viewport(&s, "initial");
2316
2317        // Enter insert mode and type 30 newline-separated lines — this is
2318        // the exact "type past the bottom" complaint.
2319        s.tick(&press(KeyCode::Char('i')));
2320        assert_eq!(s.modal.mode(), Mode::Insert);
2321        for line in 0..30u32 {
2322            for c in "line".chars() {
2323                s.tick(&press(KeyCode::Char(c)));
2324                assert_cursor_in_viewport(&s, "typing chars");
2325            }
2326            s.tick(&press(KeyCode::Enter));
2327            assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
2328        }
2329
2330        // Type a long (200-char) line — the "type past the right edge"
2331        // complaint. The cursor must stay horizontally visible the whole way.
2332        for i in 0..200u32 {
2333            s.tick(&press(KeyCode::Char('x')));
2334            assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
2335        }
2336
2337        // Multi-line insert_text effect (the `(insert …)` Lisp path).
2338        s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
2339        assert_cursor_in_viewport(&s, "insert_text multiline");
2340
2341        // Back to normal mode and move in all directions / to extremes.
2342        s.tick(&press(KeyCode::Escape));
2343        assert_eq!(s.modal.mode(), Mode::Normal);
2344        for m in [
2345            Motion::DocStart,
2346            Motion::DocEnd,
2347            Motion::Down,
2348            Motion::Down,
2349            Motion::Up,
2350            Motion::Right,
2351            Motion::Right,
2352            Motion::Left,
2353            Motion::LineEnd,
2354            Motion::LineStart,
2355            Motion::GotoLine(1),
2356            Motion::GotoLine(40),
2357            Motion::PageDown,
2358            Motion::PageUp,
2359        ] {
2360            s.apply_motion(m);
2361            assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
2362        }
2363
2364        // Undo many times — the buffer shrinks; the viewport must re-follow
2365        // the (now clamped) cursor.
2366        for i in 0..50u32 {
2367            s.apply(&Action::Undo);
2368            assert_cursor_in_viewport(&s, &format!("undo {i}"));
2369        }
2370        // Redo back up.
2371        for i in 0..50u32 {
2372            s.apply(&Action::Redo);
2373            assert_cursor_in_viewport(&s, &format!("redo {i}"));
2374        }
2375    }
2376
2377    #[test]
2378    fn insert_at_eof_keeps_cursor_in_bounds() {
2379        // Inserting at the end of the buffer must leave the cursor clamped
2380        // to a valid position (and inside the viewport).
2381        let mut s = new_state_small_viewport("abc", 5, 10);
2382        s.apply_motion(Motion::DocEnd);
2383        s.tick(&press(KeyCode::Char('i')));
2384        s.tick(&press(KeyCode::Char('d')));
2385        let buf = s.buffers.get(s.active).unwrap();
2386        let clamped = buf.clamp(s.cursor());
2387        assert_eq!(
2388            s.cursor(),
2389            clamped,
2390            "cursor must be clamped in-bounds at EOF"
2391        );
2392        assert_cursor_in_viewport(&s, "insert at eof");
2393    }
2394
2395    #[test]
2396    fn count_prefix_then_sequence_repeats() {
2397        // `2` then `gj` (→ move-down) repeats the resolved action twice.
2398        let mut s = new_state_with("a\nb\nc\nd\ne");
2399        s.keymap.bind_sequence(
2400            Mode::Normal,
2401            vec![Key::Char('g'), Key::Char('j')],
2402            Action::Move(Motion::Down),
2403            "gj",
2404        );
2405        s.on_key(&Key::Char('2'));
2406        s.on_key(&Key::Char('g'));
2407        s.on_key(&Key::Char('j'));
2408        assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
2409    }
2410
2411    // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
2412
2413    #[test]
2414    fn held_key_repeat_storm_is_debounced_in_normal_mode() {
2415        // The audit's exact complaint: holding `j` floods motion events
2416        // and thrashes the viewport. Simulate an OS key-repeat storm — 20
2417        // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
2418        // — and assert only the gated subset (one per 80ms window) actually
2419        // moves the cursor.
2420        let mut s = new_state_with(&"x\n".repeat(40));
2421        let t0 = std::time::Instant::now();
2422        let mut delivered = 0u32;
2423        for i in 0..20u32 {
2424            let before = s.cursor().line;
2425            s.tick_at(
2426                &press(KeyCode::Char('j')),
2427                t0 + std::time::Duration::from_millis(u64::from(i) * 50),
2428            );
2429            if s.cursor().line != before {
2430                delivered += 1;
2431            }
2432        }
2433        // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
2434        // fewer than the 20 the ungated path would have applied.
2435        assert!(
2436            (10..=14).contains(&delivered),
2437            "expected the storm debounced to ~13 moves, got {delivered}",
2438        );
2439        assert!(
2440            delivered < 20,
2441            "the gate must drop SOME storm ticks, not pass all 20",
2442        );
2443    }
2444
2445    #[test]
2446    fn spaced_intentional_taps_all_pass() {
2447        // Intentional taps spaced past the debounce window must ALL reach
2448        // the editor — the gate filters storms, never deliberate input.
2449        let mut s = new_state_with(&"x\n".repeat(10));
2450        let t0 = std::time::Instant::now();
2451        for i in 0..5u32 {
2452            s.tick_at(
2453                &press(KeyCode::Char('j')),
2454                // 100ms apart — comfortably past the 80ms window.
2455                t0 + std::time::Duration::from_millis(u64::from(i) * 100),
2456            );
2457        }
2458        assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
2459    }
2460
2461    #[test]
2462    fn distinct_keys_have_independent_clocks() {
2463        // Holding `j` must not block a simultaneous `l` — the gate keys on
2464        // the Key, so independent keys have independent windows.
2465        let mut s = new_state_with("abc\ndef\nghi");
2466        let t = std::time::Instant::now();
2467        s.tick_at(&press(KeyCode::Char('j')), t);
2468        // `j` again within the window is dropped…
2469        s.tick_at(
2470            &press(KeyCode::Char('j')),
2471            t + std::time::Duration::from_millis(10),
2472        );
2473        assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
2474        // …but `l` at the same instant passes (its own clock).
2475        s.tick_at(
2476            &press(KeyCode::Char('l')),
2477            t + std::time::Duration::from_millis(10),
2478        );
2479        assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
2480    }
2481
2482    // ── Cursors newtype is the single cursor home ──────────────────────
2483
2484    #[test]
2485    fn cursor_home_preserves_single_cursor_behavior() {
2486        // The typed `Cursors` wrapper behaves exactly like the old bare
2487        // `Position` field for single-cursor editing: the read accessor
2488        // tracks every mutation routed through `set_cursor`, and there is
2489        // exactly one caret.
2490        let mut s = new_state_with("hello\nworld\nthere");
2491        assert_eq!(s.cursor(), Position::ZERO);
2492        assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
2493
2494        s.apply_motion(Motion::Down);
2495        s.apply_motion(Motion::Right);
2496        s.apply_motion(Motion::Right);
2497        assert_eq!(s.cursor(), Position::new(1, 2));
2498        // Still a single caret after a sequence of motions.
2499        assert_eq!(s.cursors.count(), 1);
2500
2501        // The accessor is the SAME value the viewport-follow path read.
2502        let w = s.layout.active_window().unwrap();
2503        assert!(w.viewport.top_line <= s.cursor().line);
2504    }
2505
2506    #[test]
2507    fn insert_mode_is_ungated_so_repeat_typing_works() {
2508        // Holding a key to repeat-type a character is intended in Insert
2509        // mode — the gate must NOT suppress it. 10 rapid identical `x`
2510        // keystrokes at the same instant must all land as text.
2511        let mut s = new_state_with("");
2512        s.tick(&press(KeyCode::Char('i')));
2513        assert_eq!(s.modal.mode(), Mode::Insert);
2514        let t = std::time::Instant::now();
2515        for _ in 0..10 {
2516            s.tick_at(&press(KeyCode::Char('x')), t);
2517        }
2518        assert_eq!(
2519            s.buffers.get(s.active).unwrap().to_string(),
2520            "xxxxxxxxxx",
2521            "insert-mode repeat typing is ungated",
2522        );
2523    }
2524}