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};
12#[cfg(feature = "crossterm")]
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14#[cfg(feature = "ratatui")]
15use ratatui::layout::Rect;
16use std::sync::atomic::{AtomicU16, Ordering};
17
18/// Convert a SPEC [`crate::types::Style`] to a [`ratatui::style::Style`].
19///
20/// Lossless within the styles each library represents. Lives behind the
21/// `ratatui` feature so wasm / no_std consumers that opt out don't pay
22/// for the dep. Use the engine-native [`crate::types::Style`] +
23/// [`Editor::intern_engine_style`] surface from feature-disabled hosts.
24#[cfg(feature = "ratatui")]
25pub(crate) fn engine_style_to_ratatui(s: crate::types::Style) -> ratatui::style::Style {
26    use crate::types::Attrs;
27    use ratatui::style::{Color as RColor, Modifier as RMod, Style as RStyle};
28    let mut out = RStyle::default();
29    if let Some(c) = s.fg {
30        out = out.fg(RColor::Rgb(c.0, c.1, c.2));
31    }
32    if let Some(c) = s.bg {
33        out = out.bg(RColor::Rgb(c.0, c.1, c.2));
34    }
35    let mut m = RMod::empty();
36    if s.attrs.contains(Attrs::BOLD) {
37        m |= RMod::BOLD;
38    }
39    if s.attrs.contains(Attrs::ITALIC) {
40        m |= RMod::ITALIC;
41    }
42    if s.attrs.contains(Attrs::UNDERLINE) {
43        m |= RMod::UNDERLINED;
44    }
45    if s.attrs.contains(Attrs::REVERSE) {
46        m |= RMod::REVERSED;
47    }
48    if s.attrs.contains(Attrs::DIM) {
49        m |= RMod::DIM;
50    }
51    if s.attrs.contains(Attrs::STRIKE) {
52        m |= RMod::CROSSED_OUT;
53    }
54    out.add_modifier(m)
55}
56
57/// Inverse of [`engine_style_to_ratatui`]. Lossy for ratatui colors
58/// the engine doesn't model (Indexed, named ANSI) — flattens to
59/// nearest RGB. Behind the `ratatui` feature.
60#[cfg(feature = "ratatui")]
61pub(crate) fn ratatui_style_to_engine(s: ratatui::style::Style) -> crate::types::Style {
62    use crate::types::{Attrs, Color, Style};
63    use ratatui::style::{Color as RColor, Modifier as RMod};
64    fn c(rc: RColor) -> Color {
65        match rc {
66            RColor::Rgb(r, g, b) => Color(r, g, b),
67            RColor::Black => Color(0, 0, 0),
68            RColor::Red => Color(205, 49, 49),
69            RColor::Green => Color(13, 188, 121),
70            RColor::Yellow => Color(229, 229, 16),
71            RColor::Blue => Color(36, 114, 200),
72            RColor::Magenta => Color(188, 63, 188),
73            RColor::Cyan => Color(17, 168, 205),
74            RColor::Gray => Color(229, 229, 229),
75            RColor::DarkGray => Color(102, 102, 102),
76            RColor::LightRed => Color(241, 76, 76),
77            RColor::LightGreen => Color(35, 209, 139),
78            RColor::LightYellow => Color(245, 245, 67),
79            RColor::LightBlue => Color(59, 142, 234),
80            RColor::LightMagenta => Color(214, 112, 214),
81            RColor::LightCyan => Color(41, 184, 219),
82            RColor::White => Color(255, 255, 255),
83            _ => Color(0, 0, 0),
84        }
85    }
86    let mut attrs = Attrs::empty();
87    if s.add_modifier.contains(RMod::BOLD) {
88        attrs |= Attrs::BOLD;
89    }
90    if s.add_modifier.contains(RMod::ITALIC) {
91        attrs |= Attrs::ITALIC;
92    }
93    if s.add_modifier.contains(RMod::UNDERLINED) {
94        attrs |= Attrs::UNDERLINE;
95    }
96    if s.add_modifier.contains(RMod::REVERSED) {
97        attrs |= Attrs::REVERSE;
98    }
99    if s.add_modifier.contains(RMod::DIM) {
100        attrs |= Attrs::DIM;
101    }
102    if s.add_modifier.contains(RMod::CROSSED_OUT) {
103        attrs |= Attrs::STRIKE;
104    }
105    Style {
106        fg: s.fg.map(c),
107        bg: s.bg.map(c),
108        attrs,
109    }
110}
111
112/// Map a [`hjkl_buffer::Edit`] to one or more SPEC
113/// [`crate::types::Edit`] (`EditOp`) records.
114///
115/// Most buffer edits map to a single EditOp. Block ops
116/// ([`hjkl_buffer::Edit::InsertBlock`] /
117/// [`hjkl_buffer::Edit::DeleteBlockChunks`]) emit one EditOp per row
118/// touched — they edit non-contiguous cells and a single
119/// `range..range` can't represent the rectangle.
120///
121/// Returns an empty vec when the edit isn't representable (no buffer
122/// variant currently fails this check).
123fn edit_to_editops(edit: &hjkl_buffer::Edit) -> Vec<crate::types::Edit> {
124    use crate::types::{Edit as Op, Pos};
125    use hjkl_buffer::Edit as B;
126    let to_pos = |p: hjkl_buffer::Position| Pos {
127        line: p.row as u32,
128        col: p.col as u32,
129    };
130    match edit {
131        B::InsertChar { at, ch } => vec![Op {
132            range: to_pos(*at)..to_pos(*at),
133            replacement: ch.to_string(),
134        }],
135        B::InsertStr { at, text } => vec![Op {
136            range: to_pos(*at)..to_pos(*at),
137            replacement: text.clone(),
138        }],
139        B::DeleteRange { start, end, .. } => vec![Op {
140            range: to_pos(*start)..to_pos(*end),
141            replacement: String::new(),
142        }],
143        B::Replace { start, end, with } => vec![Op {
144            range: to_pos(*start)..to_pos(*end),
145            replacement: with.clone(),
146        }],
147        B::JoinLines {
148            row,
149            count,
150            with_space,
151        } => {
152            // Joining `count` rows after `row` collapses
153            // [(row+1, 0) .. (row+count, EOL)] into the joined
154            // sentinel. The replacement is either an empty string
155            // (gJ) or " " between segments (J).
156            let start = Pos {
157                line: *row as u32 + 1,
158                col: 0,
159            };
160            let end = Pos {
161                line: (*row + *count) as u32,
162                col: u32::MAX, // covers to EOL of the last source row
163            };
164            vec![Op {
165                range: start..end,
166                replacement: if *with_space {
167                    " ".into()
168                } else {
169                    String::new()
170                },
171            }]
172        }
173        B::SplitLines {
174            row,
175            cols,
176            inserted_space: _,
177        } => {
178            // SplitLines reverses a JoinLines: insert a `\n`
179            // (and optional dropped space) at each col on `row`.
180            cols.iter()
181                .map(|c| {
182                    let p = Pos {
183                        line: *row as u32,
184                        col: *c as u32,
185                    };
186                    Op {
187                        range: p..p,
188                        replacement: "\n".into(),
189                    }
190                })
191                .collect()
192        }
193        B::InsertBlock { at, chunks } => {
194            // One EditOp per row in the block — non-contiguous edits.
195            chunks
196                .iter()
197                .enumerate()
198                .map(|(i, chunk)| {
199                    let p = Pos {
200                        line: at.row as u32 + i as u32,
201                        col: at.col as u32,
202                    };
203                    Op {
204                        range: p..p,
205                        replacement: chunk.clone(),
206                    }
207                })
208                .collect()
209        }
210        B::DeleteBlockChunks { at, widths } => {
211            // One EditOp per row, deleting `widths[i]` chars at
212            // `(at.row + i, at.col)`.
213            widths
214                .iter()
215                .enumerate()
216                .map(|(i, w)| {
217                    let start = Pos {
218                        line: at.row as u32 + i as u32,
219                        col: at.col as u32,
220                    };
221                    let end = Pos {
222                        line: at.row as u32 + i as u32,
223                        col: at.col as u32 + *w as u32,
224                    };
225                    Op {
226                        range: start..end,
227                        replacement: String::new(),
228                    }
229                })
230                .collect()
231        }
232    }
233}
234
235/// Where the cursor should land in the viewport after a `z`-family
236/// scroll (`zz` / `zt` / `zb`).
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub(super) enum CursorScrollTarget {
239    Center,
240    Top,
241    Bottom,
242}
243
244// ── Trait-surface cast helpers ────────────────────────────────────
245//
246// 0.0.42 (Patch C-δ.7): the helpers introduced in 0.0.41 were
247// promoted to [`crate::buf_helpers`] so `vim.rs` free fns can route
248// their reaches through the same primitives. Re-import via
249// `use` so the editor body keeps its terse call shape.
250
251use crate::buf_helpers::{
252    apply_buffer_edit, buf_cursor_pos, buf_cursor_rc, buf_cursor_row, buf_line, buf_lines_to_vec,
253    buf_row_count, buf_set_cursor_rc,
254};
255
256pub struct Editor<
257    B: crate::types::Buffer = hjkl_buffer::Buffer,
258    H: crate::types::Host = crate::types::DefaultHost,
259> {
260    pub keybinding_mode: KeybindingMode,
261    /// Set when the user yanks/cuts; caller drains this to write to OS clipboard.
262    pub last_yank: Option<String>,
263    /// All vim-specific state (mode, pending operator, count, dot-repeat, ...).
264    /// Internal — exposed via Editor accessor methods
265    /// ([`Editor::buffer_mark`], [`Editor::last_jump_back`],
266    /// [`Editor::last_edit_pos`], [`Editor::take_lsp_intent`], …).
267    pub(crate) vim: VimState,
268    /// Undo history: each entry is (lines, cursor) before the edit.
269    /// Internal — managed by [`Editor::push_undo`] / [`Editor::restore`]
270    /// / [`Editor::pop_last_undo`].
271    pub(crate) undo_stack: Vec<(Vec<String>, (usize, usize))>,
272    /// Redo history: entries pushed when undoing.
273    pub(super) redo_stack: Vec<(Vec<String>, (usize, usize))>,
274    /// Set whenever the buffer content changes; cleared by `take_dirty`.
275    pub(super) content_dirty: bool,
276    /// Cached snapshot of `lines().join("\n") + "\n"` wrapped in an Arc
277    /// so repeated `content_arc()` calls within the same un-mutated
278    /// window are free (ref-count bump instead of a full-buffer join).
279    /// Invalidated by every [`mark_content_dirty`] call.
280    pub(super) cached_content: Option<std::sync::Arc<String>>,
281    /// Last rendered viewport height (text rows only, no chrome). Written
282    /// by the draw path via [`set_viewport_height`] so the scroll helpers
283    /// can clamp the cursor to stay visible without plumbing the height
284    /// through every call.
285    pub(super) viewport_height: AtomicU16,
286    /// Pending LSP intent set by a normal-mode chord (e.g. `gd` for
287    /// goto-definition). The host app drains this each step and fires
288    /// the matching request against its own LSP client.
289    pub(super) pending_lsp: Option<LspIntent>,
290    /// Pending [`crate::types::FoldOp`]s raised by `z…` keystrokes,
291    /// the `:fold*` Ex commands, or the edit pipeline's
292    /// "edits-inside-a-fold open it" invalidation. Drained by hosts
293    /// via [`Editor::take_fold_ops`]; the engine also applies each op
294    /// locally through [`crate::buffer_impl::BufferFoldProviderMut`]
295    /// so the in-tree buffer fold storage stays in sync without host
296    /// cooperation. Introduced in 0.0.38 (Patch C-δ.4).
297    pub(super) pending_fold_ops: Vec<crate::types::FoldOp>,
298    /// Buffer storage.
299    ///
300    /// 0.1.0 (Patch C-δ): generic over `B: Buffer` per SPEC §"Editor
301    /// surface". Default `B = hjkl_buffer::Buffer`. The vim FSM body
302    /// and `Editor::mutate_edit` are concrete on `hjkl_buffer::Buffer`
303    /// for 0.1.0 — see SPEC.md §"Out of scope" and `crate::buf_helpers::apply_buffer_edit`.
304    pub(super) buffer: B,
305    /// Style intern table for the migration buffer's opaque
306    /// `Span::style` ids. Phase 7d-ii-a wiring — `apply_window_spans`
307    /// produces `(start, end, Style)` tuples for the textarea; we
308    /// translate those to `hjkl_buffer::Span` by interning the
309    /// `Style` here and storing the table index. The render path's
310    /// `StyleResolver` looks the style back up by id.
311    ///
312    /// Behind the `ratatui` feature; non-ratatui hosts use the
313    /// engine-native [`crate::types::Style`] surface via
314    /// [`Editor::intern_engine_style`] (which lives on a parallel
315    /// engine-side table when ratatui is off).
316    #[cfg(feature = "ratatui")]
317    pub(super) style_table: Vec<ratatui::style::Style>,
318    /// Engine-native style intern table. Used directly by
319    /// [`Editor::intern_engine_style`] when the `ratatui` feature is
320    /// off; when it's on, the table is derived from `style_table` via
321    /// [`ratatui_style_to_engine`] / [`engine_style_to_ratatui`].
322    #[cfg(not(feature = "ratatui"))]
323    pub(super) engine_style_table: Vec<crate::types::Style>,
324    /// Vim-style register bank — `"`, `"0`–`"9`, `"a`–`"z`. Sources
325    /// every `p` / `P` via the active selector (default unnamed).
326    /// Internal — read via [`Editor::registers`]; mutated by yank /
327    /// delete / paste FSM paths and by [`Editor::seed_yank`].
328    pub(crate) registers: crate::registers::Registers,
329    /// Per-row syntax styling, kept here so the host can do
330    /// incremental window updates (see `apply_window_spans` in
331    /// the host). Same `(start_byte, end_byte, Style)` tuple shape
332    /// the textarea used to host. The Buffer-side opaque-id spans are
333    /// derived from this on every install. Behind the `ratatui`
334    /// feature.
335    #[cfg(feature = "ratatui")]
336    pub styled_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
337    /// Per-editor settings tweakable via `:set`. Exposed by reference
338    /// so handlers (indent, search) read the live value rather than a
339    /// snapshot taken at startup. Read via [`Editor::settings`];
340    /// mutate via [`Editor::settings_mut`].
341    pub(crate) settings: Settings,
342    /// Unified named-marks map. Lowercase letters (`'a`–`'z`) are
343    /// per-Editor / "buffer-scope-equivalent" — set by `m{a-z}`, read
344    /// by `'{a-z}` / `` `{a-z} ``. Uppercase letters (`'A`–`'Z`) are
345    /// "file marks" that survive [`Editor::set_content`] calls so
346    /// they persist across tab swaps within the same Editor.
347    ///
348    /// 0.0.36: consolidated from three former storages:
349    /// - `hjkl_buffer::Buffer::marks` (deleted; was unused dead code).
350    /// - `vim::VimState::marks` (lowercase) (deleted).
351    /// - `Editor::file_marks` (uppercase) (replaced by this map).
352    ///
353    /// `BTreeMap` so iteration is deterministic for snapshot tests
354    /// and the `:marks` ex command. Mark-shift on edits is handled
355    /// by [`Editor::shift_marks_after_edit`].
356    pub(crate) marks: std::collections::BTreeMap<char, (usize, usize)>,
357    /// Block ranges (`(start_row, end_row)` inclusive) the host has
358    /// extracted from a syntax tree. `:foldsyntax` reads these to
359    /// populate folds. The host refreshes them on every re-parse via
360    /// [`Editor::set_syntax_fold_ranges`]; ex commands read them via
361    /// [`Editor::syntax_fold_ranges`].
362    pub(crate) syntax_fold_ranges: Vec<(usize, usize)>,
363    /// Pending edit log drained by [`Editor::take_changes`]. Each entry
364    /// is a SPEC [`crate::types::Edit`] mapped from the underlying
365    /// `hjkl_buffer::Edit` operation. Compound ops (JoinLines,
366    /// SplitLines, InsertBlock, DeleteBlockChunks) emit a single
367    /// best-effort EditOp covering the touched range; hosts wanting
368    /// per-cell deltas should diff their own snapshot of `lines()`.
369    /// Sealed at 0.1.0 trait extraction.
370    /// Drained by [`Editor::take_changes`].
371    pub(crate) change_log: Vec<crate::types::Edit>,
372    /// Vim's "sticky column" (curswant). `None` before the first
373    /// motion — the next vertical motion bootstraps from the live
374    /// cursor column. Horizontal motions refresh this to the new
375    /// column; vertical motions read it back so bouncing through a
376    /// shorter row doesn't drag the cursor to col 0. Hoisted out of
377    /// `hjkl_buffer::Buffer` (and `VimState`) in 0.0.28 — Editor is
378    /// the single owner now. Buffer motion methods that need it
379    /// take a `&mut Option<usize>` parameter.
380    pub(crate) sticky_col: Option<usize>,
381    /// Host adapter for clipboard, cursor-shape, time, viewport, and
382    /// search-prompt / cancellation side-channels.
383    ///
384    /// 0.1.0 (Patch C-δ): generic over `H: Host` per SPEC §"Editor
385    /// surface". Default `H = DefaultHost`. The pre-0.1.0 `EngineHost`
386    /// dyn-shim is gone — every method now dispatches through `H`'s
387    /// `Host` trait surface directly.
388    pub(crate) host: H,
389    /// Last public mode the cursor-shape emitter saw. Drives
390    /// [`Editor::emit_cursor_shape_if_changed`] so `Host::emit_cursor_shape`
391    /// fires exactly once per mode transition without sprinkling the
392    /// call across every `vim.mode = ...` site.
393    pub(crate) last_emitted_mode: crate::VimMode,
394    /// Search FSM state (pattern + per-row match cache + wrapscan).
395    /// 0.0.35: relocated out of `hjkl_buffer::Buffer` per
396    /// `DESIGN_33_METHOD_CLASSIFICATION.md` step 1.
397    /// 0.0.37: the buffer-side bridge (`Buffer::search_pattern`) is
398    /// gone; `BufferView` now takes the active regex as a `&Regex`
399    /// parameter, sourced from `Editor::search_state().pattern`.
400    pub(crate) search_state: crate::search::SearchState,
401    /// Per-row syntax span overlay. Source of truth for the host's
402    /// renderer ([`hjkl_buffer::BufferView::spans`]). Populated by
403    /// [`Editor::install_syntax_spans`] /
404    /// [`Editor::install_ratatui_syntax_spans`] (and, in due course,
405    /// by `Host::syntax_highlights` once the engine drives that path
406    /// directly).
407    ///
408    /// 0.0.37: lifted out of `hjkl_buffer::Buffer` per step 3 of
409    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer-side cache +
410    /// `Buffer::set_spans` / `Buffer::spans` accessors are gone.
411    pub(crate) buffer_spans: Vec<Vec<hjkl_buffer::Span>>,
412}
413
414/// Vim-style options surfaced by `:set`. New fields land here as
415/// individual ex commands gain `:set` plumbing.
416#[derive(Debug, Clone)]
417pub struct Settings {
418    /// Spaces per shift step for `>>` / `<<` / `Ctrl-T` / `Ctrl-D`.
419    pub shiftwidth: usize,
420    /// Visual width of a `\t` character. Stored for future render
421    /// hookup; not yet consumed by the buffer renderer.
422    pub tabstop: usize,
423    /// When true, `/` / `?` patterns and `:s/.../.../` ignore case
424    /// without an explicit `i` flag.
425    pub ignore_case: bool,
426    /// When true *and* `ignore_case` is true, an uppercase letter in
427    /// the pattern flips that search back to case-sensitive. Matches
428    /// vim's `:set smartcase`. Default `false`.
429    pub smartcase: bool,
430    /// Wrap searches past buffer ends. Matches vim's `:set wrapscan`.
431    /// Default `true`.
432    pub wrapscan: bool,
433    /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
434    pub textwidth: usize,
435    /// When `true`, the Tab key in insert mode inserts `tabstop` spaces
436    /// instead of a literal `\t`. Matches vim's `:set expandtab`.
437    /// Default `false`.
438    pub expandtab: bool,
439    /// Soft-wrap mode the renderer + scroll math + `gj` / `gk` use.
440    /// Default is [`hjkl_buffer::Wrap::None`] — long lines extend
441    /// past the right edge and `top_col` clips the left side.
442    /// `:set wrap` flips to char-break wrap; `:set linebreak` flips
443    /// to word-break wrap; `:set nowrap` resets.
444    pub wrap: hjkl_buffer::Wrap,
445    /// When true, the engine drops every edit before it touches the
446    /// buffer — undo, dirty flag, and change log all stay clean.
447    /// Matches vim's `:set readonly` / `:set ro`. Default `false`.
448    pub readonly: bool,
449    /// When `true`, pressing Enter in insert mode copies the leading
450    /// whitespace of the current line onto the new line. Matches vim's
451    /// `:set autoindent`. Default `true` (vim parity).
452    pub autoindent: bool,
453    /// Cap on undo-stack length. Older entries are pruned past this
454    /// bound. `0` means unlimited. Matches vim's `:set undolevels`.
455    /// Default `1000`.
456    pub undo_levels: u32,
457    /// When `true`, cursor motions inside insert mode break the
458    /// current undo group (so a single `u` only reverses the run of
459    /// keystrokes that preceded the motion). Default `true`.
460    /// Currently a no-op — engine doesn't yet break the undo group
461    /// on insert-mode motions; field is wired through `:set
462    /// undobreak` for forward compatibility.
463    pub undo_break_on_motion: bool,
464    /// Vim-flavoured "what counts as a word" character class.
465    /// Comma-separated tokens: `@` = `is_alphabetic()`, `_` = literal
466    /// `_`, `48-57` = decimal char range, bare integer = single char
467    /// code, single ASCII punctuation = literal. Default
468    /// `"@,48-57,_,192-255"` matches vim.
469    pub iskeyword: String,
470    /// Multi-key sequence timeout (e.g. `gg`, `dd`). When the user
471    /// pauses longer than this between keys, any pending prefix is
472    /// abandoned and the next key starts a fresh sequence. Matches
473    /// vim's `:set timeoutlen` / `:set tm` (millis). Default 1000ms.
474    pub timeout_len: core::time::Duration,
475}
476
477impl Default for Settings {
478    fn default() -> Self {
479        Self {
480            shiftwidth: 2,
481            tabstop: 8,
482            ignore_case: false,
483            smartcase: false,
484            wrapscan: true,
485            textwidth: 79,
486            expandtab: false,
487            wrap: hjkl_buffer::Wrap::None,
488            readonly: false,
489            autoindent: true,
490            undo_levels: 1000,
491            undo_break_on_motion: true,
492            iskeyword: "@,48-57,_,192-255".to_string(),
493            timeout_len: core::time::Duration::from_millis(1000),
494        }
495    }
496}
497
498/// Translate a SPEC [`crate::types::Options`] into the engine's
499/// internal [`Settings`] representation. Field-by-field map; the
500/// shapes are isomorphic except for type widths
501/// (`u32` vs `usize`, [`crate::types::WrapMode`] vs
502/// [`hjkl_buffer::Wrap`]). 0.1.0 (Patch C-δ) collapses both into one
503/// type once the `Editor<B, H>::new(buffer, host, options)` constructor
504/// is the canonical entry point.
505fn settings_from_options(o: &crate::types::Options) -> Settings {
506    Settings {
507        shiftwidth: o.shiftwidth as usize,
508        tabstop: o.tabstop as usize,
509        ignore_case: o.ignorecase,
510        smartcase: o.smartcase,
511        wrapscan: o.wrapscan,
512        textwidth: o.textwidth as usize,
513        expandtab: o.expandtab,
514        wrap: match o.wrap {
515            crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
516            crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
517            crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
518        },
519        readonly: o.readonly,
520        autoindent: o.autoindent,
521        undo_levels: o.undo_levels,
522        undo_break_on_motion: o.undo_break_on_motion,
523        iskeyword: o.iskeyword.clone(),
524        timeout_len: o.timeout_len,
525    }
526}
527
528/// Host-observable LSP requests triggered by editor bindings. The
529/// hjkl-engine crate doesn't talk to an LSP itself — it just raises an
530/// intent that the TUI layer picks up and routes to `sqls`.
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum LspIntent {
533    /// `gd` — textDocument/definition at the cursor.
534    GotoDefinition,
535}
536
537impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
538    /// Build an [`Editor`] from a buffer, host adapter, and SPEC options.
539    ///
540    /// 0.1.0 (Patch C-δ): canonical, frozen constructor per SPEC §"Editor
541    /// surface". Replaces the pre-0.1.0 `Editor::new(KeybindingMode)` /
542    /// `with_host` / `with_options` triad — there is no shim.
543    ///
544    /// Consumers that don't need a custom host pass
545    /// [`crate::types::DefaultHost::new()`]; consumers that don't need
546    /// custom options pass [`crate::types::Options::default()`].
547    pub fn new(buffer: hjkl_buffer::Buffer, host: H, options: crate::types::Options) -> Self {
548        let settings = settings_from_options(&options);
549        Self {
550            keybinding_mode: KeybindingMode::Vim,
551            last_yank: None,
552            vim: VimState::default(),
553            undo_stack: Vec::new(),
554            redo_stack: Vec::new(),
555            content_dirty: false,
556            cached_content: None,
557            viewport_height: AtomicU16::new(0),
558            pending_lsp: None,
559            pending_fold_ops: Vec::new(),
560            buffer,
561            #[cfg(feature = "ratatui")]
562            style_table: Vec::new(),
563            #[cfg(not(feature = "ratatui"))]
564            engine_style_table: Vec::new(),
565            registers: crate::registers::Registers::default(),
566            #[cfg(feature = "ratatui")]
567            styled_spans: Vec::new(),
568            settings,
569            marks: std::collections::BTreeMap::new(),
570            syntax_fold_ranges: Vec::new(),
571            change_log: Vec::new(),
572            sticky_col: None,
573            host,
574            last_emitted_mode: crate::VimMode::Normal,
575            search_state: crate::search::SearchState::new(),
576            buffer_spans: Vec::new(),
577        }
578    }
579}
580
581impl<B: crate::types::Buffer, H: crate::types::Host> Editor<B, H> {
582    /// Borrow the buffer (typed `&B`). Host renders through this via
583    /// `hjkl_buffer::BufferView` when `B = hjkl_buffer::Buffer`.
584    pub fn buffer(&self) -> &B {
585        &self.buffer
586    }
587
588    /// Mutably borrow the buffer (typed `&mut B`).
589    pub fn buffer_mut(&mut self) -> &mut B {
590        &mut self.buffer
591    }
592
593    /// Borrow the host adapter directly (typed `&H`).
594    pub fn host(&self) -> &H {
595        &self.host
596    }
597
598    /// Mutably borrow the host adapter (typed `&mut H`).
599    pub fn host_mut(&mut self) -> &mut H {
600        &mut self.host
601    }
602}
603
604impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
605    /// Update the active `iskeyword` spec for word motions
606    /// (`w`/`b`/`e`/`ge` and engine-side `*`/`#` pickup). 0.0.28
607    /// hoisted iskeyword storage out of `Buffer` — `Editor` is the
608    /// single owner now. Equivalent to assigning
609    /// `settings_mut().iskeyword` directly; the dedicated setter is
610    /// retained for source-compatibility with 0.0.27 callers.
611    pub fn set_iskeyword(&mut self, spec: impl Into<String>) {
612        self.settings.iskeyword = spec.into();
613    }
614
615    /// Emit `Host::emit_cursor_shape` if the public mode has changed
616    /// since the last emit. Engine calls this at the end of every input
617    /// step so mode transitions surface to the host without sprinkling
618    /// the call across every `vim.mode = ...` site.
619    pub(crate) fn emit_cursor_shape_if_changed(&mut self) {
620        let mode = self.vim_mode();
621        if mode == self.last_emitted_mode {
622            return;
623        }
624        let shape = match mode {
625            crate::VimMode::Insert => crate::types::CursorShape::Bar,
626            _ => crate::types::CursorShape::Block,
627        };
628        self.host.emit_cursor_shape(shape);
629        self.last_emitted_mode = mode;
630    }
631
632    /// Record a yank/cut payload. Writes both the legacy
633    /// [`Editor::last_yank`] field (drained directly by 0.0.28-era
634    /// hosts) and the new [`crate::types::Host::write_clipboard`]
635    /// side-channel (Patch B). Consumers should migrate to a `Host`
636    /// impl whose `write_clipboard` queues the platform-clipboard
637    /// write; the `last_yank` mirror will be removed at 0.1.0.
638    pub(crate) fn record_yank_to_host(&mut self, text: String) {
639        self.host.write_clipboard(text.clone());
640        self.last_yank = Some(text);
641    }
642
643    /// Vim's sticky column (curswant). `None` before the first motion;
644    /// hosts shouldn't normally need to read this directly — it's
645    /// surfaced for migration off `Buffer::sticky_col` and for
646    /// snapshot tests.
647    pub fn sticky_col(&self) -> Option<usize> {
648        self.sticky_col
649    }
650
651    /// Replace the sticky column. Hosts should rarely touch this —
652    /// motion code maintains it through the standard horizontal /
653    /// vertical motion paths.
654    pub fn set_sticky_col(&mut self, col: Option<usize>) {
655        self.sticky_col = col;
656    }
657
658    /// Host hook: replace the cached syntax-derived block ranges that
659    /// `:foldsyntax` consumes. the host calls this on every re-parse;
660    /// the cost is just a `Vec` swap.
661    /// Look up a named mark by character. Returns `(row, col)` if
662    /// set; `None` otherwise. Both lowercase (`'a`–`'z`) and
663    /// uppercase (`'A`–`'Z`) marks live in the same unified
664    /// [`Editor::marks`] map as of 0.0.36.
665    pub fn mark(&self, c: char) -> Option<(usize, usize)> {
666        self.marks.get(&c).copied()
667    }
668
669    /// Set the named mark `c` to `(row, col)`. Used by the FSM's
670    /// `m{a-zA-Z}` keystroke and by [`Editor::restore_snapshot`].
671    pub fn set_mark(&mut self, c: char, pos: (usize, usize)) {
672        self.marks.insert(c, pos);
673    }
674
675    /// Remove the named mark `c` (no-op if unset).
676    pub fn clear_mark(&mut self, c: char) {
677        self.marks.remove(&c);
678    }
679
680    /// Look up a buffer-local lowercase mark (`'a`–`'z`). Kept as a
681    /// thin wrapper over [`Editor::mark`] for source compatibility
682    /// with pre-0.0.36 callers; new code should call
683    /// [`Editor::mark`] directly.
684    #[deprecated(
685        since = "0.0.36",
686        note = "use Editor::mark — lowercase + uppercase marks now live in a single map"
687    )]
688    pub fn buffer_mark(&self, c: char) -> Option<(usize, usize)> {
689        self.mark(c)
690    }
691
692    /// Discard the most recent undo entry. Used by ex commands that
693    /// pre-emptively pushed an undo state (`:s`, `:r`) but ended up
694    /// matching nothing — popping prevents a no-op undo step from
695    /// polluting the user's history.
696    ///
697    /// Returns `true` if an entry was discarded.
698    pub fn pop_last_undo(&mut self) -> bool {
699        self.undo_stack.pop().is_some()
700    }
701
702    /// Read all named marks set this session — both lowercase
703    /// (`'a`–`'z`) and uppercase (`'A`–`'Z`). Iteration is
704    /// deterministic (BTreeMap-ordered) so snapshot / `:marks`
705    /// output is stable.
706    pub fn marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
707        self.marks.iter().map(|(c, p)| (*c, *p))
708    }
709
710    /// Read all buffer-local lowercase marks. Kept for source
711    /// compatibility with pre-0.0.36 callers (e.g. `:marks` ex
712    /// command); new code should use [`Editor::marks`] which
713    /// iterates the unified map.
714    #[deprecated(
715        since = "0.0.36",
716        note = "use Editor::marks — lowercase + uppercase marks now live in a single map"
717    )]
718    pub fn buffer_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
719        self.marks
720            .iter()
721            .filter(|(c, _)| c.is_ascii_lowercase())
722            .map(|(c, p)| (*c, *p))
723    }
724
725    /// Position the cursor was at when the user last jumped via
726    /// `<C-o>` / `g;` / similar. `None` before any jump.
727    pub fn last_jump_back(&self) -> Option<(usize, usize)> {
728        self.vim.jump_back.last().copied()
729    }
730
731    /// Position of the last edit (where `.` would replay). `None` if
732    /// no edit has happened yet in this session.
733    pub fn last_edit_pos(&self) -> Option<(usize, usize)> {
734        self.vim.last_edit_pos
735    }
736
737    /// Read-only view of the file-marks table — uppercase / "file"
738    /// marks (`'A`–`'Z`) the host has set this session. Returns an
739    /// iterator of `(mark_char, (row, col))` pairs.
740    ///
741    /// Mutate via the FSM (`m{A-Z}` keystroke) or via
742    /// [`Editor::restore_snapshot`].
743    ///
744    /// 0.0.36: file marks now live in the unified [`Editor::marks`]
745    /// map; this accessor is kept for source compatibility and
746    /// filters the unified map to uppercase entries.
747    pub fn file_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
748        self.marks
749            .iter()
750            .filter(|(c, _)| c.is_ascii_uppercase())
751            .map(|(c, p)| (*c, *p))
752    }
753
754    /// Read-only view of the cached syntax-derived block ranges that
755    /// `:foldsyntax` consumes. Returns the slice the host last
756    /// installed via [`Editor::set_syntax_fold_ranges`]; empty when
757    /// no syntax integration is active.
758    pub fn syntax_fold_ranges(&self) -> &[(usize, usize)] {
759        &self.syntax_fold_ranges
760    }
761
762    pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
763        self.syntax_fold_ranges = ranges;
764    }
765
766    /// Live settings (read-only). `:set` mutates these via
767    /// [`Editor::settings_mut`].
768    pub fn settings(&self) -> &Settings {
769        &self.settings
770    }
771
772    /// Live settings (mutable). `:set` flows through here to mutate
773    /// shiftwidth / tabstop / textwidth / ignore_case / wrap. Hosts
774    /// configuring at startup typically construct a [`Settings`]
775    /// snapshot and overwrite via `*editor.settings_mut() = …`.
776    pub fn settings_mut(&mut self) -> &mut Settings {
777        &mut self.settings
778    }
779
780    /// Borrow the engine search state. Hosts inspecting the
781    /// committed `/` / `?` pattern (e.g. for status-line display) or
782    /// feeding the active regex into `BufferView::search_pattern`
783    /// read it from here.
784    pub fn search_state(&self) -> &crate::search::SearchState {
785        &self.search_state
786    }
787
788    /// Mutable engine search state. Hosts driving search
789    /// programmatically (test fixtures, scripted demos) write the
790    /// pattern through here.
791    pub fn search_state_mut(&mut self) -> &mut crate::search::SearchState {
792        &mut self.search_state
793    }
794
795    /// Install `pattern` as the active search regex on the engine
796    /// state and clear the cached row matches. Pass `None` to clear.
797    /// 0.0.37: dropped the buffer-side mirror that 0.0.35 introduced
798    /// — `BufferView` now takes the regex through its `search_pattern`
799    /// field per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`.
800    pub fn set_search_pattern(&mut self, pattern: Option<regex::Regex>) {
801        self.search_state.set_pattern(pattern);
802    }
803
804    /// Drive `n` (or the `/` commit equivalent) — advance the cursor
805    /// to the next match of `search_state.pattern` from the cursor's
806    /// current position. Returns `true` when a match was found.
807    /// `skip_current = true` excludes a match the cursor sits on.
808    pub fn search_advance_forward(&mut self, skip_current: bool) -> bool {
809        crate::search::search_forward(&mut self.buffer, &mut self.search_state, skip_current)
810    }
811
812    /// Drive `N` — symmetric counterpart of [`Editor::search_advance_forward`].
813    pub fn search_advance_backward(&mut self, skip_current: bool) -> bool {
814        crate::search::search_backward(&mut self.buffer, &mut self.search_state, skip_current)
815    }
816
817    /// Install styled syntax spans using `ratatui::style::Style`. The
818    /// ratatui-flavoured variant of [`Editor::install_syntax_spans`].
819    /// Drops zero-width runs and clamps `end` to the line's char length
820    /// so the buffer cache doesn't see runaway ranges. Behind the
821    /// `ratatui` feature; non-ratatui hosts use the unprefixed
822    /// [`Editor::install_syntax_spans`] (engine-native `Style`).
823    ///
824    /// Renamed from `install_syntax_spans` in 0.0.32 — the unprefixed
825    /// name now belongs to the engine-native variant per SPEC 0.1.0
826    /// freeze ("engine never imports ratatui").
827    #[cfg(feature = "ratatui")]
828    pub fn install_ratatui_syntax_spans(
829        &mut self,
830        spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
831    ) {
832        let line_byte_lens: Vec<usize> = (0..buf_row_count(&self.buffer))
833            .map(|r| buf_line(&self.buffer, r).map(str::len).unwrap_or(0))
834            .collect();
835        let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
836        for (row, row_spans) in spans.iter().enumerate() {
837            let line_len = line_byte_lens.get(row).copied().unwrap_or(0);
838            let mut translated = Vec::with_capacity(row_spans.len());
839            for (start, end, style) in row_spans {
840                let end_clamped = (*end).min(line_len);
841                if end_clamped <= *start {
842                    continue;
843                }
844                let id = self.intern_ratatui_style(*style);
845                translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
846            }
847            by_row.push(translated);
848        }
849        self.buffer_spans = by_row;
850        self.styled_spans = spans;
851    }
852
853    /// Snapshot of the unnamed register (the default `p` / `P` source).
854    pub fn yank(&self) -> &str {
855        &self.registers.unnamed.text
856    }
857
858    /// Borrow the full register bank — `"`, `"0`–`"9`, `"a`–`"z`.
859    pub fn registers(&self) -> &crate::registers::Registers {
860        &self.registers
861    }
862
863    /// Host hook: load the OS clipboard's contents into the `"+` / `"*`
864    /// register slot. the host calls this before letting vim consume a
865    /// paste so `"*p` / `"+p` reflect the live clipboard rather than a
866    /// stale snapshot from the last yank.
867    pub fn sync_clipboard_register(&mut self, text: String, linewise: bool) {
868        self.registers.set_clipboard(text, linewise);
869    }
870
871    /// True when the user's pending register selector is `+` or `*`.
872    /// the host peeks this so it can refresh `sync_clipboard_register`
873    /// only when a clipboard read is actually about to happen.
874    pub fn pending_register_is_clipboard(&self) -> bool {
875        matches!(self.vim.pending_register, Some('+') | Some('*'))
876    }
877
878    /// Replace the unnamed register without touching any other slot.
879    /// For host-driven imports (e.g. system clipboard); operator
880    /// code uses [`record_yank`] / [`record_delete`].
881    pub fn set_yank(&mut self, text: impl Into<String>) {
882        let text = text.into();
883        let linewise = self.vim.yank_linewise;
884        self.registers.unnamed = crate::registers::Slot { text, linewise };
885    }
886
887    /// Record a yank into `"` and `"0`, plus the named target if the
888    /// user prefixed `"reg`. Updates `vim.yank_linewise` for the
889    /// paste path.
890    pub(crate) fn record_yank(&mut self, text: String, linewise: bool) {
891        self.vim.yank_linewise = linewise;
892        let target = self.vim.pending_register.take();
893        self.registers.record_yank(text, linewise, target);
894    }
895
896    /// Direct write to a named register slot — bypasses the unnamed
897    /// `"` and `"0` updates that `record_yank` does. Used by the
898    /// macro recorder so finishing a `q{reg}` recording doesn't
899    /// pollute the user's last yank.
900    pub(crate) fn set_named_register_text(&mut self, reg: char, text: String) {
901        if let Some(slot) = match reg {
902            'a'..='z' => Some(&mut self.registers.named[(reg as u8 - b'a') as usize]),
903            'A'..='Z' => {
904                Some(&mut self.registers.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize])
905            }
906            _ => None,
907        } {
908            slot.text = text;
909            slot.linewise = false;
910        }
911    }
912
913    /// Record a delete / change into `"` and the `"1`–`"9` ring.
914    /// Honours the active named-register prefix.
915    pub(crate) fn record_delete(&mut self, text: String, linewise: bool) {
916        self.vim.yank_linewise = linewise;
917        let target = self.vim.pending_register.take();
918        self.registers.record_delete(text, linewise, target);
919    }
920
921    /// Install styled syntax spans using the engine-native
922    /// [`crate::types::Style`]. Always available, regardless of the
923    /// `ratatui` feature. Hosts depending on ratatui can use the
924    /// ratatui-flavoured [`Editor::install_ratatui_syntax_spans`].
925    ///
926    /// Renamed from `install_engine_syntax_spans` in 0.0.32 — at the
927    /// 0.1.0 freeze the unprefixed name is the universally-available
928    /// engine-native variant ("engine never imports ratatui").
929    pub fn install_syntax_spans(&mut self, spans: Vec<Vec<(usize, usize, crate::types::Style)>>) {
930        let line_byte_lens: Vec<usize> = (0..buf_row_count(&self.buffer))
931            .map(|r| buf_line(&self.buffer, r).map(str::len).unwrap_or(0))
932            .collect();
933        let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
934        #[cfg(feature = "ratatui")]
935        let mut ratatui_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>> =
936            Vec::with_capacity(spans.len());
937        for (row, row_spans) in spans.iter().enumerate() {
938            let line_len = line_byte_lens.get(row).copied().unwrap_or(0);
939            let mut translated = Vec::with_capacity(row_spans.len());
940            #[cfg(feature = "ratatui")]
941            let mut translated_r = Vec::with_capacity(row_spans.len());
942            for (start, end, style) in row_spans {
943                let end_clamped = (*end).min(line_len);
944                if end_clamped <= *start {
945                    continue;
946                }
947                let id = self.intern_style(*style);
948                translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
949                #[cfg(feature = "ratatui")]
950                translated_r.push((*start, end_clamped, engine_style_to_ratatui(*style)));
951            }
952            by_row.push(translated);
953            #[cfg(feature = "ratatui")]
954            ratatui_spans.push(translated_r);
955        }
956        self.buffer_spans = by_row;
957        #[cfg(feature = "ratatui")]
958        {
959            self.styled_spans = ratatui_spans;
960        }
961    }
962
963    /// Intern a `ratatui::style::Style` and return the opaque id used
964    /// in `hjkl_buffer::Span::style`. The ratatui-flavoured variant of
965    /// [`Editor::intern_style`]. Linear-scan dedup — the table grows
966    /// only as new tree-sitter token kinds appear, so it stays tiny.
967    /// Behind the `ratatui` feature.
968    ///
969    /// Renamed from `intern_style` in 0.0.32 — at 0.1.0 freeze the
970    /// unprefixed name belongs to the engine-native variant.
971    #[cfg(feature = "ratatui")]
972    pub fn intern_ratatui_style(&mut self, style: ratatui::style::Style) -> u32 {
973        if let Some(idx) = self.style_table.iter().position(|s| *s == style) {
974            return idx as u32;
975        }
976        self.style_table.push(style);
977        (self.style_table.len() - 1) as u32
978    }
979
980    /// Read-only view of the style table — id `i` → `style_table[i]`.
981    /// The render path passes a closure backed by this slice as the
982    /// `StyleResolver` for `BufferView`. Behind the `ratatui` feature.
983    #[cfg(feature = "ratatui")]
984    pub fn style_table(&self) -> &[ratatui::style::Style] {
985        &self.style_table
986    }
987
988    /// Per-row syntax span overlay, one `Vec<Span>` per buffer row.
989    /// Hosts feed this slice into [`hjkl_buffer::BufferView::spans`]
990    /// per draw frame.
991    ///
992    /// 0.0.37: replaces `editor.buffer().spans()` per step 3 of
993    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer no longer
994    /// caches spans; they live on the engine and route through the
995    /// `Host::syntax_highlights` pipeline.
996    pub fn buffer_spans(&self) -> &[Vec<hjkl_buffer::Span>] {
997        &self.buffer_spans
998    }
999
1000    /// Intern a SPEC [`crate::types::Style`] and return its opaque id.
1001    /// With the `ratatui` feature on, the id matches the one
1002    /// [`Editor::intern_ratatui_style`] would return for the equivalent
1003    /// `ratatui::Style` (both share the underlying table). With it off,
1004    /// the engine keeps a parallel `crate::types::Style`-keyed table
1005    /// — ids are still stable per-editor.
1006    ///
1007    /// Hosts that don't depend on ratatui (buffr, future GUI shells)
1008    /// reach this method to populate the table during syntax span
1009    /// installation.
1010    ///
1011    /// Renamed from `intern_engine_style` in 0.0.32 — at 0.1.0 freeze
1012    /// the unprefixed name is the universally-available engine-native
1013    /// variant.
1014    pub fn intern_style(&mut self, style: crate::types::Style) -> u32 {
1015        #[cfg(feature = "ratatui")]
1016        {
1017            let r = engine_style_to_ratatui(style);
1018            self.intern_ratatui_style(r)
1019        }
1020        #[cfg(not(feature = "ratatui"))]
1021        {
1022            if let Some(idx) = self.engine_style_table.iter().position(|s| *s == style) {
1023                return idx as u32;
1024            }
1025            self.engine_style_table.push(style);
1026            (self.engine_style_table.len() - 1) as u32
1027        }
1028    }
1029
1030    /// Look up an interned style by id and return it as a SPEC
1031    /// [`crate::types::Style`]. Returns `None` for ids past the end
1032    /// of the table.
1033    pub fn engine_style_at(&self, id: u32) -> Option<crate::types::Style> {
1034        #[cfg(feature = "ratatui")]
1035        {
1036            let r = self.style_table.get(id as usize).copied()?;
1037            Some(ratatui_style_to_engine(r))
1038        }
1039        #[cfg(not(feature = "ratatui"))]
1040        {
1041            self.engine_style_table.get(id as usize).copied()
1042        }
1043    }
1044
1045    /// Historical reverse-sync hook from when the textarea mirrored
1046    /// the buffer. Now that Buffer is the cursor authority this is a
1047    /// no-op; call sites can remain in place during the migration.
1048    pub(crate) fn push_buffer_cursor_to_textarea(&mut self) {}
1049
1050    /// Force the host viewport's top row without touching the
1051    /// cursor. Used by tests that simulate a scroll without the
1052    /// SCROLLOFF cursor adjustment that `scroll_down` / `scroll_up`
1053    /// apply.
1054    ///
1055    /// 0.0.34 (Patch C-δ.1): writes through `Host::viewport_mut`
1056    /// instead of the (now-deleted) `Buffer::viewport_mut`.
1057    pub fn set_viewport_top(&mut self, row: usize) {
1058        let last = buf_row_count(&self.buffer).saturating_sub(1);
1059        let target = row.min(last);
1060        self.host.viewport_mut().top_row = target;
1061    }
1062
1063    /// Set the cursor to `(row, col)`, clamped to the buffer's
1064    /// content. Hosts use this for goto-line, jump-to-mark, and
1065    /// programmatic cursor placement.
1066    pub fn jump_cursor(&mut self, row: usize, col: usize) {
1067        buf_set_cursor_rc(&mut self.buffer, row, col);
1068    }
1069
1070    /// `(row, col)` cursor read sourced from the migration buffer.
1071    /// Equivalent to `self.textarea.cursor()` when the two are in
1072    /// sync — which is the steady state during Phase 7f because
1073    /// every step opens with `sync_buffer_content_from_textarea` and
1074    /// every ported motion pushes the result back. Prefer this over
1075    /// `self.textarea.cursor()` so call sites keep working unchanged
1076    /// once the textarea field is ripped.
1077    pub fn cursor(&self) -> (usize, usize) {
1078        buf_cursor_rc(&self.buffer)
1079    }
1080
1081    /// Drain any pending LSP intent raised by the last key. Returns
1082    /// `None` when no intent is armed.
1083    pub fn take_lsp_intent(&mut self) -> Option<LspIntent> {
1084        self.pending_lsp.take()
1085    }
1086
1087    /// Drain every [`crate::types::FoldOp`] raised since the last
1088    /// call. Hosts that mirror the engine's fold storage (or that
1089    /// project folds onto a separate fold tree, LSP folding ranges,
1090    /// …) drain this each step and dispatch as their own
1091    /// [`crate::types::Host::Intent`] requires.
1092    ///
1093    /// The engine has already applied every op locally against the
1094    /// in-tree [`hjkl_buffer::Buffer`] fold storage via
1095    /// [`crate::buffer_impl::BufferFoldProviderMut`], so hosts that
1096    /// don't track folds independently can ignore the queue
1097    /// (or simply never call this drain).
1098    ///
1099    /// Introduced in 0.0.38 (Patch C-δ.4).
1100    pub fn take_fold_ops(&mut self) -> Vec<crate::types::FoldOp> {
1101        std::mem::take(&mut self.pending_fold_ops)
1102    }
1103
1104    /// Dispatch a [`crate::types::FoldOp`] through the canonical fold
1105    /// surface: queue it for host observation (drained by
1106    /// [`Editor::take_fold_ops`]) and apply it locally against the
1107    /// in-tree buffer fold storage via
1108    /// [`crate::buffer_impl::BufferFoldProviderMut`]. Engine call sites
1109    /// (vim FSM `z…` chords, `:fold*` Ex commands, edit-pipeline
1110    /// invalidation) route every fold mutation through this method.
1111    ///
1112    /// Introduced in 0.0.38 (Patch C-δ.4).
1113    pub fn apply_fold_op(&mut self, op: crate::types::FoldOp) {
1114        use crate::types::FoldProvider;
1115        self.pending_fold_ops.push(op);
1116        let mut provider = crate::buffer_impl::BufferFoldProviderMut::new(&mut self.buffer);
1117        provider.apply(op);
1118    }
1119
1120    /// Refresh the host viewport's height from the cached
1121    /// `viewport_height_value()`. Called from the per-step
1122    /// boilerplate; was the textarea → buffer mirror before Phase 7f
1123    /// put Buffer in charge. 0.0.28 hoisted sticky_col out of
1124    /// `Buffer`. 0.0.34 (Patch C-δ.1) routes the height write through
1125    /// `Host::viewport_mut`.
1126    pub(crate) fn sync_buffer_from_textarea(&mut self) {
1127        let height = self.viewport_height_value();
1128        self.host.viewport_mut().height = height;
1129    }
1130
1131    /// Was the full textarea → buffer content sync. Buffer is the
1132    /// content authority now; this remains as a no-op so the per-step
1133    /// call sites don't have to be ripped in the same patch.
1134    pub(crate) fn sync_buffer_content_from_textarea(&mut self) {
1135        self.sync_buffer_from_textarea();
1136    }
1137
1138    /// Push a `(row, col)` onto the back-jumplist so `Ctrl-o` returns
1139    /// to it later. Used by host-driven jumps (e.g. `gd`) that move
1140    /// the cursor without going through the vim engine's motion
1141    /// machinery, where push_jump fires automatically.
1142    pub fn record_jump(&mut self, pos: (usize, usize)) {
1143        const JUMPLIST_MAX: usize = 100;
1144        self.vim.jump_back.push(pos);
1145        if self.vim.jump_back.len() > JUMPLIST_MAX {
1146            self.vim.jump_back.remove(0);
1147        }
1148        self.vim.jump_fwd.clear();
1149    }
1150
1151    /// Host apps call this each draw with the current text area height so
1152    /// scroll helpers can clamp the cursor without recomputing layout.
1153    pub fn set_viewport_height(&self, height: u16) {
1154        self.viewport_height.store(height, Ordering::Relaxed);
1155    }
1156
1157    /// Last height published by `set_viewport_height` (in rows).
1158    pub fn viewport_height_value(&self) -> u16 {
1159        self.viewport_height.load(Ordering::Relaxed)
1160    }
1161
1162    /// Apply `edit` against the buffer and return the inverse so the
1163    /// host can push it onto an undo stack. Side effects: dirty
1164    /// flag, change-list ring, mark / jump-list shifts, change_log
1165    /// append, fold invalidation around the touched rows.
1166    ///
1167    /// The primary edit funnel — both FSM operators and ex commands
1168    /// route mutations through here so the side effects fire
1169    /// uniformly.
1170    pub fn mutate_edit(&mut self, edit: hjkl_buffer::Edit) -> hjkl_buffer::Edit {
1171        // `:set readonly` short-circuits every mutation funnel: no
1172        // buffer change, no dirty flag, no undo entry, no change-log
1173        // emission. We swallow the requested `edit` and hand back a
1174        // self-inverse no-op (`InsertStr` of an empty string at the
1175        // current cursor) so callers that push the return value onto
1176        // an undo stack still get a structurally valid round trip.
1177        if self.settings.readonly {
1178            let _ = edit;
1179            return hjkl_buffer::Edit::InsertStr {
1180                at: buf_cursor_pos(&self.buffer),
1181                text: String::new(),
1182            };
1183        }
1184        let pre_row = buf_cursor_row(&self.buffer);
1185        let pre_rows = buf_row_count(&self.buffer);
1186        // Map the underlying buffer edit to a SPEC EditOp for
1187        // change-log emission before consuming it. Coarse — see
1188        // change_log field doc on the struct.
1189        self.change_log.extend(edit_to_editops(&edit));
1190        // 0.0.42 (Patch C-δ.7): the `apply_edit` reach is centralized
1191        // in [`crate::buf_helpers::apply_buffer_edit`] (option (c) of
1192        // the 0.0.42 plan — see that fn's doc comment). The free fn
1193        // takes `&mut hjkl_buffer::Buffer` so the editor body itself
1194        // no longer carries a `self.buffer.<inherent>` hop.
1195        let inverse = apply_buffer_edit(&mut self.buffer, edit);
1196        let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
1197        // Drop any folds the edit's range overlapped — vim opens the
1198        // surrounding fold automatically when you edit inside it. The
1199        // approximation here invalidates folds covering either the
1200        // pre-edit cursor row or the post-edit cursor row, which
1201        // catches the common single-line / multi-line edit shapes.
1202        let lo = pre_row.min(pos_row);
1203        let hi = pre_row.max(pos_row);
1204        self.apply_fold_op(crate::types::FoldOp::Invalidate {
1205            start_row: lo,
1206            end_row: hi,
1207        });
1208        self.vim.last_edit_pos = Some((pos_row, pos_col));
1209        // Append to the change-list ring (skip when the cursor sits on
1210        // the same cell as the last entry — back-to-back keystrokes on
1211        // one column shouldn't pollute the ring). A new edit while
1212        // walking the ring trims the forward half, vim style.
1213        let entry = (pos_row, pos_col);
1214        if self.vim.change_list.last() != Some(&entry) {
1215            if let Some(idx) = self.vim.change_list_cursor.take() {
1216                self.vim.change_list.truncate(idx + 1);
1217            }
1218            self.vim.change_list.push(entry);
1219            let len = self.vim.change_list.len();
1220            if len > crate::vim::CHANGE_LIST_MAX {
1221                self.vim
1222                    .change_list
1223                    .drain(0..len - crate::vim::CHANGE_LIST_MAX);
1224            }
1225        }
1226        self.vim.change_list_cursor = None;
1227        // Shift / drop marks + jump-list entries to track the row
1228        // delta the edit produced. Without this, every line-changing
1229        // edit silently invalidates `'a`-style positions.
1230        let post_rows = buf_row_count(&self.buffer);
1231        let delta = post_rows as isize - pre_rows as isize;
1232        if delta != 0 {
1233            self.shift_marks_after_edit(pre_row, delta);
1234        }
1235        self.push_buffer_content_to_textarea();
1236        self.mark_content_dirty();
1237        inverse
1238    }
1239
1240    /// Migrate user marks + jumplist entries when an edit at row
1241    /// `edit_start` changes the buffer's row count by `delta` (positive
1242    /// for inserts, negative for deletes). Marks tied to a deleted row
1243    /// are dropped; marks past the affected band shift by `delta`.
1244    fn shift_marks_after_edit(&mut self, edit_start: usize, delta: isize) {
1245        if delta == 0 {
1246            return;
1247        }
1248        // Deleted-row band (only meaningful for delta < 0). Inclusive
1249        // start, exclusive end.
1250        let drop_end = if delta < 0 {
1251            edit_start.saturating_add((-delta) as usize)
1252        } else {
1253            edit_start
1254        };
1255        let shift_threshold = drop_end.max(edit_start.saturating_add(1));
1256
1257        // 0.0.36: lowercase + uppercase marks share the unified
1258        // `marks` map; one pass migrates both.
1259        let mut to_drop: Vec<char> = Vec::new();
1260        for (c, (row, _col)) in self.marks.iter_mut() {
1261            if (edit_start..drop_end).contains(row) {
1262                to_drop.push(*c);
1263            } else if *row >= shift_threshold {
1264                *row = ((*row as isize) + delta).max(0) as usize;
1265            }
1266        }
1267        for c in to_drop {
1268            self.marks.remove(&c);
1269        }
1270
1271        let shift_jumps = |entries: &mut Vec<(usize, usize)>| {
1272            entries.retain(|(row, _)| !(edit_start..drop_end).contains(row));
1273            for (row, _) in entries.iter_mut() {
1274                if *row >= shift_threshold {
1275                    *row = ((*row as isize) + delta).max(0) as usize;
1276                }
1277            }
1278        };
1279        shift_jumps(&mut self.vim.jump_back);
1280        shift_jumps(&mut self.vim.jump_fwd);
1281    }
1282
1283    /// Reverse-sync helper paired with [`Editor::mutate_edit`]: rebuild
1284    /// the textarea from the buffer's lines + cursor, preserving yank
1285    /// text. Heavy (allocates a fresh `TextArea`) but correct; the
1286    /// textarea field disappears at the end of Phase 7f anyway.
1287    /// No-op since Buffer is the content authority. Retained as a
1288    /// shim so call sites in `mutate_edit` and friends don't have to
1289    /// be ripped in lockstep with the field removal.
1290    pub(crate) fn push_buffer_content_to_textarea(&mut self) {}
1291
1292    /// Single choke-point for "the buffer just changed". Sets the
1293    /// dirty flag and drops the cached `content_arc` snapshot so
1294    /// subsequent reads rebuild from the live textarea. Callers
1295    /// mutating `textarea` directly (e.g. the TUI's bracketed-paste
1296    /// path) must invoke this to keep the cache honest.
1297    pub fn mark_content_dirty(&mut self) {
1298        self.content_dirty = true;
1299        self.cached_content = None;
1300    }
1301
1302    /// Returns true if content changed since the last call, then clears the flag.
1303    pub fn take_dirty(&mut self) -> bool {
1304        let dirty = self.content_dirty;
1305        self.content_dirty = false;
1306        dirty
1307    }
1308
1309    /// Pull-model coarse change observation. If content changed since
1310    /// the last call, returns `Some(Arc<String>)` with the new content
1311    /// and clears the dirty flag; otherwise returns `None`.
1312    ///
1313    /// Hosts that need fine-grained edit deltas (e.g., DOM patching at
1314    /// the character level) should diff against their own previous
1315    /// snapshot. The SPEC `take_changes() -> Vec<EditOp>` API lands
1316    /// once every edit path inside the engine is instrumented; this
1317    /// coarse form covers the pull-model use case in the meantime.
1318    pub fn take_content_change(&mut self) -> Option<std::sync::Arc<String>> {
1319        if !self.content_dirty {
1320            return None;
1321        }
1322        let arc = self.content_arc();
1323        self.content_dirty = false;
1324        Some(arc)
1325    }
1326
1327    /// Returns the cursor's row within the visible textarea (0-based), updating
1328    /// the stored viewport top so subsequent calls remain accurate.
1329    pub fn cursor_screen_row(&mut self, height: u16) -> u16 {
1330        let cursor = buf_cursor_row(&self.buffer);
1331        let top = self.host.viewport().top_row;
1332        cursor.saturating_sub(top).min(height as usize - 1) as u16
1333    }
1334
1335    /// Returns the cursor's screen position `(x, y)` for the textarea
1336    /// described by `(area_x, area_y, area_width, area_height)`.
1337    /// Accounts for line-number gutter and viewport scroll. Returns
1338    /// `None` if the cursor is outside the visible viewport. Always
1339    /// available (engine-native; no ratatui dependency).
1340    ///
1341    /// Renamed from `cursor_screen_pos_xywh` in 0.0.32 — the
1342    /// ratatui-flavoured `Rect` variant is now
1343    /// [`Editor::cursor_screen_pos_in_rect`] (cfg `ratatui`).
1344    pub fn cursor_screen_pos(
1345        &self,
1346        area_x: u16,
1347        area_y: u16,
1348        area_width: u16,
1349        area_height: u16,
1350    ) -> Option<(u16, u16)> {
1351        let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
1352        let v = self.host.viewport();
1353        if pos_row < v.top_row || pos_col < v.top_col {
1354            return None;
1355        }
1356        let lnum_width = buf_row_count(&self.buffer).to_string().len() as u16 + 2;
1357        let dy = (pos_row - v.top_row) as u16;
1358        let dx = (pos_col - v.top_col) as u16;
1359        if dy >= area_height || dx + lnum_width >= area_width {
1360            return None;
1361        }
1362        Some((area_x + lnum_width + dx, area_y + dy))
1363    }
1364
1365    /// Ratatui [`Rect`]-flavoured wrapper around
1366    /// [`Editor::cursor_screen_pos`]. Behind the `ratatui` feature.
1367    ///
1368    /// Renamed from `cursor_screen_pos` in 0.0.32 — the unprefixed
1369    /// name now belongs to the engine-native variant.
1370    #[cfg(feature = "ratatui")]
1371    pub fn cursor_screen_pos_in_rect(&self, area: Rect) -> Option<(u16, u16)> {
1372        self.cursor_screen_pos(area.x, area.y, area.width, area.height)
1373    }
1374
1375    pub fn vim_mode(&self) -> VimMode {
1376        self.vim.public_mode()
1377    }
1378
1379    /// Bounds of the active visual-block rectangle as
1380    /// `(top_row, bot_row, left_col, right_col)` — all inclusive.
1381    /// `None` when we're not in VisualBlock mode.
1382    /// Read-only view of the live `/` or `?` prompt. `None` outside
1383    /// search-prompt mode.
1384    pub fn search_prompt(&self) -> Option<&crate::vim::SearchPrompt> {
1385        self.vim.search_prompt.as_ref()
1386    }
1387
1388    /// Most recent committed search pattern (persists across `n` / `N`
1389    /// and across prompt exits). `None` before the first search.
1390    pub fn last_search(&self) -> Option<&str> {
1391        self.vim.last_search.as_deref()
1392    }
1393
1394    /// Start/end `(row, col)` of the active char-wise Visual selection
1395    /// (inclusive on both ends, positionally ordered). `None` when not
1396    /// in Visual mode.
1397    pub fn char_highlight(&self) -> Option<((usize, usize), (usize, usize))> {
1398        if self.vim_mode() != VimMode::Visual {
1399            return None;
1400        }
1401        let anchor = self.vim.visual_anchor;
1402        let cursor = self.cursor();
1403        let (start, end) = if anchor <= cursor {
1404            (anchor, cursor)
1405        } else {
1406            (cursor, anchor)
1407        };
1408        Some((start, end))
1409    }
1410
1411    /// Top/bottom rows of the active VisualLine selection (inclusive).
1412    /// `None` when we're not in VisualLine mode.
1413    pub fn line_highlight(&self) -> Option<(usize, usize)> {
1414        if self.vim_mode() != VimMode::VisualLine {
1415            return None;
1416        }
1417        let anchor = self.vim.visual_line_anchor;
1418        let cursor = buf_cursor_row(&self.buffer);
1419        Some((anchor.min(cursor), anchor.max(cursor)))
1420    }
1421
1422    pub fn block_highlight(&self) -> Option<(usize, usize, usize, usize)> {
1423        if self.vim_mode() != VimMode::VisualBlock {
1424            return None;
1425        }
1426        let (ar, ac) = self.vim.block_anchor;
1427        let cr = buf_cursor_row(&self.buffer);
1428        let cc = self.vim.block_vcol;
1429        let top = ar.min(cr);
1430        let bot = ar.max(cr);
1431        let left = ac.min(cc);
1432        let right = ac.max(cc);
1433        Some((top, bot, left, right))
1434    }
1435
1436    /// Active selection in `hjkl_buffer::Selection` shape. `None` when
1437    /// not in a Visual mode. Phase 7d-i wiring — the host hands this
1438    /// straight to `BufferView` once render flips off textarea
1439    /// (Phase 7d-ii drops the `paint_*_overlay` calls on the same
1440    /// switch).
1441    pub fn buffer_selection(&self) -> Option<hjkl_buffer::Selection> {
1442        use hjkl_buffer::{Position, Selection};
1443        match self.vim_mode() {
1444            VimMode::Visual => {
1445                let (ar, ac) = self.vim.visual_anchor;
1446                let head = buf_cursor_pos(&self.buffer);
1447                Some(Selection::Char {
1448                    anchor: Position::new(ar, ac),
1449                    head,
1450                })
1451            }
1452            VimMode::VisualLine => {
1453                let anchor_row = self.vim.visual_line_anchor;
1454                let head_row = buf_cursor_row(&self.buffer);
1455                Some(Selection::Line {
1456                    anchor_row,
1457                    head_row,
1458                })
1459            }
1460            VimMode::VisualBlock => {
1461                let (ar, ac) = self.vim.block_anchor;
1462                let cr = buf_cursor_row(&self.buffer);
1463                let cc = self.vim.block_vcol;
1464                Some(Selection::Block {
1465                    anchor: Position::new(ar, ac),
1466                    head: Position::new(cr, cc),
1467                })
1468            }
1469            _ => None,
1470        }
1471    }
1472
1473    /// Force back to normal mode (used when dismissing completions etc.)
1474    pub fn force_normal(&mut self) {
1475        self.vim.force_normal();
1476    }
1477
1478    pub fn content(&self) -> String {
1479        let n = buf_row_count(&self.buffer);
1480        let mut s = String::new();
1481        for r in 0..n {
1482            if r > 0 {
1483                s.push('\n');
1484            }
1485            s.push_str(crate::types::Query::line(&self.buffer, r as u32));
1486        }
1487        s.push('\n');
1488        s
1489    }
1490
1491    /// Same logical output as [`content`], but returns a cached
1492    /// `Arc<String>` so back-to-back reads within an un-mutated window
1493    /// are ref-count bumps instead of multi-MB joins. The cache is
1494    /// invalidated by every [`mark_content_dirty`] call.
1495    pub fn content_arc(&mut self) -> std::sync::Arc<String> {
1496        if let Some(arc) = &self.cached_content {
1497            return std::sync::Arc::clone(arc);
1498        }
1499        let arc = std::sync::Arc::new(self.content());
1500        self.cached_content = Some(std::sync::Arc::clone(&arc));
1501        arc
1502    }
1503
1504    pub fn set_content(&mut self, text: &str) {
1505        let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
1506        while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
1507            lines.pop();
1508        }
1509        if lines.is_empty() {
1510            lines.push(String::new());
1511        }
1512        let _ = lines;
1513        crate::types::BufferEdit::replace_all(&mut self.buffer, text);
1514        self.undo_stack.clear();
1515        self.redo_stack.clear();
1516        self.mark_content_dirty();
1517    }
1518
1519    /// Feed an SPEC [`crate::PlannedInput`] into the engine.
1520    ///
1521    /// Bridge for hosts that don't carry crossterm — buffr's CEF
1522    /// shell, future GUI frontends. Converts directly to the engine's
1523    /// internal [`Input`] type and dispatches through the vim FSM,
1524    /// bypassing crossterm entirely so this entry point is always
1525    /// available regardless of the `crossterm` feature.
1526    ///
1527    /// `Input::Mouse`, `Input::Paste`, `Input::FocusGained`,
1528    /// `Input::FocusLost`, and `Input::Resize` currently fall through
1529    /// without effect — the legacy FSM doesn't dispatch them. They're
1530    /// accepted so the host can pump them into the engine without
1531    /// special-casing.
1532    ///
1533    /// Returns `true` when the keystroke was consumed.
1534    pub fn feed_input(&mut self, input: crate::PlannedInput) -> bool {
1535        use crate::{PlannedInput, SpecialKey};
1536        let (key, mods) = match input {
1537            PlannedInput::Char(c, m) => (Key::Char(c), m),
1538            PlannedInput::Key(k, m) => {
1539                let key = match k {
1540                    SpecialKey::Esc => Key::Esc,
1541                    SpecialKey::Enter => Key::Enter,
1542                    SpecialKey::Backspace => Key::Backspace,
1543                    SpecialKey::Tab => Key::Tab,
1544                    // Engine's internal `Key` doesn't model BackTab as a
1545                    // distinct variant — fall through to the FSM as
1546                    // shift+Tab, matching crossterm semantics.
1547                    SpecialKey::BackTab => Key::Tab,
1548                    SpecialKey::Up => Key::Up,
1549                    SpecialKey::Down => Key::Down,
1550                    SpecialKey::Left => Key::Left,
1551                    SpecialKey::Right => Key::Right,
1552                    SpecialKey::Home => Key::Home,
1553                    SpecialKey::End => Key::End,
1554                    SpecialKey::PageUp => Key::PageUp,
1555                    SpecialKey::PageDown => Key::PageDown,
1556                    // Engine's `Key` has no Insert / F(n) — drop to Null
1557                    // (FSM ignores it) which matches the crossterm path
1558                    // (`crossterm_to_input` mapped these to Null too).
1559                    SpecialKey::Insert => Key::Null,
1560                    SpecialKey::Delete => Key::Delete,
1561                    SpecialKey::F(_) => Key::Null,
1562                };
1563                let m = if matches!(k, SpecialKey::BackTab) {
1564                    crate::Modifiers { shift: true, ..m }
1565                } else {
1566                    m
1567                };
1568                (key, m)
1569            }
1570            // Variants the legacy FSM doesn't consume yet.
1571            PlannedInput::Mouse(_)
1572            | PlannedInput::Paste(_)
1573            | PlannedInput::FocusGained
1574            | PlannedInput::FocusLost
1575            | PlannedInput::Resize(_, _) => return false,
1576        };
1577        if key == Key::Null {
1578            return false;
1579        }
1580        let event = Input {
1581            key,
1582            ctrl: mods.ctrl,
1583            alt: mods.alt,
1584            shift: mods.shift,
1585        };
1586        let consumed = vim::step(self, event);
1587        self.emit_cursor_shape_if_changed();
1588        consumed
1589    }
1590
1591    /// Drain the pending change log produced by buffer mutations.
1592    ///
1593    /// Returns a `Vec<EditOp>` covering edits applied since the last
1594    /// call. Empty when no edits ran. Pull-model, complementary to
1595    /// [`Editor::take_content_change`] which gives back the new full
1596    /// content.
1597    ///
1598    /// Mapping coverage:
1599    /// - InsertChar / InsertStr → exact `EditOp` with empty range +
1600    ///   replacement.
1601    /// - DeleteRange (`Char` kind) → exact range + empty replacement.
1602    /// - Replace → exact range + new replacement.
1603    /// - DeleteRange (`Line`/`Block`), JoinLines, SplitLines,
1604    ///   InsertBlock, DeleteBlockChunks → best-effort placeholder
1605    ///   covering the touched range. Hosts wanting per-cell deltas
1606    ///   should diff their own `lines()` snapshot.
1607    pub fn take_changes(&mut self) -> Vec<crate::types::Edit> {
1608        std::mem::take(&mut self.change_log)
1609    }
1610
1611    /// Read the engine's current settings as a SPEC
1612    /// [`crate::types::Options`].
1613    ///
1614    /// Bridges between the legacy [`Settings`] (which carries fewer
1615    /// fields than SPEC) and the planned 0.1.0 trait surface. Fields
1616    /// not present in `Settings` fall back to vim defaults (e.g.,
1617    /// `expandtab=false`, `wrapscan=true`, `timeout_len=1000ms`).
1618    /// Once trait extraction lands, this becomes the canonical config
1619    /// reader and `Settings` retires.
1620    pub fn current_options(&self) -> crate::types::Options {
1621        crate::types::Options {
1622            shiftwidth: self.settings.shiftwidth as u32,
1623            tabstop: self.settings.tabstop as u32,
1624            textwidth: self.settings.textwidth as u32,
1625            expandtab: self.settings.expandtab,
1626            ignorecase: self.settings.ignore_case,
1627            smartcase: self.settings.smartcase,
1628            wrapscan: self.settings.wrapscan,
1629            wrap: match self.settings.wrap {
1630                hjkl_buffer::Wrap::None => crate::types::WrapMode::None,
1631                hjkl_buffer::Wrap::Char => crate::types::WrapMode::Char,
1632                hjkl_buffer::Wrap::Word => crate::types::WrapMode::Word,
1633            },
1634            readonly: self.settings.readonly,
1635            autoindent: self.settings.autoindent,
1636            undo_levels: self.settings.undo_levels,
1637            undo_break_on_motion: self.settings.undo_break_on_motion,
1638            iskeyword: self.settings.iskeyword.clone(),
1639            timeout_len: self.settings.timeout_len,
1640            ..crate::types::Options::default()
1641        }
1642    }
1643
1644    /// Apply a SPEC [`crate::types::Options`] to the engine's settings.
1645    /// Only the fields backed by today's [`Settings`] take effect;
1646    /// remaining options become live once trait extraction wires them
1647    /// through.
1648    pub fn apply_options(&mut self, opts: &crate::types::Options) {
1649        self.settings.shiftwidth = opts.shiftwidth as usize;
1650        self.settings.tabstop = opts.tabstop as usize;
1651        self.settings.textwidth = opts.textwidth as usize;
1652        self.settings.expandtab = opts.expandtab;
1653        self.settings.ignore_case = opts.ignorecase;
1654        self.settings.smartcase = opts.smartcase;
1655        self.settings.wrapscan = opts.wrapscan;
1656        self.settings.wrap = match opts.wrap {
1657            crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
1658            crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
1659            crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
1660        };
1661        self.settings.readonly = opts.readonly;
1662        self.settings.autoindent = opts.autoindent;
1663        self.settings.undo_levels = opts.undo_levels;
1664        self.settings.undo_break_on_motion = opts.undo_break_on_motion;
1665        self.set_iskeyword(opts.iskeyword.clone());
1666        self.settings.timeout_len = opts.timeout_len;
1667    }
1668
1669    /// Active visual selection as a SPEC [`crate::types::Highlight`]
1670    /// with [`crate::types::HighlightKind::Selection`].
1671    ///
1672    /// Returns `None` when the editor isn't in a Visual mode.
1673    /// Visual-line and visual-block selections collapse to the
1674    /// bounding char range of the selection — the SPEC `Selection`
1675    /// kind doesn't carry sub-line info today; hosts that need full
1676    /// line / block geometry continue to read [`buffer_selection`]
1677    /// (the legacy [`hjkl_buffer::Selection`] shape).
1678    pub fn selection_highlight(&self) -> Option<crate::types::Highlight> {
1679        use crate::types::{Highlight, HighlightKind, Pos};
1680        let sel = self.buffer_selection()?;
1681        let (start, end) = match sel {
1682            hjkl_buffer::Selection::Char { anchor, head } => {
1683                let a = (anchor.row, anchor.col);
1684                let h = (head.row, head.col);
1685                if a <= h { (a, h) } else { (h, a) }
1686            }
1687            hjkl_buffer::Selection::Line {
1688                anchor_row,
1689                head_row,
1690            } => {
1691                let (top, bot) = if anchor_row <= head_row {
1692                    (anchor_row, head_row)
1693                } else {
1694                    (head_row, anchor_row)
1695                };
1696                let last_col = buf_line(&self.buffer, bot).map(|l| l.len()).unwrap_or(0);
1697                ((top, 0), (bot, last_col))
1698            }
1699            hjkl_buffer::Selection::Block { anchor, head } => {
1700                let (top, bot) = if anchor.row <= head.row {
1701                    (anchor.row, head.row)
1702                } else {
1703                    (head.row, anchor.row)
1704                };
1705                let (left, right) = if anchor.col <= head.col {
1706                    (anchor.col, head.col)
1707                } else {
1708                    (head.col, anchor.col)
1709                };
1710                ((top, left), (bot, right))
1711            }
1712        };
1713        Some(Highlight {
1714            range: Pos {
1715                line: start.0 as u32,
1716                col: start.1 as u32,
1717            }..Pos {
1718                line: end.0 as u32,
1719                col: end.1 as u32,
1720            },
1721            kind: HighlightKind::Selection,
1722        })
1723    }
1724
1725    /// SPEC-typed highlights for `line`.
1726    ///
1727    /// Two emission modes:
1728    ///
1729    /// - **IncSearch**: the user is typing a `/` or `?` prompt and
1730    ///   `Editor::search_prompt` is `Some`. Live-preview matches of
1731    ///   the in-flight pattern surface as
1732    ///   [`crate::types::HighlightKind::IncSearch`].
1733    /// - **SearchMatch**: the prompt has been committed (or absent)
1734    ///   and the buffer's armed pattern is non-empty. Matches surface
1735    ///   as [`crate::types::HighlightKind::SearchMatch`].
1736    ///
1737    /// Selection / MatchParen / Syntax(id) variants land once the
1738    /// trait extraction routes the FSM's selection set + the host's
1739    /// syntax pipeline through the [`crate::types::Host`] trait.
1740    ///
1741    /// Returns an empty vec when there is nothing to highlight or
1742    /// `line` is out of bounds.
1743    pub fn highlights_for_line(&mut self, line: u32) -> Vec<crate::types::Highlight> {
1744        use crate::types::{Highlight, HighlightKind, Pos};
1745        let row = line as usize;
1746        if row >= buf_row_count(&self.buffer) {
1747            return Vec::new();
1748        }
1749
1750        // Live preview while the prompt is open beats the committed
1751        // pattern.
1752        if let Some(prompt) = self.search_prompt() {
1753            if prompt.text.is_empty() {
1754                return Vec::new();
1755            }
1756            let Ok(re) = regex::Regex::new(&prompt.text) else {
1757                return Vec::new();
1758            };
1759            let Some(haystack) = buf_line(&self.buffer, row) else {
1760                return Vec::new();
1761            };
1762            return re
1763                .find_iter(haystack)
1764                .map(|m| Highlight {
1765                    range: Pos {
1766                        line,
1767                        col: m.start() as u32,
1768                    }..Pos {
1769                        line,
1770                        col: m.end() as u32,
1771                    },
1772                    kind: HighlightKind::IncSearch,
1773                })
1774                .collect();
1775        }
1776
1777        if self.search_state.pattern.is_none() {
1778            return Vec::new();
1779        }
1780        let dgen = crate::types::Query::dirty_gen(&self.buffer);
1781        crate::search::search_matches(&self.buffer, &mut self.search_state, dgen, row)
1782            .into_iter()
1783            .map(|(start, end)| Highlight {
1784                range: Pos {
1785                    line,
1786                    col: start as u32,
1787                }..Pos {
1788                    line,
1789                    col: end as u32,
1790                },
1791                kind: HighlightKind::SearchMatch,
1792            })
1793            .collect()
1794    }
1795
1796    /// Build the engine's [`crate::types::RenderFrame`] for the
1797    /// current state. Hosts call this once per redraw and diff
1798    /// across frames.
1799    ///
1800    /// Coarse today — covers mode + cursor + cursor shape + viewport
1801    /// top + line count. SPEC-target fields (selections, highlights,
1802    /// command line, search prompt, status line) land once trait
1803    /// extraction routes them through `SelectionSet` and the
1804    /// `Highlight` pipeline.
1805    pub fn render_frame(&self) -> crate::types::RenderFrame {
1806        use crate::types::{CursorShape, RenderFrame, SnapshotMode};
1807        let (cursor_row, cursor_col) = self.cursor();
1808        let (mode, shape) = match self.vim_mode() {
1809            crate::VimMode::Normal => (SnapshotMode::Normal, CursorShape::Block),
1810            crate::VimMode::Insert => (SnapshotMode::Insert, CursorShape::Bar),
1811            crate::VimMode::Visual => (SnapshotMode::Visual, CursorShape::Block),
1812            crate::VimMode::VisualLine => (SnapshotMode::VisualLine, CursorShape::Block),
1813            crate::VimMode::VisualBlock => (SnapshotMode::VisualBlock, CursorShape::Block),
1814        };
1815        RenderFrame {
1816            mode,
1817            cursor_row: cursor_row as u32,
1818            cursor_col: cursor_col as u32,
1819            cursor_shape: shape,
1820            viewport_top: self.host.viewport().top_row as u32,
1821            line_count: crate::types::Query::line_count(&self.buffer),
1822        }
1823    }
1824
1825    /// Capture the editor's coarse state into a serde-friendly
1826    /// [`crate::types::EditorSnapshot`].
1827    ///
1828    /// Today's snapshot covers mode, cursor, lines, viewport top.
1829    /// Registers, marks, jump list, undo tree, and full options arrive
1830    /// once phase 5 trait extraction lands the generic
1831    /// `Editor<B: Buffer, H: Host>` constructor — this method's surface
1832    /// stays stable; only the snapshot's internal fields grow.
1833    ///
1834    /// Distinct from the internal `snapshot` used by undo (which
1835    /// returns `(Vec<String>, (usize, usize))`); host-facing
1836    /// persistence goes through this one.
1837    pub fn take_snapshot(&self) -> crate::types::EditorSnapshot {
1838        use crate::types::{EditorSnapshot, SnapshotMode};
1839        let mode = match self.vim_mode() {
1840            crate::VimMode::Normal => SnapshotMode::Normal,
1841            crate::VimMode::Insert => SnapshotMode::Insert,
1842            crate::VimMode::Visual => SnapshotMode::Visual,
1843            crate::VimMode::VisualLine => SnapshotMode::VisualLine,
1844            crate::VimMode::VisualBlock => SnapshotMode::VisualBlock,
1845        };
1846        let cursor = self.cursor();
1847        let cursor = (cursor.0 as u32, cursor.1 as u32);
1848        let lines: Vec<String> = buf_lines_to_vec(&self.buffer);
1849        let viewport_top = self.host.viewport().top_row as u32;
1850        let marks = self
1851            .marks
1852            .iter()
1853            .map(|(c, (r, col))| (*c, (*r as u32, *col as u32)))
1854            .collect();
1855        EditorSnapshot {
1856            version: EditorSnapshot::VERSION,
1857            mode,
1858            cursor,
1859            lines,
1860            viewport_top,
1861            registers: self.registers.clone(),
1862            marks,
1863        }
1864    }
1865
1866    /// Restore editor state from an [`EditorSnapshot`]. Returns
1867    /// [`crate::EngineError::SnapshotVersion`] if the snapshot's
1868    /// `version` doesn't match [`EditorSnapshot::VERSION`].
1869    ///
1870    /// Mode is best-effort: `SnapshotMode` only round-trips the
1871    /// status-line summary, not the full FSM state. Visual / Insert
1872    /// mode entry happens through synthetic key dispatch when needed.
1873    pub fn restore_snapshot(
1874        &mut self,
1875        snap: crate::types::EditorSnapshot,
1876    ) -> Result<(), crate::EngineError> {
1877        use crate::types::EditorSnapshot;
1878        if snap.version != EditorSnapshot::VERSION {
1879            return Err(crate::EngineError::SnapshotVersion(
1880                snap.version,
1881                EditorSnapshot::VERSION,
1882            ));
1883        }
1884        let text = snap.lines.join("\n");
1885        self.set_content(&text);
1886        self.jump_cursor(snap.cursor.0 as usize, snap.cursor.1 as usize);
1887        self.host.viewport_mut().top_row = snap.viewport_top as usize;
1888        self.registers = snap.registers;
1889        self.marks = snap
1890            .marks
1891            .into_iter()
1892            .map(|(c, (r, col))| (c, (r as usize, col as usize)))
1893            .collect();
1894        Ok(())
1895    }
1896
1897    /// Install `text` as the pending yank buffer so the next `p`/`P` pastes
1898    /// it. Linewise is inferred from a trailing newline, matching how `yy`/`dd`
1899    /// shape their payload.
1900    pub fn seed_yank(&mut self, text: String) {
1901        let linewise = text.ends_with('\n');
1902        self.vim.yank_linewise = linewise;
1903        self.registers.unnamed = crate::registers::Slot { text, linewise };
1904    }
1905
1906    /// Scroll the viewport down by `rows`. The cursor stays on its
1907    /// absolute line (vim convention) unless the scroll would take it
1908    /// off-screen — in that case it's clamped to the first row still
1909    /// visible.
1910    pub fn scroll_down(&mut self, rows: i16) {
1911        self.scroll_viewport(rows);
1912    }
1913
1914    /// Scroll the viewport up by `rows`. Cursor stays unless it would
1915    /// fall off the bottom of the new viewport, then clamp to the
1916    /// bottom-most visible row.
1917    pub fn scroll_up(&mut self, rows: i16) {
1918        self.scroll_viewport(-rows);
1919    }
1920
1921    /// Vim's `scrolloff` default — keep the cursor at least this many
1922    /// rows away from the top / bottom edge of the viewport while
1923    /// scrolling. Collapses to `height / 2` for tiny viewports.
1924    const SCROLLOFF: usize = 5;
1925
1926    /// Scroll the viewport so the cursor stays at least `SCROLLOFF`
1927    /// rows from each edge. Replaces the bare
1928    /// `Buffer::ensure_cursor_visible` call at end-of-step so motions
1929    /// don't park the cursor on the very last visible row.
1930    pub(crate) fn ensure_cursor_in_scrolloff(&mut self) {
1931        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
1932        if height == 0 {
1933            // 0.0.42 (Patch C-δ.7): viewport math lifted onto engine
1934            // free fns over `B: Query [+ Cursor]` + `&dyn FoldProvider`.
1935            // Disjoint-field borrow split: `self.buffer` (immutable via
1936            // `folds` snapshot + cursor) and `self.host` (mutable
1937            // viewport ref) live on distinct struct fields, so one
1938            // statement satisfies the borrow checker.
1939            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
1940            crate::viewport_math::ensure_cursor_visible(
1941                &self.buffer,
1942                &folds,
1943                self.host.viewport_mut(),
1944            );
1945            return;
1946        }
1947        // Cap margin at (height - 1) / 2 so the upper + lower bands
1948        // can't overlap on tiny windows (margin=5 + height=10 would
1949        // otherwise produce contradictory clamp ranges).
1950        let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
1951        // Soft-wrap path: scrolloff math runs in *screen rows*, not
1952        // doc rows, since a wrapped doc row spans many visual lines.
1953        if !matches!(self.host.viewport().wrap, hjkl_buffer::Wrap::None) {
1954            self.ensure_scrolloff_wrap(height, margin);
1955            return;
1956        }
1957        let cursor_row = buf_cursor_row(&self.buffer);
1958        let last_row = buf_row_count(&self.buffer).saturating_sub(1);
1959        let v = self.host.viewport_mut();
1960        // Top edge: cursor_row should sit at >= top_row + margin.
1961        if cursor_row < v.top_row + margin {
1962            v.top_row = cursor_row.saturating_sub(margin);
1963        }
1964        // Bottom edge: cursor_row should sit at <= top_row + height - 1 - margin.
1965        let max_bottom = height.saturating_sub(1).saturating_sub(margin);
1966        if cursor_row > v.top_row + max_bottom {
1967            v.top_row = cursor_row.saturating_sub(max_bottom);
1968        }
1969        // Clamp top_row so we never scroll past the buffer's bottom.
1970        let max_top = last_row.saturating_sub(height.saturating_sub(1));
1971        if v.top_row > max_top {
1972            v.top_row = max_top;
1973        }
1974        // Defer to Buffer for column-side scroll (no scrolloff for
1975        // horizontal scrolling — vim default `sidescrolloff = 0`).
1976        let cursor = buf_cursor_pos(&self.buffer);
1977        self.host.viewport_mut().ensure_visible(cursor);
1978    }
1979
1980    /// Soft-wrap-aware scrolloff. Walks `top_row` one visible doc row
1981    /// at a time so the cursor's *screen* row stays inside
1982    /// `[margin, height - 1 - margin]`, then clamps `top_row` so the
1983    /// buffer's bottom never leaves blank rows below it.
1984    fn ensure_scrolloff_wrap(&mut self, height: usize, margin: usize) {
1985        let cursor_row = buf_cursor_row(&self.buffer);
1986        // Step 1 — cursor above viewport: snap top to cursor row,
1987        // then we'll fix up the margin below.
1988        if cursor_row < self.host.viewport().top_row {
1989            let v = self.host.viewport_mut();
1990            v.top_row = cursor_row;
1991            v.top_col = 0;
1992        }
1993        // Step 2 — push top forward until cursor's screen row is
1994        // within the bottom margin (`csr <= height - 1 - margin`).
1995        // 0.0.33 (Patch C-γ): fold-iteration goes through the
1996        // [`crate::types::FoldProvider`] surface via
1997        // [`crate::buffer_impl::BufferFoldProvider`]. 0.0.34 (Patch
1998        // C-δ.1): `cursor_screen_row` / `max_top_for_height` now take
1999        // a `&Viewport` parameter; the host owns the viewport, so the
2000        // disjoint `(self.host, self.buffer)` borrows split cleanly.
2001        let max_csr = height.saturating_sub(1).saturating_sub(margin);
2002        loop {
2003            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2004            let csr =
2005                crate::viewport_math::cursor_screen_row(&self.buffer, &folds, self.host.viewport())
2006                    .unwrap_or(0);
2007            if csr <= max_csr {
2008                break;
2009            }
2010            let top = self.host.viewport().top_row;
2011            let row_count = buf_row_count(&self.buffer);
2012            let next = {
2013                let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2014                <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::next_visible_row(&folds, top, row_count)
2015            };
2016            let Some(next) = next else {
2017                break;
2018            };
2019            // Don't walk past the cursor's row.
2020            if next > cursor_row {
2021                self.host.viewport_mut().top_row = cursor_row;
2022                break;
2023            }
2024            self.host.viewport_mut().top_row = next;
2025        }
2026        // Step 3 — pull top backward until cursor's screen row is
2027        // past the top margin (`csr >= margin`).
2028        loop {
2029            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2030            let csr =
2031                crate::viewport_math::cursor_screen_row(&self.buffer, &folds, self.host.viewport())
2032                    .unwrap_or(0);
2033            if csr >= margin {
2034                break;
2035            }
2036            let top = self.host.viewport().top_row;
2037            let prev = {
2038                let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2039                <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::prev_visible_row(&folds, top)
2040            };
2041            let Some(prev) = prev else {
2042                break;
2043            };
2044            self.host.viewport_mut().top_row = prev;
2045        }
2046        // Step 4 — clamp top so the buffer's bottom doesn't leave
2047        // blank rows below it. `max_top_for_height` walks segments
2048        // backward from the last row until it accumulates `height`
2049        // screen rows.
2050        let max_top = {
2051            let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2052            crate::viewport_math::max_top_for_height(
2053                &self.buffer,
2054                &folds,
2055                self.host.viewport(),
2056                height,
2057            )
2058        };
2059        if self.host.viewport().top_row > max_top {
2060            self.host.viewport_mut().top_row = max_top;
2061        }
2062        self.host.viewport_mut().top_col = 0;
2063    }
2064
2065    fn scroll_viewport(&mut self, delta: i16) {
2066        if delta == 0 {
2067            return;
2068        }
2069        // Bump the host viewport's top within bounds.
2070        let total_rows = buf_row_count(&self.buffer) as isize;
2071        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2072        let cur_top = self.host.viewport().top_row as isize;
2073        let new_top = (cur_top + delta as isize)
2074            .max(0)
2075            .min((total_rows - 1).max(0)) as usize;
2076        self.host.viewport_mut().top_row = new_top;
2077        // Mirror to textarea so its viewport reads (still consumed by
2078        // a couple of helpers) stay accurate.
2079        let _ = cur_top;
2080        if height == 0 {
2081            return;
2082        }
2083        // Apply scrolloff: keep the cursor at least SCROLLOFF rows
2084        // from the visible viewport edges.
2085        let (cursor_row, cursor_col) = buf_cursor_rc(&self.buffer);
2086        let margin = Self::SCROLLOFF.min(height / 2);
2087        let min_row = new_top + margin;
2088        let max_row = new_top + height.saturating_sub(1).saturating_sub(margin);
2089        let target_row = cursor_row.clamp(min_row, max_row.max(min_row));
2090        if target_row != cursor_row {
2091            let line_len = buf_line(&self.buffer, target_row)
2092                .map(|l| l.chars().count())
2093                .unwrap_or(0);
2094            let target_col = cursor_col.min(line_len.saturating_sub(1));
2095            buf_set_cursor_rc(&mut self.buffer, target_row, target_col);
2096        }
2097    }
2098
2099    pub fn goto_line(&mut self, line: usize) {
2100        let row = line.saturating_sub(1);
2101        let max = buf_row_count(&self.buffer).saturating_sub(1);
2102        let target = row.min(max);
2103        buf_set_cursor_rc(&mut self.buffer, target, 0);
2104    }
2105
2106    /// Scroll so the cursor row lands at the given viewport position:
2107    /// `Center` → middle row, `Top` → first row, `Bottom` → last row.
2108    /// Cursor stays on its absolute line; only the viewport moves.
2109    pub(super) fn scroll_cursor_to(&mut self, pos: CursorScrollTarget) {
2110        let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2111        if height == 0 {
2112            return;
2113        }
2114        let cur_row = buf_cursor_row(&self.buffer);
2115        let cur_top = self.host.viewport().top_row;
2116        // Scrolloff awareness: `zt` lands the cursor at the top edge
2117        // of the viable area (top + margin), `zb` at the bottom edge
2118        // (top + height - 1 - margin). Match the cap used by
2119        // `ensure_cursor_in_scrolloff` so contradictory bounds are
2120        // impossible on tiny viewports.
2121        let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
2122        let new_top = match pos {
2123            CursorScrollTarget::Center => cur_row.saturating_sub(height / 2),
2124            CursorScrollTarget::Top => cur_row.saturating_sub(margin),
2125            CursorScrollTarget::Bottom => {
2126                cur_row.saturating_sub(height.saturating_sub(1).saturating_sub(margin))
2127            }
2128        };
2129        if new_top == cur_top {
2130            return;
2131        }
2132        self.host.viewport_mut().top_row = new_top;
2133    }
2134
2135    /// Translate a terminal mouse position into a (row, col) inside
2136    /// the document. The outer editor area is described by `(area_x,
2137    /// area_y, area_width)` (height is unused). 1-row tab bar at the
2138    /// top, then the textarea with 1 cell of horizontal pane padding
2139    /// on each side. Clicks past the line's last character clamp to
2140    /// the last char (Normal-mode invariant) — never past it.
2141    /// Char-counted, not byte-counted.
2142    ///
2143    /// Ratatui-free; [`Editor::mouse_to_doc_pos`] (behind the
2144    /// `ratatui` feature) is a thin `Rect`-flavoured wrapper.
2145    fn mouse_to_doc_pos_xy(&self, area_x: u16, area_y: u16, col: u16, row: u16) -> (usize, usize) {
2146        let n = buf_row_count(&self.buffer);
2147        let inner_top = area_y.saturating_add(1); // tab bar row
2148        let lnum_width = n.to_string().len() as u16 + 2;
2149        let content_x = area_x.saturating_add(1).saturating_add(lnum_width);
2150        let rel_row = row.saturating_sub(inner_top) as usize;
2151        let top = self.host.viewport().top_row;
2152        let doc_row = (top + rel_row).min(n.saturating_sub(1));
2153        let rel_col = col.saturating_sub(content_x) as usize;
2154        let line_chars = buf_line(&self.buffer, doc_row)
2155            .map(|l| l.chars().count())
2156            .unwrap_or(0);
2157        let last_col = line_chars.saturating_sub(1);
2158        (doc_row, rel_col.min(last_col))
2159    }
2160
2161    /// Jump the cursor to the given 1-based line/column, clamped to the document.
2162    pub fn jump_to(&mut self, line: usize, col: usize) {
2163        let r = line.saturating_sub(1);
2164        let max_row = buf_row_count(&self.buffer).saturating_sub(1);
2165        let r = r.min(max_row);
2166        let line_len = buf_line(&self.buffer, r)
2167            .map(|l| l.chars().count())
2168            .unwrap_or(0);
2169        let c = col.saturating_sub(1).min(line_len);
2170        buf_set_cursor_rc(&mut self.buffer, r, c);
2171    }
2172
2173    /// Jump cursor to the terminal-space mouse position; exits Visual
2174    /// modes if active. Engine-native coordinate flavour — pass the
2175    /// outer editor rect's `(x, y)` plus the click `(col, row)`.
2176    /// Always available (no ratatui dependency).
2177    ///
2178    /// Renamed from `mouse_click_xy` in 0.0.32 — at 0.1.0 freeze the
2179    /// unprefixed name belongs to the universally-available variant.
2180    pub fn mouse_click(&mut self, area_x: u16, area_y: u16, col: u16, row: u16) {
2181        if self.vim.is_visual() {
2182            self.vim.force_normal();
2183        }
2184        // Mouse-position click counts as a motion — break the active
2185        // insert-mode undo group when the toggle is on (vim parity).
2186        crate::vim::break_undo_group_in_insert(self);
2187        let (r, c) = self.mouse_to_doc_pos_xy(area_x, area_y, col, row);
2188        buf_set_cursor_rc(&mut self.buffer, r, c);
2189    }
2190
2191    /// Ratatui [`Rect`]-flavoured wrapper around
2192    /// [`Editor::mouse_click`]. Behind the `ratatui` feature.
2193    ///
2194    /// Renamed from `mouse_click` in 0.0.32 — the unprefixed name now
2195    /// belongs to the engine-native variant.
2196    #[cfg(feature = "ratatui")]
2197    pub fn mouse_click_in_rect(&mut self, area: Rect, col: u16, row: u16) {
2198        self.mouse_click(area.x, area.y, col, row);
2199    }
2200
2201    /// Begin a mouse-drag selection: anchor at current cursor and enter Visual mode.
2202    pub fn mouse_begin_drag(&mut self) {
2203        if !self.vim.is_visual_char() {
2204            let cursor = self.cursor();
2205            self.vim.enter_visual(cursor);
2206        }
2207    }
2208
2209    /// Extend an in-progress mouse drag to the given terminal-space
2210    /// position. Engine-native coordinate flavour. Always available.
2211    ///
2212    /// Renamed from `mouse_extend_drag_xy` in 0.0.32 — at 0.1.0 freeze
2213    /// the unprefixed name belongs to the universally-available variant.
2214    pub fn mouse_extend_drag(&mut self, area_x: u16, area_y: u16, col: u16, row: u16) {
2215        let (r, c) = self.mouse_to_doc_pos_xy(area_x, area_y, col, row);
2216        buf_set_cursor_rc(&mut self.buffer, r, c);
2217    }
2218
2219    /// Ratatui [`Rect`]-flavoured wrapper around
2220    /// [`Editor::mouse_extend_drag`]. Behind the `ratatui` feature.
2221    ///
2222    /// Renamed from `mouse_extend_drag` in 0.0.32 — the unprefixed
2223    /// name now belongs to the engine-native variant.
2224    #[cfg(feature = "ratatui")]
2225    pub fn mouse_extend_drag_in_rect(&mut self, area: Rect, col: u16, row: u16) {
2226        self.mouse_extend_drag(area.x, area.y, col, row);
2227    }
2228
2229    pub fn insert_str(&mut self, text: &str) {
2230        let pos = crate::types::Cursor::cursor(&self.buffer);
2231        crate::types::BufferEdit::insert_at(&mut self.buffer, pos, text);
2232        self.push_buffer_content_to_textarea();
2233        self.mark_content_dirty();
2234    }
2235
2236    pub fn accept_completion(&mut self, completion: &str) {
2237        use crate::types::{BufferEdit, Cursor as CursorTrait, Pos};
2238        let cursor_pos = CursorTrait::cursor(&self.buffer);
2239        let cursor_row = cursor_pos.line as usize;
2240        let cursor_col = cursor_pos.col as usize;
2241        let line = buf_line(&self.buffer, cursor_row).unwrap_or("").to_string();
2242        let chars: Vec<char> = line.chars().collect();
2243        let prefix_len = chars[..cursor_col.min(chars.len())]
2244            .iter()
2245            .rev()
2246            .take_while(|c| c.is_alphanumeric() || **c == '_')
2247            .count();
2248        if prefix_len > 0 {
2249            let start = Pos {
2250                line: cursor_row as u32,
2251                col: (cursor_col - prefix_len) as u32,
2252            };
2253            BufferEdit::delete_range(&mut self.buffer, start..cursor_pos);
2254        }
2255        let cursor = CursorTrait::cursor(&self.buffer);
2256        BufferEdit::insert_at(&mut self.buffer, cursor, completion);
2257        self.push_buffer_content_to_textarea();
2258        self.mark_content_dirty();
2259    }
2260
2261    pub(super) fn snapshot(&self) -> (Vec<String>, (usize, usize)) {
2262        let rc = buf_cursor_rc(&self.buffer);
2263        (buf_lines_to_vec(&self.buffer), rc)
2264    }
2265
2266    /// Walk one step back through the undo history. Equivalent to the
2267    /// user pressing `u` in normal mode. Drains the most recent undo
2268    /// entry and pushes it onto the redo stack.
2269    pub fn undo(&mut self) {
2270        crate::vim::do_undo(self);
2271    }
2272
2273    /// Walk one step forward through the redo history. Equivalent to
2274    /// `<C-r>` in normal mode.
2275    pub fn redo(&mut self) {
2276        crate::vim::do_redo(self);
2277    }
2278
2279    /// Snapshot current buffer state onto the undo stack and clear
2280    /// the redo stack. Bounded by `settings.undo_levels` — older
2281    /// entries pruned. Call before any group of buffer mutations the
2282    /// user might want to undo as a single step.
2283    pub fn push_undo(&mut self) {
2284        let snap = self.snapshot();
2285        self.undo_stack.push(snap);
2286        self.cap_undo();
2287        self.redo_stack.clear();
2288    }
2289
2290    /// Trim the undo stack down to `settings.undo_levels`, dropping
2291    /// the oldest entries. `undo_levels == 0` is treated as
2292    /// "unlimited" (vim's 0-means-no-undo semantics intentionally
2293    /// skipped — guarding with `> 0` is one line shorter than gating
2294    /// the cap path with an explicit zero-check above the call site).
2295    pub(crate) fn cap_undo(&mut self) {
2296        let cap = self.settings.undo_levels as usize;
2297        if cap > 0 && self.undo_stack.len() > cap {
2298            let diff = self.undo_stack.len() - cap;
2299            self.undo_stack.drain(..diff);
2300        }
2301    }
2302
2303    /// Test-only accessor for the undo stack length.
2304    #[doc(hidden)]
2305    pub fn undo_stack_len(&self) -> usize {
2306        self.undo_stack.len()
2307    }
2308
2309    /// Replace the buffer with `lines` joined by `\n` and set the
2310    /// cursor to `cursor`. Used by undo / `:e!` / snapshot restore
2311    /// paths. Marks the editor dirty.
2312    pub fn restore(&mut self, lines: Vec<String>, cursor: (usize, usize)) {
2313        let text = lines.join("\n");
2314        crate::types::BufferEdit::replace_all(&mut self.buffer, &text);
2315        buf_set_cursor_rc(&mut self.buffer, cursor.0, cursor.1);
2316        self.mark_content_dirty();
2317    }
2318
2319    /// Returns true if the key was consumed by the editor.
2320    #[cfg(feature = "crossterm")]
2321    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
2322        let input = crossterm_to_input(key);
2323        if input.key == Key::Null {
2324            return false;
2325        }
2326        let consumed = vim::step(self, input);
2327        self.emit_cursor_shape_if_changed();
2328        consumed
2329    }
2330}
2331
2332#[cfg(feature = "crossterm")]
2333impl From<KeyEvent> for Input {
2334    fn from(key: KeyEvent) -> Self {
2335        let k = match key.code {
2336            KeyCode::Char(c) => Key::Char(c),
2337            KeyCode::Backspace => Key::Backspace,
2338            KeyCode::Delete => Key::Delete,
2339            KeyCode::Enter => Key::Enter,
2340            KeyCode::Left => Key::Left,
2341            KeyCode::Right => Key::Right,
2342            KeyCode::Up => Key::Up,
2343            KeyCode::Down => Key::Down,
2344            KeyCode::Home => Key::Home,
2345            KeyCode::End => Key::End,
2346            KeyCode::Tab => Key::Tab,
2347            KeyCode::Esc => Key::Esc,
2348            _ => Key::Null,
2349        };
2350        Input {
2351            key: k,
2352            ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
2353            alt: key.modifiers.contains(KeyModifiers::ALT),
2354            shift: key.modifiers.contains(KeyModifiers::SHIFT),
2355        }
2356    }
2357}
2358
2359/// Crossterm `KeyEvent` → engine `Input`. Thin wrapper that delegates
2360/// to the [`From`] impl above; kept as a free fn for the in-tree
2361/// callers in the legacy ratatui-coupled paths.
2362#[cfg(feature = "crossterm")]
2363pub(super) fn crossterm_to_input(key: KeyEvent) -> Input {
2364    Input::from(key)
2365}
2366
2367#[cfg(all(test, feature = "crossterm", feature = "ratatui"))]
2368mod tests {
2369    use super::*;
2370    use crate::types::Host;
2371    use crossterm::event::KeyEvent;
2372
2373    fn key(code: KeyCode) -> KeyEvent {
2374        KeyEvent::new(code, KeyModifiers::NONE)
2375    }
2376    fn shift_key(code: KeyCode) -> KeyEvent {
2377        KeyEvent::new(code, KeyModifiers::SHIFT)
2378    }
2379    fn ctrl_key(code: KeyCode) -> KeyEvent {
2380        KeyEvent::new(code, KeyModifiers::CONTROL)
2381    }
2382
2383    #[test]
2384    fn vim_normal_to_insert() {
2385        let mut e = Editor::new(
2386            hjkl_buffer::Buffer::new(),
2387            crate::types::DefaultHost::new(),
2388            crate::types::Options::default(),
2389        );
2390        e.handle_key(key(KeyCode::Char('i')));
2391        assert_eq!(e.vim_mode(), VimMode::Insert);
2392    }
2393
2394    #[test]
2395    fn with_options_constructs_from_spec_options() {
2396        // 0.0.33 (Patch C-γ): SPEC-shaped constructor preview.
2397        // Build with custom Options + DefaultHost; confirm the
2398        // settings translation honours the SPEC field names.
2399        let opts = crate::types::Options {
2400            shiftwidth: 4,
2401            tabstop: 4,
2402            expandtab: true,
2403            iskeyword: "@,a-z".to_string(),
2404            wrap: crate::types::WrapMode::Word,
2405            ..crate::types::Options::default()
2406        };
2407        let mut e = Editor::new(
2408            hjkl_buffer::Buffer::new(),
2409            crate::types::DefaultHost::new(),
2410            opts,
2411        );
2412        assert_eq!(e.settings().shiftwidth, 4);
2413        assert_eq!(e.settings().tabstop, 4);
2414        assert!(e.settings().expandtab);
2415        assert_eq!(e.settings().iskeyword, "@,a-z");
2416        assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
2417        // Confirm input plumbing still works.
2418        e.handle_key(key(KeyCode::Char('i')));
2419        assert_eq!(e.vim_mode(), VimMode::Insert);
2420    }
2421
2422    #[test]
2423    fn feed_input_char_routes_through_handle_key() {
2424        use crate::{Modifiers, PlannedInput};
2425        let mut e = Editor::new(
2426            hjkl_buffer::Buffer::new(),
2427            crate::types::DefaultHost::new(),
2428            crate::types::Options::default(),
2429        );
2430        e.set_content("abc");
2431        // `i` enters insert mode via SPEC input.
2432        e.feed_input(PlannedInput::Char('i', Modifiers::default()));
2433        assert_eq!(e.vim_mode(), VimMode::Insert);
2434        // Type 'X' via SPEC input.
2435        e.feed_input(PlannedInput::Char('X', Modifiers::default()));
2436        assert!(e.content().contains('X'));
2437    }
2438
2439    #[test]
2440    fn feed_input_special_key_routes() {
2441        use crate::{Modifiers, PlannedInput, SpecialKey};
2442        let mut e = Editor::new(
2443            hjkl_buffer::Buffer::new(),
2444            crate::types::DefaultHost::new(),
2445            crate::types::Options::default(),
2446        );
2447        e.set_content("abc");
2448        e.feed_input(PlannedInput::Char('i', Modifiers::default()));
2449        assert_eq!(e.vim_mode(), VimMode::Insert);
2450        e.feed_input(PlannedInput::Key(SpecialKey::Esc, Modifiers::default()));
2451        assert_eq!(e.vim_mode(), VimMode::Normal);
2452    }
2453
2454    #[test]
2455    fn feed_input_mouse_paste_focus_resize_no_op() {
2456        use crate::{MouseEvent, MouseKind, PlannedInput, Pos};
2457        let mut e = Editor::new(
2458            hjkl_buffer::Buffer::new(),
2459            crate::types::DefaultHost::new(),
2460            crate::types::Options::default(),
2461        );
2462        e.set_content("abc");
2463        let mode_before = e.vim_mode();
2464        let consumed = e.feed_input(PlannedInput::Mouse(MouseEvent {
2465            kind: MouseKind::Press,
2466            pos: Pos::new(0, 0),
2467            mods: Default::default(),
2468        }));
2469        assert!(!consumed);
2470        assert_eq!(e.vim_mode(), mode_before);
2471        assert!(!e.feed_input(PlannedInput::Paste("xx".into())));
2472        assert!(!e.feed_input(PlannedInput::FocusGained));
2473        assert!(!e.feed_input(PlannedInput::FocusLost));
2474        assert!(!e.feed_input(PlannedInput::Resize(80, 24)));
2475    }
2476
2477    #[test]
2478    fn intern_style_dedups_engine_native_styles() {
2479        use crate::types::{Attrs, Color, Style};
2480        let mut e = Editor::new(
2481            hjkl_buffer::Buffer::new(),
2482            crate::types::DefaultHost::new(),
2483            crate::types::Options::default(),
2484        );
2485        let s = Style {
2486            fg: Some(Color(255, 0, 0)),
2487            bg: None,
2488            attrs: Attrs::BOLD,
2489        };
2490        let id_a = e.intern_style(s);
2491        // Re-interning the same engine style returns the same id.
2492        let id_b = e.intern_style(s);
2493        assert_eq!(id_a, id_b);
2494        // Engine accessor returns the same style back.
2495        let back = e.engine_style_at(id_a).expect("interned");
2496        assert_eq!(back, s);
2497    }
2498
2499    #[test]
2500    fn engine_style_at_out_of_range_returns_none() {
2501        let e = Editor::new(
2502            hjkl_buffer::Buffer::new(),
2503            crate::types::DefaultHost::new(),
2504            crate::types::Options::default(),
2505        );
2506        assert!(e.engine_style_at(99).is_none());
2507    }
2508
2509    #[test]
2510    fn take_changes_emits_per_row_for_block_insert() {
2511        // Visual-block insert (`Ctrl-V` then `I` then text then Esc)
2512        // produces an InsertBlock buffer edit with one chunk per
2513        // selected row. take_changes should surface N EditOps,
2514        // not a single placeholder.
2515        let mut e = Editor::new(
2516            hjkl_buffer::Buffer::new(),
2517            crate::types::DefaultHost::new(),
2518            crate::types::Options::default(),
2519        );
2520        e.set_content("aaa\nbbb\nccc\nddd");
2521        // Place cursor at (0, 0), enter visual-block, extend down 2.
2522        e.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2523        e.handle_key(key(KeyCode::Char('j')));
2524        e.handle_key(key(KeyCode::Char('j')));
2525        // `I` to enter insert mode at the block left edge.
2526        e.handle_key(shift_key(KeyCode::Char('I')));
2527        e.handle_key(key(KeyCode::Char('X')));
2528        e.handle_key(key(KeyCode::Esc));
2529
2530        let changes = e.take_changes();
2531        // Expect at least 3 entries — one per row in the 3-row block.
2532        // Vim's block-I inserts on Esc; the cleanup may add more
2533        // EditOps for cursor sync, hence >= rather than ==.
2534        assert!(
2535            changes.len() >= 3,
2536            "expected >=3 EditOps for 3-row block insert, got {}: {changes:?}",
2537            changes.len()
2538        );
2539    }
2540
2541    #[test]
2542    fn take_changes_drains_after_insert() {
2543        let mut e = Editor::new(
2544            hjkl_buffer::Buffer::new(),
2545            crate::types::DefaultHost::new(),
2546            crate::types::Options::default(),
2547        );
2548        e.set_content("abc");
2549        // Empty initially.
2550        assert!(e.take_changes().is_empty());
2551        // Type a char in insert mode.
2552        e.handle_key(key(KeyCode::Char('i')));
2553        e.handle_key(key(KeyCode::Char('X')));
2554        let changes = e.take_changes();
2555        assert!(
2556            !changes.is_empty(),
2557            "insert mode keystroke should produce a change"
2558        );
2559        // Drained — second call empty.
2560        assert!(e.take_changes().is_empty());
2561    }
2562
2563    #[test]
2564    fn options_bridge_roundtrip() {
2565        let mut e = Editor::new(
2566            hjkl_buffer::Buffer::new(),
2567            crate::types::DefaultHost::new(),
2568            crate::types::Options::default(),
2569        );
2570        let opts = e.current_options();
2571        // 0.1.0: SPEC-faithful Options::default — shiftwidth=8 / tabstop=8.
2572        assert_eq!(opts.shiftwidth, 8);
2573        assert_eq!(opts.tabstop, 8);
2574
2575        let new_opts = crate::types::Options {
2576            shiftwidth: 4,
2577            tabstop: 2,
2578            ignorecase: true,
2579            ..crate::types::Options::default()
2580        };
2581        e.apply_options(&new_opts);
2582
2583        let after = e.current_options();
2584        assert_eq!(after.shiftwidth, 4);
2585        assert_eq!(after.tabstop, 2);
2586        assert!(after.ignorecase);
2587    }
2588
2589    #[test]
2590    fn selection_highlight_none_in_normal() {
2591        let mut e = Editor::new(
2592            hjkl_buffer::Buffer::new(),
2593            crate::types::DefaultHost::new(),
2594            crate::types::Options::default(),
2595        );
2596        e.set_content("hello");
2597        assert!(e.selection_highlight().is_none());
2598    }
2599
2600    #[test]
2601    fn selection_highlight_some_in_visual() {
2602        use crate::types::HighlightKind;
2603        let mut e = Editor::new(
2604            hjkl_buffer::Buffer::new(),
2605            crate::types::DefaultHost::new(),
2606            crate::types::Options::default(),
2607        );
2608        e.set_content("hello world");
2609        e.handle_key(key(KeyCode::Char('v')));
2610        e.handle_key(key(KeyCode::Char('l')));
2611        e.handle_key(key(KeyCode::Char('l')));
2612        let h = e
2613            .selection_highlight()
2614            .expect("visual mode should produce a highlight");
2615        assert_eq!(h.kind, HighlightKind::Selection);
2616        assert_eq!(h.range.start.line, 0);
2617        assert_eq!(h.range.end.line, 0);
2618    }
2619
2620    #[test]
2621    fn highlights_emit_incsearch_during_active_prompt() {
2622        use crate::types::HighlightKind;
2623        let mut e = Editor::new(
2624            hjkl_buffer::Buffer::new(),
2625            crate::types::DefaultHost::new(),
2626            crate::types::Options::default(),
2627        );
2628        e.set_content("foo bar foo\nbaz\n");
2629        // Open the `/` prompt and type `f` `o` `o`.
2630        e.handle_key(key(KeyCode::Char('/')));
2631        e.handle_key(key(KeyCode::Char('f')));
2632        e.handle_key(key(KeyCode::Char('o')));
2633        e.handle_key(key(KeyCode::Char('o')));
2634        // Prompt should be active.
2635        assert!(e.search_prompt().is_some());
2636        let hs = e.highlights_for_line(0);
2637        assert_eq!(hs.len(), 2);
2638        for h in &hs {
2639            assert_eq!(h.kind, HighlightKind::IncSearch);
2640        }
2641    }
2642
2643    #[test]
2644    fn highlights_empty_for_blank_prompt() {
2645        let mut e = Editor::new(
2646            hjkl_buffer::Buffer::new(),
2647            crate::types::DefaultHost::new(),
2648            crate::types::Options::default(),
2649        );
2650        e.set_content("foo");
2651        e.handle_key(key(KeyCode::Char('/')));
2652        // Nothing typed yet — prompt active but text empty.
2653        assert!(e.search_prompt().is_some());
2654        assert!(e.highlights_for_line(0).is_empty());
2655    }
2656
2657    #[test]
2658    fn highlights_emit_search_matches() {
2659        use crate::types::HighlightKind;
2660        let mut e = Editor::new(
2661            hjkl_buffer::Buffer::new(),
2662            crate::types::DefaultHost::new(),
2663            crate::types::Options::default(),
2664        );
2665        e.set_content("foo bar foo\nbaz qux\n");
2666        // 0.0.35: arm via the engine search state. The buffer
2667        // accessor still works (deprecated) but new code goes
2668        // through Editor.
2669        e.set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
2670        let hs = e.highlights_for_line(0);
2671        assert_eq!(hs.len(), 2);
2672        for h in &hs {
2673            assert_eq!(h.kind, HighlightKind::SearchMatch);
2674            assert_eq!(h.range.start.line, 0);
2675            assert_eq!(h.range.end.line, 0);
2676        }
2677    }
2678
2679    #[test]
2680    fn highlights_empty_without_pattern() {
2681        let mut e = Editor::new(
2682            hjkl_buffer::Buffer::new(),
2683            crate::types::DefaultHost::new(),
2684            crate::types::Options::default(),
2685        );
2686        e.set_content("foo bar");
2687        assert!(e.highlights_for_line(0).is_empty());
2688    }
2689
2690    #[test]
2691    fn highlights_empty_for_out_of_range_line() {
2692        let mut e = Editor::new(
2693            hjkl_buffer::Buffer::new(),
2694            crate::types::DefaultHost::new(),
2695            crate::types::Options::default(),
2696        );
2697        e.set_content("foo");
2698        e.set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
2699        assert!(e.highlights_for_line(99).is_empty());
2700    }
2701
2702    #[test]
2703    fn render_frame_reflects_mode_and_cursor() {
2704        use crate::types::{CursorShape, SnapshotMode};
2705        let mut e = Editor::new(
2706            hjkl_buffer::Buffer::new(),
2707            crate::types::DefaultHost::new(),
2708            crate::types::Options::default(),
2709        );
2710        e.set_content("alpha\nbeta");
2711        let f = e.render_frame();
2712        assert_eq!(f.mode, SnapshotMode::Normal);
2713        assert_eq!(f.cursor_shape, CursorShape::Block);
2714        assert_eq!(f.line_count, 2);
2715
2716        e.handle_key(key(KeyCode::Char('i')));
2717        let f = e.render_frame();
2718        assert_eq!(f.mode, SnapshotMode::Insert);
2719        assert_eq!(f.cursor_shape, CursorShape::Bar);
2720    }
2721
2722    #[test]
2723    fn snapshot_roundtrips_through_restore() {
2724        use crate::types::SnapshotMode;
2725        let mut e = Editor::new(
2726            hjkl_buffer::Buffer::new(),
2727            crate::types::DefaultHost::new(),
2728            crate::types::Options::default(),
2729        );
2730        e.set_content("alpha\nbeta\ngamma");
2731        e.jump_cursor(2, 3);
2732        let snap = e.take_snapshot();
2733        assert_eq!(snap.mode, SnapshotMode::Normal);
2734        assert_eq!(snap.cursor, (2, 3));
2735        assert_eq!(snap.lines.len(), 3);
2736
2737        let mut other = Editor::new(
2738            hjkl_buffer::Buffer::new(),
2739            crate::types::DefaultHost::new(),
2740            crate::types::Options::default(),
2741        );
2742        other.restore_snapshot(snap).expect("restore");
2743        assert_eq!(other.cursor(), (2, 3));
2744        assert_eq!(other.buffer().lines().len(), 3);
2745    }
2746
2747    #[test]
2748    fn restore_snapshot_rejects_version_mismatch() {
2749        let mut e = Editor::new(
2750            hjkl_buffer::Buffer::new(),
2751            crate::types::DefaultHost::new(),
2752            crate::types::Options::default(),
2753        );
2754        let mut snap = e.take_snapshot();
2755        snap.version = 9999;
2756        match e.restore_snapshot(snap) {
2757            Err(crate::EngineError::SnapshotVersion(got, want)) => {
2758                assert_eq!(got, 9999);
2759                assert_eq!(want, crate::types::EditorSnapshot::VERSION);
2760            }
2761            other => panic!("expected SnapshotVersion err, got {other:?}"),
2762        }
2763    }
2764
2765    #[test]
2766    fn take_content_change_returns_some_on_first_dirty() {
2767        let mut e = Editor::new(
2768            hjkl_buffer::Buffer::new(),
2769            crate::types::DefaultHost::new(),
2770            crate::types::Options::default(),
2771        );
2772        e.set_content("hello");
2773        let first = e.take_content_change();
2774        assert!(first.is_some());
2775        let second = e.take_content_change();
2776        assert!(second.is_none());
2777    }
2778
2779    #[test]
2780    fn take_content_change_none_until_mutation() {
2781        let mut e = Editor::new(
2782            hjkl_buffer::Buffer::new(),
2783            crate::types::DefaultHost::new(),
2784            crate::types::Options::default(),
2785        );
2786        e.set_content("hello");
2787        // drain
2788        e.take_content_change();
2789        assert!(e.take_content_change().is_none());
2790        // mutate via insert mode
2791        e.handle_key(key(KeyCode::Char('i')));
2792        e.handle_key(key(KeyCode::Char('x')));
2793        let after = e.take_content_change();
2794        assert!(after.is_some());
2795        assert!(after.unwrap().contains('x'));
2796    }
2797
2798    #[test]
2799    fn vim_insert_to_normal() {
2800        let mut e = Editor::new(
2801            hjkl_buffer::Buffer::new(),
2802            crate::types::DefaultHost::new(),
2803            crate::types::Options::default(),
2804        );
2805        e.handle_key(key(KeyCode::Char('i')));
2806        e.handle_key(key(KeyCode::Esc));
2807        assert_eq!(e.vim_mode(), VimMode::Normal);
2808    }
2809
2810    #[test]
2811    fn vim_normal_to_visual() {
2812        let mut e = Editor::new(
2813            hjkl_buffer::Buffer::new(),
2814            crate::types::DefaultHost::new(),
2815            crate::types::Options::default(),
2816        );
2817        e.handle_key(key(KeyCode::Char('v')));
2818        assert_eq!(e.vim_mode(), VimMode::Visual);
2819    }
2820
2821    #[test]
2822    fn vim_visual_to_normal() {
2823        let mut e = Editor::new(
2824            hjkl_buffer::Buffer::new(),
2825            crate::types::DefaultHost::new(),
2826            crate::types::Options::default(),
2827        );
2828        e.handle_key(key(KeyCode::Char('v')));
2829        e.handle_key(key(KeyCode::Esc));
2830        assert_eq!(e.vim_mode(), VimMode::Normal);
2831    }
2832
2833    #[test]
2834    fn vim_shift_i_moves_to_first_non_whitespace() {
2835        let mut e = Editor::new(
2836            hjkl_buffer::Buffer::new(),
2837            crate::types::DefaultHost::new(),
2838            crate::types::Options::default(),
2839        );
2840        e.set_content("   hello");
2841        e.jump_cursor(0, 8);
2842        e.handle_key(shift_key(KeyCode::Char('I')));
2843        assert_eq!(e.vim_mode(), VimMode::Insert);
2844        assert_eq!(e.cursor(), (0, 3));
2845    }
2846
2847    #[test]
2848    fn vim_shift_a_moves_to_end_and_insert() {
2849        let mut e = Editor::new(
2850            hjkl_buffer::Buffer::new(),
2851            crate::types::DefaultHost::new(),
2852            crate::types::Options::default(),
2853        );
2854        e.set_content("hello");
2855        e.handle_key(shift_key(KeyCode::Char('A')));
2856        assert_eq!(e.vim_mode(), VimMode::Insert);
2857        assert_eq!(e.cursor().1, 5);
2858    }
2859
2860    #[test]
2861    fn count_10j_moves_down_10() {
2862        let mut e = Editor::new(
2863            hjkl_buffer::Buffer::new(),
2864            crate::types::DefaultHost::new(),
2865            crate::types::Options::default(),
2866        );
2867        e.set_content(
2868            (0..20)
2869                .map(|i| format!("line{i}"))
2870                .collect::<Vec<_>>()
2871                .join("\n")
2872                .as_str(),
2873        );
2874        for d in "10".chars() {
2875            e.handle_key(key(KeyCode::Char(d)));
2876        }
2877        e.handle_key(key(KeyCode::Char('j')));
2878        assert_eq!(e.cursor().0, 10);
2879    }
2880
2881    #[test]
2882    fn count_o_repeats_insert_on_esc() {
2883        let mut e = Editor::new(
2884            hjkl_buffer::Buffer::new(),
2885            crate::types::DefaultHost::new(),
2886            crate::types::Options::default(),
2887        );
2888        e.set_content("hello");
2889        for d in "3".chars() {
2890            e.handle_key(key(KeyCode::Char(d)));
2891        }
2892        e.handle_key(key(KeyCode::Char('o')));
2893        assert_eq!(e.vim_mode(), VimMode::Insert);
2894        for c in "world".chars() {
2895            e.handle_key(key(KeyCode::Char(c)));
2896        }
2897        e.handle_key(key(KeyCode::Esc));
2898        assert_eq!(e.vim_mode(), VimMode::Normal);
2899        assert_eq!(e.buffer().lines().len(), 4);
2900        assert!(e.buffer().lines().iter().skip(1).all(|l| l == "world"));
2901    }
2902
2903    #[test]
2904    fn count_i_repeats_text_on_esc() {
2905        let mut e = Editor::new(
2906            hjkl_buffer::Buffer::new(),
2907            crate::types::DefaultHost::new(),
2908            crate::types::Options::default(),
2909        );
2910        e.set_content("");
2911        for d in "3".chars() {
2912            e.handle_key(key(KeyCode::Char(d)));
2913        }
2914        e.handle_key(key(KeyCode::Char('i')));
2915        for c in "ab".chars() {
2916            e.handle_key(key(KeyCode::Char(c)));
2917        }
2918        e.handle_key(key(KeyCode::Esc));
2919        assert_eq!(e.vim_mode(), VimMode::Normal);
2920        assert_eq!(e.buffer().lines()[0], "ababab");
2921    }
2922
2923    #[test]
2924    fn vim_shift_o_opens_line_above() {
2925        let mut e = Editor::new(
2926            hjkl_buffer::Buffer::new(),
2927            crate::types::DefaultHost::new(),
2928            crate::types::Options::default(),
2929        );
2930        e.set_content("hello");
2931        e.handle_key(shift_key(KeyCode::Char('O')));
2932        assert_eq!(e.vim_mode(), VimMode::Insert);
2933        assert_eq!(e.cursor(), (0, 0));
2934        assert_eq!(e.buffer().lines().len(), 2);
2935    }
2936
2937    #[test]
2938    fn vim_gg_goes_to_top() {
2939        let mut e = Editor::new(
2940            hjkl_buffer::Buffer::new(),
2941            crate::types::DefaultHost::new(),
2942            crate::types::Options::default(),
2943        );
2944        e.set_content("a\nb\nc");
2945        e.jump_cursor(2, 0);
2946        e.handle_key(key(KeyCode::Char('g')));
2947        e.handle_key(key(KeyCode::Char('g')));
2948        assert_eq!(e.cursor().0, 0);
2949    }
2950
2951    #[test]
2952    fn vim_shift_g_goes_to_bottom() {
2953        let mut e = Editor::new(
2954            hjkl_buffer::Buffer::new(),
2955            crate::types::DefaultHost::new(),
2956            crate::types::Options::default(),
2957        );
2958        e.set_content("a\nb\nc");
2959        e.handle_key(shift_key(KeyCode::Char('G')));
2960        assert_eq!(e.cursor().0, 2);
2961    }
2962
2963    #[test]
2964    fn vim_dd_deletes_line() {
2965        let mut e = Editor::new(
2966            hjkl_buffer::Buffer::new(),
2967            crate::types::DefaultHost::new(),
2968            crate::types::Options::default(),
2969        );
2970        e.set_content("first\nsecond");
2971        e.handle_key(key(KeyCode::Char('d')));
2972        e.handle_key(key(KeyCode::Char('d')));
2973        assert_eq!(e.buffer().lines().len(), 1);
2974        assert_eq!(e.buffer().lines()[0], "second");
2975    }
2976
2977    #[test]
2978    fn vim_dw_deletes_word() {
2979        let mut e = Editor::new(
2980            hjkl_buffer::Buffer::new(),
2981            crate::types::DefaultHost::new(),
2982            crate::types::Options::default(),
2983        );
2984        e.set_content("hello world");
2985        e.handle_key(key(KeyCode::Char('d')));
2986        e.handle_key(key(KeyCode::Char('w')));
2987        assert_eq!(e.vim_mode(), VimMode::Normal);
2988        assert!(!e.buffer().lines()[0].starts_with("hello"));
2989    }
2990
2991    #[test]
2992    fn vim_yy_yanks_line() {
2993        let mut e = Editor::new(
2994            hjkl_buffer::Buffer::new(),
2995            crate::types::DefaultHost::new(),
2996            crate::types::Options::default(),
2997        );
2998        e.set_content("hello\nworld");
2999        e.handle_key(key(KeyCode::Char('y')));
3000        e.handle_key(key(KeyCode::Char('y')));
3001        assert!(e.last_yank.as_deref().unwrap_or("").starts_with("hello"));
3002    }
3003
3004    #[test]
3005    fn vim_yy_does_not_move_cursor() {
3006        let mut e = Editor::new(
3007            hjkl_buffer::Buffer::new(),
3008            crate::types::DefaultHost::new(),
3009            crate::types::Options::default(),
3010        );
3011        e.set_content("first\nsecond\nthird");
3012        e.jump_cursor(1, 0);
3013        let before = e.cursor();
3014        e.handle_key(key(KeyCode::Char('y')));
3015        e.handle_key(key(KeyCode::Char('y')));
3016        assert_eq!(e.cursor(), before);
3017        assert_eq!(e.vim_mode(), VimMode::Normal);
3018    }
3019
3020    #[test]
3021    fn vim_yw_yanks_word() {
3022        let mut e = Editor::new(
3023            hjkl_buffer::Buffer::new(),
3024            crate::types::DefaultHost::new(),
3025            crate::types::Options::default(),
3026        );
3027        e.set_content("hello world");
3028        e.handle_key(key(KeyCode::Char('y')));
3029        e.handle_key(key(KeyCode::Char('w')));
3030        assert_eq!(e.vim_mode(), VimMode::Normal);
3031        assert!(e.last_yank.is_some());
3032    }
3033
3034    #[test]
3035    fn vim_cc_changes_line() {
3036        let mut e = Editor::new(
3037            hjkl_buffer::Buffer::new(),
3038            crate::types::DefaultHost::new(),
3039            crate::types::Options::default(),
3040        );
3041        e.set_content("hello\nworld");
3042        e.handle_key(key(KeyCode::Char('c')));
3043        e.handle_key(key(KeyCode::Char('c')));
3044        assert_eq!(e.vim_mode(), VimMode::Insert);
3045    }
3046
3047    #[test]
3048    fn vim_u_undoes_insert_session_as_chunk() {
3049        let mut e = Editor::new(
3050            hjkl_buffer::Buffer::new(),
3051            crate::types::DefaultHost::new(),
3052            crate::types::Options::default(),
3053        );
3054        e.set_content("hello");
3055        e.handle_key(key(KeyCode::Char('i')));
3056        e.handle_key(key(KeyCode::Enter));
3057        e.handle_key(key(KeyCode::Enter));
3058        e.handle_key(key(KeyCode::Esc));
3059        assert_eq!(e.buffer().lines().len(), 3);
3060        e.handle_key(key(KeyCode::Char('u')));
3061        assert_eq!(e.buffer().lines().len(), 1);
3062        assert_eq!(e.buffer().lines()[0], "hello");
3063    }
3064
3065    #[test]
3066    fn vim_undo_redo_roundtrip() {
3067        let mut e = Editor::new(
3068            hjkl_buffer::Buffer::new(),
3069            crate::types::DefaultHost::new(),
3070            crate::types::Options::default(),
3071        );
3072        e.set_content("hello");
3073        e.handle_key(key(KeyCode::Char('i')));
3074        for c in "world".chars() {
3075            e.handle_key(key(KeyCode::Char(c)));
3076        }
3077        e.handle_key(key(KeyCode::Esc));
3078        let after = e.buffer().lines()[0].clone();
3079        e.handle_key(key(KeyCode::Char('u')));
3080        assert_eq!(e.buffer().lines()[0], "hello");
3081        e.handle_key(ctrl_key(KeyCode::Char('r')));
3082        assert_eq!(e.buffer().lines()[0], after);
3083    }
3084
3085    #[test]
3086    fn vim_u_undoes_dd() {
3087        let mut e = Editor::new(
3088            hjkl_buffer::Buffer::new(),
3089            crate::types::DefaultHost::new(),
3090            crate::types::Options::default(),
3091        );
3092        e.set_content("first\nsecond");
3093        e.handle_key(key(KeyCode::Char('d')));
3094        e.handle_key(key(KeyCode::Char('d')));
3095        assert_eq!(e.buffer().lines().len(), 1);
3096        e.handle_key(key(KeyCode::Char('u')));
3097        assert_eq!(e.buffer().lines().len(), 2);
3098        assert_eq!(e.buffer().lines()[0], "first");
3099    }
3100
3101    #[test]
3102    fn vim_ctrl_r_redoes() {
3103        let mut e = Editor::new(
3104            hjkl_buffer::Buffer::new(),
3105            crate::types::DefaultHost::new(),
3106            crate::types::Options::default(),
3107        );
3108        e.set_content("hello");
3109        e.handle_key(ctrl_key(KeyCode::Char('r')));
3110    }
3111
3112    #[test]
3113    fn vim_r_replaces_char() {
3114        let mut e = Editor::new(
3115            hjkl_buffer::Buffer::new(),
3116            crate::types::DefaultHost::new(),
3117            crate::types::Options::default(),
3118        );
3119        e.set_content("hello");
3120        e.handle_key(key(KeyCode::Char('r')));
3121        e.handle_key(key(KeyCode::Char('x')));
3122        assert_eq!(e.buffer().lines()[0].chars().next(), Some('x'));
3123    }
3124
3125    #[test]
3126    fn vim_tilde_toggles_case() {
3127        let mut e = Editor::new(
3128            hjkl_buffer::Buffer::new(),
3129            crate::types::DefaultHost::new(),
3130            crate::types::Options::default(),
3131        );
3132        e.set_content("hello");
3133        e.handle_key(key(KeyCode::Char('~')));
3134        assert_eq!(e.buffer().lines()[0].chars().next(), Some('H'));
3135    }
3136
3137    #[test]
3138    fn vim_visual_d_cuts() {
3139        let mut e = Editor::new(
3140            hjkl_buffer::Buffer::new(),
3141            crate::types::DefaultHost::new(),
3142            crate::types::Options::default(),
3143        );
3144        e.set_content("hello");
3145        e.handle_key(key(KeyCode::Char('v')));
3146        e.handle_key(key(KeyCode::Char('l')));
3147        e.handle_key(key(KeyCode::Char('l')));
3148        e.handle_key(key(KeyCode::Char('d')));
3149        assert_eq!(e.vim_mode(), VimMode::Normal);
3150        assert!(e.last_yank.is_some());
3151    }
3152
3153    #[test]
3154    fn vim_visual_c_enters_insert() {
3155        let mut e = Editor::new(
3156            hjkl_buffer::Buffer::new(),
3157            crate::types::DefaultHost::new(),
3158            crate::types::Options::default(),
3159        );
3160        e.set_content("hello");
3161        e.handle_key(key(KeyCode::Char('v')));
3162        e.handle_key(key(KeyCode::Char('l')));
3163        e.handle_key(key(KeyCode::Char('c')));
3164        assert_eq!(e.vim_mode(), VimMode::Insert);
3165    }
3166
3167    #[test]
3168    fn vim_normal_unknown_key_consumed() {
3169        let mut e = Editor::new(
3170            hjkl_buffer::Buffer::new(),
3171            crate::types::DefaultHost::new(),
3172            crate::types::Options::default(),
3173        );
3174        // Unknown keys are consumed (swallowed) rather than returning false.
3175        let consumed = e.handle_key(key(KeyCode::Char('z')));
3176        assert!(consumed);
3177    }
3178
3179    #[test]
3180    fn force_normal_clears_operator() {
3181        let mut e = Editor::new(
3182            hjkl_buffer::Buffer::new(),
3183            crate::types::DefaultHost::new(),
3184            crate::types::Options::default(),
3185        );
3186        e.handle_key(key(KeyCode::Char('d')));
3187        e.force_normal();
3188        assert_eq!(e.vim_mode(), VimMode::Normal);
3189    }
3190
3191    fn many_lines(n: usize) -> String {
3192        (0..n)
3193            .map(|i| format!("line{i}"))
3194            .collect::<Vec<_>>()
3195            .join("\n")
3196    }
3197
3198    fn prime_viewport<H: Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, height: u16) {
3199        e.set_viewport_height(height);
3200    }
3201
3202    #[test]
3203    fn zz_centers_cursor_in_viewport() {
3204        let mut e = Editor::new(
3205            hjkl_buffer::Buffer::new(),
3206            crate::types::DefaultHost::new(),
3207            crate::types::Options::default(),
3208        );
3209        e.set_content(&many_lines(100));
3210        prime_viewport(&mut e, 20);
3211        e.jump_cursor(50, 0);
3212        e.handle_key(key(KeyCode::Char('z')));
3213        e.handle_key(key(KeyCode::Char('z')));
3214        assert_eq!(e.host().viewport().top_row, 40);
3215        assert_eq!(e.cursor().0, 50);
3216    }
3217
3218    #[test]
3219    fn zt_puts_cursor_at_viewport_top_with_scrolloff() {
3220        let mut e = Editor::new(
3221            hjkl_buffer::Buffer::new(),
3222            crate::types::DefaultHost::new(),
3223            crate::types::Options::default(),
3224        );
3225        e.set_content(&many_lines(100));
3226        prime_viewport(&mut e, 20);
3227        e.jump_cursor(50, 0);
3228        e.handle_key(key(KeyCode::Char('z')));
3229        e.handle_key(key(KeyCode::Char('t')));
3230        // Cursor lands at top of viable area = top + SCROLLOFF (5).
3231        // Viewport top therefore sits at cursor - 5.
3232        assert_eq!(e.host().viewport().top_row, 45);
3233        assert_eq!(e.cursor().0, 50);
3234    }
3235
3236    #[test]
3237    fn ctrl_a_increments_number_at_cursor() {
3238        let mut e = Editor::new(
3239            hjkl_buffer::Buffer::new(),
3240            crate::types::DefaultHost::new(),
3241            crate::types::Options::default(),
3242        );
3243        e.set_content("x = 41");
3244        e.handle_key(ctrl_key(KeyCode::Char('a')));
3245        assert_eq!(e.buffer().lines()[0], "x = 42");
3246        assert_eq!(e.cursor(), (0, 5));
3247    }
3248
3249    #[test]
3250    fn ctrl_a_finds_number_to_right_of_cursor() {
3251        let mut e = Editor::new(
3252            hjkl_buffer::Buffer::new(),
3253            crate::types::DefaultHost::new(),
3254            crate::types::Options::default(),
3255        );
3256        e.set_content("foo 99 bar");
3257        e.handle_key(ctrl_key(KeyCode::Char('a')));
3258        assert_eq!(e.buffer().lines()[0], "foo 100 bar");
3259        assert_eq!(e.cursor(), (0, 6));
3260    }
3261
3262    #[test]
3263    fn ctrl_a_with_count_adds_count() {
3264        let mut e = Editor::new(
3265            hjkl_buffer::Buffer::new(),
3266            crate::types::DefaultHost::new(),
3267            crate::types::Options::default(),
3268        );
3269        e.set_content("x = 10");
3270        for d in "5".chars() {
3271            e.handle_key(key(KeyCode::Char(d)));
3272        }
3273        e.handle_key(ctrl_key(KeyCode::Char('a')));
3274        assert_eq!(e.buffer().lines()[0], "x = 15");
3275    }
3276
3277    #[test]
3278    fn ctrl_x_decrements_number() {
3279        let mut e = Editor::new(
3280            hjkl_buffer::Buffer::new(),
3281            crate::types::DefaultHost::new(),
3282            crate::types::Options::default(),
3283        );
3284        e.set_content("n=5");
3285        e.handle_key(ctrl_key(KeyCode::Char('x')));
3286        assert_eq!(e.buffer().lines()[0], "n=4");
3287    }
3288
3289    #[test]
3290    fn ctrl_x_crosses_zero_into_negative() {
3291        let mut e = Editor::new(
3292            hjkl_buffer::Buffer::new(),
3293            crate::types::DefaultHost::new(),
3294            crate::types::Options::default(),
3295        );
3296        e.set_content("v=0");
3297        e.handle_key(ctrl_key(KeyCode::Char('x')));
3298        assert_eq!(e.buffer().lines()[0], "v=-1");
3299    }
3300
3301    #[test]
3302    fn ctrl_a_on_negative_number_increments_toward_zero() {
3303        let mut e = Editor::new(
3304            hjkl_buffer::Buffer::new(),
3305            crate::types::DefaultHost::new(),
3306            crate::types::Options::default(),
3307        );
3308        e.set_content("a = -5");
3309        e.handle_key(ctrl_key(KeyCode::Char('a')));
3310        assert_eq!(e.buffer().lines()[0], "a = -4");
3311    }
3312
3313    #[test]
3314    fn ctrl_a_noop_when_no_digit_on_line() {
3315        let mut e = Editor::new(
3316            hjkl_buffer::Buffer::new(),
3317            crate::types::DefaultHost::new(),
3318            crate::types::Options::default(),
3319        );
3320        e.set_content("no digits here");
3321        e.handle_key(ctrl_key(KeyCode::Char('a')));
3322        assert_eq!(e.buffer().lines()[0], "no digits here");
3323    }
3324
3325    #[test]
3326    fn zb_puts_cursor_at_viewport_bottom_with_scrolloff() {
3327        let mut e = Editor::new(
3328            hjkl_buffer::Buffer::new(),
3329            crate::types::DefaultHost::new(),
3330            crate::types::Options::default(),
3331        );
3332        e.set_content(&many_lines(100));
3333        prime_viewport(&mut e, 20);
3334        e.jump_cursor(50, 0);
3335        e.handle_key(key(KeyCode::Char('z')));
3336        e.handle_key(key(KeyCode::Char('b')));
3337        // Cursor lands at bottom of viable area = top + height - 1 -
3338        // SCROLLOFF. For height 20, scrolloff 5: cursor at top + 14,
3339        // so top = cursor - 14 = 36.
3340        assert_eq!(e.host().viewport().top_row, 36);
3341        assert_eq!(e.cursor().0, 50);
3342    }
3343
3344    /// Contract that the TUI drain relies on: `set_content` flags the
3345    /// editor dirty (so the next `take_dirty` call reports the change),
3346    /// and a second `take_dirty` returns `false` after consumption. The
3347    /// TUI drains this flag after every programmatic content load so
3348    /// opening a tab doesn't get mistaken for a user edit and mark the
3349    /// tab dirty (which would then trigger the quit-prompt on `:q`).
3350    #[test]
3351    fn set_content_dirties_then_take_dirty_clears() {
3352        let mut e = Editor::new(
3353            hjkl_buffer::Buffer::new(),
3354            crate::types::DefaultHost::new(),
3355            crate::types::Options::default(),
3356        );
3357        e.set_content("hello");
3358        assert!(
3359            e.take_dirty(),
3360            "set_content should leave content_dirty=true"
3361        );
3362        assert!(!e.take_dirty(), "take_dirty should clear the flag");
3363    }
3364
3365    #[test]
3366    fn content_arc_returns_same_arc_until_mutation() {
3367        let mut e = Editor::new(
3368            hjkl_buffer::Buffer::new(),
3369            crate::types::DefaultHost::new(),
3370            crate::types::Options::default(),
3371        );
3372        e.set_content("hello");
3373        let a = e.content_arc();
3374        let b = e.content_arc();
3375        assert!(
3376            std::sync::Arc::ptr_eq(&a, &b),
3377            "repeated content_arc() should hit the cache"
3378        );
3379
3380        // Any mutation must invalidate the cache.
3381        e.handle_key(key(KeyCode::Char('i')));
3382        e.handle_key(key(KeyCode::Char('!')));
3383        let c = e.content_arc();
3384        assert!(
3385            !std::sync::Arc::ptr_eq(&a, &c),
3386            "mutation should invalidate content_arc() cache"
3387        );
3388        assert!(c.contains('!'));
3389    }
3390
3391    #[test]
3392    fn content_arc_cache_invalidated_by_set_content() {
3393        let mut e = Editor::new(
3394            hjkl_buffer::Buffer::new(),
3395            crate::types::DefaultHost::new(),
3396            crate::types::Options::default(),
3397        );
3398        e.set_content("one");
3399        let a = e.content_arc();
3400        e.set_content("two");
3401        let b = e.content_arc();
3402        assert!(!std::sync::Arc::ptr_eq(&a, &b));
3403        assert!(b.starts_with("two"));
3404    }
3405
3406    /// Click past the last char of a line should land the cursor on
3407    /// the line's last char (Normal mode), not one past it. The
3408    /// previous bug clamped to the line's BYTE length and used `>=`
3409    /// past-end, so clicking deep into the trailing space parked the
3410    /// cursor at `chars().count()` — past where Normal mode lives.
3411    #[test]
3412    fn mouse_click_past_eol_lands_on_last_char() {
3413        let mut e = Editor::new(
3414            hjkl_buffer::Buffer::new(),
3415            crate::types::DefaultHost::new(),
3416            crate::types::Options::default(),
3417        );
3418        e.set_content("hello");
3419        // Outer editor area: x=0, y=0, width=80. mouse_to_doc_pos
3420        // reserves row 0 for the tab bar and adds gutter padding,
3421        // so click row 1, way past the line end.
3422        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
3423        e.mouse_click_in_rect(area, 78, 1);
3424        assert_eq!(e.cursor(), (0, 4));
3425    }
3426
3427    #[test]
3428    fn mouse_click_past_eol_handles_multibyte_line() {
3429        let mut e = Editor::new(
3430            hjkl_buffer::Buffer::new(),
3431            crate::types::DefaultHost::new(),
3432            crate::types::Options::default(),
3433        );
3434        // 5 chars, 6 bytes — old code's `String::len()` clamp was
3435        // wrong here.
3436        e.set_content("héllo");
3437        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
3438        e.mouse_click_in_rect(area, 78, 1);
3439        assert_eq!(e.cursor(), (0, 4));
3440    }
3441
3442    #[test]
3443    fn mouse_click_inside_line_lands_on_clicked_char() {
3444        let mut e = Editor::new(
3445            hjkl_buffer::Buffer::new(),
3446            crate::types::DefaultHost::new(),
3447            crate::types::Options::default(),
3448        );
3449        e.set_content("hello world");
3450        // Gutter is `lnum_width + 1` = (1-digit row count + 2) + 1
3451        // pane padding = 4 cells; click col 4 is the first char.
3452        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
3453        e.mouse_click_in_rect(area, 4, 1);
3454        assert_eq!(e.cursor(), (0, 0));
3455        e.mouse_click_in_rect(area, 6, 1);
3456        assert_eq!(e.cursor(), (0, 2));
3457    }
3458
3459    /// Vim parity: a mouse-position click during insert mode counts
3460    /// as a motion and breaks the active undo group (when
3461    /// `undo_break_on_motion` is on, the default). After clicking and
3462    /// typing more chars, `u` should reverse only the post-click run.
3463    #[test]
3464    fn mouse_click_breaks_insert_undo_group_when_undobreak_on() {
3465        let mut e = Editor::new(
3466            hjkl_buffer::Buffer::new(),
3467            crate::types::DefaultHost::new(),
3468            crate::types::Options::default(),
3469        );
3470        e.set_content("hello world");
3471        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
3472        // Default settings.undo_break_on_motion = true.
3473        assert!(e.settings().undo_break_on_motion);
3474        // Enter insert mode and type "AAA" before the line content.
3475        e.handle_key(key(KeyCode::Char('i')));
3476        e.handle_key(key(KeyCode::Char('A')));
3477        e.handle_key(key(KeyCode::Char('A')));
3478        e.handle_key(key(KeyCode::Char('A')));
3479        // Mouse click somewhere else on the line (still insert mode).
3480        e.mouse_click_in_rect(area, 10, 1);
3481        // Type more chars at the new cursor position.
3482        e.handle_key(key(KeyCode::Char('B')));
3483        e.handle_key(key(KeyCode::Char('B')));
3484        e.handle_key(key(KeyCode::Char('B')));
3485        // Leave insert and undo once.
3486        e.handle_key(key(KeyCode::Esc));
3487        e.handle_key(key(KeyCode::Char('u')));
3488        let line = e.buffer().line(0).unwrap_or("").to_string();
3489        assert!(
3490            line.contains("AAA"),
3491            "AAA must survive undo (separate group): {line:?}"
3492        );
3493        assert!(
3494            !line.contains("BBB"),
3495            "BBB must be undone (post-click group): {line:?}"
3496        );
3497    }
3498
3499    /// With `:set noundobreak`, the entire insert run — including
3500    /// chars typed before AND after a mouse click — should collapse
3501    /// into one undo group, so `u` clears everything.
3502    #[test]
3503    fn mouse_click_keeps_one_undo_group_when_undobreak_off() {
3504        let mut e = Editor::new(
3505            hjkl_buffer::Buffer::new(),
3506            crate::types::DefaultHost::new(),
3507            crate::types::Options::default(),
3508        );
3509        e.set_content("hello world");
3510        e.settings_mut().undo_break_on_motion = false;
3511        let area = ratatui::layout::Rect::new(0, 0, 80, 10);
3512        e.handle_key(key(KeyCode::Char('i')));
3513        e.handle_key(key(KeyCode::Char('A')));
3514        e.handle_key(key(KeyCode::Char('A')));
3515        e.mouse_click_in_rect(area, 10, 1);
3516        e.handle_key(key(KeyCode::Char('B')));
3517        e.handle_key(key(KeyCode::Char('B')));
3518        e.handle_key(key(KeyCode::Esc));
3519        e.handle_key(key(KeyCode::Char('u')));
3520        let line = e.buffer().line(0).unwrap_or("").to_string();
3521        assert!(
3522            !line.contains("AA") && !line.contains("BB"),
3523            "with undobreak off, single `u` must reverse whole insert: {line:?}"
3524        );
3525        assert_eq!(line, "hello world");
3526    }
3527
3528    // ── Patch B (0.0.29): Host trait wired into Editor ──
3529
3530    #[test]
3531    fn host_clipboard_round_trip_via_default_host() {
3532        // DefaultHost stores write_clipboard in-memory; read_clipboard
3533        // returns the most recent payload.
3534        let mut e = Editor::new(
3535            hjkl_buffer::Buffer::new(),
3536            crate::types::DefaultHost::new(),
3537            crate::types::Options::default(),
3538        );
3539        e.host_mut().write_clipboard("payload".to_string());
3540        assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("payload"));
3541    }
3542
3543    #[test]
3544    fn host_records_clipboard_on_yank() {
3545        // `yy` on a single-line buffer must drive `Host::write_clipboard`
3546        // (the new Patch B side-channel) in addition to the legacy
3547        // `last_yank` mirror.
3548        let mut e = Editor::new(
3549            hjkl_buffer::Buffer::new(),
3550            crate::types::DefaultHost::new(),
3551            crate::types::Options::default(),
3552        );
3553        e.set_content("hello\n");
3554        e.handle_key(key(KeyCode::Char('y')));
3555        e.handle_key(key(KeyCode::Char('y')));
3556        // Clipboard cache holds the linewise yank.
3557        let clip = e.host_mut().read_clipboard();
3558        assert!(
3559            clip.as_deref().unwrap_or("").starts_with("hello"),
3560            "host clipboard should carry the yank: {clip:?}"
3561        );
3562        // Legacy mirror still populated for 0.0.28-era hosts.
3563        assert!(e.last_yank.as_deref().unwrap_or("").starts_with("hello"));
3564    }
3565
3566    #[test]
3567    fn host_cursor_shape_via_shared_recorder() {
3568        // Recording host backed by a leaked `Mutex` so the test can
3569        // inspect the emit sequence after the editor has consumed the
3570        // host. (Host: Send rules out Rc/RefCell.)
3571        let shapes_ptr: &'static std::sync::Mutex<Vec<crate::types::CursorShape>> =
3572            Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
3573        struct LeakHost {
3574            shapes: &'static std::sync::Mutex<Vec<crate::types::CursorShape>>,
3575            viewport: crate::types::Viewport,
3576        }
3577        impl crate::types::Host for LeakHost {
3578            type Intent = ();
3579            fn write_clipboard(&mut self, _: String) {}
3580            fn read_clipboard(&mut self) -> Option<String> {
3581                None
3582            }
3583            fn now(&self) -> core::time::Duration {
3584                core::time::Duration::ZERO
3585            }
3586            fn prompt_search(&mut self) -> Option<String> {
3587                None
3588            }
3589            fn emit_cursor_shape(&mut self, s: crate::types::CursorShape) {
3590                self.shapes.lock().unwrap().push(s);
3591            }
3592            fn viewport(&self) -> &crate::types::Viewport {
3593                &self.viewport
3594            }
3595            fn viewport_mut(&mut self) -> &mut crate::types::Viewport {
3596                &mut self.viewport
3597            }
3598            fn emit_intent(&mut self, _: Self::Intent) {}
3599        }
3600        let mut e = Editor::new(
3601            hjkl_buffer::Buffer::new(),
3602            LeakHost {
3603                shapes: shapes_ptr,
3604                viewport: crate::types::Viewport::default(),
3605            },
3606            crate::types::Options::default(),
3607        );
3608        e.set_content("abc");
3609        // Normal → Insert: Bar emit.
3610        e.handle_key(key(KeyCode::Char('i')));
3611        // Insert → Normal: Block emit.
3612        e.handle_key(key(KeyCode::Esc));
3613        let shapes = shapes_ptr.lock().unwrap().clone();
3614        assert_eq!(
3615            shapes,
3616            vec![
3617                crate::types::CursorShape::Bar,
3618                crate::types::CursorShape::Block,
3619            ],
3620            "host should observe Insert(Bar) → Normal(Block) transitions"
3621        );
3622    }
3623
3624    #[test]
3625    fn host_now_drives_chord_timeout_deterministically() {
3626        // Custom host whose `now()` is host-controlled; we drive it
3627        // forward by `timeout_len + 1ms` between the first `g` and
3628        // the second so the chord-timeout fires regardless of
3629        // wall-clock progress.
3630        let now_ptr: &'static std::sync::Mutex<core::time::Duration> =
3631            Box::leak(Box::new(std::sync::Mutex::new(core::time::Duration::ZERO)));
3632        struct ClockHost {
3633            now: &'static std::sync::Mutex<core::time::Duration>,
3634            viewport: crate::types::Viewport,
3635        }
3636        impl crate::types::Host for ClockHost {
3637            type Intent = ();
3638            fn write_clipboard(&mut self, _: String) {}
3639            fn read_clipboard(&mut self) -> Option<String> {
3640                None
3641            }
3642            fn now(&self) -> core::time::Duration {
3643                *self.now.lock().unwrap()
3644            }
3645            fn prompt_search(&mut self) -> Option<String> {
3646                None
3647            }
3648            fn emit_cursor_shape(&mut self, _: crate::types::CursorShape) {}
3649            fn viewport(&self) -> &crate::types::Viewport {
3650                &self.viewport
3651            }
3652            fn viewport_mut(&mut self) -> &mut crate::types::Viewport {
3653                &mut self.viewport
3654            }
3655            fn emit_intent(&mut self, _: Self::Intent) {}
3656        }
3657        let mut e = Editor::new(
3658            hjkl_buffer::Buffer::new(),
3659            ClockHost {
3660                now: now_ptr,
3661                viewport: crate::types::Viewport::default(),
3662            },
3663            crate::types::Options::default(),
3664        );
3665        e.set_content("a\nb\nc\n");
3666        e.jump_cursor(2, 0);
3667        // First `g` — host time = 0ms, lands in g-pending.
3668        e.handle_key(key(KeyCode::Char('g')));
3669        // Advance host time well past timeout_len (default 1000ms).
3670        *now_ptr.lock().unwrap() = core::time::Duration::from_secs(60);
3671        // Second `g` — chord-timeout fires; bare `g` re-dispatches and
3672        // does nothing on its own. Cursor must NOT have jumped to row 0.
3673        e.handle_key(key(KeyCode::Char('g')));
3674        assert_eq!(
3675            e.cursor().0,
3676            2,
3677            "Host::now() must drive `:set timeoutlen` deterministically"
3678        );
3679    }
3680}