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