Skip to main content

hjkl_engine/
editor.rs

1//! Editor — the public sqeel-vim type, layered over `hjkl_buffer::Buffer`.
2//!
3//! This file owns the public Editor API — construction, content access,
4//! mouse and goto helpers, the (buffer-level) undo stack, and insert-mode
5//! session bookkeeping. All vim-specific keyboard handling lives in
6//! [`vim`] and communicates with Editor through a small internal API
7//! exposed via `pub(super)` fields and helper methods.
8
9use crate::input::{Input, Key};
10use crate::vim::{self, VimState};
11use crate::{KeybindingMode, VimMode};
12use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13use ratatui::layout::Rect;
14use std::sync::atomic::{AtomicU16, Ordering};
15
16/// Convert a SPEC [`crate::types::Style`] to a [`ratatui::style::Style`].
17///
18/// Lossless within the styles each library represents. Lives in the
19/// engine because hjkl-engine pulls ratatui as a mandatory dep today;
20/// once trait extraction lands, this becomes a trivial pass-through
21/// once the field types flip.
22pub(crate) fn engine_style_to_ratatui(s: crate::types::Style) -> ratatui::style::Style {
23    use crate::types::Attrs;
24    use ratatui::style::{Color as RColor, Modifier as RMod, Style as RStyle};
25    let mut out = RStyle::default();
26    if let Some(c) = s.fg {
27        out = out.fg(RColor::Rgb(c.0, c.1, c.2));
28    }
29    if let Some(c) = s.bg {
30        out = out.bg(RColor::Rgb(c.0, c.1, c.2));
31    }
32    let mut m = RMod::empty();
33    if s.attrs.contains(Attrs::BOLD) {
34        m |= RMod::BOLD;
35    }
36    if s.attrs.contains(Attrs::ITALIC) {
37        m |= RMod::ITALIC;
38    }
39    if s.attrs.contains(Attrs::UNDERLINE) {
40        m |= RMod::UNDERLINED;
41    }
42    if s.attrs.contains(Attrs::REVERSE) {
43        m |= RMod::REVERSED;
44    }
45    if s.attrs.contains(Attrs::DIM) {
46        m |= RMod::DIM;
47    }
48    if s.attrs.contains(Attrs::STRIKE) {
49        m |= RMod::CROSSED_OUT;
50    }
51    out.add_modifier(m)
52}
53
54/// Inverse of [`engine_style_to_ratatui`]. Lossy for ratatui colors
55/// the engine doesn't model (Indexed, named ANSI) — flattens to
56/// nearest RGB.
57pub(crate) fn ratatui_style_to_engine(s: ratatui::style::Style) -> crate::types::Style {
58    use crate::types::{Attrs, Color, Style};
59    use ratatui::style::{Color as RColor, Modifier as RMod};
60    fn c(rc: RColor) -> Color {
61        match rc {
62            RColor::Rgb(r, g, b) => Color(r, g, b),
63            RColor::Black => Color(0, 0, 0),
64            RColor::Red => Color(205, 49, 49),
65            RColor::Green => Color(13, 188, 121),
66            RColor::Yellow => Color(229, 229, 16),
67            RColor::Blue => Color(36, 114, 200),
68            RColor::Magenta => Color(188, 63, 188),
69            RColor::Cyan => Color(17, 168, 205),
70            RColor::Gray => Color(229, 229, 229),
71            RColor::DarkGray => Color(102, 102, 102),
72            RColor::LightRed => Color(241, 76, 76),
73            RColor::LightGreen => Color(35, 209, 139),
74            RColor::LightYellow => Color(245, 245, 67),
75            RColor::LightBlue => Color(59, 142, 234),
76            RColor::LightMagenta => Color(214, 112, 214),
77            RColor::LightCyan => Color(41, 184, 219),
78            RColor::White => Color(255, 255, 255),
79            _ => Color(0, 0, 0),
80        }
81    }
82    let mut attrs = Attrs::empty();
83    if s.add_modifier.contains(RMod::BOLD) {
84        attrs |= Attrs::BOLD;
85    }
86    if s.add_modifier.contains(RMod::ITALIC) {
87        attrs |= Attrs::ITALIC;
88    }
89    if s.add_modifier.contains(RMod::UNDERLINED) {
90        attrs |= Attrs::UNDERLINE;
91    }
92    if s.add_modifier.contains(RMod::REVERSED) {
93        attrs |= Attrs::REVERSE;
94    }
95    if s.add_modifier.contains(RMod::DIM) {
96        attrs |= Attrs::DIM;
97    }
98    if s.add_modifier.contains(RMod::CROSSED_OUT) {
99        attrs |= Attrs::STRIKE;
100    }
101    Style {
102        fg: s.fg.map(c),
103        bg: s.bg.map(c),
104        attrs,
105    }
106}
107
108/// Map a [`hjkl_buffer::Edit`] to the SPEC [`crate::types::Edit`]
109/// (`EditOp`). Returns `None` when the buffer edit isn't representable
110/// as a single SPEC EditOp; today every variant maps so this is
111/// always `Some`, but the option keeps room for future no-log
112/// signals.
113fn edit_to_editop(edit: &hjkl_buffer::Edit) -> Option<crate::types::Edit> {
114    use crate::types::{Edit as Op, Pos};
115    use hjkl_buffer::Edit as B;
116    let to_pos = |p: hjkl_buffer::Position| Pos {
117        line: p.row as u32,
118        col: p.col as u32,
119    };
120    Some(match edit {
121        B::InsertChar { at, ch } => Op {
122            range: to_pos(*at)..to_pos(*at),
123            replacement: ch.to_string(),
124        },
125        B::InsertStr { at, text } => Op {
126            range: to_pos(*at)..to_pos(*at),
127            replacement: text.clone(),
128        },
129        B::DeleteRange { start, end, .. } => Op {
130            range: to_pos(*start)..to_pos(*end),
131            replacement: String::new(),
132        },
133        B::Replace { start, end, with } => Op {
134            range: to_pos(*start)..to_pos(*end),
135            replacement: with.clone(),
136        },
137        B::JoinLines { row, count, .. } => {
138            let start = Pos {
139                line: *row as u32,
140                col: 0,
141            };
142            let end = Pos {
143                line: (*row + *count) as u32,
144                col: 0,
145            };
146            Op {
147                range: start..end,
148                replacement: String::new(),
149            }
150        }
151        B::SplitLines { row, .. } => {
152            let p = Pos {
153                line: *row as u32,
154                col: 0,
155            };
156            Op {
157                range: p..p,
158                replacement: String::new(),
159            }
160        }
161        B::InsertBlock { at, .. } => {
162            let p = to_pos(*at);
163            Op {
164                range: p..p,
165                replacement: String::new(),
166            }
167        }
168        B::DeleteBlockChunks { at, .. } => {
169            let p = to_pos(*at);
170            Op {
171                range: p..p,
172                replacement: String::new(),
173            }
174        }
175    })
176}
177
178/// Where the cursor should land in the viewport after a `z`-family
179/// scroll (`zz` / `zt` / `zb`).
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub(super) enum CursorScrollTarget {
182    Center,
183    Top,
184    Bottom,
185}
186
187pub struct Editor<'a> {
188    pub keybinding_mode: KeybindingMode,
189    /// Reserved for the lifetime parameter — Editor used to wrap a
190    /// `TextArea<'a>` whose lifetime came from this slot. Phase 7f
191    /// ripped the field but the lifetime stays so downstream
192    /// `Editor<'a>` consumers don't have to churn.
193    _marker: std::marker::PhantomData<&'a ()>,
194    /// Set when the user yanks/cuts; caller drains this to write to OS clipboard.
195    pub last_yank: Option<String>,
196    /// All vim-specific state (mode, pending operator, count, dot-repeat, ...).
197    /// Internal — exposed via Editor accessor methods
198    /// ([`Editor::buffer_mark`], [`Editor::last_jump_back`],
199    /// [`Editor::last_edit_pos`], [`Editor::take_lsp_intent`], …).
200    pub(crate) vim: VimState,
201    /// Undo history: each entry is (lines, cursor) before the edit.
202    /// Internal — managed by [`Editor::push_undo`] / [`Editor::restore`]
203    /// / [`Editor::pop_last_undo`].
204    pub(crate) undo_stack: Vec<(Vec<String>, (usize, usize))>,
205    /// Redo history: entries pushed when undoing.
206    pub(super) redo_stack: Vec<(Vec<String>, (usize, usize))>,
207    /// Set whenever the buffer content changes; cleared by `take_dirty`.
208    pub(super) content_dirty: bool,
209    /// Cached snapshot of `lines().join("\n") + "\n"` wrapped in an Arc
210    /// so repeated `content_arc()` calls within the same un-mutated
211    /// window are free (ref-count bump instead of a full-buffer join).
212    /// Invalidated by every [`mark_content_dirty`] call.
213    pub(super) cached_content: Option<std::sync::Arc<String>>,
214    /// Last rendered viewport height (text rows only, no chrome). Written
215    /// by the draw path via [`set_viewport_height`] so the scroll helpers
216    /// can clamp the cursor to stay visible without plumbing the height
217    /// through every call.
218    pub(super) viewport_height: AtomicU16,
219    /// Pending LSP intent set by a normal-mode chord (e.g. `gd` for
220    /// goto-definition). The host app drains this each step and fires
221    /// the matching request against its own LSP client.
222    pub(super) pending_lsp: Option<LspIntent>,
223    /// Mirror buffer for the in-flight migration off tui-textarea.
224    /// Phase 7a: content syncs on every `set_content` so the rest of
225    /// the engine can start reading from / writing to it in
226    /// follow-up commits without behaviour changing today.
227    pub(super) buffer: hjkl_buffer::Buffer,
228    /// Style intern table for the migration buffer's opaque
229    /// `Span::style` ids. Phase 7d-ii-a wiring — `apply_window_spans`
230    /// produces `(start, end, Style)` tuples for the textarea; we
231    /// translate those to `hjkl_buffer::Span` by interning the
232    /// `Style` here and storing the table index. The render path's
233    /// `StyleResolver` looks the style back up by id.
234    pub(super) style_table: Vec<ratatui::style::Style>,
235    /// Vim-style register bank — `"`, `"0`–`"9`, `"a`–`"z`. Sources
236    /// every `p` / `P` via the active selector (default unnamed).
237    /// Internal — read via [`Editor::registers`]; mutated by yank /
238    /// delete / paste FSM paths and by [`Editor::seed_yank`].
239    pub(crate) registers: crate::registers::Registers,
240    /// Per-row syntax styling, kept here so the host can do
241    /// incremental window updates (see `apply_window_spans` in
242    /// the host). Same `(start_byte, end_byte, Style)` tuple shape
243    /// the textarea used to host. The Buffer-side opaque-id spans are
244    /// derived from this on every install.
245    pub styled_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
246    /// Per-editor settings tweakable via `:set`. Exposed by reference
247    /// so handlers (indent, search) read the live value rather than a
248    /// snapshot taken at startup. Read via [`Editor::settings`];
249    /// mutate via [`Editor::settings_mut`].
250    pub(crate) settings: Settings,
251    /// Vim's uppercase / "file" marks. Survive `set_content` calls so
252    /// they persist across tab swaps within the same Editor — the
253    /// closest the host can get to vim's per-file marks without
254    /// host-side persistence. Lowercase marks stay buffer-local on
255    /// `vim.marks`. Ex commands iterate via [`Editor::file_marks`];
256    /// snapshot persistence goes through
257    /// [`Editor::take_snapshot`] / [`Editor::restore_snapshot`].
258    pub(crate) file_marks: std::collections::HashMap<char, (usize, usize)>,
259    /// Block ranges (`(start_row, end_row)` inclusive) the host has
260    /// extracted from a syntax tree. `:foldsyntax` reads these to
261    /// populate folds. The host refreshes them on every re-parse via
262    /// [`Editor::set_syntax_fold_ranges`]; ex commands read them via
263    /// [`Editor::syntax_fold_ranges`].
264    pub(crate) syntax_fold_ranges: Vec<(usize, usize)>,
265    /// Pending edit log drained by [`Editor::take_changes`]. Each entry
266    /// is a SPEC [`crate::types::Edit`] mapped from the underlying
267    /// `hjkl_buffer::Edit` operation. Compound ops (JoinLines,
268    /// SplitLines, InsertBlock, DeleteBlockChunks) emit a single
269    /// best-effort EditOp covering the touched range; hosts wanting
270    /// per-cell deltas should diff their own snapshot of `lines()`.
271    /// Sealed at 0.1.0 trait extraction.
272    /// Drained by [`Editor::take_changes`].
273    pub(crate) change_log: Vec<crate::types::Edit>,
274}
275
276/// Vim-style options surfaced by `:set`. New fields land here as
277/// individual ex commands gain `:set` plumbing.
278#[derive(Debug, Clone)]
279pub struct Settings {
280    /// Spaces per shift step for `>>` / `<<` / `Ctrl-T` / `Ctrl-D`.
281    pub shiftwidth: usize,
282    /// Visual width of a `\t` character. Stored for future render
283    /// hookup; not yet consumed by the buffer renderer.
284    pub tabstop: usize,
285    /// When true, `/` / `?` patterns and `:s/.../.../` ignore case
286    /// without an explicit `i` flag.
287    pub ignore_case: bool,
288    /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
289    pub textwidth: usize,
290    /// Soft-wrap mode the renderer + scroll math + `gj` / `gk` use.
291    /// Default is [`hjkl_buffer::Wrap::None`] — long lines extend
292    /// past the right edge and `top_col` clips the left side.
293    /// `:set wrap` flips to char-break wrap; `:set linebreak` flips
294    /// to word-break wrap; `:set nowrap` resets.
295    pub wrap: hjkl_buffer::Wrap,
296}
297
298impl Default for Settings {
299    fn default() -> Self {
300        Self {
301            shiftwidth: 2,
302            tabstop: 8,
303            ignore_case: false,
304            textwidth: 79,
305            wrap: hjkl_buffer::Wrap::None,
306        }
307    }
308}
309
310/// Host-observable LSP requests triggered by editor bindings. The
311/// hjkl-engine crate doesn't talk to an LSP itself — it just raises an
312/// intent that the TUI layer picks up and routes to `sqls`.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum LspIntent {
315    /// `gd` — textDocument/definition at the cursor.
316    GotoDefinition,
317}
318
319impl<'a> Editor<'a> {
320    pub fn new(keybinding_mode: KeybindingMode) -> Self {
321        Self {
322            _marker: std::marker::PhantomData,
323            keybinding_mode,
324            last_yank: None,
325            vim: VimState::default(),
326            undo_stack: Vec::new(),
327            redo_stack: Vec::new(),
328            content_dirty: false,
329            cached_content: None,
330            viewport_height: AtomicU16::new(0),
331            pending_lsp: None,
332            buffer: hjkl_buffer::Buffer::new(),
333            style_table: Vec::new(),
334            registers: crate::registers::Registers::default(),
335            styled_spans: Vec::new(),
336            settings: Settings::default(),
337            file_marks: std::collections::HashMap::new(),
338            syntax_fold_ranges: Vec::new(),
339            change_log: Vec::new(),
340        }
341    }
342
343    /// Host hook: replace the cached syntax-derived block ranges that
344    /// `:foldsyntax` consumes. the host calls this on every re-parse;
345    /// the cost is just a `Vec` swap.
346    /// Look up a buffer-local lowercase mark (`'a`–`'z`). Returns
347    /// `(row, col)` if set; `None` otherwise. Uppercase / file marks
348    /// live separately — read those via [`Editor::file_marks`].
349    pub fn buffer_mark(&self, c: char) -> Option<(usize, usize)> {
350        self.vim.marks.get(&c).copied()
351    }
352
353    /// Discard the most recent undo entry. Used by ex commands that
354    /// pre-emptively pushed an undo state (`:s`, `:r`) but ended up
355    /// matching nothing — popping prevents a no-op undo step from
356    /// polluting the user's history.
357    ///
358    /// Returns `true` if an entry was discarded.
359    pub fn pop_last_undo(&mut self) -> bool {
360        self.undo_stack.pop().is_some()
361    }
362
363    /// Read all buffer-local marks set this session.
364    pub fn buffer_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
365        self.vim.marks.iter().map(|(c, p)| (*c, *p))
366    }
367
368    /// Position the cursor was at when the user last jumped via
369    /// `<C-o>` / `g;` / similar. `None` before any jump.
370    pub fn last_jump_back(&self) -> Option<(usize, usize)> {
371        self.vim.jump_back.last().copied()
372    }
373
374    /// Position of the last edit (where `.` would replay). `None` if
375    /// no edit has happened yet in this session.
376    pub fn last_edit_pos(&self) -> Option<(usize, usize)> {
377        self.vim.last_edit_pos
378    }
379
380    /// Read-only view of the file-marks table — uppercase / "file"
381    /// marks (`'A`–`'Z`) the host has set this session. Returns an
382    /// iterator of `(mark_char, (row, col))` pairs.
383    ///
384    /// Mutate via the FSM (`m{A-Z}` keystroke) or via
385    /// [`Editor::restore_snapshot`].
386    pub fn file_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
387        self.file_marks.iter().map(|(c, p)| (*c, *p))
388    }
389
390    /// Read-only view of the cached syntax-derived block ranges that
391    /// `:foldsyntax` consumes. Returns the slice the host last
392    /// installed via [`Editor::set_syntax_fold_ranges`]; empty when
393    /// no syntax integration is active.
394    pub fn syntax_fold_ranges(&self) -> &[(usize, usize)] {
395        &self.syntax_fold_ranges
396    }
397
398    pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
399        self.syntax_fold_ranges = ranges;
400    }
401
402    /// Live settings (read-only). `:set` mutates these via
403    /// [`Editor::settings_mut`].
404    pub fn settings(&self) -> &Settings {
405        &self.settings
406    }
407
408    /// Live settings (mutable). `:set` flows through here to mutate
409    /// shiftwidth / tabstop / textwidth / ignore_case / wrap. Hosts
410    /// configuring at startup typically construct a [`Settings`]
411    /// snapshot and overwrite via `*editor.settings_mut() = …`.
412    pub fn settings_mut(&mut self) -> &mut Settings {
413        &mut self.settings
414    }
415
416    /// Install styled syntax spans into both the host-visible cache
417    /// (`styled_spans`) and the buffer's opaque-id span table. Drops
418    /// zero-width runs and clamps `end` to the line's char length so
419    /// the buffer cache doesn't see runaway ranges. Replaces the
420    /// previous `set_syntax_spans` + `sync_buffer_spans_from_textarea`
421    /// round-trip.
422    pub fn install_syntax_spans(&mut self, spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>) {
423        let line_byte_lens: Vec<usize> = self.buffer.lines().iter().map(|l| l.len()).collect();
424        let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
425        for (row, row_spans) in spans.iter().enumerate() {
426            let line_len = line_byte_lens.get(row).copied().unwrap_or(0);
427            let mut translated = Vec::with_capacity(row_spans.len());
428            for (start, end, style) in row_spans {
429                let end_clamped = (*end).min(line_len);
430                if end_clamped <= *start {
431                    continue;
432                }
433                let id = self.intern_style(*style);
434                translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
435            }
436            by_row.push(translated);
437        }
438        self.buffer.set_spans(by_row);
439        self.styled_spans = spans;
440    }
441
442    /// Snapshot of the unnamed register (the default `p` / `P` source).
443    pub fn yank(&self) -> &str {
444        &self.registers.unnamed.text
445    }
446
447    /// Borrow the full register bank — `"`, `"0`–`"9`, `"a`–`"z`.
448    pub fn registers(&self) -> &crate::registers::Registers {
449        &self.registers
450    }
451
452    /// Host hook: load the OS clipboard's contents into the `"+` / `"*`
453    /// register slot. the host calls this before letting vim consume a
454    /// paste so `"*p` / `"+p` reflect the live clipboard rather than a
455    /// stale snapshot from the last yank.
456    pub fn sync_clipboard_register(&mut self, text: String, linewise: bool) {
457        self.registers.set_clipboard(text, linewise);
458    }
459
460    /// True when the user's pending register selector is `+` or `*`.
461    /// the host peeks this so it can refresh `sync_clipboard_register`
462    /// only when a clipboard read is actually about to happen.
463    pub fn pending_register_is_clipboard(&self) -> bool {
464        matches!(self.vim.pending_register, Some('+') | Some('*'))
465    }
466
467    /// Replace the unnamed register without touching any other slot.
468    /// For host-driven imports (e.g. system clipboard); operator
469    /// code uses [`record_yank`] / [`record_delete`].
470    pub fn set_yank(&mut self, text: impl Into<String>) {
471        let text = text.into();
472        let linewise = self.vim.yank_linewise;
473        self.registers.unnamed = crate::registers::Slot { text, linewise };
474    }
475
476    /// Record a yank into `"` and `"0`, plus the named target if the
477    /// user prefixed `"reg`. Updates `vim.yank_linewise` for the
478    /// paste path.
479    pub(crate) fn record_yank(&mut self, text: String, linewise: bool) {
480        self.vim.yank_linewise = linewise;
481        let target = self.vim.pending_register.take();
482        self.registers.record_yank(text, linewise, target);
483    }
484
485    /// Direct write to a named register slot — bypasses the unnamed
486    /// `"` and `"0` updates that `record_yank` does. Used by the
487    /// macro recorder so finishing a `q{reg}` recording doesn't
488    /// pollute the user's last yank.
489    pub(crate) fn set_named_register_text(&mut self, reg: char, text: String) {
490        if let Some(slot) = match reg {
491            'a'..='z' => Some(&mut self.registers.named[(reg as u8 - b'a') as usize]),
492            'A'..='Z' => {
493                Some(&mut self.registers.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize])
494            }
495            _ => None,
496        } {
497            slot.text = text;
498            slot.linewise = false;
499        }
500    }
501
502    /// Record a delete / change into `"` and the `"1`–`"9` ring.
503    /// Honours the active named-register prefix.
504    pub(crate) fn record_delete(&mut self, text: String, linewise: bool) {
505        self.vim.yank_linewise = linewise;
506        let target = self.vim.pending_register.take();
507        self.registers.record_delete(text, linewise, target);
508    }
509
510    /// Intern a `ratatui::style::Style` and return the opaque id used
511    /// in `hjkl_buffer::Span::style`. The render-side `StyleResolver`
512    /// closure (built by [`Editor::style_resolver`]) uses the id to
513    /// look up the style back. Linear-scan dedup — the table grows
514    /// only as new tree-sitter token kinds appear, so it stays tiny.
515    pub fn intern_style(&mut self, style: ratatui::style::Style) -> u32 {
516        if let Some(idx) = self.style_table.iter().position(|s| *s == style) {
517            return idx as u32;
518        }
519        self.style_table.push(style);
520        (self.style_table.len() - 1) as u32
521    }
522
523    /// Read-only view of the style table — id `i` → `style_table[i]`.
524    /// The render path passes a closure backed by this slice as the
525    /// `StyleResolver` for `BufferView`.
526    pub fn style_table(&self) -> &[ratatui::style::Style] {
527        &self.style_table
528    }
529
530    /// Intern a SPEC [`crate::types::Style`] and return its opaque id.
531    /// The id matches the one [`intern_style`] would return for the
532    /// equivalent `ratatui::Style` — both methods share the underlying
533    /// table.
534    ///
535    /// Hosts that don't depend on ratatui (buffr, future GUI shells)
536    /// reach this method to populate the table during syntax span
537    /// installation. Internally converts to ratatui::Style today; the
538    /// trait extraction will flip the storage to engine-native.
539    pub fn intern_engine_style(&mut self, style: crate::types::Style) -> u32 {
540        let r = engine_style_to_ratatui(style);
541        self.intern_style(r)
542    }
543
544    /// Look up an interned style by id and return it as a SPEC
545    /// [`crate::types::Style`]. Returns `None` for ids past the end
546    /// of the table.
547    pub fn engine_style_at(&self, id: u32) -> Option<crate::types::Style> {
548        let r = self.style_table.get(id as usize).copied()?;
549        Some(ratatui_style_to_engine(r))
550    }
551
552    /// Borrow the migration buffer. Host renders through this via
553    /// `hjkl_buffer::BufferView`.
554    pub fn buffer(&self) -> &hjkl_buffer::Buffer {
555        &self.buffer
556    }
557
558    pub fn buffer_mut(&mut self) -> &mut hjkl_buffer::Buffer {
559        &mut self.buffer
560    }
561
562    /// Historical reverse-sync hook from when the textarea mirrored
563    /// the buffer. Now that Buffer is the cursor authority this is a
564    /// no-op; call sites can remain in place during the migration.
565    pub(crate) fn push_buffer_cursor_to_textarea(&mut self) {}
566
567    /// Force the buffer viewport's top row without touching the
568    /// cursor. Used by tests that simulate a scroll without the
569    /// SCROLLOFF cursor adjustment that `scroll_down` / `scroll_up`
570    /// apply. Note: does not touch the textarea — the migration
571    /// buffer's viewport is what `BufferView` renders from, and the
572    /// textarea's own scroll path would clamp the cursor into its
573    /// (often-zero) visible window.
574    pub fn set_viewport_top(&mut self, row: usize) {
575        let last = self.buffer.row_count().saturating_sub(1);
576        let target = row.min(last);
577        self.buffer.viewport_mut().top_row = target;
578    }
579
580    /// Set the cursor to `(row, col)`, clamped to the buffer's
581    /// content. Hosts use this for goto-line, jump-to-mark, and
582    /// programmatic cursor placement.
583    pub fn jump_cursor(&mut self, row: usize, col: usize) {
584        self.buffer.set_cursor(hjkl_buffer::Position::new(row, col));
585    }
586
587    /// `(row, col)` cursor read sourced from the migration buffer.
588    /// Equivalent to `self.textarea.cursor()` when the two are in
589    /// sync — which is the steady state during Phase 7f because
590    /// every step opens with `sync_buffer_content_from_textarea` and
591    /// every ported motion pushes the result back. Prefer this over
592    /// `self.textarea.cursor()` so call sites keep working unchanged
593    /// once the textarea field is ripped.
594    pub fn cursor(&self) -> (usize, usize) {
595        let pos = self.buffer.cursor();
596        (pos.row, pos.col)
597    }
598
599    /// Drain any pending LSP intent raised by the last key. Returns
600    /// `None` when no intent is armed.
601    pub fn take_lsp_intent(&mut self) -> Option<LspIntent> {
602        self.pending_lsp.take()
603    }
604
605    /// Refresh the buffer's host-side state — sticky col + viewport
606    /// height. Called from the per-step boilerplate; was the textarea
607    /// → buffer mirror before Phase 7f put Buffer in charge.
608    pub(crate) fn sync_buffer_from_textarea(&mut self) {
609        self.buffer.set_sticky_col(self.vim.sticky_col);
610        let height = self.viewport_height_value();
611        self.buffer.viewport_mut().height = height;
612    }
613
614    /// Was the full textarea → buffer content sync. Buffer is the
615    /// content authority now; this remains as a no-op so the per-step
616    /// call sites don't have to be ripped in the same patch.
617    pub(crate) fn sync_buffer_content_from_textarea(&mut self) {
618        self.sync_buffer_from_textarea();
619    }
620
621    /// Push a `(row, col)` onto the back-jumplist so `Ctrl-o` returns
622    /// to it later. Used by host-driven jumps (e.g. `gd`) that move
623    /// the cursor without going through the vim engine's motion
624    /// machinery, where push_jump fires automatically.
625    pub fn record_jump(&mut self, pos: (usize, usize)) {
626        const JUMPLIST_MAX: usize = 100;
627        self.vim.jump_back.push(pos);
628        if self.vim.jump_back.len() > JUMPLIST_MAX {
629            self.vim.jump_back.remove(0);
630        }
631        self.vim.jump_fwd.clear();
632    }
633
634    /// Host apps call this each draw with the current text area height so
635    /// scroll helpers can clamp the cursor without recomputing layout.
636    pub fn set_viewport_height(&self, height: u16) {
637        self.viewport_height.store(height, Ordering::Relaxed);
638    }
639
640    /// Last height published by `set_viewport_height` (in rows).
641    pub fn viewport_height_value(&self) -> u16 {
642        self.viewport_height.load(Ordering::Relaxed)
643    }
644
645    /// Apply `edit` against the buffer and return the inverse so the
646    /// host can push it onto an undo stack. Side effects: dirty
647    /// flag, change-list ring, mark / jump-list shifts, change_log
648    /// append, fold invalidation around the touched rows.
649    ///
650    /// The primary edit funnel — both FSM operators and ex commands
651    /// route mutations through here so the side effects fire
652    /// uniformly.
653    pub fn mutate_edit(&mut self, edit: hjkl_buffer::Edit) -> hjkl_buffer::Edit {
654        let pre_row = self.buffer.cursor().row;
655        let pre_rows = self.buffer.row_count();
656        // Map the underlying buffer edit to a SPEC EditOp for
657        // change-log emission before consuming it. Coarse — see
658        // change_log field doc on the struct.
659        if let Some(op) = edit_to_editop(&edit) {
660            self.change_log.push(op);
661        }
662        let inverse = self.buffer.apply_edit(edit);
663        let pos = self.buffer.cursor();
664        // Drop any folds the edit's range overlapped — vim opens the
665        // surrounding fold automatically when you edit inside it. The
666        // approximation here invalidates folds covering either the
667        // pre-edit cursor row or the post-edit cursor row, which
668        // catches the common single-line / multi-line edit shapes.
669        let lo = pre_row.min(pos.row);
670        let hi = pre_row.max(pos.row);
671        self.buffer.invalidate_folds_in_range(lo, hi);
672        self.vim.last_edit_pos = Some((pos.row, pos.col));
673        // Append to the change-list ring (skip when the cursor sits on
674        // the same cell as the last entry — back-to-back keystrokes on
675        // one column shouldn't pollute the ring). A new edit while
676        // walking the ring trims the forward half, vim style.
677        let entry = (pos.row, pos.col);
678        if self.vim.change_list.last() != Some(&entry) {
679            if let Some(idx) = self.vim.change_list_cursor.take() {
680                self.vim.change_list.truncate(idx + 1);
681            }
682            self.vim.change_list.push(entry);
683            let len = self.vim.change_list.len();
684            if len > crate::vim::CHANGE_LIST_MAX {
685                self.vim
686                    .change_list
687                    .drain(0..len - crate::vim::CHANGE_LIST_MAX);
688            }
689        }
690        self.vim.change_list_cursor = None;
691        // Shift / drop marks + jump-list entries to track the row
692        // delta the edit produced. Without this, every line-changing
693        // edit silently invalidates `'a`-style positions.
694        let post_rows = self.buffer.row_count();
695        let delta = post_rows as isize - pre_rows as isize;
696        if delta != 0 {
697            self.shift_marks_after_edit(pre_row, delta);
698        }
699        self.push_buffer_content_to_textarea();
700        self.mark_content_dirty();
701        inverse
702    }
703
704    /// Migrate user marks + jumplist entries when an edit at row
705    /// `edit_start` changes the buffer's row count by `delta` (positive
706    /// for inserts, negative for deletes). Marks tied to a deleted row
707    /// are dropped; marks past the affected band shift by `delta`.
708    fn shift_marks_after_edit(&mut self, edit_start: usize, delta: isize) {
709        if delta == 0 {
710            return;
711        }
712        // Deleted-row band (only meaningful for delta < 0). Inclusive
713        // start, exclusive end.
714        let drop_end = if delta < 0 {
715            edit_start.saturating_add((-delta) as usize)
716        } else {
717            edit_start
718        };
719        let shift_threshold = drop_end.max(edit_start.saturating_add(1));
720
721        let mut to_drop: Vec<char> = Vec::new();
722        for (c, (row, _col)) in self.vim.marks.iter_mut() {
723            if (edit_start..drop_end).contains(row) {
724                to_drop.push(*c);
725            } else if *row >= shift_threshold {
726                *row = ((*row as isize) + delta).max(0) as usize;
727            }
728        }
729        for c in to_drop {
730            self.vim.marks.remove(&c);
731        }
732
733        // File marks migrate the same way — only the storage differs.
734        let mut to_drop: Vec<char> = Vec::new();
735        for (c, (row, _col)) in self.file_marks.iter_mut() {
736            if (edit_start..drop_end).contains(row) {
737                to_drop.push(*c);
738            } else if *row >= shift_threshold {
739                *row = ((*row as isize) + delta).max(0) as usize;
740            }
741        }
742        for c in to_drop {
743            self.file_marks.remove(&c);
744        }
745
746        let shift_jumps = |entries: &mut Vec<(usize, usize)>| {
747            entries.retain(|(row, _)| !(edit_start..drop_end).contains(row));
748            for (row, _) in entries.iter_mut() {
749                if *row >= shift_threshold {
750                    *row = ((*row as isize) + delta).max(0) as usize;
751                }
752            }
753        };
754        shift_jumps(&mut self.vim.jump_back);
755        shift_jumps(&mut self.vim.jump_fwd);
756    }
757
758    /// Reverse-sync helper paired with [`Editor::mutate_edit`]: rebuild
759    /// the textarea from the buffer's lines + cursor, preserving yank
760    /// text. Heavy (allocates a fresh `TextArea`) but correct; the
761    /// textarea field disappears at the end of Phase 7f anyway.
762    /// No-op since Buffer is the content authority. Retained as a
763    /// shim so call sites in `mutate_edit` and friends don't have to
764    /// be ripped in lockstep with the field removal.
765    pub(crate) fn push_buffer_content_to_textarea(&mut self) {}
766
767    /// Single choke-point for "the buffer just changed". Sets the
768    /// dirty flag and drops the cached `content_arc` snapshot so
769    /// subsequent reads rebuild from the live textarea. Callers
770    /// mutating `textarea` directly (e.g. the TUI's bracketed-paste
771    /// path) must invoke this to keep the cache honest.
772    pub fn mark_content_dirty(&mut self) {
773        self.content_dirty = true;
774        self.cached_content = None;
775    }
776
777    /// Returns true if content changed since the last call, then clears the flag.
778    pub fn take_dirty(&mut self) -> bool {
779        let dirty = self.content_dirty;
780        self.content_dirty = false;
781        dirty
782    }
783
784    /// Pull-model coarse change observation. If content changed since
785    /// the last call, returns `Some(Arc<String>)` with the new content
786    /// and clears the dirty flag; otherwise returns `None`.
787    ///
788    /// Hosts that need fine-grained edit deltas (e.g., DOM patching at
789    /// the character level) should diff against their own previous
790    /// snapshot. The SPEC `take_changes() -> Vec<EditOp>` API lands
791    /// once every edit path inside the engine is instrumented; this
792    /// coarse form covers the pull-model use case in the meantime.
793    pub fn take_content_change(&mut self) -> Option<std::sync::Arc<String>> {
794        if !self.content_dirty {
795            return None;
796        }
797        let arc = self.content_arc();
798        self.content_dirty = false;
799        Some(arc)
800    }
801
802    /// Returns the cursor's row within the visible textarea (0-based), updating
803    /// the stored viewport top so subsequent calls remain accurate.
804    pub fn cursor_screen_row(&mut self, height: u16) -> u16 {
805        let cursor = self.buffer.cursor().row;
806        let top = self.buffer.viewport().top_row;
807        cursor.saturating_sub(top).min(height as usize - 1) as u16
808    }
809
810    /// Returns the cursor's screen position `(x, y)` for `area` (the textarea
811    /// rect). Accounts for line-number gutter and viewport scroll. Returns
812    /// `None` if the cursor is outside the visible viewport.
813    pub fn cursor_screen_pos(&self, area: Rect) -> Option<(u16, u16)> {
814        let pos = self.buffer.cursor();
815        let v = self.buffer.viewport();
816        if pos.row < v.top_row || pos.col < v.top_col {
817            return None;
818        }
819        let lnum_width = self.buffer.row_count().to_string().len() as u16 + 2;
820        let dy = (pos.row - v.top_row) as u16;
821        let dx = (pos.col - v.top_col) as u16;
822        if dy >= area.height || dx + lnum_width >= area.width {
823            return None;
824        }
825        Some((area.x + lnum_width + dx, area.y + dy))
826    }
827
828    pub fn vim_mode(&self) -> VimMode {
829        self.vim.public_mode()
830    }
831
832    /// Bounds of the active visual-block rectangle as
833    /// `(top_row, bot_row, left_col, right_col)` — all inclusive.
834    /// `None` when we're not in VisualBlock mode.
835    /// Read-only view of the live `/` or `?` prompt. `None` outside
836    /// search-prompt mode.
837    pub fn search_prompt(&self) -> Option<&crate::vim::SearchPrompt> {
838        self.vim.search_prompt.as_ref()
839    }
840
841    /// Most recent committed search pattern (persists across `n` / `N`
842    /// and across prompt exits). `None` before the first search.
843    pub fn last_search(&self) -> Option<&str> {
844        self.vim.last_search.as_deref()
845    }
846
847    /// Start/end `(row, col)` of the active char-wise Visual selection
848    /// (inclusive on both ends, positionally ordered). `None` when not
849    /// in Visual mode.
850    pub fn char_highlight(&self) -> Option<((usize, usize), (usize, usize))> {
851        if self.vim_mode() != VimMode::Visual {
852            return None;
853        }
854        let anchor = self.vim.visual_anchor;
855        let cursor = self.cursor();
856        let (start, end) = if anchor <= cursor {
857            (anchor, cursor)
858        } else {
859            (cursor, anchor)
860        };
861        Some((start, end))
862    }
863
864    /// Top/bottom rows of the active VisualLine selection (inclusive).
865    /// `None` when we're not in VisualLine mode.
866    pub fn line_highlight(&self) -> Option<(usize, usize)> {
867        if self.vim_mode() != VimMode::VisualLine {
868            return None;
869        }
870        let anchor = self.vim.visual_line_anchor;
871        let cursor = self.buffer.cursor().row;
872        Some((anchor.min(cursor), anchor.max(cursor)))
873    }
874
875    pub fn block_highlight(&self) -> Option<(usize, usize, usize, usize)> {
876        if self.vim_mode() != VimMode::VisualBlock {
877            return None;
878        }
879        let (ar, ac) = self.vim.block_anchor;
880        let cr = self.buffer.cursor().row;
881        let cc = self.vim.block_vcol;
882        let top = ar.min(cr);
883        let bot = ar.max(cr);
884        let left = ac.min(cc);
885        let right = ac.max(cc);
886        Some((top, bot, left, right))
887    }
888
889    /// Active selection in `hjkl_buffer::Selection` shape. `None` when
890    /// not in a Visual mode. Phase 7d-i wiring — the host hands this
891    /// straight to `BufferView` once render flips off textarea
892    /// (Phase 7d-ii drops the `paint_*_overlay` calls on the same
893    /// switch).
894    pub fn buffer_selection(&self) -> Option<hjkl_buffer::Selection> {
895        use hjkl_buffer::{Position, Selection};
896        match self.vim_mode() {
897            VimMode::Visual => {
898                let (ar, ac) = self.vim.visual_anchor;
899                let head = self.buffer.cursor();
900                Some(Selection::Char {
901                    anchor: Position::new(ar, ac),
902                    head,
903                })
904            }
905            VimMode::VisualLine => {
906                let anchor_row = self.vim.visual_line_anchor;
907                let head_row = self.buffer.cursor().row;
908                Some(Selection::Line {
909                    anchor_row,
910                    head_row,
911                })
912            }
913            VimMode::VisualBlock => {
914                let (ar, ac) = self.vim.block_anchor;
915                let cr = self.buffer.cursor().row;
916                let cc = self.vim.block_vcol;
917                Some(Selection::Block {
918                    anchor: Position::new(ar, ac),
919                    head: Position::new(cr, cc),
920                })
921            }
922            _ => None,
923        }
924    }
925
926    /// Force back to normal mode (used when dismissing completions etc.)
927    pub fn force_normal(&mut self) {
928        self.vim.force_normal();
929    }
930
931    pub fn content(&self) -> String {
932        let mut s = self.buffer.lines().join("\n");
933        s.push('\n');
934        s
935    }
936
937    /// Same logical output as [`content`], but returns a cached
938    /// `Arc<String>` so back-to-back reads within an un-mutated window
939    /// are ref-count bumps instead of multi-MB joins. The cache is
940    /// invalidated by every [`mark_content_dirty`] call.
941    pub fn content_arc(&mut self) -> std::sync::Arc<String> {
942        if let Some(arc) = &self.cached_content {
943            return std::sync::Arc::clone(arc);
944        }
945        let arc = std::sync::Arc::new(self.content());
946        self.cached_content = Some(std::sync::Arc::clone(&arc));
947        arc
948    }
949
950    pub fn set_content(&mut self, text: &str) {
951        let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
952        while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
953            lines.pop();
954        }
955        if lines.is_empty() {
956            lines.push(String::new());
957        }
958        let _ = lines;
959        self.buffer = hjkl_buffer::Buffer::from_str(text);
960        self.undo_stack.clear();
961        self.redo_stack.clear();
962        self.mark_content_dirty();
963    }
964
965    /// Feed an SPEC [`crate::PlannedInput`] into the engine.
966    ///
967    /// Bridge for hosts that don't carry crossterm — buffr's CEF
968    /// shell, future GUI frontends. Internally converts to the
969    /// crossterm KeyEvent that [`Editor::handle_key`] expects, then
970    /// dispatches.
971    ///
972    /// `Input::Mouse`, `Input::Paste`, `Input::FocusGained`,
973    /// `Input::FocusLost`, and `Input::Resize` currently fall through
974    /// without effect — the legacy FSM doesn't dispatch them. They're
975    /// accepted so the host can pump them into the engine without
976    /// special-casing.
977    ///
978    /// Returns `true` when the keystroke was consumed.
979    pub fn feed_input(&mut self, input: crate::PlannedInput) -> bool {
980        use crate::{Modifiers, PlannedInput, SpecialKey};
981        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
982        let to_mods = |m: Modifiers| {
983            let mut k = KeyModifiers::NONE;
984            if m.ctrl {
985                k |= KeyModifiers::CONTROL;
986            }
987            if m.shift {
988                k |= KeyModifiers::SHIFT;
989            }
990            if m.alt {
991                k |= KeyModifiers::ALT;
992            }
993            if m.super_ {
994                k |= KeyModifiers::SUPER;
995            }
996            k
997        };
998        let (code, mods) = match input {
999            PlannedInput::Char(c, m) => (KeyCode::Char(c), to_mods(m)),
1000            PlannedInput::Key(k, m) => {
1001                let code = match k {
1002                    SpecialKey::Esc => KeyCode::Esc,
1003                    SpecialKey::Enter => KeyCode::Enter,
1004                    SpecialKey::Backspace => KeyCode::Backspace,
1005                    SpecialKey::Tab => KeyCode::Tab,
1006                    SpecialKey::BackTab => KeyCode::BackTab,
1007                    SpecialKey::Up => KeyCode::Up,
1008                    SpecialKey::Down => KeyCode::Down,
1009                    SpecialKey::Left => KeyCode::Left,
1010                    SpecialKey::Right => KeyCode::Right,
1011                    SpecialKey::Home => KeyCode::Home,
1012                    SpecialKey::End => KeyCode::End,
1013                    SpecialKey::PageUp => KeyCode::PageUp,
1014                    SpecialKey::PageDown => KeyCode::PageDown,
1015                    SpecialKey::Insert => KeyCode::Insert,
1016                    SpecialKey::Delete => KeyCode::Delete,
1017                    SpecialKey::F(n) => KeyCode::F(n),
1018                };
1019                (code, to_mods(m))
1020            }
1021            // Variants the legacy FSM doesn't consume yet.
1022            PlannedInput::Mouse(_)
1023            | PlannedInput::Paste(_)
1024            | PlannedInput::FocusGained
1025            | PlannedInput::FocusLost
1026            | PlannedInput::Resize(_, _) => return false,
1027        };
1028        self.handle_key(KeyEvent::new(code, mods))
1029    }
1030
1031    /// Drain the pending change log produced by buffer mutations.
1032    ///
1033    /// Returns a `Vec<EditOp>` covering edits applied since the last
1034    /// call. Empty when no edits ran. Pull-model, complementary to
1035    /// [`Editor::take_content_change`] which gives back the new full
1036    /// content.
1037    ///
1038    /// Mapping coverage:
1039    /// - InsertChar / InsertStr → exact `EditOp` with empty range +
1040    ///   replacement.
1041    /// - DeleteRange (`Char` kind) → exact range + empty replacement.
1042    /// - Replace → exact range + new replacement.
1043    /// - DeleteRange (`Line`/`Block`), JoinLines, SplitLines,
1044    ///   InsertBlock, DeleteBlockChunks → best-effort placeholder
1045    ///   covering the touched range. Hosts wanting per-cell deltas
1046    ///   should diff their own `lines()` snapshot.
1047    pub fn take_changes(&mut self) -> Vec<crate::types::Edit> {
1048        std::mem::take(&mut self.change_log)
1049    }
1050
1051    /// Read the engine's current settings as a SPEC
1052    /// [`crate::types::Options`].
1053    ///
1054    /// Bridges between the legacy [`Settings`] (which carries fewer
1055    /// fields than SPEC) and the planned 0.1.0 trait surface. Fields
1056    /// not present in `Settings` fall back to vim defaults (e.g.,
1057    /// `expandtab=false`, `wrapscan=true`, `timeout_len=1000ms`).
1058    /// Once trait extraction lands, this becomes the canonical config
1059    /// reader and `Settings` retires.
1060    pub fn current_options(&self) -> crate::types::Options {
1061        crate::types::Options {
1062            shiftwidth: self.settings.shiftwidth as u32,
1063            tabstop: self.settings.tabstop as u32,
1064            ignorecase: self.settings.ignore_case,
1065            ..crate::types::Options::default()
1066        }
1067    }
1068
1069    /// Apply a SPEC [`crate::types::Options`] to the engine's settings.
1070    /// Only the fields backed by today's [`Settings`] take effect;
1071    /// remaining options become live once trait extraction wires them
1072    /// through.
1073    pub fn apply_options(&mut self, opts: &crate::types::Options) {
1074        self.settings.shiftwidth = opts.shiftwidth as usize;
1075        self.settings.tabstop = opts.tabstop as usize;
1076        self.settings.ignore_case = opts.ignorecase;
1077    }
1078
1079    /// Active visual selection as a SPEC [`crate::types::Highlight`]
1080    /// with [`crate::types::HighlightKind::Selection`].
1081    ///
1082    /// Returns `None` when the editor isn't in a Visual mode.
1083    /// Visual-line and visual-block selections collapse to the
1084    /// bounding char range of the selection — the SPEC `Selection`
1085    /// kind doesn't carry sub-line info today; hosts that need full
1086    /// line / block geometry continue to read [`buffer_selection`]
1087    /// (the legacy [`hjkl_buffer::Selection`] shape).
1088    pub fn selection_highlight(&self) -> Option<crate::types::Highlight> {
1089        use crate::types::{Highlight, HighlightKind, Pos};
1090        let sel = self.buffer_selection()?;
1091        let (start, end) = match sel {
1092            hjkl_buffer::Selection::Char { anchor, head } => {
1093                let a = (anchor.row, anchor.col);
1094                let h = (head.row, head.col);
1095                if a <= h { (a, h) } else { (h, a) }
1096            }
1097            hjkl_buffer::Selection::Line {
1098                anchor_row,
1099                head_row,
1100            } => {
1101                let (top, bot) = if anchor_row <= head_row {
1102                    (anchor_row, head_row)
1103                } else {
1104                    (head_row, anchor_row)
1105                };
1106                let last_col = self.buffer.line(bot).map(|l| l.len()).unwrap_or(0);
1107                ((top, 0), (bot, last_col))
1108            }
1109            hjkl_buffer::Selection::Block { anchor, head } => {
1110                let (top, bot) = if anchor.row <= head.row {
1111                    (anchor.row, head.row)
1112                } else {
1113                    (head.row, anchor.row)
1114                };
1115                let (left, right) = if anchor.col <= head.col {
1116                    (anchor.col, head.col)
1117                } else {
1118                    (head.col, anchor.col)
1119                };
1120                ((top, left), (bot, right))
1121            }
1122        };
1123        Some(Highlight {
1124            range: Pos {
1125                line: start.0 as u32,
1126                col: start.1 as u32,
1127            }..Pos {
1128                line: end.0 as u32,
1129                col: end.1 as u32,
1130            },
1131            kind: HighlightKind::Selection,
1132        })
1133    }
1134
1135    /// SPEC-typed highlights for `line`.
1136    ///
1137    /// Two emission modes:
1138    ///
1139    /// - **IncSearch**: the user is typing a `/` or `?` prompt and
1140    ///   `Editor::search_prompt` is `Some`. Live-preview matches of
1141    ///   the in-flight pattern surface as
1142    ///   [`crate::types::HighlightKind::IncSearch`].
1143    /// - **SearchMatch**: the prompt has been committed (or absent)
1144    ///   and the buffer's armed pattern is non-empty. Matches surface
1145    ///   as [`crate::types::HighlightKind::SearchMatch`].
1146    ///
1147    /// Selection / MatchParen / Syntax(id) variants land once the
1148    /// trait extraction routes the FSM's selection set + the host's
1149    /// syntax pipeline through the [`crate::types::Host`] trait.
1150    ///
1151    /// Returns an empty vec when there is nothing to highlight or
1152    /// `line` is out of bounds.
1153    pub fn highlights_for_line(&mut self, line: u32) -> Vec<crate::types::Highlight> {
1154        use crate::types::{Highlight, HighlightKind, Pos};
1155        let row = line as usize;
1156        if row >= self.buffer.lines().len() {
1157            return Vec::new();
1158        }
1159
1160        // Live preview while the prompt is open beats the committed
1161        // pattern.
1162        if let Some(prompt) = self.search_prompt() {
1163            if prompt.text.is_empty() {
1164                return Vec::new();
1165            }
1166            let Ok(re) = regex::Regex::new(&prompt.text) else {
1167                return Vec::new();
1168            };
1169            let Some(haystack) = self.buffer.line(row) else {
1170                return Vec::new();
1171            };
1172            return re
1173                .find_iter(haystack)
1174                .map(|m| Highlight {
1175                    range: Pos {
1176                        line,
1177                        col: m.start() as u32,
1178                    }..Pos {
1179                        line,
1180                        col: m.end() as u32,
1181                    },
1182                    kind: HighlightKind::IncSearch,
1183                })
1184                .collect();
1185        }
1186
1187        if self.buffer.search_pattern().is_none() {
1188            return Vec::new();
1189        }
1190        self.buffer
1191            .search_matches(row)
1192            .into_iter()
1193            .map(|(start, end)| Highlight {
1194                range: Pos {
1195                    line,
1196                    col: start as u32,
1197                }..Pos {
1198                    line,
1199                    col: end as u32,
1200                },
1201                kind: HighlightKind::SearchMatch,
1202            })
1203            .collect()
1204    }
1205
1206    /// Build the engine's [`crate::types::RenderFrame`] for the
1207    /// current state. Hosts call this once per redraw and diff
1208    /// across frames.
1209    ///
1210    /// Coarse today — covers mode + cursor + cursor shape + viewport
1211    /// top + line count. SPEC-target fields (selections, highlights,
1212    /// command line, search prompt, status line) land once trait
1213    /// extraction routes them through `SelectionSet` and the
1214    /// `Highlight` pipeline.
1215    pub fn render_frame(&self) -> crate::types::RenderFrame {
1216        use crate::types::{CursorShape, RenderFrame, SnapshotMode};
1217        let (cursor_row, cursor_col) = self.cursor();
1218        let (mode, shape) = match self.vim_mode() {
1219            crate::VimMode::Normal => (SnapshotMode::Normal, CursorShape::Block),
1220            crate::VimMode::Insert => (SnapshotMode::Insert, CursorShape::Bar),
1221            crate::VimMode::Visual => (SnapshotMode::Visual, CursorShape::Block),
1222            crate::VimMode::VisualLine => (SnapshotMode::VisualLine, CursorShape::Block),
1223            crate::VimMode::VisualBlock => (SnapshotMode::VisualBlock, CursorShape::Block),
1224        };
1225        RenderFrame {
1226            mode,
1227            cursor_row: cursor_row as u32,
1228            cursor_col: cursor_col as u32,
1229            cursor_shape: shape,
1230            viewport_top: self.buffer.viewport().top_row as u32,
1231            line_count: self.buffer.lines().len() as u32,
1232        }
1233    }
1234
1235    /// Capture the editor's coarse state into a serde-friendly
1236    /// [`crate::types::EditorSnapshot`].
1237    ///
1238    /// Today's snapshot covers mode, cursor, lines, viewport top.
1239    /// Registers, marks, jump list, undo tree, and full options arrive
1240    /// once phase 5 trait extraction lands the generic
1241    /// `Editor<B: Buffer, H: Host>` constructor — this method's surface
1242    /// stays stable; only the snapshot's internal fields grow.
1243    ///
1244    /// Distinct from the internal `snapshot` used by undo (which
1245    /// returns `(Vec<String>, (usize, usize))`); host-facing
1246    /// persistence goes through this one.
1247    pub fn take_snapshot(&self) -> crate::types::EditorSnapshot {
1248        use crate::types::{EditorSnapshot, SnapshotMode};
1249        let mode = match self.vim_mode() {
1250            crate::VimMode::Normal => SnapshotMode::Normal,
1251            crate::VimMode::Insert => SnapshotMode::Insert,
1252            crate::VimMode::Visual => SnapshotMode::Visual,
1253            crate::VimMode::VisualLine => SnapshotMode::VisualLine,
1254            crate::VimMode::VisualBlock => SnapshotMode::VisualBlock,
1255        };
1256        let cursor = self.cursor();
1257        let cursor = (cursor.0 as u32, cursor.1 as u32);
1258        let lines: Vec<String> = self.buffer.lines().to_vec();
1259        let viewport_top = self.buffer.viewport().top_row as u32;
1260        let file_marks = self
1261            .file_marks
1262            .iter()
1263            .map(|(c, (r, col))| (*c, (*r as u32, *col as u32)))
1264            .collect();
1265        EditorSnapshot {
1266            version: EditorSnapshot::VERSION,
1267            mode,
1268            cursor,
1269            lines,
1270            viewport_top,
1271            registers: self.registers.clone(),
1272            file_marks,
1273        }
1274    }
1275
1276    /// Restore editor state from an [`EditorSnapshot`]. Returns
1277    /// [`crate::EngineError::SnapshotVersion`] if the snapshot's
1278    /// `version` doesn't match [`EditorSnapshot::VERSION`].
1279    ///
1280    /// Mode is best-effort: `SnapshotMode` only round-trips the
1281    /// status-line summary, not the full FSM state. Visual / Insert
1282    /// mode entry happens through synthetic key dispatch when needed.
1283    pub fn restore_snapshot(
1284        &mut self,
1285        snap: crate::types::EditorSnapshot,
1286    ) -> Result<(), crate::EngineError> {
1287        use crate::types::EditorSnapshot;
1288        if snap.version != EditorSnapshot::VERSION {
1289            return Err(crate::EngineError::SnapshotVersion(
1290                snap.version,
1291                EditorSnapshot::VERSION,
1292            ));
1293        }
1294        let text = snap.lines.join("\n");
1295        self.set_content(&text);
1296        self.jump_cursor(snap.cursor.0 as usize, snap.cursor.1 as usize);
1297        let mut vp = self.buffer.viewport();
1298        vp.top_row = snap.viewport_top as usize;
1299        *self.buffer.viewport_mut() = vp;
1300        self.registers = snap.registers;
1301        self.file_marks = snap
1302            .file_marks
1303            .into_iter()
1304            .map(|(c, (r, col))| (c, (r as usize, col as usize)))
1305            .collect();
1306        Ok(())
1307    }
1308
1309    /// Install `text` as the pending yank buffer so the next `p`/`P` pastes
1310    /// it. Linewise is inferred from a trailing newline, matching how `yy`/`dd`
1311    /// shape their payload.
1312    pub fn seed_yank(&mut self, text: String) {
1313        let linewise = text.ends_with('\n');
1314        self.vim.yank_linewise = linewise;
1315        self.registers.unnamed = crate::registers::Slot { text, linewise };
1316    }
1317
1318    /// Scroll the viewport down by `rows`. The cursor stays on its
1319    /// absolute line (vim convention) unless the scroll would take it
1320    /// off-screen — in that case it's clamped to the first row still
1321    /// visible.
1322    pub fn scroll_down(&mut self, rows: i16) {
1323        self.scroll_viewport(rows);
1324    }
1325
1326    /// Scroll the viewport up by `rows`. Cursor stays unless it would
1327    /// fall off the bottom of the new viewport, then clamp to the
1328    /// bottom-most visible row.
1329    pub fn scroll_up(&mut self, rows: i16) {
1330        self.scroll_viewport(-rows);
1331    }
1332
1333    /// Vim's `scrolloff` default — keep the cursor at least this many
1334    /// rows away from the top / bottom edge of the viewport while
1335    /// scrolling. Collapses to `height / 2` for tiny viewports.
1336    const SCROLLOFF: usize = 5;
1337
1338    /// Scroll the viewport so the cursor stays at least `SCROLLOFF`
1339    /// rows from each edge. Replaces the bare
1340    /// `Buffer::ensure_cursor_visible` call at end-of-step so motions
1341    /// don't park the cursor on the very last visible row.
1342    pub(crate) fn ensure_cursor_in_scrolloff(&mut self) {
1343        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1344        if height == 0 {
1345            self.buffer.ensure_cursor_visible();
1346            return;
1347        }
1348        // Cap margin at (height - 1) / 2 so the upper + lower bands
1349        // can't overlap on tiny windows (margin=5 + height=10 would
1350        // otherwise produce contradictory clamp ranges).
1351        let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
1352        // Soft-wrap path: scrolloff math runs in *screen rows*, not
1353        // doc rows, since a wrapped doc row spans many visual lines.
1354        if !matches!(self.buffer.viewport().wrap, hjkl_buffer::Wrap::None) {
1355            self.ensure_scrolloff_wrap(height, margin);
1356            return;
1357        }
1358        let cursor_row = self.buffer.cursor().row;
1359        let last_row = self.buffer.row_count().saturating_sub(1);
1360        let v = self.buffer.viewport_mut();
1361        // Top edge: cursor_row should sit at >= top_row + margin.
1362        if cursor_row < v.top_row + margin {
1363            v.top_row = cursor_row.saturating_sub(margin);
1364        }
1365        // Bottom edge: cursor_row should sit at <= top_row + height - 1 - margin.
1366        let max_bottom = height.saturating_sub(1).saturating_sub(margin);
1367        if cursor_row > v.top_row + max_bottom {
1368            v.top_row = cursor_row.saturating_sub(max_bottom);
1369        }
1370        // Clamp top_row so we never scroll past the buffer's bottom.
1371        let max_top = last_row.saturating_sub(height.saturating_sub(1));
1372        if v.top_row > max_top {
1373            v.top_row = max_top;
1374        }
1375        // Defer to Buffer for column-side scroll (no scrolloff for
1376        // horizontal scrolling — vim default `sidescrolloff = 0`).
1377        let cursor = self.buffer.cursor();
1378        self.buffer.viewport_mut().ensure_visible(cursor);
1379    }
1380
1381    /// Soft-wrap-aware scrolloff. Walks `top_row` one visible doc row
1382    /// at a time so the cursor's *screen* row stays inside
1383    /// `[margin, height - 1 - margin]`, then clamps `top_row` so the
1384    /// buffer's bottom never leaves blank rows below it.
1385    fn ensure_scrolloff_wrap(&mut self, height: usize, margin: usize) {
1386        let cursor_row = self.buffer.cursor().row;
1387        // Step 1 — cursor above viewport: snap top to cursor row,
1388        // then we'll fix up the margin below.
1389        if cursor_row < self.buffer.viewport().top_row {
1390            self.buffer.viewport_mut().top_row = cursor_row;
1391            self.buffer.viewport_mut().top_col = 0;
1392        }
1393        // Step 2 — push top forward until cursor's screen row is
1394        // within the bottom margin (`csr <= height - 1 - margin`).
1395        let max_csr = height.saturating_sub(1).saturating_sub(margin);
1396        loop {
1397            let csr = self.buffer.cursor_screen_row().unwrap_or(0);
1398            if csr <= max_csr {
1399                break;
1400            }
1401            let top = self.buffer.viewport().top_row;
1402            let Some(next) = self.buffer.next_visible_row(top) else {
1403                break;
1404            };
1405            // Don't walk past the cursor's row.
1406            if next > cursor_row {
1407                self.buffer.viewport_mut().top_row = cursor_row;
1408                break;
1409            }
1410            self.buffer.viewport_mut().top_row = next;
1411        }
1412        // Step 3 — pull top backward until cursor's screen row is
1413        // past the top margin (`csr >= margin`).
1414        loop {
1415            let csr = self.buffer.cursor_screen_row().unwrap_or(0);
1416            if csr >= margin {
1417                break;
1418            }
1419            let top = self.buffer.viewport().top_row;
1420            let Some(prev) = self.buffer.prev_visible_row(top) else {
1421                break;
1422            };
1423            self.buffer.viewport_mut().top_row = prev;
1424        }
1425        // Step 4 — clamp top so the buffer's bottom doesn't leave
1426        // blank rows below it. `max_top_for_height` walks segments
1427        // backward from the last row until it accumulates `height`
1428        // screen rows.
1429        let max_top = self.buffer.max_top_for_height(height);
1430        if self.buffer.viewport().top_row > max_top {
1431            self.buffer.viewport_mut().top_row = max_top;
1432        }
1433        self.buffer.viewport_mut().top_col = 0;
1434    }
1435
1436    fn scroll_viewport(&mut self, delta: i16) {
1437        if delta == 0 {
1438            return;
1439        }
1440        // Bump the buffer's viewport top within bounds.
1441        let total_rows = self.buffer.row_count() as isize;
1442        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1443        let cur_top = self.buffer.viewport().top_row as isize;
1444        let new_top = (cur_top + delta as isize)
1445            .max(0)
1446            .min((total_rows - 1).max(0)) as usize;
1447        self.buffer.viewport_mut().top_row = new_top;
1448        // Mirror to textarea so its viewport reads (still consumed by
1449        // a couple of helpers) stay accurate.
1450        let _ = cur_top;
1451        if height == 0 {
1452            return;
1453        }
1454        // Apply scrolloff: keep the cursor at least SCROLLOFF rows
1455        // from the visible viewport edges.
1456        let cursor = self.buffer.cursor();
1457        let margin = Self::SCROLLOFF.min(height / 2);
1458        let min_row = new_top + margin;
1459        let max_row = new_top + height.saturating_sub(1).saturating_sub(margin);
1460        let target_row = cursor.row.clamp(min_row, max_row.max(min_row));
1461        if target_row != cursor.row {
1462            let line_len = self
1463                .buffer
1464                .line(target_row)
1465                .map(|l| l.chars().count())
1466                .unwrap_or(0);
1467            let target_col = cursor.col.min(line_len.saturating_sub(1));
1468            self.buffer
1469                .set_cursor(hjkl_buffer::Position::new(target_row, target_col));
1470        }
1471    }
1472
1473    pub fn goto_line(&mut self, line: usize) {
1474        let row = line.saturating_sub(1);
1475        let max = self.buffer.row_count().saturating_sub(1);
1476        let target = row.min(max);
1477        self.buffer
1478            .set_cursor(hjkl_buffer::Position::new(target, 0));
1479    }
1480
1481    /// Scroll so the cursor row lands at the given viewport position:
1482    /// `Center` → middle row, `Top` → first row, `Bottom` → last row.
1483    /// Cursor stays on its absolute line; only the viewport moves.
1484    pub(super) fn scroll_cursor_to(&mut self, pos: CursorScrollTarget) {
1485        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1486        if height == 0 {
1487            return;
1488        }
1489        let cur_row = self.buffer.cursor().row;
1490        let cur_top = self.buffer.viewport().top_row;
1491        // Scrolloff awareness: `zt` lands the cursor at the top edge
1492        // of the viable area (top + margin), `zb` at the bottom edge
1493        // (top + height - 1 - margin). Match the cap used by
1494        // `ensure_cursor_in_scrolloff` so contradictory bounds are
1495        // impossible on tiny viewports.
1496        let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
1497        let new_top = match pos {
1498            CursorScrollTarget::Center => cur_row.saturating_sub(height / 2),
1499            CursorScrollTarget::Top => cur_row.saturating_sub(margin),
1500            CursorScrollTarget::Bottom => {
1501                cur_row.saturating_sub(height.saturating_sub(1).saturating_sub(margin))
1502            }
1503        };
1504        if new_top == cur_top {
1505            return;
1506        }
1507        self.buffer.viewport_mut().top_row = new_top;
1508    }
1509
1510    /// Translate a terminal mouse position into a (row, col) inside the document.
1511    /// `area` is the outer editor rect: 1-row tab bar at top (flush), then the
1512    /// textarea with 1 cell of horizontal pane padding on each side. Clicks
1513    /// past the line's last character clamp to the last char (Normal-mode
1514    /// invariant) — never past it. Char-counted, not byte-counted, so
1515    /// multibyte runs land where the user expects.
1516    fn mouse_to_doc_pos(&self, area: Rect, col: u16, row: u16) -> (usize, usize) {
1517        let lines = self.buffer.lines();
1518        let inner_top = area.y.saturating_add(1); // tab bar row
1519        let lnum_width = lines.len().to_string().len() as u16 + 2;
1520        let content_x = area.x.saturating_add(1).saturating_add(lnum_width);
1521        let rel_row = row.saturating_sub(inner_top) as usize;
1522        let top = self.buffer.viewport().top_row;
1523        let doc_row = (top + rel_row).min(lines.len().saturating_sub(1));
1524        let rel_col = col.saturating_sub(content_x) as usize;
1525        let line_chars = lines.get(doc_row).map(|l| l.chars().count()).unwrap_or(0);
1526        let last_col = line_chars.saturating_sub(1);
1527        (doc_row, rel_col.min(last_col))
1528    }
1529
1530    /// Jump the cursor to the given 1-based line/column, clamped to the document.
1531    pub fn jump_to(&mut self, line: usize, col: usize) {
1532        let r = line.saturating_sub(1);
1533        let max_row = self.buffer.row_count().saturating_sub(1);
1534        let r = r.min(max_row);
1535        let line_len = self.buffer.line(r).map(|l| l.chars().count()).unwrap_or(0);
1536        let c = col.saturating_sub(1).min(line_len);
1537        self.buffer.set_cursor(hjkl_buffer::Position::new(r, c));
1538    }
1539
1540    /// Jump cursor to the terminal-space mouse position; exits Visual modes if active.
1541    pub fn mouse_click(&mut self, area: Rect, col: u16, row: u16) {
1542        if self.vim.is_visual() {
1543            self.vim.force_normal();
1544        }
1545        let (r, c) = self.mouse_to_doc_pos(area, col, row);
1546        self.buffer.set_cursor(hjkl_buffer::Position::new(r, c));
1547    }
1548
1549    /// Begin a mouse-drag selection: anchor at current cursor and enter Visual mode.
1550    pub fn mouse_begin_drag(&mut self) {
1551        if !self.vim.is_visual_char() {
1552            let cursor = self.cursor();
1553            self.vim.enter_visual(cursor);
1554        }
1555    }
1556
1557    /// Extend an in-progress mouse drag to the given terminal-space position.
1558    pub fn mouse_extend_drag(&mut self, area: Rect, col: u16, row: u16) {
1559        let (r, c) = self.mouse_to_doc_pos(area, col, row);
1560        self.buffer.set_cursor(hjkl_buffer::Position::new(r, c));
1561    }
1562
1563    pub fn insert_str(&mut self, text: &str) {
1564        let pos = self.buffer.cursor();
1565        self.buffer.apply_edit(hjkl_buffer::Edit::InsertStr {
1566            at: pos,
1567            text: text.to_string(),
1568        });
1569        self.push_buffer_content_to_textarea();
1570        self.mark_content_dirty();
1571    }
1572
1573    pub fn accept_completion(&mut self, completion: &str) {
1574        use hjkl_buffer::{Edit, MotionKind, Position};
1575        let cursor = self.buffer.cursor();
1576        let line = self.buffer.line(cursor.row).unwrap_or("").to_string();
1577        let chars: Vec<char> = line.chars().collect();
1578        let prefix_len = chars[..cursor.col.min(chars.len())]
1579            .iter()
1580            .rev()
1581            .take_while(|c| c.is_alphanumeric() || **c == '_')
1582            .count();
1583        if prefix_len > 0 {
1584            let start = Position::new(cursor.row, cursor.col - prefix_len);
1585            self.buffer.apply_edit(Edit::DeleteRange {
1586                start,
1587                end: cursor,
1588                kind: MotionKind::Char,
1589            });
1590        }
1591        let cursor = self.buffer.cursor();
1592        self.buffer.apply_edit(Edit::InsertStr {
1593            at: cursor,
1594            text: completion.to_string(),
1595        });
1596        self.push_buffer_content_to_textarea();
1597        self.mark_content_dirty();
1598    }
1599
1600    pub(super) fn snapshot(&self) -> (Vec<String>, (usize, usize)) {
1601        let pos = self.buffer.cursor();
1602        (self.buffer.lines().to_vec(), (pos.row, pos.col))
1603    }
1604
1605    /// Walk one step back through the undo history. Equivalent to the
1606    /// user pressing `u` in normal mode. Drains the most recent undo
1607    /// entry and pushes it onto the redo stack.
1608    pub fn undo(&mut self) {
1609        crate::vim::do_undo(self);
1610    }
1611
1612    /// Walk one step forward through the redo history. Equivalent to
1613    /// `<C-r>` in normal mode.
1614    pub fn redo(&mut self) {
1615        crate::vim::do_redo(self);
1616    }
1617
1618    /// Snapshot current buffer state onto the undo stack and clear
1619    /// the redo stack. Bounded at 200 entries — older entries pruned.
1620    /// Call before any group of buffer mutations the user might want
1621    /// to undo as a single step.
1622    pub fn push_undo(&mut self) {
1623        let snap = self.snapshot();
1624        if self.undo_stack.len() >= 200 {
1625            self.undo_stack.remove(0);
1626        }
1627        self.undo_stack.push(snap);
1628        self.redo_stack.clear();
1629    }
1630
1631    /// Replace the buffer with `lines` joined by `\n` and set the
1632    /// cursor to `cursor`. Used by undo / `:e!` / snapshot restore
1633    /// paths. Marks the editor dirty.
1634    pub fn restore(&mut self, lines: Vec<String>, cursor: (usize, usize)) {
1635        let text = lines.join("\n");
1636        self.buffer.replace_all(&text);
1637        self.buffer
1638            .set_cursor(hjkl_buffer::Position::new(cursor.0, cursor.1));
1639        self.mark_content_dirty();
1640    }
1641
1642    /// Returns true if the key was consumed by the editor.
1643    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
1644        let input = crossterm_to_input(key);
1645        if input.key == Key::Null {
1646            return false;
1647        }
1648        vim::step(self, input)
1649    }
1650}
1651
1652pub(super) fn crossterm_to_input(key: KeyEvent) -> Input {
1653    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1654    let alt = key.modifiers.contains(KeyModifiers::ALT);
1655    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1656    let k = match key.code {
1657        KeyCode::Char(c) => Key::Char(c),
1658        KeyCode::Backspace => Key::Backspace,
1659        KeyCode::Delete => Key::Delete,
1660        KeyCode::Enter => Key::Enter,
1661        KeyCode::Left => Key::Left,
1662        KeyCode::Right => Key::Right,
1663        KeyCode::Up => Key::Up,
1664        KeyCode::Down => Key::Down,
1665        KeyCode::Home => Key::Home,
1666        KeyCode::End => Key::End,
1667        KeyCode::Tab => Key::Tab,
1668        KeyCode::Esc => Key::Esc,
1669        _ => Key::Null,
1670    };
1671    Input {
1672        key: k,
1673        ctrl,
1674        alt,
1675        shift,
1676    }
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681    use super::*;
1682    use crossterm::event::KeyEvent;
1683
1684    fn key(code: KeyCode) -> KeyEvent {
1685        KeyEvent::new(code, KeyModifiers::NONE)
1686    }
1687    fn shift_key(code: KeyCode) -> KeyEvent {
1688        KeyEvent::new(code, KeyModifiers::SHIFT)
1689    }
1690    fn ctrl_key(code: KeyCode) -> KeyEvent {
1691        KeyEvent::new(code, KeyModifiers::CONTROL)
1692    }
1693
1694    #[test]
1695    fn vim_normal_to_insert() {
1696        let mut e = Editor::new(KeybindingMode::Vim);
1697        e.handle_key(key(KeyCode::Char('i')));
1698        assert_eq!(e.vim_mode(), VimMode::Insert);
1699    }
1700
1701    #[test]
1702    fn feed_input_char_routes_through_handle_key() {
1703        use crate::{Modifiers, PlannedInput};
1704        let mut e = Editor::new(KeybindingMode::Vim);
1705        e.set_content("abc");
1706        // `i` enters insert mode via SPEC input.
1707        e.feed_input(PlannedInput::Char('i', Modifiers::default()));
1708        assert_eq!(e.vim_mode(), VimMode::Insert);
1709        // Type 'X' via SPEC input.
1710        e.feed_input(PlannedInput::Char('X', Modifiers::default()));
1711        assert!(e.content().contains('X'));
1712    }
1713
1714    #[test]
1715    fn feed_input_special_key_routes() {
1716        use crate::{Modifiers, PlannedInput, SpecialKey};
1717        let mut e = Editor::new(KeybindingMode::Vim);
1718        e.set_content("abc");
1719        e.feed_input(PlannedInput::Char('i', Modifiers::default()));
1720        assert_eq!(e.vim_mode(), VimMode::Insert);
1721        e.feed_input(PlannedInput::Key(SpecialKey::Esc, Modifiers::default()));
1722        assert_eq!(e.vim_mode(), VimMode::Normal);
1723    }
1724
1725    #[test]
1726    fn feed_input_mouse_paste_focus_resize_no_op() {
1727        use crate::{MouseEvent, MouseKind, PlannedInput, Pos};
1728        let mut e = Editor::new(KeybindingMode::Vim);
1729        e.set_content("abc");
1730        let mode_before = e.vim_mode();
1731        let consumed = e.feed_input(PlannedInput::Mouse(MouseEvent {
1732            kind: MouseKind::Press,
1733            pos: Pos::new(0, 0),
1734            mods: Default::default(),
1735        }));
1736        assert!(!consumed);
1737        assert_eq!(e.vim_mode(), mode_before);
1738        assert!(!e.feed_input(PlannedInput::Paste("xx".into())));
1739        assert!(!e.feed_input(PlannedInput::FocusGained));
1740        assert!(!e.feed_input(PlannedInput::FocusLost));
1741        assert!(!e.feed_input(PlannedInput::Resize(80, 24)));
1742    }
1743
1744    #[test]
1745    fn intern_engine_style_dedups_with_intern_style() {
1746        use crate::types::{Attrs, Color, Style};
1747        let mut e = Editor::new(KeybindingMode::Vim);
1748        let s = Style {
1749            fg: Some(Color(255, 0, 0)),
1750            bg: None,
1751            attrs: Attrs::BOLD,
1752        };
1753        let id_a = e.intern_engine_style(s);
1754        // Re-interning the same engine style returns the same id.
1755        let id_b = e.intern_engine_style(s);
1756        assert_eq!(id_a, id_b);
1757        // Engine accessor returns the same style back.
1758        let back = e.engine_style_at(id_a).expect("interned");
1759        assert_eq!(back, s);
1760    }
1761
1762    #[test]
1763    fn engine_style_at_out_of_range_returns_none() {
1764        let e = Editor::new(KeybindingMode::Vim);
1765        assert!(e.engine_style_at(99).is_none());
1766    }
1767
1768    #[test]
1769    fn take_changes_drains_after_insert() {
1770        let mut e = Editor::new(KeybindingMode::Vim);
1771        e.set_content("abc");
1772        // Empty initially.
1773        assert!(e.take_changes().is_empty());
1774        // Type a char in insert mode.
1775        e.handle_key(key(KeyCode::Char('i')));
1776        e.handle_key(key(KeyCode::Char('X')));
1777        let changes = e.take_changes();
1778        assert!(
1779            !changes.is_empty(),
1780            "insert mode keystroke should produce a change"
1781        );
1782        // Drained — second call empty.
1783        assert!(e.take_changes().is_empty());
1784    }
1785
1786    #[test]
1787    fn options_bridge_roundtrip() {
1788        let mut e = Editor::new(KeybindingMode::Vim);
1789        let opts = e.current_options();
1790        assert_eq!(opts.shiftwidth, 2); // legacy Settings default
1791        assert_eq!(opts.tabstop, 8);
1792
1793        let new_opts = crate::types::Options {
1794            shiftwidth: 4,
1795            tabstop: 2,
1796            ignorecase: true,
1797            ..crate::types::Options::default()
1798        };
1799        e.apply_options(&new_opts);
1800
1801        let after = e.current_options();
1802        assert_eq!(after.shiftwidth, 4);
1803        assert_eq!(after.tabstop, 2);
1804        assert!(after.ignorecase);
1805    }
1806
1807    #[test]
1808    fn selection_highlight_none_in_normal() {
1809        let mut e = Editor::new(KeybindingMode::Vim);
1810        e.set_content("hello");
1811        assert!(e.selection_highlight().is_none());
1812    }
1813
1814    #[test]
1815    fn selection_highlight_some_in_visual() {
1816        use crate::types::HighlightKind;
1817        let mut e = Editor::new(KeybindingMode::Vim);
1818        e.set_content("hello world");
1819        e.handle_key(key(KeyCode::Char('v')));
1820        e.handle_key(key(KeyCode::Char('l')));
1821        e.handle_key(key(KeyCode::Char('l')));
1822        let h = e
1823            .selection_highlight()
1824            .expect("visual mode should produce a highlight");
1825        assert_eq!(h.kind, HighlightKind::Selection);
1826        assert_eq!(h.range.start.line, 0);
1827        assert_eq!(h.range.end.line, 0);
1828    }
1829
1830    #[test]
1831    fn highlights_emit_incsearch_during_active_prompt() {
1832        use crate::types::HighlightKind;
1833        let mut e = Editor::new(KeybindingMode::Vim);
1834        e.set_content("foo bar foo\nbaz\n");
1835        // Open the `/` prompt and type `f` `o` `o`.
1836        e.handle_key(key(KeyCode::Char('/')));
1837        e.handle_key(key(KeyCode::Char('f')));
1838        e.handle_key(key(KeyCode::Char('o')));
1839        e.handle_key(key(KeyCode::Char('o')));
1840        // Prompt should be active.
1841        assert!(e.search_prompt().is_some());
1842        let hs = e.highlights_for_line(0);
1843        assert_eq!(hs.len(), 2);
1844        for h in &hs {
1845            assert_eq!(h.kind, HighlightKind::IncSearch);
1846        }
1847    }
1848
1849    #[test]
1850    fn highlights_empty_for_blank_prompt() {
1851        let mut e = Editor::new(KeybindingMode::Vim);
1852        e.set_content("foo");
1853        e.handle_key(key(KeyCode::Char('/')));
1854        // Nothing typed yet — prompt active but text empty.
1855        assert!(e.search_prompt().is_some());
1856        assert!(e.highlights_for_line(0).is_empty());
1857    }
1858
1859    #[test]
1860    fn highlights_emit_search_matches() {
1861        use crate::types::HighlightKind;
1862        let mut e = Editor::new(KeybindingMode::Vim);
1863        e.set_content("foo bar foo\nbaz qux\n");
1864        // Arm a search via buffer's pattern setter.
1865        e.buffer_mut()
1866            .set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
1867        let hs = e.highlights_for_line(0);
1868        assert_eq!(hs.len(), 2);
1869        for h in &hs {
1870            assert_eq!(h.kind, HighlightKind::SearchMatch);
1871            assert_eq!(h.range.start.line, 0);
1872            assert_eq!(h.range.end.line, 0);
1873        }
1874    }
1875
1876    #[test]
1877    fn highlights_empty_without_pattern() {
1878        let mut e = Editor::new(KeybindingMode::Vim);
1879        e.set_content("foo bar");
1880        assert!(e.highlights_for_line(0).is_empty());
1881    }
1882
1883    #[test]
1884    fn highlights_empty_for_out_of_range_line() {
1885        let mut e = Editor::new(KeybindingMode::Vim);
1886        e.set_content("foo");
1887        e.buffer_mut()
1888            .set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
1889        assert!(e.highlights_for_line(99).is_empty());
1890    }
1891
1892    #[test]
1893    fn render_frame_reflects_mode_and_cursor() {
1894        use crate::types::{CursorShape, SnapshotMode};
1895        let mut e = Editor::new(KeybindingMode::Vim);
1896        e.set_content("alpha\nbeta");
1897        let f = e.render_frame();
1898        assert_eq!(f.mode, SnapshotMode::Normal);
1899        assert_eq!(f.cursor_shape, CursorShape::Block);
1900        assert_eq!(f.line_count, 2);
1901
1902        e.handle_key(key(KeyCode::Char('i')));
1903        let f = e.render_frame();
1904        assert_eq!(f.mode, SnapshotMode::Insert);
1905        assert_eq!(f.cursor_shape, CursorShape::Bar);
1906    }
1907
1908    #[test]
1909    fn snapshot_roundtrips_through_restore() {
1910        use crate::types::SnapshotMode;
1911        let mut e = Editor::new(KeybindingMode::Vim);
1912        e.set_content("alpha\nbeta\ngamma");
1913        e.jump_cursor(2, 3);
1914        let snap = e.take_snapshot();
1915        assert_eq!(snap.mode, SnapshotMode::Normal);
1916        assert_eq!(snap.cursor, (2, 3));
1917        assert_eq!(snap.lines.len(), 3);
1918
1919        let mut other = Editor::new(KeybindingMode::Vim);
1920        other.restore_snapshot(snap).expect("restore");
1921        assert_eq!(other.cursor(), (2, 3));
1922        assert_eq!(other.buffer().lines().len(), 3);
1923    }
1924
1925    #[test]
1926    fn restore_snapshot_rejects_version_mismatch() {
1927        let mut e = Editor::new(KeybindingMode::Vim);
1928        let mut snap = e.take_snapshot();
1929        snap.version = 9999;
1930        match e.restore_snapshot(snap) {
1931            Err(crate::EngineError::SnapshotVersion(got, want)) => {
1932                assert_eq!(got, 9999);
1933                assert_eq!(want, crate::types::EditorSnapshot::VERSION);
1934            }
1935            other => panic!("expected SnapshotVersion err, got {other:?}"),
1936        }
1937    }
1938
1939    #[test]
1940    fn take_content_change_returns_some_on_first_dirty() {
1941        let mut e = Editor::new(KeybindingMode::Vim);
1942        e.set_content("hello");
1943        let first = e.take_content_change();
1944        assert!(first.is_some());
1945        let second = e.take_content_change();
1946        assert!(second.is_none());
1947    }
1948
1949    #[test]
1950    fn take_content_change_none_until_mutation() {
1951        let mut e = Editor::new(KeybindingMode::Vim);
1952        e.set_content("hello");
1953        // drain
1954        e.take_content_change();
1955        assert!(e.take_content_change().is_none());
1956        // mutate via insert mode
1957        e.handle_key(key(KeyCode::Char('i')));
1958        e.handle_key(key(KeyCode::Char('x')));
1959        let after = e.take_content_change();
1960        assert!(after.is_some());
1961        assert!(after.unwrap().contains('x'));
1962    }
1963
1964    #[test]
1965    fn vim_insert_to_normal() {
1966        let mut e = Editor::new(KeybindingMode::Vim);
1967        e.handle_key(key(KeyCode::Char('i')));
1968        e.handle_key(key(KeyCode::Esc));
1969        assert_eq!(e.vim_mode(), VimMode::Normal);
1970    }
1971
1972    #[test]
1973    fn vim_normal_to_visual() {
1974        let mut e = Editor::new(KeybindingMode::Vim);
1975        e.handle_key(key(KeyCode::Char('v')));
1976        assert_eq!(e.vim_mode(), VimMode::Visual);
1977    }
1978
1979    #[test]
1980    fn vim_visual_to_normal() {
1981        let mut e = Editor::new(KeybindingMode::Vim);
1982        e.handle_key(key(KeyCode::Char('v')));
1983        e.handle_key(key(KeyCode::Esc));
1984        assert_eq!(e.vim_mode(), VimMode::Normal);
1985    }
1986
1987    #[test]
1988    fn vim_shift_i_moves_to_first_non_whitespace() {
1989        let mut e = Editor::new(KeybindingMode::Vim);
1990        e.set_content("   hello");
1991        e.jump_cursor(0, 8);
1992        e.handle_key(shift_key(KeyCode::Char('I')));
1993        assert_eq!(e.vim_mode(), VimMode::Insert);
1994        assert_eq!(e.cursor(), (0, 3));
1995    }
1996
1997    #[test]
1998    fn vim_shift_a_moves_to_end_and_insert() {
1999        let mut e = Editor::new(KeybindingMode::Vim);
2000        e.set_content("hello");
2001        e.handle_key(shift_key(KeyCode::Char('A')));
2002        assert_eq!(e.vim_mode(), VimMode::Insert);
2003        assert_eq!(e.cursor().1, 5);
2004    }
2005
2006    #[test]
2007    fn count_10j_moves_down_10() {
2008        let mut e = Editor::new(KeybindingMode::Vim);
2009        e.set_content(
2010            (0..20)
2011                .map(|i| format!("line{i}"))
2012                .collect::<Vec<_>>()
2013                .join("\n")
2014                .as_str(),
2015        );
2016        for d in "10".chars() {
2017            e.handle_key(key(KeyCode::Char(d)));
2018        }
2019        e.handle_key(key(KeyCode::Char('j')));
2020        assert_eq!(e.cursor().0, 10);
2021    }
2022
2023    #[test]
2024    fn count_o_repeats_insert_on_esc() {
2025        let mut e = Editor::new(KeybindingMode::Vim);
2026        e.set_content("hello");
2027        for d in "3".chars() {
2028            e.handle_key(key(KeyCode::Char(d)));
2029        }
2030        e.handle_key(key(KeyCode::Char('o')));
2031        assert_eq!(e.vim_mode(), VimMode::Insert);
2032        for c in "world".chars() {
2033            e.handle_key(key(KeyCode::Char(c)));
2034        }
2035        e.handle_key(key(KeyCode::Esc));
2036        assert_eq!(e.vim_mode(), VimMode::Normal);
2037        assert_eq!(e.buffer().lines().len(), 4);
2038        assert!(e.buffer().lines().iter().skip(1).all(|l| l == "world"));
2039    }
2040
2041    #[test]
2042    fn count_i_repeats_text_on_esc() {
2043        let mut e = Editor::new(KeybindingMode::Vim);
2044        e.set_content("");
2045        for d in "3".chars() {
2046            e.handle_key(key(KeyCode::Char(d)));
2047        }
2048        e.handle_key(key(KeyCode::Char('i')));
2049        for c in "ab".chars() {
2050            e.handle_key(key(KeyCode::Char(c)));
2051        }
2052        e.handle_key(key(KeyCode::Esc));
2053        assert_eq!(e.vim_mode(), VimMode::Normal);
2054        assert_eq!(e.buffer().lines()[0], "ababab");
2055    }
2056
2057    #[test]
2058    fn vim_shift_o_opens_line_above() {
2059        let mut e = Editor::new(KeybindingMode::Vim);
2060        e.set_content("hello");
2061        e.handle_key(shift_key(KeyCode::Char('O')));
2062        assert_eq!(e.vim_mode(), VimMode::Insert);
2063        assert_eq!(e.cursor(), (0, 0));
2064        assert_eq!(e.buffer().lines().len(), 2);
2065    }
2066
2067    #[test]
2068    fn vim_gg_goes_to_top() {
2069        let mut e = Editor::new(KeybindingMode::Vim);
2070        e.set_content("a\nb\nc");
2071        e.jump_cursor(2, 0);
2072        e.handle_key(key(KeyCode::Char('g')));
2073        e.handle_key(key(KeyCode::Char('g')));
2074        assert_eq!(e.cursor().0, 0);
2075    }
2076
2077    #[test]
2078    fn vim_shift_g_goes_to_bottom() {
2079        let mut e = Editor::new(KeybindingMode::Vim);
2080        e.set_content("a\nb\nc");
2081        e.handle_key(shift_key(KeyCode::Char('G')));
2082        assert_eq!(e.cursor().0, 2);
2083    }
2084
2085    #[test]
2086    fn vim_dd_deletes_line() {
2087        let mut e = Editor::new(KeybindingMode::Vim);
2088        e.set_content("first\nsecond");
2089        e.handle_key(key(KeyCode::Char('d')));
2090        e.handle_key(key(KeyCode::Char('d')));
2091        assert_eq!(e.buffer().lines().len(), 1);
2092        assert_eq!(e.buffer().lines()[0], "second");
2093    }
2094
2095    #[test]
2096    fn vim_dw_deletes_word() {
2097        let mut e = Editor::new(KeybindingMode::Vim);
2098        e.set_content("hello world");
2099        e.handle_key(key(KeyCode::Char('d')));
2100        e.handle_key(key(KeyCode::Char('w')));
2101        assert_eq!(e.vim_mode(), VimMode::Normal);
2102        assert!(!e.buffer().lines()[0].starts_with("hello"));
2103    }
2104
2105    #[test]
2106    fn vim_yy_yanks_line() {
2107        let mut e = Editor::new(KeybindingMode::Vim);
2108        e.set_content("hello\nworld");
2109        e.handle_key(key(KeyCode::Char('y')));
2110        e.handle_key(key(KeyCode::Char('y')));
2111        assert!(e.last_yank.as_deref().unwrap_or("").starts_with("hello"));
2112    }
2113
2114    #[test]
2115    fn vim_yy_does_not_move_cursor() {
2116        let mut e = Editor::new(KeybindingMode::Vim);
2117        e.set_content("first\nsecond\nthird");
2118        e.jump_cursor(1, 0);
2119        let before = e.cursor();
2120        e.handle_key(key(KeyCode::Char('y')));
2121        e.handle_key(key(KeyCode::Char('y')));
2122        assert_eq!(e.cursor(), before);
2123        assert_eq!(e.vim_mode(), VimMode::Normal);
2124    }
2125
2126    #[test]
2127    fn vim_yw_yanks_word() {
2128        let mut e = Editor::new(KeybindingMode::Vim);
2129        e.set_content("hello world");
2130        e.handle_key(key(KeyCode::Char('y')));
2131        e.handle_key(key(KeyCode::Char('w')));
2132        assert_eq!(e.vim_mode(), VimMode::Normal);
2133        assert!(e.last_yank.is_some());
2134    }
2135
2136    #[test]
2137    fn vim_cc_changes_line() {
2138        let mut e = Editor::new(KeybindingMode::Vim);
2139        e.set_content("hello\nworld");
2140        e.handle_key(key(KeyCode::Char('c')));
2141        e.handle_key(key(KeyCode::Char('c')));
2142        assert_eq!(e.vim_mode(), VimMode::Insert);
2143    }
2144
2145    #[test]
2146    fn vim_u_undoes_insert_session_as_chunk() {
2147        let mut e = Editor::new(KeybindingMode::Vim);
2148        e.set_content("hello");
2149        e.handle_key(key(KeyCode::Char('i')));
2150        e.handle_key(key(KeyCode::Enter));
2151        e.handle_key(key(KeyCode::Enter));
2152        e.handle_key(key(KeyCode::Esc));
2153        assert_eq!(e.buffer().lines().len(), 3);
2154        e.handle_key(key(KeyCode::Char('u')));
2155        assert_eq!(e.buffer().lines().len(), 1);
2156        assert_eq!(e.buffer().lines()[0], "hello");
2157    }
2158
2159    #[test]
2160    fn vim_undo_redo_roundtrip() {
2161        let mut e = Editor::new(KeybindingMode::Vim);
2162        e.set_content("hello");
2163        e.handle_key(key(KeyCode::Char('i')));
2164        for c in "world".chars() {
2165            e.handle_key(key(KeyCode::Char(c)));
2166        }
2167        e.handle_key(key(KeyCode::Esc));
2168        let after = e.buffer().lines()[0].clone();
2169        e.handle_key(key(KeyCode::Char('u')));
2170        assert_eq!(e.buffer().lines()[0], "hello");
2171        e.handle_key(ctrl_key(KeyCode::Char('r')));
2172        assert_eq!(e.buffer().lines()[0], after);
2173    }
2174
2175    #[test]
2176    fn vim_u_undoes_dd() {
2177        let mut e = Editor::new(KeybindingMode::Vim);
2178        e.set_content("first\nsecond");
2179        e.handle_key(key(KeyCode::Char('d')));
2180        e.handle_key(key(KeyCode::Char('d')));
2181        assert_eq!(e.buffer().lines().len(), 1);
2182        e.handle_key(key(KeyCode::Char('u')));
2183        assert_eq!(e.buffer().lines().len(), 2);
2184        assert_eq!(e.buffer().lines()[0], "first");
2185    }
2186
2187    #[test]
2188    fn vim_ctrl_r_redoes() {
2189        let mut e = Editor::new(KeybindingMode::Vim);
2190        e.set_content("hello");
2191        e.handle_key(ctrl_key(KeyCode::Char('r')));
2192    }
2193
2194    #[test]
2195    fn vim_r_replaces_char() {
2196        let mut e = Editor::new(KeybindingMode::Vim);
2197        e.set_content("hello");
2198        e.handle_key(key(KeyCode::Char('r')));
2199        e.handle_key(key(KeyCode::Char('x')));
2200        assert_eq!(e.buffer().lines()[0].chars().next(), Some('x'));
2201    }
2202
2203    #[test]
2204    fn vim_tilde_toggles_case() {
2205        let mut e = Editor::new(KeybindingMode::Vim);
2206        e.set_content("hello");
2207        e.handle_key(key(KeyCode::Char('~')));
2208        assert_eq!(e.buffer().lines()[0].chars().next(), Some('H'));
2209    }
2210
2211    #[test]
2212    fn vim_visual_d_cuts() {
2213        let mut e = Editor::new(KeybindingMode::Vim);
2214        e.set_content("hello");
2215        e.handle_key(key(KeyCode::Char('v')));
2216        e.handle_key(key(KeyCode::Char('l')));
2217        e.handle_key(key(KeyCode::Char('l')));
2218        e.handle_key(key(KeyCode::Char('d')));
2219        assert_eq!(e.vim_mode(), VimMode::Normal);
2220        assert!(e.last_yank.is_some());
2221    }
2222
2223    #[test]
2224    fn vim_visual_c_enters_insert() {
2225        let mut e = Editor::new(KeybindingMode::Vim);
2226        e.set_content("hello");
2227        e.handle_key(key(KeyCode::Char('v')));
2228        e.handle_key(key(KeyCode::Char('l')));
2229        e.handle_key(key(KeyCode::Char('c')));
2230        assert_eq!(e.vim_mode(), VimMode::Insert);
2231    }
2232
2233    #[test]
2234    fn vim_normal_unknown_key_consumed() {
2235        let mut e = Editor::new(KeybindingMode::Vim);
2236        // Unknown keys are consumed (swallowed) rather than returning false.
2237        let consumed = e.handle_key(key(KeyCode::Char('z')));
2238        assert!(consumed);
2239    }
2240
2241    #[test]
2242    fn force_normal_clears_operator() {
2243        let mut e = Editor::new(KeybindingMode::Vim);
2244        e.handle_key(key(KeyCode::Char('d')));
2245        e.force_normal();
2246        assert_eq!(e.vim_mode(), VimMode::Normal);
2247    }
2248
2249    fn many_lines(n: usize) -> String {
2250        (0..n)
2251            .map(|i| format!("line{i}"))
2252            .collect::<Vec<_>>()
2253            .join("\n")
2254    }
2255
2256    fn prime_viewport(e: &mut Editor<'_>, height: u16) {
2257        e.set_viewport_height(height);
2258    }
2259
2260    #[test]
2261    fn zz_centers_cursor_in_viewport() {
2262        let mut e = Editor::new(KeybindingMode::Vim);
2263        e.set_content(&many_lines(100));
2264        prime_viewport(&mut e, 20);
2265        e.jump_cursor(50, 0);
2266        e.handle_key(key(KeyCode::Char('z')));
2267        e.handle_key(key(KeyCode::Char('z')));
2268        assert_eq!(e.buffer().viewport().top_row, 40);
2269        assert_eq!(e.cursor().0, 50);
2270    }
2271
2272    #[test]
2273    fn zt_puts_cursor_at_viewport_top_with_scrolloff() {
2274        let mut e = Editor::new(KeybindingMode::Vim);
2275        e.set_content(&many_lines(100));
2276        prime_viewport(&mut e, 20);
2277        e.jump_cursor(50, 0);
2278        e.handle_key(key(KeyCode::Char('z')));
2279        e.handle_key(key(KeyCode::Char('t')));
2280        // Cursor lands at top of viable area = top + SCROLLOFF (5).
2281        // Viewport top therefore sits at cursor - 5.
2282        assert_eq!(e.buffer().viewport().top_row, 45);
2283        assert_eq!(e.cursor().0, 50);
2284    }
2285
2286    #[test]
2287    fn ctrl_a_increments_number_at_cursor() {
2288        let mut e = Editor::new(KeybindingMode::Vim);
2289        e.set_content("x = 41");
2290        e.handle_key(ctrl_key(KeyCode::Char('a')));
2291        assert_eq!(e.buffer().lines()[0], "x = 42");
2292        assert_eq!(e.cursor(), (0, 5));
2293    }
2294
2295    #[test]
2296    fn ctrl_a_finds_number_to_right_of_cursor() {
2297        let mut e = Editor::new(KeybindingMode::Vim);
2298        e.set_content("foo 99 bar");
2299        e.handle_key(ctrl_key(KeyCode::Char('a')));
2300        assert_eq!(e.buffer().lines()[0], "foo 100 bar");
2301        assert_eq!(e.cursor(), (0, 6));
2302    }
2303
2304    #[test]
2305    fn ctrl_a_with_count_adds_count() {
2306        let mut e = Editor::new(KeybindingMode::Vim);
2307        e.set_content("x = 10");
2308        for d in "5".chars() {
2309            e.handle_key(key(KeyCode::Char(d)));
2310        }
2311        e.handle_key(ctrl_key(KeyCode::Char('a')));
2312        assert_eq!(e.buffer().lines()[0], "x = 15");
2313    }
2314
2315    #[test]
2316    fn ctrl_x_decrements_number() {
2317        let mut e = Editor::new(KeybindingMode::Vim);
2318        e.set_content("n=5");
2319        e.handle_key(ctrl_key(KeyCode::Char('x')));
2320        assert_eq!(e.buffer().lines()[0], "n=4");
2321    }
2322
2323    #[test]
2324    fn ctrl_x_crosses_zero_into_negative() {
2325        let mut e = Editor::new(KeybindingMode::Vim);
2326        e.set_content("v=0");
2327        e.handle_key(ctrl_key(KeyCode::Char('x')));
2328        assert_eq!(e.buffer().lines()[0], "v=-1");
2329    }
2330
2331    #[test]
2332    fn ctrl_a_on_negative_number_increments_toward_zero() {
2333        let mut e = Editor::new(KeybindingMode::Vim);
2334        e.set_content("a = -5");
2335        e.handle_key(ctrl_key(KeyCode::Char('a')));
2336        assert_eq!(e.buffer().lines()[0], "a = -4");
2337    }
2338
2339    #[test]
2340    fn ctrl_a_noop_when_no_digit_on_line() {
2341        let mut e = Editor::new(KeybindingMode::Vim);
2342        e.set_content("no digits here");
2343        e.handle_key(ctrl_key(KeyCode::Char('a')));
2344        assert_eq!(e.buffer().lines()[0], "no digits here");
2345    }
2346
2347    #[test]
2348    fn zb_puts_cursor_at_viewport_bottom_with_scrolloff() {
2349        let mut e = Editor::new(KeybindingMode::Vim);
2350        e.set_content(&many_lines(100));
2351        prime_viewport(&mut e, 20);
2352        e.jump_cursor(50, 0);
2353        e.handle_key(key(KeyCode::Char('z')));
2354        e.handle_key(key(KeyCode::Char('b')));
2355        // Cursor lands at bottom of viable area = top + height - 1 -
2356        // SCROLLOFF. For height 20, scrolloff 5: cursor at top + 14,
2357        // so top = cursor - 14 = 36.
2358        assert_eq!(e.buffer().viewport().top_row, 36);
2359        assert_eq!(e.cursor().0, 50);
2360    }
2361
2362    /// Contract that the TUI drain relies on: `set_content` flags the
2363    /// editor dirty (so the next `take_dirty` call reports the change),
2364    /// and a second `take_dirty` returns `false` after consumption. The
2365    /// TUI drains this flag after every programmatic content load so
2366    /// opening a tab doesn't get mistaken for a user edit and mark the
2367    /// tab dirty (which would then trigger the quit-prompt on `:q`).
2368    #[test]
2369    fn set_content_dirties_then_take_dirty_clears() {
2370        let mut e = Editor::new(KeybindingMode::Vim);
2371        e.set_content("hello");
2372        assert!(
2373            e.take_dirty(),
2374            "set_content should leave content_dirty=true"
2375        );
2376        assert!(!e.take_dirty(), "take_dirty should clear the flag");
2377    }
2378
2379    #[test]
2380    fn content_arc_returns_same_arc_until_mutation() {
2381        let mut e = Editor::new(KeybindingMode::Vim);
2382        e.set_content("hello");
2383        let a = e.content_arc();
2384        let b = e.content_arc();
2385        assert!(
2386            std::sync::Arc::ptr_eq(&a, &b),
2387            "repeated content_arc() should hit the cache"
2388        );
2389
2390        // Any mutation must invalidate the cache.
2391        e.handle_key(key(KeyCode::Char('i')));
2392        e.handle_key(key(KeyCode::Char('!')));
2393        let c = e.content_arc();
2394        assert!(
2395            !std::sync::Arc::ptr_eq(&a, &c),
2396            "mutation should invalidate content_arc() cache"
2397        );
2398        assert!(c.contains('!'));
2399    }
2400
2401    #[test]
2402    fn content_arc_cache_invalidated_by_set_content() {
2403        let mut e = Editor::new(KeybindingMode::Vim);
2404        e.set_content("one");
2405        let a = e.content_arc();
2406        e.set_content("two");
2407        let b = e.content_arc();
2408        assert!(!std::sync::Arc::ptr_eq(&a, &b));
2409        assert!(b.starts_with("two"));
2410    }
2411
2412    /// Click past the last char of a line should land the cursor on
2413    /// the line's last char (Normal mode), not one past it. The
2414    /// previous bug clamped to the line's BYTE length and used `>=`
2415    /// past-end, so clicking deep into the trailing space parked the
2416    /// cursor at `chars().count()` — past where Normal mode lives.
2417    #[test]
2418    fn mouse_click_past_eol_lands_on_last_char() {
2419        let mut e = Editor::new(KeybindingMode::Vim);
2420        e.set_content("hello");
2421        // Outer editor area: x=0, y=0, width=80. mouse_to_doc_pos
2422        // reserves row 0 for the tab bar and adds gutter padding,
2423        // so click row 1, way past the line end.
2424        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
2425        e.mouse_click(area, 78, 1);
2426        assert_eq!(e.cursor(), (0, 4));
2427    }
2428
2429    #[test]
2430    fn mouse_click_past_eol_handles_multibyte_line() {
2431        let mut e = Editor::new(KeybindingMode::Vim);
2432        // 5 chars, 6 bytes — old code's `String::len()` clamp was
2433        // wrong here.
2434        e.set_content("héllo");
2435        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
2436        e.mouse_click(area, 78, 1);
2437        assert_eq!(e.cursor(), (0, 4));
2438    }
2439
2440    #[test]
2441    fn mouse_click_inside_line_lands_on_clicked_char() {
2442        let mut e = Editor::new(KeybindingMode::Vim);
2443        e.set_content("hello world");
2444        // Gutter is `lnum_width + 1` = (1-digit row count + 2) + 1
2445        // pane padding = 4 cells; click col 4 is the first char.
2446        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
2447        e.mouse_click(area, 4, 1);
2448        assert_eq!(e.cursor(), (0, 0));
2449        e.mouse_click(area, 6, 1);
2450        assert_eq!(e.cursor(), (0, 2));
2451    }
2452}