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