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