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        let mut o = crate::types::Options::default();
1062        o.shiftwidth = self.settings.shiftwidth as u32;
1063        o.tabstop = self.settings.tabstop as u32;
1064        o.ignorecase = self.settings.ignore_case;
1065        o
1066    }
1067
1068    /// Apply a SPEC [`crate::types::Options`] to the engine's settings.
1069    /// Only the fields backed by today's [`Settings`] take effect;
1070    /// remaining options become live once trait extraction wires them
1071    /// through.
1072    pub fn apply_options(&mut self, opts: &crate::types::Options) {
1073        self.settings.shiftwidth = opts.shiftwidth as usize;
1074        self.settings.tabstop = opts.tabstop as usize;
1075        self.settings.ignore_case = opts.ignorecase;
1076    }
1077
1078    /// Active visual selection as a SPEC [`crate::types::Highlight`]
1079    /// with [`crate::types::HighlightKind::Selection`].
1080    ///
1081    /// Returns `None` when the editor isn't in a Visual mode.
1082    /// Visual-line and visual-block selections collapse to the
1083    /// bounding char range of the selection — the SPEC `Selection`
1084    /// kind doesn't carry sub-line info today; hosts that need full
1085    /// line / block geometry continue to read [`buffer_selection`]
1086    /// (the legacy [`hjkl_buffer::Selection`] shape).
1087    pub fn selection_highlight(&self) -> Option<crate::types::Highlight> {
1088        use crate::types::{Highlight, HighlightKind, Pos};
1089        let sel = self.buffer_selection()?;
1090        let (start, end) = match sel {
1091            hjkl_buffer::Selection::Char { anchor, head } => {
1092                let a = (anchor.row, anchor.col);
1093                let h = (head.row, head.col);
1094                if a <= h { (a, h) } else { (h, a) }
1095            }
1096            hjkl_buffer::Selection::Line {
1097                anchor_row,
1098                head_row,
1099            } => {
1100                let (top, bot) = if anchor_row <= head_row {
1101                    (anchor_row, head_row)
1102                } else {
1103                    (head_row, anchor_row)
1104                };
1105                let last_col = self.buffer.line(bot).map(|l| l.len()).unwrap_or(0);
1106                ((top, 0), (bot, last_col))
1107            }
1108            hjkl_buffer::Selection::Block { anchor, head } => {
1109                let (top, bot) = if anchor.row <= head.row {
1110                    (anchor.row, head.row)
1111                } else {
1112                    (head.row, anchor.row)
1113                };
1114                let (left, right) = if anchor.col <= head.col {
1115                    (anchor.col, head.col)
1116                } else {
1117                    (head.col, anchor.col)
1118                };
1119                ((top, left), (bot, right))
1120            }
1121        };
1122        Some(Highlight {
1123            range: Pos {
1124                line: start.0 as u32,
1125                col: start.1 as u32,
1126            }..Pos {
1127                line: end.0 as u32,
1128                col: end.1 as u32,
1129            },
1130            kind: HighlightKind::Selection,
1131        })
1132    }
1133
1134    /// SPEC-typed highlights for `line`.
1135    ///
1136    /// Today's emission is search-match-only: when the buffer has an
1137    /// armed search pattern, every regex hit on that line surfaces as
1138    /// a [`crate::types::Highlight`] with kind
1139    /// [`crate::types::HighlightKind::SearchMatch`]. Selection,
1140    /// IncSearch, MatchParen, and Syntax variants land once the trait
1141    /// extraction routes the FSM's selection set + the host's syntax
1142    /// pipeline through the [`crate::types::Host`] trait.
1143    ///
1144    /// Returns an empty vec when the buffer has no search pattern
1145    /// or `line` is out of bounds.
1146    pub fn highlights_for_line(&mut self, line: u32) -> Vec<crate::types::Highlight> {
1147        use crate::types::{Highlight, HighlightKind, Pos};
1148        let row = line as usize;
1149        if row >= self.buffer.lines().len() {
1150            return Vec::new();
1151        }
1152        if self.buffer.search_pattern().is_none() {
1153            return Vec::new();
1154        }
1155        self.buffer
1156            .search_matches(row)
1157            .into_iter()
1158            .map(|(start, end)| Highlight {
1159                range: Pos {
1160                    line,
1161                    col: start as u32,
1162                }..Pos {
1163                    line,
1164                    col: end as u32,
1165                },
1166                kind: HighlightKind::SearchMatch,
1167            })
1168            .collect()
1169    }
1170
1171    /// Build the engine's [`crate::types::RenderFrame`] for the
1172    /// current state. Hosts call this once per redraw and diff
1173    /// across frames.
1174    ///
1175    /// Coarse today — covers mode + cursor + cursor shape + viewport
1176    /// top + line count. SPEC-target fields (selections, highlights,
1177    /// command line, search prompt, status line) land once trait
1178    /// extraction routes them through `SelectionSet` and the
1179    /// `Highlight` pipeline.
1180    pub fn render_frame(&self) -> crate::types::RenderFrame {
1181        use crate::types::{CursorShape, RenderFrame, SnapshotMode};
1182        let (cursor_row, cursor_col) = self.cursor();
1183        let (mode, shape) = match self.vim_mode() {
1184            crate::VimMode::Normal => (SnapshotMode::Normal, CursorShape::Block),
1185            crate::VimMode::Insert => (SnapshotMode::Insert, CursorShape::Bar),
1186            crate::VimMode::Visual => (SnapshotMode::Visual, CursorShape::Block),
1187            crate::VimMode::VisualLine => (SnapshotMode::VisualLine, CursorShape::Block),
1188            crate::VimMode::VisualBlock => (SnapshotMode::VisualBlock, CursorShape::Block),
1189        };
1190        RenderFrame {
1191            mode,
1192            cursor_row: cursor_row as u32,
1193            cursor_col: cursor_col as u32,
1194            cursor_shape: shape,
1195            viewport_top: self.buffer.viewport().top_row as u32,
1196            line_count: self.buffer.lines().len() as u32,
1197        }
1198    }
1199
1200    /// Capture the editor's coarse state into a serde-friendly
1201    /// [`crate::types::EditorSnapshot`].
1202    ///
1203    /// Today's snapshot covers mode, cursor, lines, viewport top.
1204    /// Registers, marks, jump list, undo tree, and full options arrive
1205    /// once phase 5 trait extraction lands the generic
1206    /// `Editor<B: Buffer, H: Host>` constructor — this method's surface
1207    /// stays stable; only the snapshot's internal fields grow.
1208    ///
1209    /// Distinct from the internal `snapshot` used by undo (which
1210    /// returns `(Vec<String>, (usize, usize))`); host-facing
1211    /// persistence goes through this one.
1212    pub fn take_snapshot(&self) -> crate::types::EditorSnapshot {
1213        use crate::types::{EditorSnapshot, SnapshotMode};
1214        let mode = match self.vim_mode() {
1215            crate::VimMode::Normal => SnapshotMode::Normal,
1216            crate::VimMode::Insert => SnapshotMode::Insert,
1217            crate::VimMode::Visual => SnapshotMode::Visual,
1218            crate::VimMode::VisualLine => SnapshotMode::VisualLine,
1219            crate::VimMode::VisualBlock => SnapshotMode::VisualBlock,
1220        };
1221        let cursor = self.cursor();
1222        let cursor = (cursor.0 as u32, cursor.1 as u32);
1223        let lines: Vec<String> = self.buffer.lines().to_vec();
1224        let viewport_top = self.buffer.viewport().top_row as u32;
1225        let file_marks = self
1226            .file_marks
1227            .iter()
1228            .map(|(c, (r, col))| (*c, (*r as u32, *col as u32)))
1229            .collect();
1230        EditorSnapshot {
1231            version: EditorSnapshot::VERSION,
1232            mode,
1233            cursor,
1234            lines,
1235            viewport_top,
1236            registers: self.registers.clone(),
1237            file_marks,
1238        }
1239    }
1240
1241    /// Restore editor state from an [`EditorSnapshot`]. Returns
1242    /// [`crate::EngineError::SnapshotVersion`] if the snapshot's
1243    /// `version` doesn't match [`EditorSnapshot::VERSION`].
1244    ///
1245    /// Mode is best-effort: `SnapshotMode` only round-trips the
1246    /// status-line summary, not the full FSM state. Visual / Insert
1247    /// mode entry happens through synthetic key dispatch when needed.
1248    pub fn restore_snapshot(
1249        &mut self,
1250        snap: crate::types::EditorSnapshot,
1251    ) -> Result<(), crate::EngineError> {
1252        use crate::types::EditorSnapshot;
1253        if snap.version != EditorSnapshot::VERSION {
1254            return Err(crate::EngineError::SnapshotVersion(
1255                snap.version,
1256                EditorSnapshot::VERSION,
1257            ));
1258        }
1259        let text = snap.lines.join("\n");
1260        self.set_content(&text);
1261        self.jump_cursor(snap.cursor.0 as usize, snap.cursor.1 as usize);
1262        let mut vp = self.buffer.viewport();
1263        vp.top_row = snap.viewport_top as usize;
1264        *self.buffer.viewport_mut() = vp;
1265        self.registers = snap.registers;
1266        self.file_marks = snap
1267            .file_marks
1268            .into_iter()
1269            .map(|(c, (r, col))| (c, (r as usize, col as usize)))
1270            .collect();
1271        Ok(())
1272    }
1273
1274    /// Install `text` as the pending yank buffer so the next `p`/`P` pastes
1275    /// it. Linewise is inferred from a trailing newline, matching how `yy`/`dd`
1276    /// shape their payload.
1277    pub fn seed_yank(&mut self, text: String) {
1278        let linewise = text.ends_with('\n');
1279        self.vim.yank_linewise = linewise;
1280        self.registers.unnamed = crate::registers::Slot { text, linewise };
1281    }
1282
1283    /// Scroll the viewport down by `rows`. The cursor stays on its
1284    /// absolute line (vim convention) unless the scroll would take it
1285    /// off-screen — in that case it's clamped to the first row still
1286    /// visible.
1287    pub fn scroll_down(&mut self, rows: i16) {
1288        self.scroll_viewport(rows);
1289    }
1290
1291    /// Scroll the viewport up by `rows`. Cursor stays unless it would
1292    /// fall off the bottom of the new viewport, then clamp to the
1293    /// bottom-most visible row.
1294    pub fn scroll_up(&mut self, rows: i16) {
1295        self.scroll_viewport(-rows);
1296    }
1297
1298    /// Vim's `scrolloff` default — keep the cursor at least this many
1299    /// rows away from the top / bottom edge of the viewport while
1300    /// scrolling. Collapses to `height / 2` for tiny viewports.
1301    const SCROLLOFF: usize = 5;
1302
1303    /// Scroll the viewport so the cursor stays at least `SCROLLOFF`
1304    /// rows from each edge. Replaces the bare
1305    /// `Buffer::ensure_cursor_visible` call at end-of-step so motions
1306    /// don't park the cursor on the very last visible row.
1307    pub(crate) fn ensure_cursor_in_scrolloff(&mut self) {
1308        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1309        if height == 0 {
1310            self.buffer.ensure_cursor_visible();
1311            return;
1312        }
1313        // Cap margin at (height - 1) / 2 so the upper + lower bands
1314        // can't overlap on tiny windows (margin=5 + height=10 would
1315        // otherwise produce contradictory clamp ranges).
1316        let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
1317        // Soft-wrap path: scrolloff math runs in *screen rows*, not
1318        // doc rows, since a wrapped doc row spans many visual lines.
1319        if !matches!(self.buffer.viewport().wrap, hjkl_buffer::Wrap::None) {
1320            self.ensure_scrolloff_wrap(height, margin);
1321            return;
1322        }
1323        let cursor_row = self.buffer.cursor().row;
1324        let last_row = self.buffer.row_count().saturating_sub(1);
1325        let v = self.buffer.viewport_mut();
1326        // Top edge: cursor_row should sit at >= top_row + margin.
1327        if cursor_row < v.top_row + margin {
1328            v.top_row = cursor_row.saturating_sub(margin);
1329        }
1330        // Bottom edge: cursor_row should sit at <= top_row + height - 1 - margin.
1331        let max_bottom = height.saturating_sub(1).saturating_sub(margin);
1332        if cursor_row > v.top_row + max_bottom {
1333            v.top_row = cursor_row.saturating_sub(max_bottom);
1334        }
1335        // Clamp top_row so we never scroll past the buffer's bottom.
1336        let max_top = last_row.saturating_sub(height.saturating_sub(1));
1337        if v.top_row > max_top {
1338            v.top_row = max_top;
1339        }
1340        // Defer to Buffer for column-side scroll (no scrolloff for
1341        // horizontal scrolling — vim default `sidescrolloff = 0`).
1342        let cursor = self.buffer.cursor();
1343        self.buffer.viewport_mut().ensure_visible(cursor);
1344    }
1345
1346    /// Soft-wrap-aware scrolloff. Walks `top_row` one visible doc row
1347    /// at a time so the cursor's *screen* row stays inside
1348    /// `[margin, height - 1 - margin]`, then clamps `top_row` so the
1349    /// buffer's bottom never leaves blank rows below it.
1350    fn ensure_scrolloff_wrap(&mut self, height: usize, margin: usize) {
1351        let cursor_row = self.buffer.cursor().row;
1352        // Step 1 — cursor above viewport: snap top to cursor row,
1353        // then we'll fix up the margin below.
1354        if cursor_row < self.buffer.viewport().top_row {
1355            self.buffer.viewport_mut().top_row = cursor_row;
1356            self.buffer.viewport_mut().top_col = 0;
1357        }
1358        // Step 2 — push top forward until cursor's screen row is
1359        // within the bottom margin (`csr <= height - 1 - margin`).
1360        let max_csr = height.saturating_sub(1).saturating_sub(margin);
1361        loop {
1362            let csr = self.buffer.cursor_screen_row().unwrap_or(0);
1363            if csr <= max_csr {
1364                break;
1365            }
1366            let top = self.buffer.viewport().top_row;
1367            let Some(next) = self.buffer.next_visible_row(top) else {
1368                break;
1369            };
1370            // Don't walk past the cursor's row.
1371            if next > cursor_row {
1372                self.buffer.viewport_mut().top_row = cursor_row;
1373                break;
1374            }
1375            self.buffer.viewport_mut().top_row = next;
1376        }
1377        // Step 3 — pull top backward until cursor's screen row is
1378        // past the top margin (`csr >= margin`).
1379        loop {
1380            let csr = self.buffer.cursor_screen_row().unwrap_or(0);
1381            if csr >= margin {
1382                break;
1383            }
1384            let top = self.buffer.viewport().top_row;
1385            let Some(prev) = self.buffer.prev_visible_row(top) else {
1386                break;
1387            };
1388            self.buffer.viewport_mut().top_row = prev;
1389        }
1390        // Step 4 — clamp top so the buffer's bottom doesn't leave
1391        // blank rows below it. `max_top_for_height` walks segments
1392        // backward from the last row until it accumulates `height`
1393        // screen rows.
1394        let max_top = self.buffer.max_top_for_height(height);
1395        if self.buffer.viewport().top_row > max_top {
1396            self.buffer.viewport_mut().top_row = max_top;
1397        }
1398        self.buffer.viewport_mut().top_col = 0;
1399    }
1400
1401    fn scroll_viewport(&mut self, delta: i16) {
1402        if delta == 0 {
1403            return;
1404        }
1405        // Bump the buffer's viewport top within bounds.
1406        let total_rows = self.buffer.row_count() as isize;
1407        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1408        let cur_top = self.buffer.viewport().top_row as isize;
1409        let new_top = (cur_top + delta as isize)
1410            .max(0)
1411            .min((total_rows - 1).max(0)) as usize;
1412        self.buffer.viewport_mut().top_row = new_top;
1413        // Mirror to textarea so its viewport reads (still consumed by
1414        // a couple of helpers) stay accurate.
1415        let _ = cur_top;
1416        if height == 0 {
1417            return;
1418        }
1419        // Apply scrolloff: keep the cursor at least SCROLLOFF rows
1420        // from the visible viewport edges.
1421        let cursor = self.buffer.cursor();
1422        let margin = Self::SCROLLOFF.min(height / 2);
1423        let min_row = new_top + margin;
1424        let max_row = new_top + height.saturating_sub(1).saturating_sub(margin);
1425        let target_row = cursor.row.clamp(min_row, max_row.max(min_row));
1426        if target_row != cursor.row {
1427            let line_len = self
1428                .buffer
1429                .line(target_row)
1430                .map(|l| l.chars().count())
1431                .unwrap_or(0);
1432            let target_col = cursor.col.min(line_len.saturating_sub(1));
1433            self.buffer
1434                .set_cursor(hjkl_buffer::Position::new(target_row, target_col));
1435        }
1436    }
1437
1438    pub fn goto_line(&mut self, line: usize) {
1439        let row = line.saturating_sub(1);
1440        let max = self.buffer.row_count().saturating_sub(1);
1441        let target = row.min(max);
1442        self.buffer
1443            .set_cursor(hjkl_buffer::Position::new(target, 0));
1444    }
1445
1446    /// Scroll so the cursor row lands at the given viewport position:
1447    /// `Center` → middle row, `Top` → first row, `Bottom` → last row.
1448    /// Cursor stays on its absolute line; only the viewport moves.
1449    pub(super) fn scroll_cursor_to(&mut self, pos: CursorScrollTarget) {
1450        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1451        if height == 0 {
1452            return;
1453        }
1454        let cur_row = self.buffer.cursor().row;
1455        let cur_top = self.buffer.viewport().top_row;
1456        // Scrolloff awareness: `zt` lands the cursor at the top edge
1457        // of the viable area (top + margin), `zb` at the bottom edge
1458        // (top + height - 1 - margin). Match the cap used by
1459        // `ensure_cursor_in_scrolloff` so contradictory bounds are
1460        // impossible on tiny viewports.
1461        let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
1462        let new_top = match pos {
1463            CursorScrollTarget::Center => cur_row.saturating_sub(height / 2),
1464            CursorScrollTarget::Top => cur_row.saturating_sub(margin),
1465            CursorScrollTarget::Bottom => {
1466                cur_row.saturating_sub(height.saturating_sub(1).saturating_sub(margin))
1467            }
1468        };
1469        if new_top == cur_top {
1470            return;
1471        }
1472        self.buffer.viewport_mut().top_row = new_top;
1473    }
1474
1475    /// Translate a terminal mouse position into a (row, col) inside the document.
1476    /// `area` is the outer editor rect: 1-row tab bar at top (flush), then the
1477    /// textarea with 1 cell of horizontal pane padding on each side. Clicks
1478    /// past the line's last character clamp to the last char (Normal-mode
1479    /// invariant) — never past it. Char-counted, not byte-counted, so
1480    /// multibyte runs land where the user expects.
1481    fn mouse_to_doc_pos(&self, area: Rect, col: u16, row: u16) -> (usize, usize) {
1482        let lines = self.buffer.lines();
1483        let inner_top = area.y.saturating_add(1); // tab bar row
1484        let lnum_width = lines.len().to_string().len() as u16 + 2;
1485        let content_x = area.x.saturating_add(1).saturating_add(lnum_width);
1486        let rel_row = row.saturating_sub(inner_top) as usize;
1487        let top = self.buffer.viewport().top_row;
1488        let doc_row = (top + rel_row).min(lines.len().saturating_sub(1));
1489        let rel_col = col.saturating_sub(content_x) as usize;
1490        let line_chars = lines.get(doc_row).map(|l| l.chars().count()).unwrap_or(0);
1491        let last_col = line_chars.saturating_sub(1);
1492        (doc_row, rel_col.min(last_col))
1493    }
1494
1495    /// Jump the cursor to the given 1-based line/column, clamped to the document.
1496    pub fn jump_to(&mut self, line: usize, col: usize) {
1497        let r = line.saturating_sub(1);
1498        let max_row = self.buffer.row_count().saturating_sub(1);
1499        let r = r.min(max_row);
1500        let line_len = self.buffer.line(r).map(|l| l.chars().count()).unwrap_or(0);
1501        let c = col.saturating_sub(1).min(line_len);
1502        self.buffer.set_cursor(hjkl_buffer::Position::new(r, c));
1503    }
1504
1505    /// Jump cursor to the terminal-space mouse position; exits Visual modes if active.
1506    pub fn mouse_click(&mut self, area: Rect, col: u16, row: u16) {
1507        if self.vim.is_visual() {
1508            self.vim.force_normal();
1509        }
1510        let (r, c) = self.mouse_to_doc_pos(area, col, row);
1511        self.buffer.set_cursor(hjkl_buffer::Position::new(r, c));
1512    }
1513
1514    /// Begin a mouse-drag selection: anchor at current cursor and enter Visual mode.
1515    pub fn mouse_begin_drag(&mut self) {
1516        if !self.vim.is_visual_char() {
1517            let cursor = self.cursor();
1518            self.vim.enter_visual(cursor);
1519        }
1520    }
1521
1522    /// Extend an in-progress mouse drag to the given terminal-space position.
1523    pub fn mouse_extend_drag(&mut self, area: Rect, col: u16, row: u16) {
1524        let (r, c) = self.mouse_to_doc_pos(area, col, row);
1525        self.buffer.set_cursor(hjkl_buffer::Position::new(r, c));
1526    }
1527
1528    pub fn insert_str(&mut self, text: &str) {
1529        let pos = self.buffer.cursor();
1530        self.buffer.apply_edit(hjkl_buffer::Edit::InsertStr {
1531            at: pos,
1532            text: text.to_string(),
1533        });
1534        self.push_buffer_content_to_textarea();
1535        self.mark_content_dirty();
1536    }
1537
1538    pub fn accept_completion(&mut self, completion: &str) {
1539        use hjkl_buffer::{Edit, MotionKind, Position};
1540        let cursor = self.buffer.cursor();
1541        let line = self.buffer.line(cursor.row).unwrap_or("").to_string();
1542        let chars: Vec<char> = line.chars().collect();
1543        let prefix_len = chars[..cursor.col.min(chars.len())]
1544            .iter()
1545            .rev()
1546            .take_while(|c| c.is_alphanumeric() || **c == '_')
1547            .count();
1548        if prefix_len > 0 {
1549            let start = Position::new(cursor.row, cursor.col - prefix_len);
1550            self.buffer.apply_edit(Edit::DeleteRange {
1551                start,
1552                end: cursor,
1553                kind: MotionKind::Char,
1554            });
1555        }
1556        let cursor = self.buffer.cursor();
1557        self.buffer.apply_edit(Edit::InsertStr {
1558            at: cursor,
1559            text: completion.to_string(),
1560        });
1561        self.push_buffer_content_to_textarea();
1562        self.mark_content_dirty();
1563    }
1564
1565    pub(super) fn snapshot(&self) -> (Vec<String>, (usize, usize)) {
1566        let pos = self.buffer.cursor();
1567        (self.buffer.lines().to_vec(), (pos.row, pos.col))
1568    }
1569
1570    /// Walk one step back through the undo history. Equivalent to the
1571    /// user pressing `u` in normal mode. Drains the most recent undo
1572    /// entry and pushes it onto the redo stack.
1573    pub fn undo(&mut self) {
1574        crate::vim::do_undo(self);
1575    }
1576
1577    /// Walk one step forward through the redo history. Equivalent to
1578    /// `<C-r>` in normal mode.
1579    pub fn redo(&mut self) {
1580        crate::vim::do_redo(self);
1581    }
1582
1583    /// Snapshot current buffer state onto the undo stack and clear
1584    /// the redo stack. Bounded at 200 entries — older entries pruned.
1585    /// Call before any group of buffer mutations the user might want
1586    /// to undo as a single step.
1587    pub fn push_undo(&mut self) {
1588        let snap = self.snapshot();
1589        if self.undo_stack.len() >= 200 {
1590            self.undo_stack.remove(0);
1591        }
1592        self.undo_stack.push(snap);
1593        self.redo_stack.clear();
1594    }
1595
1596    /// Replace the buffer with `lines` joined by `\n` and set the
1597    /// cursor to `cursor`. Used by undo / `:e!` / snapshot restore
1598    /// paths. Marks the editor dirty.
1599    pub fn restore(&mut self, lines: Vec<String>, cursor: (usize, usize)) {
1600        let text = lines.join("\n");
1601        self.buffer.replace_all(&text);
1602        self.buffer
1603            .set_cursor(hjkl_buffer::Position::new(cursor.0, cursor.1));
1604        self.mark_content_dirty();
1605    }
1606
1607    /// Returns true if the key was consumed by the editor.
1608    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
1609        let input = crossterm_to_input(key);
1610        if input.key == Key::Null {
1611            return false;
1612        }
1613        vim::step(self, input)
1614    }
1615}
1616
1617pub(super) fn crossterm_to_input(key: KeyEvent) -> Input {
1618    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1619    let alt = key.modifiers.contains(KeyModifiers::ALT);
1620    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1621    let k = match key.code {
1622        KeyCode::Char(c) => Key::Char(c),
1623        KeyCode::Backspace => Key::Backspace,
1624        KeyCode::Delete => Key::Delete,
1625        KeyCode::Enter => Key::Enter,
1626        KeyCode::Left => Key::Left,
1627        KeyCode::Right => Key::Right,
1628        KeyCode::Up => Key::Up,
1629        KeyCode::Down => Key::Down,
1630        KeyCode::Home => Key::Home,
1631        KeyCode::End => Key::End,
1632        KeyCode::Tab => Key::Tab,
1633        KeyCode::Esc => Key::Esc,
1634        _ => Key::Null,
1635    };
1636    Input {
1637        key: k,
1638        ctrl,
1639        alt,
1640        shift,
1641    }
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646    use super::*;
1647    use crossterm::event::KeyEvent;
1648
1649    fn key(code: KeyCode) -> KeyEvent {
1650        KeyEvent::new(code, KeyModifiers::NONE)
1651    }
1652    fn shift_key(code: KeyCode) -> KeyEvent {
1653        KeyEvent::new(code, KeyModifiers::SHIFT)
1654    }
1655    fn ctrl_key(code: KeyCode) -> KeyEvent {
1656        KeyEvent::new(code, KeyModifiers::CONTROL)
1657    }
1658
1659    #[test]
1660    fn vim_normal_to_insert() {
1661        let mut e = Editor::new(KeybindingMode::Vim);
1662        e.handle_key(key(KeyCode::Char('i')));
1663        assert_eq!(e.vim_mode(), VimMode::Insert);
1664    }
1665
1666    #[test]
1667    fn feed_input_char_routes_through_handle_key() {
1668        use crate::{Modifiers, PlannedInput};
1669        let mut e = Editor::new(KeybindingMode::Vim);
1670        e.set_content("abc");
1671        // `i` enters insert mode via SPEC input.
1672        e.feed_input(PlannedInput::Char('i', Modifiers::default()));
1673        assert_eq!(e.vim_mode(), VimMode::Insert);
1674        // Type 'X' via SPEC input.
1675        e.feed_input(PlannedInput::Char('X', Modifiers::default()));
1676        assert!(e.content().contains('X'));
1677    }
1678
1679    #[test]
1680    fn feed_input_special_key_routes() {
1681        use crate::{Modifiers, PlannedInput, SpecialKey};
1682        let mut e = Editor::new(KeybindingMode::Vim);
1683        e.set_content("abc");
1684        e.feed_input(PlannedInput::Char('i', Modifiers::default()));
1685        assert_eq!(e.vim_mode(), VimMode::Insert);
1686        e.feed_input(PlannedInput::Key(SpecialKey::Esc, Modifiers::default()));
1687        assert_eq!(e.vim_mode(), VimMode::Normal);
1688    }
1689
1690    #[test]
1691    fn feed_input_mouse_paste_focus_resize_no_op() {
1692        use crate::{MouseEvent, MouseKind, PlannedInput, Pos};
1693        let mut e = Editor::new(KeybindingMode::Vim);
1694        e.set_content("abc");
1695        let mode_before = e.vim_mode();
1696        let consumed = e.feed_input(PlannedInput::Mouse(MouseEvent {
1697            kind: MouseKind::Press,
1698            pos: Pos::new(0, 0),
1699            mods: Default::default(),
1700        }));
1701        assert!(!consumed);
1702        assert_eq!(e.vim_mode(), mode_before);
1703        assert!(!e.feed_input(PlannedInput::Paste("xx".into())));
1704        assert!(!e.feed_input(PlannedInput::FocusGained));
1705        assert!(!e.feed_input(PlannedInput::FocusLost));
1706        assert!(!e.feed_input(PlannedInput::Resize(80, 24)));
1707    }
1708
1709    #[test]
1710    fn intern_engine_style_dedups_with_intern_style() {
1711        use crate::types::{Attrs, Color, Style};
1712        let mut e = Editor::new(KeybindingMode::Vim);
1713        let s = Style {
1714            fg: Some(Color(255, 0, 0)),
1715            bg: None,
1716            attrs: Attrs::BOLD,
1717        };
1718        let id_a = e.intern_engine_style(s);
1719        // Re-interning the same engine style returns the same id.
1720        let id_b = e.intern_engine_style(s);
1721        assert_eq!(id_a, id_b);
1722        // Engine accessor returns the same style back.
1723        let back = e.engine_style_at(id_a).expect("interned");
1724        assert_eq!(back, s);
1725    }
1726
1727    #[test]
1728    fn engine_style_at_out_of_range_returns_none() {
1729        let e = Editor::new(KeybindingMode::Vim);
1730        assert!(e.engine_style_at(99).is_none());
1731    }
1732
1733    #[test]
1734    fn take_changes_drains_after_insert() {
1735        let mut e = Editor::new(KeybindingMode::Vim);
1736        e.set_content("abc");
1737        // Empty initially.
1738        assert!(e.take_changes().is_empty());
1739        // Type a char in insert mode.
1740        e.handle_key(key(KeyCode::Char('i')));
1741        e.handle_key(key(KeyCode::Char('X')));
1742        let changes = e.take_changes();
1743        assert!(
1744            !changes.is_empty(),
1745            "insert mode keystroke should produce a change"
1746        );
1747        // Drained — second call empty.
1748        assert!(e.take_changes().is_empty());
1749    }
1750
1751    #[test]
1752    fn options_bridge_roundtrip() {
1753        let mut e = Editor::new(KeybindingMode::Vim);
1754        let opts = e.current_options();
1755        assert_eq!(opts.shiftwidth, 2); // legacy Settings default
1756        assert_eq!(opts.tabstop, 8);
1757
1758        let mut new_opts = crate::types::Options::default();
1759        new_opts.shiftwidth = 4;
1760        new_opts.tabstop = 2;
1761        new_opts.ignorecase = true;
1762        e.apply_options(&new_opts);
1763
1764        let after = e.current_options();
1765        assert_eq!(after.shiftwidth, 4);
1766        assert_eq!(after.tabstop, 2);
1767        assert!(after.ignorecase);
1768    }
1769
1770    #[test]
1771    fn selection_highlight_none_in_normal() {
1772        let mut e = Editor::new(KeybindingMode::Vim);
1773        e.set_content("hello");
1774        assert!(e.selection_highlight().is_none());
1775    }
1776
1777    #[test]
1778    fn selection_highlight_some_in_visual() {
1779        use crate::types::HighlightKind;
1780        let mut e = Editor::new(KeybindingMode::Vim);
1781        e.set_content("hello world");
1782        e.handle_key(key(KeyCode::Char('v')));
1783        e.handle_key(key(KeyCode::Char('l')));
1784        e.handle_key(key(KeyCode::Char('l')));
1785        let h = e
1786            .selection_highlight()
1787            .expect("visual mode should produce a highlight");
1788        assert_eq!(h.kind, HighlightKind::Selection);
1789        assert_eq!(h.range.start.line, 0);
1790        assert_eq!(h.range.end.line, 0);
1791    }
1792
1793    #[test]
1794    fn highlights_emit_search_matches() {
1795        use crate::types::HighlightKind;
1796        let mut e = Editor::new(KeybindingMode::Vim);
1797        e.set_content("foo bar foo\nbaz qux\n");
1798        // Arm a search via buffer's pattern setter.
1799        e.buffer_mut()
1800            .set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
1801        let hs = e.highlights_for_line(0);
1802        assert_eq!(hs.len(), 2);
1803        for h in &hs {
1804            assert_eq!(h.kind, HighlightKind::SearchMatch);
1805            assert_eq!(h.range.start.line, 0);
1806            assert_eq!(h.range.end.line, 0);
1807        }
1808    }
1809
1810    #[test]
1811    fn highlights_empty_without_pattern() {
1812        let mut e = Editor::new(KeybindingMode::Vim);
1813        e.set_content("foo bar");
1814        assert!(e.highlights_for_line(0).is_empty());
1815    }
1816
1817    #[test]
1818    fn highlights_empty_for_out_of_range_line() {
1819        let mut e = Editor::new(KeybindingMode::Vim);
1820        e.set_content("foo");
1821        e.buffer_mut()
1822            .set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
1823        assert!(e.highlights_for_line(99).is_empty());
1824    }
1825
1826    #[test]
1827    fn render_frame_reflects_mode_and_cursor() {
1828        use crate::types::{CursorShape, SnapshotMode};
1829        let mut e = Editor::new(KeybindingMode::Vim);
1830        e.set_content("alpha\nbeta");
1831        let f = e.render_frame();
1832        assert_eq!(f.mode, SnapshotMode::Normal);
1833        assert_eq!(f.cursor_shape, CursorShape::Block);
1834        assert_eq!(f.line_count, 2);
1835
1836        e.handle_key(key(KeyCode::Char('i')));
1837        let f = e.render_frame();
1838        assert_eq!(f.mode, SnapshotMode::Insert);
1839        assert_eq!(f.cursor_shape, CursorShape::Bar);
1840    }
1841
1842    #[test]
1843    fn snapshot_roundtrips_through_restore() {
1844        use crate::types::SnapshotMode;
1845        let mut e = Editor::new(KeybindingMode::Vim);
1846        e.set_content("alpha\nbeta\ngamma");
1847        e.jump_cursor(2, 3);
1848        let snap = e.take_snapshot();
1849        assert_eq!(snap.mode, SnapshotMode::Normal);
1850        assert_eq!(snap.cursor, (2, 3));
1851        assert_eq!(snap.lines.len(), 3);
1852
1853        let mut other = Editor::new(KeybindingMode::Vim);
1854        other.restore_snapshot(snap).expect("restore");
1855        assert_eq!(other.cursor(), (2, 3));
1856        assert_eq!(other.buffer().lines().len(), 3);
1857    }
1858
1859    #[test]
1860    fn restore_snapshot_rejects_version_mismatch() {
1861        let mut e = Editor::new(KeybindingMode::Vim);
1862        let mut snap = e.take_snapshot();
1863        snap.version = 9999;
1864        match e.restore_snapshot(snap) {
1865            Err(crate::EngineError::SnapshotVersion(got, want)) => {
1866                assert_eq!(got, 9999);
1867                assert_eq!(want, crate::types::EditorSnapshot::VERSION);
1868            }
1869            other => panic!("expected SnapshotVersion err, got {other:?}"),
1870        }
1871    }
1872
1873    #[test]
1874    fn take_content_change_returns_some_on_first_dirty() {
1875        let mut e = Editor::new(KeybindingMode::Vim);
1876        e.set_content("hello");
1877        let first = e.take_content_change();
1878        assert!(first.is_some());
1879        let second = e.take_content_change();
1880        assert!(second.is_none());
1881    }
1882
1883    #[test]
1884    fn take_content_change_none_until_mutation() {
1885        let mut e = Editor::new(KeybindingMode::Vim);
1886        e.set_content("hello");
1887        // drain
1888        e.take_content_change();
1889        assert!(e.take_content_change().is_none());
1890        // mutate via insert mode
1891        e.handle_key(key(KeyCode::Char('i')));
1892        e.handle_key(key(KeyCode::Char('x')));
1893        let after = e.take_content_change();
1894        assert!(after.is_some());
1895        assert!(after.unwrap().contains('x'));
1896    }
1897
1898    #[test]
1899    fn vim_insert_to_normal() {
1900        let mut e = Editor::new(KeybindingMode::Vim);
1901        e.handle_key(key(KeyCode::Char('i')));
1902        e.handle_key(key(KeyCode::Esc));
1903        assert_eq!(e.vim_mode(), VimMode::Normal);
1904    }
1905
1906    #[test]
1907    fn vim_normal_to_visual() {
1908        let mut e = Editor::new(KeybindingMode::Vim);
1909        e.handle_key(key(KeyCode::Char('v')));
1910        assert_eq!(e.vim_mode(), VimMode::Visual);
1911    }
1912
1913    #[test]
1914    fn vim_visual_to_normal() {
1915        let mut e = Editor::new(KeybindingMode::Vim);
1916        e.handle_key(key(KeyCode::Char('v')));
1917        e.handle_key(key(KeyCode::Esc));
1918        assert_eq!(e.vim_mode(), VimMode::Normal);
1919    }
1920
1921    #[test]
1922    fn vim_shift_i_moves_to_first_non_whitespace() {
1923        let mut e = Editor::new(KeybindingMode::Vim);
1924        e.set_content("   hello");
1925        e.jump_cursor(0, 8);
1926        e.handle_key(shift_key(KeyCode::Char('I')));
1927        assert_eq!(e.vim_mode(), VimMode::Insert);
1928        assert_eq!(e.cursor(), (0, 3));
1929    }
1930
1931    #[test]
1932    fn vim_shift_a_moves_to_end_and_insert() {
1933        let mut e = Editor::new(KeybindingMode::Vim);
1934        e.set_content("hello");
1935        e.handle_key(shift_key(KeyCode::Char('A')));
1936        assert_eq!(e.vim_mode(), VimMode::Insert);
1937        assert_eq!(e.cursor().1, 5);
1938    }
1939
1940    #[test]
1941    fn count_10j_moves_down_10() {
1942        let mut e = Editor::new(KeybindingMode::Vim);
1943        e.set_content(
1944            (0..20)
1945                .map(|i| format!("line{i}"))
1946                .collect::<Vec<_>>()
1947                .join("\n")
1948                .as_str(),
1949        );
1950        for d in "10".chars() {
1951            e.handle_key(key(KeyCode::Char(d)));
1952        }
1953        e.handle_key(key(KeyCode::Char('j')));
1954        assert_eq!(e.cursor().0, 10);
1955    }
1956
1957    #[test]
1958    fn count_o_repeats_insert_on_esc() {
1959        let mut e = Editor::new(KeybindingMode::Vim);
1960        e.set_content("hello");
1961        for d in "3".chars() {
1962            e.handle_key(key(KeyCode::Char(d)));
1963        }
1964        e.handle_key(key(KeyCode::Char('o')));
1965        assert_eq!(e.vim_mode(), VimMode::Insert);
1966        for c in "world".chars() {
1967            e.handle_key(key(KeyCode::Char(c)));
1968        }
1969        e.handle_key(key(KeyCode::Esc));
1970        assert_eq!(e.vim_mode(), VimMode::Normal);
1971        assert_eq!(e.buffer().lines().len(), 4);
1972        assert!(e.buffer().lines().iter().skip(1).all(|l| l == "world"));
1973    }
1974
1975    #[test]
1976    fn count_i_repeats_text_on_esc() {
1977        let mut e = Editor::new(KeybindingMode::Vim);
1978        e.set_content("");
1979        for d in "3".chars() {
1980            e.handle_key(key(KeyCode::Char(d)));
1981        }
1982        e.handle_key(key(KeyCode::Char('i')));
1983        for c in "ab".chars() {
1984            e.handle_key(key(KeyCode::Char(c)));
1985        }
1986        e.handle_key(key(KeyCode::Esc));
1987        assert_eq!(e.vim_mode(), VimMode::Normal);
1988        assert_eq!(e.buffer().lines()[0], "ababab");
1989    }
1990
1991    #[test]
1992    fn vim_shift_o_opens_line_above() {
1993        let mut e = Editor::new(KeybindingMode::Vim);
1994        e.set_content("hello");
1995        e.handle_key(shift_key(KeyCode::Char('O')));
1996        assert_eq!(e.vim_mode(), VimMode::Insert);
1997        assert_eq!(e.cursor(), (0, 0));
1998        assert_eq!(e.buffer().lines().len(), 2);
1999    }
2000
2001    #[test]
2002    fn vim_gg_goes_to_top() {
2003        let mut e = Editor::new(KeybindingMode::Vim);
2004        e.set_content("a\nb\nc");
2005        e.jump_cursor(2, 0);
2006        e.handle_key(key(KeyCode::Char('g')));
2007        e.handle_key(key(KeyCode::Char('g')));
2008        assert_eq!(e.cursor().0, 0);
2009    }
2010
2011    #[test]
2012    fn vim_shift_g_goes_to_bottom() {
2013        let mut e = Editor::new(KeybindingMode::Vim);
2014        e.set_content("a\nb\nc");
2015        e.handle_key(shift_key(KeyCode::Char('G')));
2016        assert_eq!(e.cursor().0, 2);
2017    }
2018
2019    #[test]
2020    fn vim_dd_deletes_line() {
2021        let mut e = Editor::new(KeybindingMode::Vim);
2022        e.set_content("first\nsecond");
2023        e.handle_key(key(KeyCode::Char('d')));
2024        e.handle_key(key(KeyCode::Char('d')));
2025        assert_eq!(e.buffer().lines().len(), 1);
2026        assert_eq!(e.buffer().lines()[0], "second");
2027    }
2028
2029    #[test]
2030    fn vim_dw_deletes_word() {
2031        let mut e = Editor::new(KeybindingMode::Vim);
2032        e.set_content("hello world");
2033        e.handle_key(key(KeyCode::Char('d')));
2034        e.handle_key(key(KeyCode::Char('w')));
2035        assert_eq!(e.vim_mode(), VimMode::Normal);
2036        assert!(!e.buffer().lines()[0].starts_with("hello"));
2037    }
2038
2039    #[test]
2040    fn vim_yy_yanks_line() {
2041        let mut e = Editor::new(KeybindingMode::Vim);
2042        e.set_content("hello\nworld");
2043        e.handle_key(key(KeyCode::Char('y')));
2044        e.handle_key(key(KeyCode::Char('y')));
2045        assert!(e.last_yank.as_deref().unwrap_or("").starts_with("hello"));
2046    }
2047
2048    #[test]
2049    fn vim_yy_does_not_move_cursor() {
2050        let mut e = Editor::new(KeybindingMode::Vim);
2051        e.set_content("first\nsecond\nthird");
2052        e.jump_cursor(1, 0);
2053        let before = e.cursor();
2054        e.handle_key(key(KeyCode::Char('y')));
2055        e.handle_key(key(KeyCode::Char('y')));
2056        assert_eq!(e.cursor(), before);
2057        assert_eq!(e.vim_mode(), VimMode::Normal);
2058    }
2059
2060    #[test]
2061    fn vim_yw_yanks_word() {
2062        let mut e = Editor::new(KeybindingMode::Vim);
2063        e.set_content("hello world");
2064        e.handle_key(key(KeyCode::Char('y')));
2065        e.handle_key(key(KeyCode::Char('w')));
2066        assert_eq!(e.vim_mode(), VimMode::Normal);
2067        assert!(e.last_yank.is_some());
2068    }
2069
2070    #[test]
2071    fn vim_cc_changes_line() {
2072        let mut e = Editor::new(KeybindingMode::Vim);
2073        e.set_content("hello\nworld");
2074        e.handle_key(key(KeyCode::Char('c')));
2075        e.handle_key(key(KeyCode::Char('c')));
2076        assert_eq!(e.vim_mode(), VimMode::Insert);
2077    }
2078
2079    #[test]
2080    fn vim_u_undoes_insert_session_as_chunk() {
2081        let mut e = Editor::new(KeybindingMode::Vim);
2082        e.set_content("hello");
2083        e.handle_key(key(KeyCode::Char('i')));
2084        e.handle_key(key(KeyCode::Enter));
2085        e.handle_key(key(KeyCode::Enter));
2086        e.handle_key(key(KeyCode::Esc));
2087        assert_eq!(e.buffer().lines().len(), 3);
2088        e.handle_key(key(KeyCode::Char('u')));
2089        assert_eq!(e.buffer().lines().len(), 1);
2090        assert_eq!(e.buffer().lines()[0], "hello");
2091    }
2092
2093    #[test]
2094    fn vim_undo_redo_roundtrip() {
2095        let mut e = Editor::new(KeybindingMode::Vim);
2096        e.set_content("hello");
2097        e.handle_key(key(KeyCode::Char('i')));
2098        for c in "world".chars() {
2099            e.handle_key(key(KeyCode::Char(c)));
2100        }
2101        e.handle_key(key(KeyCode::Esc));
2102        let after = e.buffer().lines()[0].clone();
2103        e.handle_key(key(KeyCode::Char('u')));
2104        assert_eq!(e.buffer().lines()[0], "hello");
2105        e.handle_key(ctrl_key(KeyCode::Char('r')));
2106        assert_eq!(e.buffer().lines()[0], after);
2107    }
2108
2109    #[test]
2110    fn vim_u_undoes_dd() {
2111        let mut e = Editor::new(KeybindingMode::Vim);
2112        e.set_content("first\nsecond");
2113        e.handle_key(key(KeyCode::Char('d')));
2114        e.handle_key(key(KeyCode::Char('d')));
2115        assert_eq!(e.buffer().lines().len(), 1);
2116        e.handle_key(key(KeyCode::Char('u')));
2117        assert_eq!(e.buffer().lines().len(), 2);
2118        assert_eq!(e.buffer().lines()[0], "first");
2119    }
2120
2121    #[test]
2122    fn vim_ctrl_r_redoes() {
2123        let mut e = Editor::new(KeybindingMode::Vim);
2124        e.set_content("hello");
2125        e.handle_key(ctrl_key(KeyCode::Char('r')));
2126    }
2127
2128    #[test]
2129    fn vim_r_replaces_char() {
2130        let mut e = Editor::new(KeybindingMode::Vim);
2131        e.set_content("hello");
2132        e.handle_key(key(KeyCode::Char('r')));
2133        e.handle_key(key(KeyCode::Char('x')));
2134        assert_eq!(e.buffer().lines()[0].chars().next(), Some('x'));
2135    }
2136
2137    #[test]
2138    fn vim_tilde_toggles_case() {
2139        let mut e = Editor::new(KeybindingMode::Vim);
2140        e.set_content("hello");
2141        e.handle_key(key(KeyCode::Char('~')));
2142        assert_eq!(e.buffer().lines()[0].chars().next(), Some('H'));
2143    }
2144
2145    #[test]
2146    fn vim_visual_d_cuts() {
2147        let mut e = Editor::new(KeybindingMode::Vim);
2148        e.set_content("hello");
2149        e.handle_key(key(KeyCode::Char('v')));
2150        e.handle_key(key(KeyCode::Char('l')));
2151        e.handle_key(key(KeyCode::Char('l')));
2152        e.handle_key(key(KeyCode::Char('d')));
2153        assert_eq!(e.vim_mode(), VimMode::Normal);
2154        assert!(e.last_yank.is_some());
2155    }
2156
2157    #[test]
2158    fn vim_visual_c_enters_insert() {
2159        let mut e = Editor::new(KeybindingMode::Vim);
2160        e.set_content("hello");
2161        e.handle_key(key(KeyCode::Char('v')));
2162        e.handle_key(key(KeyCode::Char('l')));
2163        e.handle_key(key(KeyCode::Char('c')));
2164        assert_eq!(e.vim_mode(), VimMode::Insert);
2165    }
2166
2167    #[test]
2168    fn vim_normal_unknown_key_consumed() {
2169        let mut e = Editor::new(KeybindingMode::Vim);
2170        // Unknown keys are consumed (swallowed) rather than returning false.
2171        let consumed = e.handle_key(key(KeyCode::Char('z')));
2172        assert!(consumed);
2173    }
2174
2175    #[test]
2176    fn force_normal_clears_operator() {
2177        let mut e = Editor::new(KeybindingMode::Vim);
2178        e.handle_key(key(KeyCode::Char('d')));
2179        e.force_normal();
2180        assert_eq!(e.vim_mode(), VimMode::Normal);
2181    }
2182
2183    fn many_lines(n: usize) -> String {
2184        (0..n)
2185            .map(|i| format!("line{i}"))
2186            .collect::<Vec<_>>()
2187            .join("\n")
2188    }
2189
2190    fn prime_viewport(e: &mut Editor<'_>, height: u16) {
2191        e.set_viewport_height(height);
2192    }
2193
2194    #[test]
2195    fn zz_centers_cursor_in_viewport() {
2196        let mut e = Editor::new(KeybindingMode::Vim);
2197        e.set_content(&many_lines(100));
2198        prime_viewport(&mut e, 20);
2199        e.jump_cursor(50, 0);
2200        e.handle_key(key(KeyCode::Char('z')));
2201        e.handle_key(key(KeyCode::Char('z')));
2202        assert_eq!(e.buffer().viewport().top_row, 40);
2203        assert_eq!(e.cursor().0, 50);
2204    }
2205
2206    #[test]
2207    fn zt_puts_cursor_at_viewport_top_with_scrolloff() {
2208        let mut e = Editor::new(KeybindingMode::Vim);
2209        e.set_content(&many_lines(100));
2210        prime_viewport(&mut e, 20);
2211        e.jump_cursor(50, 0);
2212        e.handle_key(key(KeyCode::Char('z')));
2213        e.handle_key(key(KeyCode::Char('t')));
2214        // Cursor lands at top of viable area = top + SCROLLOFF (5).
2215        // Viewport top therefore sits at cursor - 5.
2216        assert_eq!(e.buffer().viewport().top_row, 45);
2217        assert_eq!(e.cursor().0, 50);
2218    }
2219
2220    #[test]
2221    fn ctrl_a_increments_number_at_cursor() {
2222        let mut e = Editor::new(KeybindingMode::Vim);
2223        e.set_content("x = 41");
2224        e.handle_key(ctrl_key(KeyCode::Char('a')));
2225        assert_eq!(e.buffer().lines()[0], "x = 42");
2226        assert_eq!(e.cursor(), (0, 5));
2227    }
2228
2229    #[test]
2230    fn ctrl_a_finds_number_to_right_of_cursor() {
2231        let mut e = Editor::new(KeybindingMode::Vim);
2232        e.set_content("foo 99 bar");
2233        e.handle_key(ctrl_key(KeyCode::Char('a')));
2234        assert_eq!(e.buffer().lines()[0], "foo 100 bar");
2235        assert_eq!(e.cursor(), (0, 6));
2236    }
2237
2238    #[test]
2239    fn ctrl_a_with_count_adds_count() {
2240        let mut e = Editor::new(KeybindingMode::Vim);
2241        e.set_content("x = 10");
2242        for d in "5".chars() {
2243            e.handle_key(key(KeyCode::Char(d)));
2244        }
2245        e.handle_key(ctrl_key(KeyCode::Char('a')));
2246        assert_eq!(e.buffer().lines()[0], "x = 15");
2247    }
2248
2249    #[test]
2250    fn ctrl_x_decrements_number() {
2251        let mut e = Editor::new(KeybindingMode::Vim);
2252        e.set_content("n=5");
2253        e.handle_key(ctrl_key(KeyCode::Char('x')));
2254        assert_eq!(e.buffer().lines()[0], "n=4");
2255    }
2256
2257    #[test]
2258    fn ctrl_x_crosses_zero_into_negative() {
2259        let mut e = Editor::new(KeybindingMode::Vim);
2260        e.set_content("v=0");
2261        e.handle_key(ctrl_key(KeyCode::Char('x')));
2262        assert_eq!(e.buffer().lines()[0], "v=-1");
2263    }
2264
2265    #[test]
2266    fn ctrl_a_on_negative_number_increments_toward_zero() {
2267        let mut e = Editor::new(KeybindingMode::Vim);
2268        e.set_content("a = -5");
2269        e.handle_key(ctrl_key(KeyCode::Char('a')));
2270        assert_eq!(e.buffer().lines()[0], "a = -4");
2271    }
2272
2273    #[test]
2274    fn ctrl_a_noop_when_no_digit_on_line() {
2275        let mut e = Editor::new(KeybindingMode::Vim);
2276        e.set_content("no digits here");
2277        e.handle_key(ctrl_key(KeyCode::Char('a')));
2278        assert_eq!(e.buffer().lines()[0], "no digits here");
2279    }
2280
2281    #[test]
2282    fn zb_puts_cursor_at_viewport_bottom_with_scrolloff() {
2283        let mut e = Editor::new(KeybindingMode::Vim);
2284        e.set_content(&many_lines(100));
2285        prime_viewport(&mut e, 20);
2286        e.jump_cursor(50, 0);
2287        e.handle_key(key(KeyCode::Char('z')));
2288        e.handle_key(key(KeyCode::Char('b')));
2289        // Cursor lands at bottom of viable area = top + height - 1 -
2290        // SCROLLOFF. For height 20, scrolloff 5: cursor at top + 14,
2291        // so top = cursor - 14 = 36.
2292        assert_eq!(e.buffer().viewport().top_row, 36);
2293        assert_eq!(e.cursor().0, 50);
2294    }
2295
2296    /// Contract that the TUI drain relies on: `set_content` flags the
2297    /// editor dirty (so the next `take_dirty` call reports the change),
2298    /// and a second `take_dirty` returns `false` after consumption. The
2299    /// TUI drains this flag after every programmatic content load so
2300    /// opening a tab doesn't get mistaken for a user edit and mark the
2301    /// tab dirty (which would then trigger the quit-prompt on `:q`).
2302    #[test]
2303    fn set_content_dirties_then_take_dirty_clears() {
2304        let mut e = Editor::new(KeybindingMode::Vim);
2305        e.set_content("hello");
2306        assert!(
2307            e.take_dirty(),
2308            "set_content should leave content_dirty=true"
2309        );
2310        assert!(!e.take_dirty(), "take_dirty should clear the flag");
2311    }
2312
2313    #[test]
2314    fn content_arc_returns_same_arc_until_mutation() {
2315        let mut e = Editor::new(KeybindingMode::Vim);
2316        e.set_content("hello");
2317        let a = e.content_arc();
2318        let b = e.content_arc();
2319        assert!(
2320            std::sync::Arc::ptr_eq(&a, &b),
2321            "repeated content_arc() should hit the cache"
2322        );
2323
2324        // Any mutation must invalidate the cache.
2325        e.handle_key(key(KeyCode::Char('i')));
2326        e.handle_key(key(KeyCode::Char('!')));
2327        let c = e.content_arc();
2328        assert!(
2329            !std::sync::Arc::ptr_eq(&a, &c),
2330            "mutation should invalidate content_arc() cache"
2331        );
2332        assert!(c.contains('!'));
2333    }
2334
2335    #[test]
2336    fn content_arc_cache_invalidated_by_set_content() {
2337        let mut e = Editor::new(KeybindingMode::Vim);
2338        e.set_content("one");
2339        let a = e.content_arc();
2340        e.set_content("two");
2341        let b = e.content_arc();
2342        assert!(!std::sync::Arc::ptr_eq(&a, &b));
2343        assert!(b.starts_with("two"));
2344    }
2345
2346    /// Click past the last char of a line should land the cursor on
2347    /// the line's last char (Normal mode), not one past it. The
2348    /// previous bug clamped to the line's BYTE length and used `>=`
2349    /// past-end, so clicking deep into the trailing space parked the
2350    /// cursor at `chars().count()` — past where Normal mode lives.
2351    #[test]
2352    fn mouse_click_past_eol_lands_on_last_char() {
2353        let mut e = Editor::new(KeybindingMode::Vim);
2354        e.set_content("hello");
2355        // Outer editor area: x=0, y=0, width=80. mouse_to_doc_pos
2356        // reserves row 0 for the tab bar and adds gutter padding,
2357        // so click row 1, way past the line end.
2358        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
2359        e.mouse_click(area, 78, 1);
2360        assert_eq!(e.cursor(), (0, 4));
2361    }
2362
2363    #[test]
2364    fn mouse_click_past_eol_handles_multibyte_line() {
2365        let mut e = Editor::new(KeybindingMode::Vim);
2366        // 5 chars, 6 bytes — old code's `String::len()` clamp was
2367        // wrong here.
2368        e.set_content("héllo");
2369        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
2370        e.mouse_click(area, 78, 1);
2371        assert_eq!(e.cursor(), (0, 4));
2372    }
2373
2374    #[test]
2375    fn mouse_click_inside_line_lands_on_clicked_char() {
2376        let mut e = Editor::new(KeybindingMode::Vim);
2377        e.set_content("hello world");
2378        // Gutter is `lnum_width + 1` = (1-digit row count + 2) + 1
2379        // pane padding = 4 cells; click col 4 is the first char.
2380        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
2381        e.mouse_click(area, 4, 1);
2382        assert_eq!(e.cursor(), (0, 0));
2383        e.mouse_click(area, 6, 1);
2384        assert_eq!(e.cursor(), (0, 2));
2385    }
2386}