Skip to main content

hjkl_engine/
editor.rs

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