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