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