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;
10#[cfg(feature = "crossterm")]
11use crate::input::Key;
12use crate::vim::{self, VimState};
13use crate::{KeybindingMode, VimMode};
14#[cfg(feature = "crossterm")]
15use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16#[cfg(feature = "ratatui")]
17use ratatui::layout::Rect;
18use std::sync::atomic::{AtomicU16, Ordering};
19
20/// Convert a SPEC [`crate::types::Style`] to a [`ratatui::style::Style`].
21///
22/// Lossless within the styles each library represents. Lives behind the
23/// `ratatui` feature so wasm / no_std consumers that opt out don't pay
24/// for the dep. Use the engine-native [`crate::types::Style`] +
25/// [`Editor::intern_engine_style`] surface from feature-disabled hosts.
26#[cfg(feature = "ratatui")]
27pub(crate) fn engine_style_to_ratatui(s: crate::types::Style) -> ratatui::style::Style {
28 use crate::types::Attrs;
29 use ratatui::style::{Color as RColor, Modifier as RMod, Style as RStyle};
30 let mut out = RStyle::default();
31 if let Some(c) = s.fg {
32 out = out.fg(RColor::Rgb(c.0, c.1, c.2));
33 }
34 if let Some(c) = s.bg {
35 out = out.bg(RColor::Rgb(c.0, c.1, c.2));
36 }
37 let mut m = RMod::empty();
38 if s.attrs.contains(Attrs::BOLD) {
39 m |= RMod::BOLD;
40 }
41 if s.attrs.contains(Attrs::ITALIC) {
42 m |= RMod::ITALIC;
43 }
44 if s.attrs.contains(Attrs::UNDERLINE) {
45 m |= RMod::UNDERLINED;
46 }
47 if s.attrs.contains(Attrs::REVERSE) {
48 m |= RMod::REVERSED;
49 }
50 if s.attrs.contains(Attrs::DIM) {
51 m |= RMod::DIM;
52 }
53 if s.attrs.contains(Attrs::STRIKE) {
54 m |= RMod::CROSSED_OUT;
55 }
56 out.add_modifier(m)
57}
58
59/// Inverse of [`engine_style_to_ratatui`]. Lossy for ratatui colors
60/// the engine doesn't model (Indexed, named ANSI) — flattens to
61/// nearest RGB. Behind the `ratatui` feature.
62#[cfg(feature = "ratatui")]
63pub(crate) fn ratatui_style_to_engine(s: ratatui::style::Style) -> crate::types::Style {
64 use crate::types::{Attrs, Color, Style};
65 use ratatui::style::{Color as RColor, Modifier as RMod};
66 fn c(rc: RColor) -> Color {
67 match rc {
68 RColor::Rgb(r, g, b) => Color(r, g, b),
69 RColor::Black => Color(0, 0, 0),
70 RColor::Red => Color(205, 49, 49),
71 RColor::Green => Color(13, 188, 121),
72 RColor::Yellow => Color(229, 229, 16),
73 RColor::Blue => Color(36, 114, 200),
74 RColor::Magenta => Color(188, 63, 188),
75 RColor::Cyan => Color(17, 168, 205),
76 RColor::Gray => Color(229, 229, 229),
77 RColor::DarkGray => Color(102, 102, 102),
78 RColor::LightRed => Color(241, 76, 76),
79 RColor::LightGreen => Color(35, 209, 139),
80 RColor::LightYellow => Color(245, 245, 67),
81 RColor::LightBlue => Color(59, 142, 234),
82 RColor::LightMagenta => Color(214, 112, 214),
83 RColor::LightCyan => Color(41, 184, 219),
84 RColor::White => Color(255, 255, 255),
85 _ => Color(0, 0, 0),
86 }
87 }
88 let mut attrs = Attrs::empty();
89 if s.add_modifier.contains(RMod::BOLD) {
90 attrs |= Attrs::BOLD;
91 }
92 if s.add_modifier.contains(RMod::ITALIC) {
93 attrs |= Attrs::ITALIC;
94 }
95 if s.add_modifier.contains(RMod::UNDERLINED) {
96 attrs |= Attrs::UNDERLINE;
97 }
98 if s.add_modifier.contains(RMod::REVERSED) {
99 attrs |= Attrs::REVERSE;
100 }
101 if s.add_modifier.contains(RMod::DIM) {
102 attrs |= Attrs::DIM;
103 }
104 if s.add_modifier.contains(RMod::CROSSED_OUT) {
105 attrs |= Attrs::STRIKE;
106 }
107 Style {
108 fg: s.fg.map(c),
109 bg: s.bg.map(c),
110 attrs,
111 }
112}
113
114/// Map a [`hjkl_buffer::Edit`] to one or more SPEC
115/// [`crate::types::Edit`] (`EditOp`) records.
116///
117/// Most buffer edits map to a single EditOp. Block ops
118/// ([`hjkl_buffer::Edit::InsertBlock`] /
119/// [`hjkl_buffer::Edit::DeleteBlockChunks`]) emit one EditOp per row
120/// touched — they edit non-contiguous cells and a single
121/// `range..range` can't represent the rectangle.
122///
123/// Returns an empty vec when the edit isn't representable (no buffer
124/// variant currently fails this check).
125fn edit_to_editops(edit: &hjkl_buffer::Edit) -> Vec<crate::types::Edit> {
126 use crate::types::{Edit as Op, Pos};
127 use hjkl_buffer::Edit as B;
128 let to_pos = |p: hjkl_buffer::Position| Pos {
129 line: p.row as u32,
130 col: p.col as u32,
131 };
132 match edit {
133 B::InsertChar { at, ch } => vec![Op {
134 range: to_pos(*at)..to_pos(*at),
135 replacement: ch.to_string(),
136 }],
137 B::InsertStr { at, text } => vec![Op {
138 range: to_pos(*at)..to_pos(*at),
139 replacement: text.clone(),
140 }],
141 B::DeleteRange { start, end, .. } => vec![Op {
142 range: to_pos(*start)..to_pos(*end),
143 replacement: String::new(),
144 }],
145 B::Replace { start, end, with } => vec![Op {
146 range: to_pos(*start)..to_pos(*end),
147 replacement: with.clone(),
148 }],
149 B::JoinLines {
150 row,
151 count,
152 with_space,
153 } => {
154 // Joining `count` rows after `row` collapses
155 // [(row+1, 0) .. (row+count, EOL)] into the joined
156 // sentinel. The replacement is either an empty string
157 // (gJ) or " " between segments (J).
158 let start = Pos {
159 line: *row as u32 + 1,
160 col: 0,
161 };
162 let end = Pos {
163 line: (*row + *count) as u32,
164 col: u32::MAX, // covers to EOL of the last source row
165 };
166 vec![Op {
167 range: start..end,
168 replacement: if *with_space {
169 " ".into()
170 } else {
171 String::new()
172 },
173 }]
174 }
175 B::SplitLines {
176 row,
177 cols,
178 inserted_space: _,
179 } => {
180 // SplitLines reverses a JoinLines: insert a `\n`
181 // (and optional dropped space) at each col on `row`.
182 cols.iter()
183 .map(|c| {
184 let p = Pos {
185 line: *row as u32,
186 col: *c as u32,
187 };
188 Op {
189 range: p..p,
190 replacement: "\n".into(),
191 }
192 })
193 .collect()
194 }
195 B::InsertBlock { at, chunks } => {
196 // One EditOp per row in the block — non-contiguous edits.
197 chunks
198 .iter()
199 .enumerate()
200 .map(|(i, chunk)| {
201 let p = Pos {
202 line: at.row as u32 + i as u32,
203 col: at.col as u32,
204 };
205 Op {
206 range: p..p,
207 replacement: chunk.clone(),
208 }
209 })
210 .collect()
211 }
212 B::DeleteBlockChunks { at, widths } => {
213 // One EditOp per row, deleting `widths[i]` chars at
214 // `(at.row + i, at.col)`.
215 widths
216 .iter()
217 .enumerate()
218 .map(|(i, w)| {
219 let start = Pos {
220 line: at.row as u32 + i as u32,
221 col: at.col as u32,
222 };
223 let end = Pos {
224 line: at.row as u32 + i as u32,
225 col: at.col as u32 + *w as u32,
226 };
227 Op {
228 range: start..end,
229 replacement: String::new(),
230 }
231 })
232 .collect()
233 }
234 }
235}
236
237/// Sum of bytes from the start of the buffer to the start of `row`.
238/// Walks lines + their separating `\n` bytes — matches the canonical
239/// `lines().join("\n")` byte rendering used by syntax tooling.
240#[inline]
241fn buffer_byte_of_row(buf: &hjkl_buffer::Buffer, row: usize) -> usize {
242 let n = buf.row_count();
243 let row = row.min(n);
244 let mut acc = 0usize;
245 for r in 0..row {
246 acc += buf.line(r).map(str::len).unwrap_or(0);
247 if r + 1 < n {
248 acc += 1; // separator '\n'
249 }
250 }
251 acc
252}
253
254/// Convert an `hjkl_buffer::Position` (char-indexed col) into byte
255/// coordinates `(byte_within_buffer, (row, col_byte))` against the
256/// **pre-edit** buffer.
257fn position_to_byte_coords(
258 buf: &hjkl_buffer::Buffer,
259 pos: hjkl_buffer::Position,
260) -> (usize, (u32, u32)) {
261 let row = pos.row.min(buf.row_count().saturating_sub(1));
262 let line = buf.line(row).unwrap_or("");
263 let col_byte = pos.byte_offset(line);
264 let byte = buffer_byte_of_row(buf, row) + col_byte;
265 (byte, (row as u32, col_byte as u32))
266}
267
268/// Compute the byte position after inserting `text` starting at
269/// `start_byte` / `start_pos`. Returns `(end_byte, end_position)`.
270fn advance_by_text(text: &str, start_byte: usize, start_pos: (u32, u32)) -> (usize, (u32, u32)) {
271 let new_end_byte = start_byte + text.len();
272 let newlines = text.bytes().filter(|&b| b == b'\n').count();
273 let end_pos = if newlines == 0 {
274 (start_pos.0, start_pos.1 + text.len() as u32)
275 } else {
276 // Bytes after the last newline determine the trailing column.
277 let last_nl = text.rfind('\n').unwrap();
278 let tail_bytes = (text.len() - last_nl - 1) as u32;
279 (start_pos.0 + newlines as u32, tail_bytes)
280 };
281 (new_end_byte, end_pos)
282}
283
284/// Translate a single `hjkl_buffer::Edit` into one or more
285/// [`crate::types::ContentEdit`] records using the **pre-edit** buffer
286/// state for byte/position lookups. Block ops fan out to one entry per
287/// touched row (matches `edit_to_editops`).
288fn content_edits_from_buffer_edit(
289 buf: &hjkl_buffer::Buffer,
290 edit: &hjkl_buffer::Edit,
291) -> Vec<crate::types::ContentEdit> {
292 use hjkl_buffer::Edit as B;
293 use hjkl_buffer::Position;
294
295 let mut out: Vec<crate::types::ContentEdit> = Vec::new();
296
297 match edit {
298 B::InsertChar { at, ch } => {
299 let (start_byte, start_pos) = position_to_byte_coords(buf, *at);
300 let new_end_byte = start_byte + ch.len_utf8();
301 let new_end_pos = (start_pos.0, start_pos.1 + ch.len_utf8() as u32);
302 out.push(crate::types::ContentEdit {
303 start_byte,
304 old_end_byte: start_byte,
305 new_end_byte,
306 start_position: start_pos,
307 old_end_position: start_pos,
308 new_end_position: new_end_pos,
309 });
310 }
311 B::InsertStr { at, text } => {
312 let (start_byte, start_pos) = position_to_byte_coords(buf, *at);
313 let (new_end_byte, new_end_pos) = advance_by_text(text, start_byte, start_pos);
314 out.push(crate::types::ContentEdit {
315 start_byte,
316 old_end_byte: start_byte,
317 new_end_byte,
318 start_position: start_pos,
319 old_end_position: start_pos,
320 new_end_position: new_end_pos,
321 });
322 }
323 B::DeleteRange { start, end, kind } => {
324 let (start, end) = if start <= end {
325 (*start, *end)
326 } else {
327 (*end, *start)
328 };
329 match kind {
330 hjkl_buffer::MotionKind::Char => {
331 let (start_byte, start_pos) = position_to_byte_coords(buf, start);
332 let (old_end_byte, old_end_pos) = position_to_byte_coords(buf, end);
333 out.push(crate::types::ContentEdit {
334 start_byte,
335 old_end_byte,
336 new_end_byte: start_byte,
337 start_position: start_pos,
338 old_end_position: old_end_pos,
339 new_end_position: start_pos,
340 });
341 }
342 hjkl_buffer::MotionKind::Line => {
343 // Linewise delete drops rows [start.row..=end.row]. Map
344 // to a span from start of `start.row` through start of
345 // (end.row + 1). The buffer's own `do_delete_range`
346 // collapses to row `start.row` after dropping.
347 let lo = start.row;
348 let hi = end.row.min(buf.row_count().saturating_sub(1));
349 let start_byte = buffer_byte_of_row(buf, lo);
350 let next_row_byte = if hi + 1 < buf.row_count() {
351 buffer_byte_of_row(buf, hi + 1)
352 } else {
353 // No row after; clamp to end-of-buffer byte.
354 buffer_byte_of_row(buf, buf.row_count())
355 + buf
356 .line(buf.row_count().saturating_sub(1))
357 .map(str::len)
358 .unwrap_or(0)
359 };
360 out.push(crate::types::ContentEdit {
361 start_byte,
362 old_end_byte: next_row_byte,
363 new_end_byte: start_byte,
364 start_position: (lo as u32, 0),
365 old_end_position: ((hi + 1) as u32, 0),
366 new_end_position: (lo as u32, 0),
367 });
368 }
369 hjkl_buffer::MotionKind::Block => {
370 // Block delete removes a rectangle of chars per row.
371 // Fan out to one ContentEdit per row.
372 let (left_col, right_col) = (start.col.min(end.col), start.col.max(end.col));
373 for row in start.row..=end.row {
374 let row_start_pos = Position::new(row, left_col);
375 let row_end_pos = Position::new(row, right_col + 1);
376 let (sb, sp) = position_to_byte_coords(buf, row_start_pos);
377 let (eb, ep) = position_to_byte_coords(buf, row_end_pos);
378 if eb <= sb {
379 continue;
380 }
381 out.push(crate::types::ContentEdit {
382 start_byte: sb,
383 old_end_byte: eb,
384 new_end_byte: sb,
385 start_position: sp,
386 old_end_position: ep,
387 new_end_position: sp,
388 });
389 }
390 }
391 }
392 }
393 B::Replace { start, end, with } => {
394 let (start, end) = if start <= end {
395 (*start, *end)
396 } else {
397 (*end, *start)
398 };
399 let (start_byte, start_pos) = position_to_byte_coords(buf, start);
400 let (old_end_byte, old_end_pos) = position_to_byte_coords(buf, end);
401 let (new_end_byte, new_end_pos) = advance_by_text(with, start_byte, start_pos);
402 out.push(crate::types::ContentEdit {
403 start_byte,
404 old_end_byte,
405 new_end_byte,
406 start_position: start_pos,
407 old_end_position: old_end_pos,
408 new_end_position: new_end_pos,
409 });
410 }
411 B::JoinLines {
412 row,
413 count,
414 with_space,
415 } => {
416 // Joining `count` rows after `row` collapses the bytes
417 // between EOL of `row` and EOL of `row + count` into either
418 // an empty string (gJ) or a single space per join (J — but
419 // only when both sides are non-empty; we approximate with
420 // a single space for simplicity).
421 let row = (*row).min(buf.row_count().saturating_sub(1));
422 let last_join_row = (row + count).min(buf.row_count().saturating_sub(1));
423 let line = buf.line(row).unwrap_or("");
424 let row_eol_byte = buffer_byte_of_row(buf, row) + line.len();
425 let row_eol_col = line.len() as u32;
426 let next_row_after = last_join_row + 1;
427 let old_end_byte = if next_row_after < buf.row_count() {
428 buffer_byte_of_row(buf, next_row_after).saturating_sub(1)
429 } else {
430 buffer_byte_of_row(buf, buf.row_count())
431 + buf
432 .line(buf.row_count().saturating_sub(1))
433 .map(str::len)
434 .unwrap_or(0)
435 };
436 let last_line = buf.line(last_join_row).unwrap_or("");
437 let old_end_pos = (last_join_row as u32, last_line.len() as u32);
438 let replacement_len = if *with_space { 1 } else { 0 };
439 let new_end_byte = row_eol_byte + replacement_len;
440 let new_end_pos = (row as u32, row_eol_col + replacement_len as u32);
441 out.push(crate::types::ContentEdit {
442 start_byte: row_eol_byte,
443 old_end_byte,
444 new_end_byte,
445 start_position: (row as u32, row_eol_col),
446 old_end_position: old_end_pos,
447 new_end_position: new_end_pos,
448 });
449 }
450 B::SplitLines {
451 row,
452 cols,
453 inserted_space,
454 } => {
455 // Splits insert "\n" (or "\n " inverse) at each col on `row`.
456 // The buffer applies all splits left-to-right via the
457 // do_split_lines path; we emit one ContentEdit per col,
458 // each treated as an insert at that col on `row`. Note: the
459 // buffer state during emission is *pre-edit*, so all cols
460 // index into the same pre-edit row.
461 let row = (*row).min(buf.row_count().saturating_sub(1));
462 let line = buf.line(row).unwrap_or("");
463 let row_byte = buffer_byte_of_row(buf, row);
464 let insert = if *inserted_space { "\n " } else { "\n" };
465 for &c in cols {
466 let pos = Position::new(row, c);
467 let col_byte = pos.byte_offset(line);
468 let start_byte = row_byte + col_byte;
469 let start_pos = (row as u32, col_byte as u32);
470 let (new_end_byte, new_end_pos) = advance_by_text(insert, start_byte, start_pos);
471 out.push(crate::types::ContentEdit {
472 start_byte,
473 old_end_byte: start_byte,
474 new_end_byte,
475 start_position: start_pos,
476 old_end_position: start_pos,
477 new_end_position: new_end_pos,
478 });
479 }
480 }
481 B::InsertBlock { at, chunks } => {
482 // One ContentEdit per chunk; each lands at `(at.row + i,
483 // at.col)` in the pre-edit buffer.
484 for (i, chunk) in chunks.iter().enumerate() {
485 let pos = Position::new(at.row + i, at.col);
486 let (start_byte, start_pos) = position_to_byte_coords(buf, pos);
487 let (new_end_byte, new_end_pos) = advance_by_text(chunk, start_byte, start_pos);
488 out.push(crate::types::ContentEdit {
489 start_byte,
490 old_end_byte: start_byte,
491 new_end_byte,
492 start_position: start_pos,
493 old_end_position: start_pos,
494 new_end_position: new_end_pos,
495 });
496 }
497 }
498 B::DeleteBlockChunks { at, widths } => {
499 for (i, w) in widths.iter().enumerate() {
500 let row = at.row + i;
501 let start_pos = Position::new(row, at.col);
502 let end_pos = Position::new(row, at.col + *w);
503 let (sb, sp) = position_to_byte_coords(buf, start_pos);
504 let (eb, ep) = position_to_byte_coords(buf, end_pos);
505 if eb <= sb {
506 continue;
507 }
508 out.push(crate::types::ContentEdit {
509 start_byte: sb,
510 old_end_byte: eb,
511 new_end_byte: sb,
512 start_position: sp,
513 old_end_position: ep,
514 new_end_position: sp,
515 });
516 }
517 }
518 }
519
520 out
521}
522
523/// Where the cursor should land in the viewport after a `z`-family
524/// scroll (`zz` / `zt` / `zb`).
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub(super) enum CursorScrollTarget {
527 Center,
528 Top,
529 Bottom,
530}
531
532// ── Trait-surface cast helpers ────────────────────────────────────
533//
534// 0.0.42 (Patch C-δ.7): the helpers introduced in 0.0.41 were
535// promoted to [`crate::buf_helpers`] so `vim.rs` free fns can route
536// their reaches through the same primitives. Re-import via
537// `use` so the editor body keeps its terse call shape.
538
539use crate::buf_helpers::{
540 apply_buffer_edit, buf_cursor_pos, buf_cursor_rc, buf_cursor_row, buf_line, buf_line_chars,
541 buf_lines_to_vec, buf_row_count, buf_set_cursor_rc,
542};
543
544pub struct Editor<
545 B: crate::types::Buffer = hjkl_buffer::Buffer,
546 H: crate::types::Host = crate::types::DefaultHost,
547> {
548 pub keybinding_mode: KeybindingMode,
549 /// Set when the user yanks/cuts; caller drains this to write to OS clipboard.
550 pub last_yank: Option<String>,
551 /// All vim-specific state (mode, pending operator, count, dot-repeat, ...).
552 /// Internal — exposed via Editor accessor methods
553 /// ([`Editor::buffer_mark`], [`Editor::last_jump_back`],
554 /// [`Editor::last_edit_pos`], [`Editor::take_lsp_intent`], …).
555 pub(crate) vim: VimState,
556 /// Undo history: each entry is (lines, cursor) before the edit.
557 /// Internal — managed by [`Editor::push_undo`] / [`Editor::restore`]
558 /// / [`Editor::pop_last_undo`].
559 pub(crate) undo_stack: Vec<(Vec<String>, (usize, usize))>,
560 /// Redo history: entries pushed when undoing.
561 pub(super) redo_stack: Vec<(Vec<String>, (usize, usize))>,
562 /// Set whenever the buffer content changes; cleared by `take_dirty`.
563 pub(super) content_dirty: bool,
564 /// Cached snapshot of `lines().join("\n") + "\n"` wrapped in an Arc
565 /// so repeated `content_arc()` calls within the same un-mutated
566 /// window are free (ref-count bump instead of a full-buffer join).
567 /// Invalidated by every [`mark_content_dirty`] call.
568 pub(super) cached_content: Option<std::sync::Arc<String>>,
569 /// Last rendered viewport height (text rows only, no chrome). Written
570 /// by the draw path via [`set_viewport_height`] so the scroll helpers
571 /// can clamp the cursor to stay visible without plumbing the height
572 /// through every call.
573 pub(super) viewport_height: AtomicU16,
574 /// Pending LSP intent set by a normal-mode chord (e.g. `gd` for
575 /// goto-definition). The host app drains this each step and fires
576 /// the matching request against its own LSP client.
577 pub(super) pending_lsp: Option<LspIntent>,
578 /// Pending [`crate::types::FoldOp`]s raised by `z…` keystrokes,
579 /// the `:fold*` Ex commands, or the edit pipeline's
580 /// "edits-inside-a-fold open it" invalidation. Drained by hosts
581 /// via [`Editor::take_fold_ops`]; the engine also applies each op
582 /// locally through [`crate::buffer_impl::BufferFoldProviderMut`]
583 /// so the in-tree buffer fold storage stays in sync without host
584 /// cooperation. Introduced in 0.0.38 (Patch C-δ.4).
585 pub(super) pending_fold_ops: Vec<crate::types::FoldOp>,
586 /// Buffer storage.
587 ///
588 /// 0.1.0 (Patch C-δ): generic over `B: Buffer` per SPEC §"Editor
589 /// surface". Default `B = hjkl_buffer::Buffer`. The vim FSM body
590 /// and `Editor::mutate_edit` are concrete on `hjkl_buffer::Buffer`
591 /// for 0.1.0 — see `crate::buf_helpers::apply_buffer_edit`.
592 pub(super) buffer: B,
593 /// Style intern table for the migration buffer's opaque
594 /// `Span::style` ids. Phase 7d-ii-a wiring — `apply_window_spans`
595 /// produces `(start, end, Style)` tuples for the textarea; we
596 /// translate those to `hjkl_buffer::Span` by interning the
597 /// `Style` here and storing the table index. The render path's
598 /// `StyleResolver` looks the style back up by id.
599 ///
600 /// Behind the `ratatui` feature; non-ratatui hosts use the
601 /// engine-native [`crate::types::Style`] surface via
602 /// [`Editor::intern_engine_style`] (which lives on a parallel
603 /// engine-side table when ratatui is off).
604 #[cfg(feature = "ratatui")]
605 pub(super) style_table: Vec<ratatui::style::Style>,
606 /// Engine-native style intern table. Used directly by
607 /// [`Editor::intern_engine_style`] when the `ratatui` feature is
608 /// off; when it's on, the table is derived from `style_table` via
609 /// [`ratatui_style_to_engine`] / [`engine_style_to_ratatui`].
610 #[cfg(not(feature = "ratatui"))]
611 pub(super) engine_style_table: Vec<crate::types::Style>,
612 /// Vim-style register bank — `"`, `"0`–`"9`, `"a`–`"z`. Sources
613 /// every `p` / `P` via the active selector (default unnamed).
614 /// Internal — read via [`Editor::registers`]; mutated by yank /
615 /// delete / paste FSM paths and by [`Editor::seed_yank`].
616 pub(crate) registers: crate::registers::Registers,
617 /// Per-row syntax styling, kept here so the host can do
618 /// incremental window updates (see `apply_window_spans` in
619 /// the host). Same `(start_byte, end_byte, Style)` tuple shape
620 /// the textarea used to host. The Buffer-side opaque-id spans are
621 /// derived from this on every install. Behind the `ratatui`
622 /// feature.
623 #[cfg(feature = "ratatui")]
624 pub styled_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
625 /// Per-editor settings tweakable via `:set`. Exposed by reference
626 /// so handlers (indent, search) read the live value rather than a
627 /// snapshot taken at startup. Read via [`Editor::settings`];
628 /// mutate via [`Editor::settings_mut`].
629 pub(crate) settings: Settings,
630 /// Unified named-marks map. Lowercase letters (`'a`–`'z`) are
631 /// per-Editor / "buffer-scope-equivalent" — set by `m{a-z}`, read
632 /// by `'{a-z}` / `` `{a-z} ``. Uppercase letters (`'A`–`'Z`) are
633 /// "file marks" that survive [`Editor::set_content`] calls so
634 /// they persist across tab swaps within the same Editor.
635 ///
636 /// 0.0.36: consolidated from three former storages:
637 /// - `hjkl_buffer::Buffer::marks` (deleted; was unused dead code).
638 /// - `vim::VimState::marks` (lowercase) (deleted).
639 /// - `Editor::file_marks` (uppercase) (replaced by this map).
640 ///
641 /// `BTreeMap` so iteration is deterministic for snapshot tests
642 /// and the `:marks` ex command. Mark-shift on edits is handled
643 /// by [`Editor::shift_marks_after_edit`].
644 pub(crate) marks: std::collections::BTreeMap<char, (usize, usize)>,
645 /// Block ranges (`(start_row, end_row)` inclusive) the host has
646 /// extracted from a syntax tree. `:foldsyntax` reads these to
647 /// populate folds. The host refreshes them on every re-parse via
648 /// [`Editor::set_syntax_fold_ranges`]; ex commands read them via
649 /// [`Editor::syntax_fold_ranges`].
650 pub(crate) syntax_fold_ranges: Vec<(usize, usize)>,
651 /// Pending edit log drained by [`Editor::take_changes`]. Each entry
652 /// is a SPEC [`crate::types::Edit`] mapped from the underlying
653 /// `hjkl_buffer::Edit` operation. Compound ops (JoinLines,
654 /// SplitLines, InsertBlock, DeleteBlockChunks) emit a single
655 /// best-effort EditOp covering the touched range; hosts wanting
656 /// per-cell deltas should diff their own snapshot of `lines()`.
657 /// Sealed at 0.1.0 trait extraction.
658 /// Drained by [`Editor::take_changes`].
659 pub(crate) change_log: Vec<crate::types::Edit>,
660 /// Vim's "sticky column" (curswant). `None` before the first
661 /// motion — the next vertical motion bootstraps from the live
662 /// cursor column. Horizontal motions refresh this to the new
663 /// column; vertical motions read it back so bouncing through a
664 /// shorter row doesn't drag the cursor to col 0. Hoisted out of
665 /// `hjkl_buffer::Buffer` (and `VimState`) in 0.0.28 — Editor is
666 /// the single owner now. Buffer motion methods that need it
667 /// take a `&mut Option<usize>` parameter.
668 pub(crate) sticky_col: Option<usize>,
669 /// Host adapter for clipboard, cursor-shape, time, viewport, and
670 /// search-prompt / cancellation side-channels.
671 ///
672 /// 0.1.0 (Patch C-δ): generic over `H: Host` per SPEC §"Editor
673 /// surface". Default `H = DefaultHost`. The pre-0.1.0 `EngineHost`
674 /// dyn-shim is gone — every method now dispatches through `H`'s
675 /// `Host` trait surface directly.
676 pub(crate) host: H,
677 /// Last public mode the cursor-shape emitter saw. Drives
678 /// [`Editor::emit_cursor_shape_if_changed`] so `Host::emit_cursor_shape`
679 /// fires exactly once per mode transition without sprinkling the
680 /// call across every `vim.mode = ...` site.
681 pub(crate) last_emitted_mode: crate::VimMode,
682 /// Search FSM state (pattern + per-row match cache + wrapscan).
683 /// 0.0.35: relocated out of `hjkl_buffer::Buffer` per
684 /// `DESIGN_33_METHOD_CLASSIFICATION.md` step 1.
685 /// 0.0.37: the buffer-side bridge (`Buffer::search_pattern`) is
686 /// gone; `BufferView` now takes the active regex as a `&Regex`
687 /// parameter, sourced from `Editor::search_state().pattern`.
688 pub(crate) search_state: crate::search::SearchState,
689 /// Per-row syntax span overlay. Source of truth for the host's
690 /// renderer ([`hjkl_buffer::BufferView::spans`]). Populated by
691 /// [`Editor::install_syntax_spans`] /
692 /// [`Editor::install_ratatui_syntax_spans`] (and, in due course,
693 /// by `Host::syntax_highlights` once the engine drives that path
694 /// directly).
695 ///
696 /// 0.0.37: lifted out of `hjkl_buffer::Buffer` per step 3 of
697 /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer-side cache +
698 /// `Buffer::set_spans` / `Buffer::spans` accessors are gone.
699 pub(crate) buffer_spans: Vec<Vec<hjkl_buffer::Span>>,
700 /// Pending `ContentEdit` records emitted by `mutate_edit`. Drained by
701 /// hosts via [`Editor::take_content_edits`] for fan-in to a syntax
702 /// tree (or any other content-change observer that needs byte-level
703 /// position deltas). Edges are byte-indexed and `(row, col_byte)`.
704 pub(crate) pending_content_edits: Vec<crate::types::ContentEdit>,
705 /// Pending "reset" flag set when the entire buffer is replaced
706 /// (e.g. `set_content` / `restore`). Supersedes any queued
707 /// `pending_content_edits` on the same frame: hosts call
708 /// [`Editor::take_content_reset`] before draining edits.
709 pub(crate) pending_content_reset: bool,
710}
711
712/// Vim-style options surfaced by `:set`. New fields land here as
713/// individual ex commands gain `:set` plumbing.
714#[derive(Debug, Clone)]
715pub struct Settings {
716 /// Spaces per shift step for `>>` / `<<` / `Ctrl-T` / `Ctrl-D`.
717 pub shiftwidth: usize,
718 /// Visual width of a `\t` character. Stored for future render
719 /// hookup; not yet consumed by the buffer renderer.
720 pub tabstop: usize,
721 /// When true, `/` / `?` patterns and `:s/.../.../` ignore case
722 /// without an explicit `i` flag.
723 pub ignore_case: bool,
724 /// When true *and* `ignore_case` is true, an uppercase letter in
725 /// the pattern flips that search back to case-sensitive. Matches
726 /// vim's `:set smartcase`. Default `false`.
727 pub smartcase: bool,
728 /// Wrap searches past buffer ends. Matches vim's `:set wrapscan`.
729 /// Default `true`.
730 pub wrapscan: bool,
731 /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
732 pub textwidth: usize,
733 /// When `true`, the Tab key in insert mode inserts `tabstop` spaces
734 /// instead of a literal `\t`. Matches vim's `:set expandtab`.
735 /// Default `false`.
736 pub expandtab: bool,
737 /// Soft tab stop in spaces. When `> 0`, Tab inserts spaces to the
738 /// next softtabstop boundary (when `expandtab`), and Backspace at the
739 /// end of a softtabstop-aligned space run deletes the entire run as
740 /// if it were one tab. `0` disables. Matches vim's `:set softtabstop`.
741 pub softtabstop: usize,
742 /// Soft-wrap mode the renderer + scroll math + `gj` / `gk` use.
743 /// Default is [`hjkl_buffer::Wrap::None`] — long lines extend
744 /// past the right edge and `top_col` clips the left side.
745 /// `:set wrap` flips to char-break wrap; `:set linebreak` flips
746 /// to word-break wrap; `:set nowrap` resets.
747 pub wrap: hjkl_buffer::Wrap,
748 /// When true, the engine drops every edit before it touches the
749 /// buffer — undo, dirty flag, and change log all stay clean.
750 /// Matches vim's `:set readonly` / `:set ro`. Default `false`.
751 pub readonly: bool,
752 /// When `true`, pressing Enter in insert mode copies the leading
753 /// whitespace of the current line onto the new line. Matches vim's
754 /// `:set autoindent`. Default `true` (vim parity).
755 pub autoindent: bool,
756 /// When `true`, bumps indent by one `shiftwidth` after a line ending
757 /// in `{` / `(` / `[`, and strips one indent unit when the user types
758 /// `}` / `)` / `]` on a whitespace-only line. See `compute_enter_indent`
759 /// in `vim.rs` for the tree-sitter plug-in seam. Default `true`.
760 pub smartindent: bool,
761 /// Cap on undo-stack length. Older entries are pruned past this
762 /// bound. `0` means unlimited. Matches vim's `:set undolevels`.
763 /// Default `1000`.
764 pub undo_levels: u32,
765 /// When `true`, cursor motions inside insert mode break the
766 /// current undo group (so a single `u` only reverses the run of
767 /// keystrokes that preceded the motion). Default `true`.
768 /// Currently a no-op — engine doesn't yet break the undo group
769 /// on insert-mode motions; field is wired through `:set
770 /// undobreak` for forward compatibility.
771 pub undo_break_on_motion: bool,
772 /// Vim-flavoured "what counts as a word" character class.
773 /// Comma-separated tokens: `@` = `is_alphabetic()`, `_` = literal
774 /// `_`, `48-57` = decimal char range, bare integer = single char
775 /// code, single ASCII punctuation = literal. Default
776 /// `"@,48-57,_,192-255"` matches vim.
777 pub iskeyword: String,
778 /// Multi-key sequence timeout (e.g. `gg`, `dd`). When the user
779 /// pauses longer than this between keys, any pending prefix is
780 /// abandoned and the next key starts a fresh sequence. Matches
781 /// vim's `:set timeoutlen` / `:set tm` (millis). Default 1000ms.
782 pub timeout_len: core::time::Duration,
783 /// When true, render absolute line numbers in the gutter. Matches
784 /// vim's `:set number` / `:set nu`. Default `true`.
785 pub number: bool,
786 /// When true, render line numbers as offsets from the cursor row.
787 /// Combined with `number`, the cursor row shows its absolute number
788 /// while other rows show the relative offset (vim's `nu+rnu` hybrid).
789 /// Matches vim's `:set relativenumber` / `:set rnu`. Default `false`.
790 pub relativenumber: bool,
791 /// Minimum gutter width in cells for the line-number column.
792 /// Width grows past this to fit the largest displayed number.
793 /// Matches vim's `:set numberwidth` / `:set nuw`. Default `4`.
794 /// Range 1..=20.
795 pub numberwidth: usize,
796 /// Highlight the row where the cursor sits. Matches vim's `:set cursorline`.
797 /// Default `false`.
798 pub cursorline: bool,
799 /// Highlight the column where the cursor sits. Matches vim's `:set cursorcolumn`.
800 /// Default `false`.
801 pub cursorcolumn: bool,
802 /// Sign-column display mode. Matches vim's `:set signcolumn`.
803 /// Default [`crate::types::SignColumnMode::Auto`].
804 pub signcolumn: crate::types::SignColumnMode,
805 /// Number of cells reserved for a fold-marker gutter.
806 /// Matches vim's `:set foldcolumn`. Default `0`.
807 pub foldcolumn: u32,
808 /// Comma-separated 1-based column indices for vertical rulers.
809 /// Matches vim's `:set colorcolumn`. Default `""`.
810 pub colorcolumn: String,
811}
812
813impl Default for Settings {
814 fn default() -> Self {
815 Self {
816 shiftwidth: 4,
817 tabstop: 4,
818 softtabstop: 4,
819 ignore_case: false,
820 smartcase: false,
821 wrapscan: true,
822 textwidth: 79,
823 expandtab: true,
824 wrap: hjkl_buffer::Wrap::None,
825 readonly: false,
826 autoindent: true,
827 smartindent: true,
828 undo_levels: 1000,
829 undo_break_on_motion: true,
830 iskeyword: "@,48-57,_,192-255".to_string(),
831 timeout_len: core::time::Duration::from_millis(1000),
832 number: true,
833 relativenumber: false,
834 numberwidth: 4,
835 cursorline: false,
836 cursorcolumn: false,
837 signcolumn: crate::types::SignColumnMode::Auto,
838 foldcolumn: 0,
839 colorcolumn: String::new(),
840 }
841 }
842}
843
844/// Translate a SPEC [`crate::types::Options`] into the engine's
845/// internal [`Settings`] representation. Field-by-field map; the
846/// shapes are isomorphic except for type widths
847/// (`u32` vs `usize`, [`crate::types::WrapMode`] vs
848/// [`hjkl_buffer::Wrap`]). 0.1.0 (Patch C-δ) collapses both into one
849/// type once the `Editor<B, H>::new(buffer, host, options)` constructor
850/// is the canonical entry point.
851fn settings_from_options(o: &crate::types::Options) -> Settings {
852 Settings {
853 shiftwidth: o.shiftwidth as usize,
854 tabstop: o.tabstop as usize,
855 softtabstop: o.softtabstop as usize,
856 ignore_case: o.ignorecase,
857 smartcase: o.smartcase,
858 wrapscan: o.wrapscan,
859 textwidth: o.textwidth as usize,
860 expandtab: o.expandtab,
861 wrap: match o.wrap {
862 crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
863 crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
864 crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
865 },
866 readonly: o.readonly,
867 autoindent: o.autoindent,
868 smartindent: o.smartindent,
869 undo_levels: o.undo_levels,
870 undo_break_on_motion: o.undo_break_on_motion,
871 iskeyword: o.iskeyword.clone(),
872 timeout_len: o.timeout_len,
873 number: o.number,
874 relativenumber: o.relativenumber,
875 numberwidth: o.numberwidth,
876 cursorline: o.cursorline,
877 cursorcolumn: o.cursorcolumn,
878 signcolumn: o.signcolumn,
879 foldcolumn: o.foldcolumn,
880 colorcolumn: o.colorcolumn.clone(),
881 }
882}
883
884/// Host-observable LSP requests triggered by editor bindings. The
885/// hjkl-engine crate doesn't talk to an LSP itself — it just raises an
886/// intent that the TUI layer picks up and routes to `sqls`.
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888pub enum LspIntent {
889 /// `gd` — textDocument/definition at the cursor.
890 GotoDefinition,
891}
892
893impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
894 /// Build an [`Editor`] from a buffer, host adapter, and SPEC options.
895 ///
896 /// 0.1.0 (Patch C-δ): canonical, frozen constructor per SPEC §"Editor
897 /// surface". Replaces the pre-0.1.0 `Editor::new(KeybindingMode)` /
898 /// `with_host` / `with_options` triad — there is no shim.
899 ///
900 /// Consumers that don't need a custom host pass
901 /// [`crate::types::DefaultHost::new()`]; consumers that don't need
902 /// custom options pass [`crate::types::Options::default()`].
903 pub fn new(buffer: hjkl_buffer::Buffer, host: H, options: crate::types::Options) -> Self {
904 let settings = settings_from_options(&options);
905 Self {
906 keybinding_mode: KeybindingMode::Vim,
907 last_yank: None,
908 vim: VimState::default(),
909 undo_stack: Vec::new(),
910 redo_stack: Vec::new(),
911 content_dirty: false,
912 cached_content: None,
913 viewport_height: AtomicU16::new(0),
914 pending_lsp: None,
915 pending_fold_ops: Vec::new(),
916 buffer,
917 #[cfg(feature = "ratatui")]
918 style_table: Vec::new(),
919 #[cfg(not(feature = "ratatui"))]
920 engine_style_table: Vec::new(),
921 registers: crate::registers::Registers::default(),
922 #[cfg(feature = "ratatui")]
923 styled_spans: Vec::new(),
924 settings,
925 marks: std::collections::BTreeMap::new(),
926 syntax_fold_ranges: Vec::new(),
927 change_log: Vec::new(),
928 sticky_col: None,
929 host,
930 last_emitted_mode: crate::VimMode::Normal,
931 search_state: crate::search::SearchState::new(),
932 buffer_spans: Vec::new(),
933 pending_content_edits: Vec::new(),
934 pending_content_reset: false,
935 }
936 }
937}
938
939impl<B: crate::types::Buffer, H: crate::types::Host> Editor<B, H> {
940 /// Borrow the buffer (typed `&B`). Host renders through this via
941 /// `hjkl_buffer::BufferView` when `B = hjkl_buffer::Buffer`.
942 pub fn buffer(&self) -> &B {
943 &self.buffer
944 }
945
946 /// Mutably borrow the buffer (typed `&mut B`).
947 pub fn buffer_mut(&mut self) -> &mut B {
948 &mut self.buffer
949 }
950
951 /// Borrow the host adapter directly (typed `&H`).
952 pub fn host(&self) -> &H {
953 &self.host
954 }
955
956 /// Mutably borrow the host adapter (typed `&mut H`).
957 pub fn host_mut(&mut self) -> &mut H {
958 &mut self.host
959 }
960}
961
962impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
963 /// Update the active `iskeyword` spec for word motions
964 /// (`w`/`b`/`e`/`ge` and engine-side `*`/`#` pickup). 0.0.28
965 /// hoisted iskeyword storage out of `Buffer` — `Editor` is the
966 /// single owner now. Equivalent to assigning
967 /// `settings_mut().iskeyword` directly; the dedicated setter is
968 /// retained for source-compatibility with 0.0.27 callers.
969 pub fn set_iskeyword(&mut self, spec: impl Into<String>) {
970 self.settings.iskeyword = spec.into();
971 }
972
973 /// Emit `Host::emit_cursor_shape` if the public mode has changed
974 /// since the last emit. Engine calls this at the end of every input
975 /// step so mode transitions surface to the host without sprinkling
976 /// the call across every `vim.mode = ...` site.
977 pub fn emit_cursor_shape_if_changed(&mut self) {
978 let mode = self.vim_mode();
979 if mode == self.last_emitted_mode {
980 return;
981 }
982 let shape = match mode {
983 crate::VimMode::Insert => crate::types::CursorShape::Bar,
984 _ => crate::types::CursorShape::Block,
985 };
986 self.host.emit_cursor_shape(shape);
987 self.last_emitted_mode = mode;
988 }
989
990 /// Record a yank/cut payload. Writes both the legacy
991 /// [`Editor::last_yank`] field (drained directly by 0.0.28-era
992 /// hosts) and the new [`crate::types::Host::write_clipboard`]
993 /// side-channel (Patch B). Consumers should migrate to a `Host`
994 /// impl whose `write_clipboard` queues the platform-clipboard
995 /// write; the `last_yank` mirror will be removed at 0.1.0.
996 pub(crate) fn record_yank_to_host(&mut self, text: String) {
997 self.host.write_clipboard(text.clone());
998 self.last_yank = Some(text);
999 }
1000
1001 /// Vim's sticky column (curswant). `None` before the first motion;
1002 /// hosts shouldn't normally need to read this directly — it's
1003 /// surfaced for migration off `Buffer::sticky_col` and for
1004 /// snapshot tests.
1005 pub fn sticky_col(&self) -> Option<usize> {
1006 self.sticky_col
1007 }
1008
1009 /// Replace the sticky column. Hosts should rarely touch this —
1010 /// motion code maintains it through the standard horizontal /
1011 /// vertical motion paths.
1012 pub fn set_sticky_col(&mut self, col: Option<usize>) {
1013 self.sticky_col = col;
1014 }
1015
1016 /// Host hook: replace the cached syntax-derived block ranges that
1017 /// `:foldsyntax` consumes. the host calls this on every re-parse;
1018 /// the cost is just a `Vec` swap.
1019 /// Look up a named mark by character. Returns `(row, col)` if
1020 /// set; `None` otherwise. Both lowercase (`'a`–`'z`) and
1021 /// uppercase (`'A`–`'Z`) marks live in the same unified
1022 /// [`Editor::marks`] map as of 0.0.36.
1023 pub fn mark(&self, c: char) -> Option<(usize, usize)> {
1024 self.marks.get(&c).copied()
1025 }
1026
1027 /// Set the named mark `c` to `(row, col)`. Used by the FSM's
1028 /// `m{a-zA-Z}` keystroke and by [`Editor::restore_snapshot`].
1029 pub fn set_mark(&mut self, c: char, pos: (usize, usize)) {
1030 self.marks.insert(c, pos);
1031 }
1032
1033 /// Remove the named mark `c` (no-op if unset).
1034 pub fn clear_mark(&mut self, c: char) {
1035 self.marks.remove(&c);
1036 }
1037
1038 /// Look up a buffer-local lowercase mark (`'a`–`'z`). Kept as a
1039 /// thin wrapper over [`Editor::mark`] for source compatibility
1040 /// with pre-0.0.36 callers; new code should call
1041 /// [`Editor::mark`] directly.
1042 #[deprecated(
1043 since = "0.0.36",
1044 note = "use Editor::mark — lowercase + uppercase marks now live in a single map"
1045 )]
1046 pub fn buffer_mark(&self, c: char) -> Option<(usize, usize)> {
1047 self.mark(c)
1048 }
1049
1050 /// Discard the most recent undo entry. Used by ex commands that
1051 /// pre-emptively pushed an undo state (`:s`, `:r`) but ended up
1052 /// matching nothing — popping prevents a no-op undo step from
1053 /// polluting the user's history.
1054 ///
1055 /// Returns `true` if an entry was discarded.
1056 pub fn pop_last_undo(&mut self) -> bool {
1057 self.undo_stack.pop().is_some()
1058 }
1059
1060 /// Read all named marks set this session — both lowercase
1061 /// (`'a`–`'z`) and uppercase (`'A`–`'Z`). Iteration is
1062 /// deterministic (BTreeMap-ordered) so snapshot / `:marks`
1063 /// output is stable.
1064 pub fn marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
1065 self.marks.iter().map(|(c, p)| (*c, *p))
1066 }
1067
1068 /// Read all buffer-local lowercase marks. Kept for source
1069 /// compatibility with pre-0.0.36 callers (e.g. `:marks` ex
1070 /// command); new code should use [`Editor::marks`] which
1071 /// iterates the unified map.
1072 #[deprecated(
1073 since = "0.0.36",
1074 note = "use Editor::marks — lowercase + uppercase marks now live in a single map"
1075 )]
1076 pub fn buffer_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
1077 self.marks
1078 .iter()
1079 .filter(|(c, _)| c.is_ascii_lowercase())
1080 .map(|(c, p)| (*c, *p))
1081 }
1082
1083 /// Position the cursor was at when the user last jumped via
1084 /// `<C-o>` / `g;` / similar. `None` before any jump.
1085 pub fn last_jump_back(&self) -> Option<(usize, usize)> {
1086 self.vim.jump_back.last().copied()
1087 }
1088
1089 /// Position of the last edit (where `.` would replay). `None` if
1090 /// no edit has happened yet in this session.
1091 pub fn last_edit_pos(&self) -> Option<(usize, usize)> {
1092 self.vim.last_edit_pos
1093 }
1094
1095 /// Read-only view of the file-marks table — uppercase / "file"
1096 /// marks (`'A`–`'Z`) the host has set this session. Returns an
1097 /// iterator of `(mark_char, (row, col))` pairs.
1098 ///
1099 /// Mutate via the FSM (`m{A-Z}` keystroke) or via
1100 /// [`Editor::restore_snapshot`].
1101 ///
1102 /// 0.0.36: file marks now live in the unified [`Editor::marks`]
1103 /// map; this accessor is kept for source compatibility and
1104 /// filters the unified map to uppercase entries.
1105 pub fn file_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
1106 self.marks
1107 .iter()
1108 .filter(|(c, _)| c.is_ascii_uppercase())
1109 .map(|(c, p)| (*c, *p))
1110 }
1111
1112 /// Read-only view of the cached syntax-derived block ranges that
1113 /// `:foldsyntax` consumes. Returns the slice the host last
1114 /// installed via [`Editor::set_syntax_fold_ranges`]; empty when
1115 /// no syntax integration is active.
1116 pub fn syntax_fold_ranges(&self) -> &[(usize, usize)] {
1117 &self.syntax_fold_ranges
1118 }
1119
1120 pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
1121 self.syntax_fold_ranges = ranges;
1122 }
1123
1124 /// Live settings (read-only). `:set` mutates these via
1125 /// [`Editor::settings_mut`].
1126 pub fn settings(&self) -> &Settings {
1127 &self.settings
1128 }
1129
1130 /// Live settings (mutable). `:set` flows through here to mutate
1131 /// shiftwidth / tabstop / textwidth / ignore_case / wrap. Hosts
1132 /// configuring at startup typically construct a [`Settings`]
1133 /// snapshot and overwrite via `*editor.settings_mut() = …`.
1134 pub fn settings_mut(&mut self) -> &mut Settings {
1135 &mut self.settings
1136 }
1137
1138 /// Returns `true` when `:set readonly` is active. Convenience
1139 /// accessor for hosts that cannot import the internal [`Settings`]
1140 /// type. Phase 5 binary uses this to gate `:w` writes.
1141 pub fn is_readonly(&self) -> bool {
1142 self.settings.readonly
1143 }
1144
1145 /// Borrow the engine search state. Hosts inspecting the
1146 /// committed `/` / `?` pattern (e.g. for status-line display) or
1147 /// feeding the active regex into `BufferView::search_pattern`
1148 /// read it from here.
1149 pub fn search_state(&self) -> &crate::search::SearchState {
1150 &self.search_state
1151 }
1152
1153 /// Mutable engine search state. Hosts driving search
1154 /// programmatically (test fixtures, scripted demos) write the
1155 /// pattern through here.
1156 pub fn search_state_mut(&mut self) -> &mut crate::search::SearchState {
1157 &mut self.search_state
1158 }
1159
1160 /// Install `pattern` as the active search regex on the engine
1161 /// state and clear the cached row matches. Pass `None` to clear.
1162 /// 0.0.37: dropped the buffer-side mirror that 0.0.35 introduced
1163 /// — `BufferView` now takes the regex through its `search_pattern`
1164 /// field per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`.
1165 pub fn set_search_pattern(&mut self, pattern: Option<regex::Regex>) {
1166 self.search_state.set_pattern(pattern);
1167 }
1168
1169 /// Drive `n` (or the `/` commit equivalent) — advance the cursor
1170 /// to the next match of `search_state.pattern` from the cursor's
1171 /// current position. Returns `true` when a match was found.
1172 /// `skip_current = true` excludes a match the cursor sits on.
1173 pub fn search_advance_forward(&mut self, skip_current: bool) -> bool {
1174 crate::search::search_forward(&mut self.buffer, &mut self.search_state, skip_current)
1175 }
1176
1177 /// Drive `N` — symmetric counterpart of [`Editor::search_advance_forward`].
1178 pub fn search_advance_backward(&mut self, skip_current: bool) -> bool {
1179 crate::search::search_backward(&mut self.buffer, &mut self.search_state, skip_current)
1180 }
1181
1182 /// Install styled syntax spans using `ratatui::style::Style`. The
1183 /// ratatui-flavoured variant of [`Editor::install_syntax_spans`].
1184 /// Drops zero-width runs and clamps `end` to the line's char length
1185 /// so the buffer cache doesn't see runaway ranges. Behind the
1186 /// `ratatui` feature; non-ratatui hosts use the unprefixed
1187 /// [`Editor::install_syntax_spans`] (engine-native `Style`).
1188 ///
1189 /// Renamed from `install_syntax_spans` in 0.0.32 — the unprefixed
1190 /// name now belongs to the engine-native variant per SPEC 0.1.0
1191 /// freeze ("engine never imports ratatui").
1192 #[cfg(feature = "ratatui")]
1193 pub fn install_ratatui_syntax_spans(
1194 &mut self,
1195 spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
1196 ) {
1197 // Look up `line_byte_lens` lazily — only fetch a row's length
1198 // when it has at least one span. On a 100k-line file with
1199 // ~50 visible rows, this avoids an O(N) buffer walk per frame.
1200 let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
1201 for (row, row_spans) in spans.iter().enumerate() {
1202 if row_spans.is_empty() {
1203 by_row.push(Vec::new());
1204 continue;
1205 }
1206 let line_len = buf_line(&self.buffer, row).map(str::len).unwrap_or(0);
1207 let mut translated = Vec::with_capacity(row_spans.len());
1208 for (start, end, style) in row_spans {
1209 let end_clamped = (*end).min(line_len);
1210 if end_clamped <= *start {
1211 continue;
1212 }
1213 let id = self.intern_ratatui_style(*style);
1214 translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
1215 }
1216 by_row.push(translated);
1217 }
1218 self.buffer_spans = by_row;
1219 self.styled_spans = spans;
1220 }
1221
1222 /// Snapshot of the unnamed register (the default `p` / `P` source).
1223 pub fn yank(&self) -> &str {
1224 &self.registers.unnamed.text
1225 }
1226
1227 /// Borrow the full register bank — `"`, `"0`–`"9`, `"a`–`"z`.
1228 pub fn registers(&self) -> &crate::registers::Registers {
1229 &self.registers
1230 }
1231
1232 /// Mutably borrow the full register bank. Hosts that share registers
1233 /// across multiple editors (e.g. multi-buffer `yy` / `p`) overwrite
1234 /// the slots here on buffer switch.
1235 pub fn registers_mut(&mut self) -> &mut crate::registers::Registers {
1236 &mut self.registers
1237 }
1238
1239 /// Host hook: load the OS clipboard's contents into the `"+` / `"*`
1240 /// register slot. the host calls this before letting vim consume a
1241 /// paste so `"*p` / `"+p` reflect the live clipboard rather than a
1242 /// stale snapshot from the last yank.
1243 pub fn sync_clipboard_register(&mut self, text: String, linewise: bool) {
1244 self.registers.set_clipboard(text, linewise);
1245 }
1246
1247 /// Return the user's pending register selection (set via `"<reg>` chord
1248 /// before an operator). `None` if no register was selected — caller should
1249 /// use the unnamed register `"`.
1250 ///
1251 /// Read-only — does not consume / clear the pending selection. The
1252 /// register is cleared by the engine after the next operator fires.
1253 ///
1254 /// Promoted in 0.6.X for Phase 4e to let the App's visual-op dispatch arm
1255 /// honor `"a` + visual op chord sequences.
1256 pub fn pending_register(&self) -> Option<char> {
1257 self.vim.pending_register
1258 }
1259
1260 /// True when the user's pending register selector is `+` or `*`.
1261 /// the host peeks this so it can refresh `sync_clipboard_register`
1262 /// only when a clipboard read is actually about to happen.
1263 pub fn pending_register_is_clipboard(&self) -> bool {
1264 matches!(self.vim.pending_register, Some('+') | Some('*'))
1265 }
1266
1267 /// Register currently being recorded into via `q{reg}`. `None` when
1268 /// no recording is active. Hosts use this to surface a "recording @r"
1269 /// indicator in the status line.
1270 pub fn recording_register(&self) -> Option<char> {
1271 self.vim.recording_macro
1272 }
1273
1274 /// Pending repeat count the user has typed but not yet resolved
1275 /// (e.g. pressing `5` before `d`). `None` when nothing is pending.
1276 /// Hosts surface this in a "showcmd" area.
1277 pub fn pending_count(&self) -> Option<u32> {
1278 self.vim.pending_count_val()
1279 }
1280
1281 /// The operator character for any in-flight operator that is waiting
1282 /// for a motion (e.g. `d` after the user types `d` but before a
1283 /// motion). Returns `None` when no operator is pending.
1284 pub fn pending_op(&self) -> Option<char> {
1285 self.vim.pending_op_char()
1286 }
1287
1288 /// `true` when the engine is in any pending chord state — waiting for
1289 /// the next key to complete a command (e.g. `r<char>` replace,
1290 /// `f<char>` find, `m<a>` set-mark, `'<a>` goto-mark, operator-pending
1291 /// after `d` / `c` / `y`, `g`-prefix continuation, `z`-prefix continuation,
1292 /// register selection `"<reg>`, macro recording target, etc).
1293 ///
1294 /// Hosts use this to bypass their own chord dispatch (keymap tries, etc.)
1295 /// and forward keys directly to the engine so in-flight commands can
1296 /// complete without the host eating their continuation keys.
1297 pub fn is_chord_pending(&self) -> bool {
1298 self.vim.is_chord_pending()
1299 }
1300
1301 /// `true` when `insert_ctrl_r_arm()` has been called and the dispatcher
1302 /// is waiting for the next typed character to name the register to paste.
1303 /// The dispatcher should call `insert_paste_register(c)` instead of
1304 /// `insert_char(c)` for the next printable key, then the flag auto-clears.
1305 ///
1306 /// Phase 6.5: exposed so the app-level `dispatch_insert_key` can branch
1307 /// without having to drive the full FSM.
1308 pub fn is_insert_register_pending(&self) -> bool {
1309 self.vim.insert_pending_register
1310 }
1311
1312 /// Clear the `Ctrl-R` register-paste pending flag. Call this immediately
1313 /// before `insert_paste_register(c)` in app-level dispatchers so that the
1314 /// flag does not persist into the next key. Call before
1315 /// `insert_paste_register_bridge` (which `hjkl_vim::insert` does).
1316 ///
1317 /// Phase 6.5: used by `dispatch_insert_key` in the app crate.
1318 pub fn clear_insert_register_pending(&mut self) {
1319 self.vim.insert_pending_register = false;
1320 }
1321
1322 /// Read-only view of the jump-back list (positions pushed on "big"
1323 /// motions). Newest entry is at the back — `Ctrl-o` pops from there.
1324 #[allow(clippy::type_complexity)]
1325 pub fn jump_list(&self) -> (&[(usize, usize)], &[(usize, usize)]) {
1326 (&self.vim.jump_back, &self.vim.jump_fwd)
1327 }
1328
1329 /// Read-only view of the change list (positions of recent edits) plus
1330 /// the current walk cursor. Newest entry is at the back.
1331 pub fn change_list(&self) -> (&[(usize, usize)], Option<usize>) {
1332 (&self.vim.change_list, self.vim.change_list_cursor)
1333 }
1334
1335 /// Replace the unnamed register without touching any other slot.
1336 /// For host-driven imports (e.g. system clipboard); operator
1337 /// code uses [`record_yank`] / [`record_delete`].
1338 pub fn set_yank(&mut self, text: impl Into<String>) {
1339 let text = text.into();
1340 let linewise = self.vim.yank_linewise;
1341 self.registers.unnamed = crate::registers::Slot { text, linewise };
1342 }
1343
1344 /// Record a yank into `"` and `"0`, plus the named target if the
1345 /// user prefixed `"reg`. Updates `vim.yank_linewise` for the
1346 /// paste path.
1347 pub(crate) fn record_yank(&mut self, text: String, linewise: bool) {
1348 self.vim.yank_linewise = linewise;
1349 let target = self.vim.pending_register.take();
1350 self.registers.record_yank(text, linewise, target);
1351 }
1352
1353 /// Direct write to a named register slot — bypasses the unnamed
1354 /// `"` and `"0` updates that `record_yank` does. Used by the
1355 /// macro recorder so finishing a `q{reg}` recording doesn't
1356 /// pollute the user's last yank.
1357 pub(crate) fn set_named_register_text(&mut self, reg: char, text: String) {
1358 if let Some(slot) = match reg {
1359 'a'..='z' => Some(&mut self.registers.named[(reg as u8 - b'a') as usize]),
1360 'A'..='Z' => {
1361 Some(&mut self.registers.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize])
1362 }
1363 _ => None,
1364 } {
1365 slot.text = text;
1366 slot.linewise = false;
1367 }
1368 }
1369
1370 /// Record a delete / change into `"` and the `"1`–`"9` ring.
1371 /// Honours the active named-register prefix.
1372 pub(crate) fn record_delete(&mut self, text: String, linewise: bool) {
1373 self.vim.yank_linewise = linewise;
1374 let target = self.vim.pending_register.take();
1375 self.registers.record_delete(text, linewise, target);
1376 }
1377
1378 /// Install styled syntax spans using the engine-native
1379 /// [`crate::types::Style`]. Always available, regardless of the
1380 /// `ratatui` feature. Hosts depending on ratatui can use the
1381 /// ratatui-flavoured [`Editor::install_ratatui_syntax_spans`].
1382 ///
1383 /// Renamed from `install_engine_syntax_spans` in 0.0.32 — at the
1384 /// 0.1.0 freeze the unprefixed name is the universally-available
1385 /// engine-native variant ("engine never imports ratatui").
1386 pub fn install_syntax_spans(&mut self, spans: Vec<Vec<(usize, usize, crate::types::Style)>>) {
1387 let line_byte_lens: Vec<usize> = (0..buf_row_count(&self.buffer))
1388 .map(|r| buf_line(&self.buffer, r).map(str::len).unwrap_or(0))
1389 .collect();
1390 let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
1391 #[cfg(feature = "ratatui")]
1392 let mut ratatui_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>> =
1393 Vec::with_capacity(spans.len());
1394 for (row, row_spans) in spans.iter().enumerate() {
1395 let line_len = line_byte_lens.get(row).copied().unwrap_or(0);
1396 let mut translated = Vec::with_capacity(row_spans.len());
1397 #[cfg(feature = "ratatui")]
1398 let mut translated_r = Vec::with_capacity(row_spans.len());
1399 for (start, end, style) in row_spans {
1400 let end_clamped = (*end).min(line_len);
1401 if end_clamped <= *start {
1402 continue;
1403 }
1404 let id = self.intern_style(*style);
1405 translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
1406 #[cfg(feature = "ratatui")]
1407 translated_r.push((*start, end_clamped, engine_style_to_ratatui(*style)));
1408 }
1409 by_row.push(translated);
1410 #[cfg(feature = "ratatui")]
1411 ratatui_spans.push(translated_r);
1412 }
1413 self.buffer_spans = by_row;
1414 #[cfg(feature = "ratatui")]
1415 {
1416 self.styled_spans = ratatui_spans;
1417 }
1418 }
1419
1420 /// Intern a `ratatui::style::Style` and return the opaque id used
1421 /// in `hjkl_buffer::Span::style`. The ratatui-flavoured variant of
1422 /// [`Editor::intern_style`]. Linear-scan dedup — the table grows
1423 /// only as new tree-sitter token kinds appear, so it stays tiny.
1424 /// Behind the `ratatui` feature.
1425 ///
1426 /// Renamed from `intern_style` in 0.0.32 — at 0.1.0 freeze the
1427 /// unprefixed name belongs to the engine-native variant.
1428 #[cfg(feature = "ratatui")]
1429 pub fn intern_ratatui_style(&mut self, style: ratatui::style::Style) -> u32 {
1430 if let Some(idx) = self.style_table.iter().position(|s| *s == style) {
1431 return idx as u32;
1432 }
1433 self.style_table.push(style);
1434 (self.style_table.len() - 1) as u32
1435 }
1436
1437 /// Read-only view of the style table — id `i` → `style_table[i]`.
1438 /// The render path passes a closure backed by this slice as the
1439 /// `StyleResolver` for `BufferView`. Behind the `ratatui` feature.
1440 #[cfg(feature = "ratatui")]
1441 pub fn style_table(&self) -> &[ratatui::style::Style] {
1442 &self.style_table
1443 }
1444
1445 /// Per-row syntax span overlay, one `Vec<Span>` per buffer row.
1446 /// Hosts feed this slice into [`hjkl_buffer::BufferView::spans`]
1447 /// per draw frame.
1448 ///
1449 /// 0.0.37: replaces `editor.buffer().spans()` per step 3 of
1450 /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer no longer
1451 /// caches spans; they live on the engine and route through the
1452 /// `Host::syntax_highlights` pipeline.
1453 pub fn buffer_spans(&self) -> &[Vec<hjkl_buffer::Span>] {
1454 &self.buffer_spans
1455 }
1456
1457 /// Intern a SPEC [`crate::types::Style`] and return its opaque id.
1458 /// With the `ratatui` feature on, the id matches the one
1459 /// [`Editor::intern_ratatui_style`] would return for the equivalent
1460 /// `ratatui::Style` (both share the underlying table). With it off,
1461 /// the engine keeps a parallel `crate::types::Style`-keyed table
1462 /// — ids are still stable per-editor.
1463 ///
1464 /// Hosts that don't depend on ratatui (buffr, future GUI shells)
1465 /// reach this method to populate the table during syntax span
1466 /// installation.
1467 ///
1468 /// Renamed from `intern_engine_style` in 0.0.32 — at 0.1.0 freeze
1469 /// the unprefixed name is the universally-available engine-native
1470 /// variant.
1471 pub fn intern_style(&mut self, style: crate::types::Style) -> u32 {
1472 #[cfg(feature = "ratatui")]
1473 {
1474 let r = engine_style_to_ratatui(style);
1475 self.intern_ratatui_style(r)
1476 }
1477 #[cfg(not(feature = "ratatui"))]
1478 {
1479 if let Some(idx) = self.engine_style_table.iter().position(|s| *s == style) {
1480 return idx as u32;
1481 }
1482 self.engine_style_table.push(style);
1483 (self.engine_style_table.len() - 1) as u32
1484 }
1485 }
1486
1487 /// Look up an interned style by id and return it as a SPEC
1488 /// [`crate::types::Style`]. Returns `None` for ids past the end
1489 /// of the table.
1490 pub fn engine_style_at(&self, id: u32) -> Option<crate::types::Style> {
1491 #[cfg(feature = "ratatui")]
1492 {
1493 let r = self.style_table.get(id as usize).copied()?;
1494 Some(ratatui_style_to_engine(r))
1495 }
1496 #[cfg(not(feature = "ratatui"))]
1497 {
1498 self.engine_style_table.get(id as usize).copied()
1499 }
1500 }
1501
1502 /// Historical reverse-sync hook from when the textarea mirrored
1503 /// the buffer. Now that Buffer is the cursor authority this is a
1504 /// no-op; call sites can remain in place during the migration.
1505 pub fn push_buffer_cursor_to_textarea(&mut self) {}
1506
1507 /// Force the host viewport's top row without touching the
1508 /// cursor. Used by tests that simulate a scroll without the
1509 /// SCROLLOFF cursor adjustment that `scroll_down` / `scroll_up`
1510 /// apply.
1511 ///
1512 /// 0.0.34 (Patch C-δ.1): writes through `Host::viewport_mut`
1513 /// instead of the (now-deleted) `Buffer::viewport_mut`.
1514 pub fn set_viewport_top(&mut self, row: usize) {
1515 let last = buf_row_count(&self.buffer).saturating_sub(1);
1516 let target = row.min(last);
1517 self.host.viewport_mut().top_row = target;
1518 }
1519
1520 /// Set the cursor to `(row, col)`, clamped to the buffer's
1521 /// content. Hosts use this for goto-line, jump-to-mark, and
1522 /// programmatic cursor placement.
1523 pub fn jump_cursor(&mut self, row: usize, col: usize) {
1524 buf_set_cursor_rc(&mut self.buffer, row, col);
1525 }
1526
1527 /// `(row, col)` cursor read sourced from the migration buffer.
1528 /// Equivalent to `self.textarea.cursor()` when the two are in
1529 /// sync — which is the steady state during Phase 7f because
1530 /// every step opens with `sync_buffer_content_from_textarea` and
1531 /// every ported motion pushes the result back. Prefer this over
1532 /// `self.textarea.cursor()` so call sites keep working unchanged
1533 /// once the textarea field is ripped.
1534 pub fn cursor(&self) -> (usize, usize) {
1535 buf_cursor_rc(&self.buffer)
1536 }
1537
1538 /// Drain any pending LSP intent raised by the last key. Returns
1539 /// `None` when no intent is armed.
1540 pub fn take_lsp_intent(&mut self) -> Option<LspIntent> {
1541 self.pending_lsp.take()
1542 }
1543
1544 /// Drain every [`crate::types::FoldOp`] raised since the last
1545 /// call. Hosts that mirror the engine's fold storage (or that
1546 /// project folds onto a separate fold tree, LSP folding ranges,
1547 /// …) drain this each step and dispatch as their own
1548 /// [`crate::types::Host::Intent`] requires.
1549 ///
1550 /// The engine has already applied every op locally against the
1551 /// in-tree [`hjkl_buffer::Buffer`] fold storage via
1552 /// [`crate::buffer_impl::BufferFoldProviderMut`], so hosts that
1553 /// don't track folds independently can ignore the queue
1554 /// (or simply never call this drain).
1555 ///
1556 /// Introduced in 0.0.38 (Patch C-δ.4).
1557 pub fn take_fold_ops(&mut self) -> Vec<crate::types::FoldOp> {
1558 std::mem::take(&mut self.pending_fold_ops)
1559 }
1560
1561 /// Dispatch a [`crate::types::FoldOp`] through the canonical fold
1562 /// surface: queue it for host observation (drained by
1563 /// [`Editor::take_fold_ops`]) and apply it locally against the
1564 /// in-tree buffer fold storage via
1565 /// [`crate::buffer_impl::BufferFoldProviderMut`]. Engine call sites
1566 /// (vim FSM `z…` chords, `:fold*` Ex commands, edit-pipeline
1567 /// invalidation) route every fold mutation through this method.
1568 ///
1569 /// Introduced in 0.0.38 (Patch C-δ.4).
1570 pub fn apply_fold_op(&mut self, op: crate::types::FoldOp) {
1571 use crate::types::FoldProvider;
1572 self.pending_fold_ops.push(op);
1573 let mut provider = crate::buffer_impl::BufferFoldProviderMut::new(&mut self.buffer);
1574 provider.apply(op);
1575 }
1576
1577 /// Refresh the host viewport's height from the cached
1578 /// `viewport_height_value()`. Called from the per-step
1579 /// boilerplate; was the textarea → buffer mirror before Phase 7f
1580 /// put Buffer in charge. 0.0.28 hoisted sticky_col out of
1581 /// `Buffer`. 0.0.34 (Patch C-δ.1) routes the height write through
1582 /// `Host::viewport_mut`.
1583 pub(crate) fn sync_buffer_from_textarea(&mut self) {
1584 let height = self.viewport_height_value();
1585 self.host.viewport_mut().height = height;
1586 }
1587
1588 /// Was the full textarea → buffer content sync. Buffer is the
1589 /// content authority now; this remains as a no-op so the per-step
1590 /// call sites don't have to be ripped in the same patch.
1591 pub(crate) fn sync_buffer_content_from_textarea(&mut self) {
1592 self.sync_buffer_from_textarea();
1593 }
1594
1595 /// Push a `(row, col)` onto the back-jumplist so `Ctrl-o` returns
1596 /// to it later. Used by host-driven jumps (e.g. `gd`) that move
1597 /// the cursor without going through the vim engine's motion
1598 /// machinery, where push_jump fires automatically.
1599 pub fn record_jump(&mut self, pos: (usize, usize)) {
1600 const JUMPLIST_MAX: usize = 100;
1601 self.vim.jump_back.push(pos);
1602 if self.vim.jump_back.len() > JUMPLIST_MAX {
1603 self.vim.jump_back.remove(0);
1604 }
1605 self.vim.jump_fwd.clear();
1606 }
1607
1608 /// Host apps call this each draw with the current text area height so
1609 /// scroll helpers can clamp the cursor without recomputing layout.
1610 pub fn set_viewport_height(&self, height: u16) {
1611 self.viewport_height.store(height, Ordering::Relaxed);
1612 }
1613
1614 /// Last height published by `set_viewport_height` (in rows).
1615 pub fn viewport_height_value(&self) -> u16 {
1616 self.viewport_height.load(Ordering::Relaxed)
1617 }
1618
1619 /// Apply `edit` against the buffer and return the inverse so the
1620 /// host can push it onto an undo stack. Side effects: dirty
1621 /// flag, change-list ring, mark / jump-list shifts, change_log
1622 /// append, fold invalidation around the touched rows.
1623 ///
1624 /// The primary edit funnel — both FSM operators and ex commands
1625 /// route mutations through here so the side effects fire
1626 /// uniformly.
1627 pub fn mutate_edit(&mut self, edit: hjkl_buffer::Edit) -> hjkl_buffer::Edit {
1628 // `:set readonly` short-circuits every mutation funnel: no
1629 // buffer change, no dirty flag, no undo entry, no change-log
1630 // emission. We swallow the requested `edit` and hand back a
1631 // self-inverse no-op (`InsertStr` of an empty string at the
1632 // current cursor) so callers that push the return value onto
1633 // an undo stack still get a structurally valid round trip.
1634 if self.settings.readonly {
1635 let _ = edit;
1636 return hjkl_buffer::Edit::InsertStr {
1637 at: buf_cursor_pos(&self.buffer),
1638 text: String::new(),
1639 };
1640 }
1641 let pre_row = buf_cursor_row(&self.buffer);
1642 let pre_rows = buf_row_count(&self.buffer);
1643 // Capture the pre-edit cursor for the dot mark (`'.` / `` `. ``).
1644 // Vim's `:h '.` says "the position where the last change was made",
1645 // meaning the change-start, not the post-insert cursor. We snap it
1646 // here before `apply_buffer_edit` moves the cursor.
1647 let (pre_edit_row, pre_edit_col) = buf_cursor_rc(&self.buffer);
1648 // Map the underlying buffer edit to a SPEC EditOp for
1649 // change-log emission before consuming it. Coarse — see
1650 // change_log field doc on the struct.
1651 self.change_log.extend(edit_to_editops(&edit));
1652 // Compute ContentEdit fan-out from the pre-edit buffer state.
1653 // Done before `apply_buffer_edit` consumes `edit` so we can
1654 // inspect the operation's fields and the buffer's pre-edit row
1655 // bytes (needed for byte_of_row / col_byte conversion). Edits
1656 // are pushed onto `pending_content_edits` for host drain.
1657 let content_edits = content_edits_from_buffer_edit(&self.buffer, &edit);
1658 self.pending_content_edits.extend(content_edits);
1659 // 0.0.42 (Patch C-δ.7): the `apply_edit` reach is centralized
1660 // in [`crate::buf_helpers::apply_buffer_edit`] (option (c) of
1661 // the 0.0.42 plan — see that fn's doc comment). The free fn
1662 // takes `&mut hjkl_buffer::Buffer` so the editor body itself
1663 // no longer carries a `self.buffer.<inherent>` hop.
1664 let inverse = apply_buffer_edit(&mut self.buffer, edit);
1665 let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
1666 // Drop any folds the edit's range overlapped — vim opens the
1667 // surrounding fold automatically when you edit inside it. The
1668 // approximation here invalidates folds covering either the
1669 // pre-edit cursor row or the post-edit cursor row, which
1670 // catches the common single-line / multi-line edit shapes.
1671 let lo = pre_row.min(pos_row);
1672 let hi = pre_row.max(pos_row);
1673 self.apply_fold_op(crate::types::FoldOp::Invalidate {
1674 start_row: lo,
1675 end_row: hi,
1676 });
1677 // Dot mark records the PRE-edit position (change start), matching
1678 // vim's `:h '.` semantics. Previously this stored the post-edit
1679 // cursor, which diverged from nvim on `iX<Esc>j`.
1680 self.vim.last_edit_pos = Some((pre_edit_row, pre_edit_col));
1681 // Append to the change-list ring (skip when the cursor sits on
1682 // the same cell as the last entry — back-to-back keystrokes on
1683 // one column shouldn't pollute the ring). A new edit while
1684 // walking the ring trims the forward half, vim style.
1685 let entry = (pos_row, pos_col);
1686 if self.vim.change_list.last() != Some(&entry) {
1687 if let Some(idx) = self.vim.change_list_cursor.take() {
1688 self.vim.change_list.truncate(idx + 1);
1689 }
1690 self.vim.change_list.push(entry);
1691 let len = self.vim.change_list.len();
1692 if len > crate::vim::CHANGE_LIST_MAX {
1693 self.vim
1694 .change_list
1695 .drain(0..len - crate::vim::CHANGE_LIST_MAX);
1696 }
1697 }
1698 self.vim.change_list_cursor = None;
1699 // Shift / drop marks + jump-list entries to track the row
1700 // delta the edit produced. Without this, every line-changing
1701 // edit silently invalidates `'a`-style positions.
1702 let post_rows = buf_row_count(&self.buffer);
1703 let delta = post_rows as isize - pre_rows as isize;
1704 if delta != 0 {
1705 self.shift_marks_after_edit(pre_row, delta);
1706 }
1707 self.push_buffer_content_to_textarea();
1708 self.mark_content_dirty();
1709 inverse
1710 }
1711
1712 /// Migrate user marks + jumplist entries when an edit at row
1713 /// `edit_start` changes the buffer's row count by `delta` (positive
1714 /// for inserts, negative for deletes). Marks tied to a deleted row
1715 /// are dropped; marks past the affected band shift by `delta`.
1716 fn shift_marks_after_edit(&mut self, edit_start: usize, delta: isize) {
1717 if delta == 0 {
1718 return;
1719 }
1720 // Deleted-row band (only meaningful for delta < 0). Inclusive
1721 // start, exclusive end.
1722 let drop_end = if delta < 0 {
1723 edit_start.saturating_add((-delta) as usize)
1724 } else {
1725 edit_start
1726 };
1727 let shift_threshold = drop_end.max(edit_start.saturating_add(1));
1728
1729 // 0.0.36: lowercase + uppercase marks share the unified
1730 // `marks` map; one pass migrates both.
1731 let mut to_drop: Vec<char> = Vec::new();
1732 for (c, (row, _col)) in self.marks.iter_mut() {
1733 if (edit_start..drop_end).contains(row) {
1734 to_drop.push(*c);
1735 } else if *row >= shift_threshold {
1736 *row = ((*row as isize) + delta).max(0) as usize;
1737 }
1738 }
1739 for c in to_drop {
1740 self.marks.remove(&c);
1741 }
1742
1743 let shift_jumps = |entries: &mut Vec<(usize, usize)>| {
1744 entries.retain(|(row, _)| !(edit_start..drop_end).contains(row));
1745 for (row, _) in entries.iter_mut() {
1746 if *row >= shift_threshold {
1747 *row = ((*row as isize) + delta).max(0) as usize;
1748 }
1749 }
1750 };
1751 shift_jumps(&mut self.vim.jump_back);
1752 shift_jumps(&mut self.vim.jump_fwd);
1753 }
1754
1755 /// Reverse-sync helper paired with [`Editor::mutate_edit`]: rebuild
1756 /// the textarea from the buffer's lines + cursor, preserving yank
1757 /// text. Heavy (allocates a fresh `TextArea`) but correct; the
1758 /// textarea field disappears at the end of Phase 7f anyway.
1759 /// No-op since Buffer is the content authority. Retained as a
1760 /// shim so call sites in `mutate_edit` and friends don't have to
1761 /// be ripped in lockstep with the field removal.
1762 pub(crate) fn push_buffer_content_to_textarea(&mut self) {}
1763
1764 /// Single choke-point for "the buffer just changed". Sets the
1765 /// dirty flag and drops the cached `content_arc` snapshot so
1766 /// subsequent reads rebuild from the live textarea. Callers
1767 /// mutating `textarea` directly (e.g. the TUI's bracketed-paste
1768 /// path) must invoke this to keep the cache honest.
1769 pub fn mark_content_dirty(&mut self) {
1770 self.content_dirty = true;
1771 self.cached_content = None;
1772 }
1773
1774 /// Returns true if content changed since the last call, then clears the flag.
1775 pub fn take_dirty(&mut self) -> bool {
1776 let dirty = self.content_dirty;
1777 self.content_dirty = false;
1778 dirty
1779 }
1780
1781 /// Drain the queue of [`crate::types::ContentEdit`]s emitted since
1782 /// the last call. Each entry corresponds to a single buffer
1783 /// mutation funnelled through [`Editor::mutate_edit`]; block edits
1784 /// fan out to one entry per row touched.
1785 ///
1786 /// Hosts call this each frame (after [`Editor::take_content_reset`])
1787 /// to fan edits into a tree-sitter parser via `Tree::edit`.
1788 pub fn take_content_edits(&mut self) -> Vec<crate::types::ContentEdit> {
1789 std::mem::take(&mut self.pending_content_edits)
1790 }
1791
1792 /// Returns `true` if a bulk buffer replacement happened since the
1793 /// last call (e.g. `set_content` / `restore` / undo restore), then
1794 /// clears the flag. When this returns `true`, hosts should drop
1795 /// any retained syntax tree before consuming
1796 /// [`Editor::take_content_edits`].
1797 pub fn take_content_reset(&mut self) -> bool {
1798 let r = self.pending_content_reset;
1799 self.pending_content_reset = false;
1800 r
1801 }
1802
1803 /// Pull-model coarse change observation. If content changed since
1804 /// the last call, returns `Some(Arc<String>)` with the new content
1805 /// and clears the dirty flag; otherwise returns `None`.
1806 ///
1807 /// Hosts that need fine-grained edit deltas (e.g., DOM patching at
1808 /// the character level) should diff against their own previous
1809 /// snapshot. The SPEC `take_changes() -> Vec<EditOp>` API lands
1810 /// once every edit path inside the engine is instrumented; this
1811 /// coarse form covers the pull-model use case in the meantime.
1812 pub fn take_content_change(&mut self) -> Option<std::sync::Arc<String>> {
1813 if !self.content_dirty {
1814 return None;
1815 }
1816 let arc = self.content_arc();
1817 self.content_dirty = false;
1818 Some(arc)
1819 }
1820
1821 /// Returns the cursor's row within the visible textarea (0-based), updating
1822 /// the stored viewport top so subsequent calls remain accurate.
1823 pub fn cursor_screen_row(&mut self, height: u16) -> u16 {
1824 let cursor = buf_cursor_row(&self.buffer);
1825 let top = self.host.viewport().top_row;
1826 cursor.saturating_sub(top).min(height as usize - 1) as u16
1827 }
1828
1829 /// Returns the cursor's screen position `(x, y)` for the textarea
1830 /// described by `(area_x, area_y, area_width, area_height)`.
1831 /// Accounts for line-number gutter and viewport scroll. Returns
1832 /// `None` if the cursor is outside the visible viewport. Always
1833 /// available (engine-native; no ratatui dependency).
1834 ///
1835 /// Renamed from `cursor_screen_pos_xywh` in 0.0.32 — the
1836 /// ratatui-flavoured `Rect` variant is now
1837 /// [`Editor::cursor_screen_pos_in_rect`] (cfg `ratatui`).
1838 pub fn cursor_screen_pos(
1839 &self,
1840 area_x: u16,
1841 area_y: u16,
1842 area_width: u16,
1843 area_height: u16,
1844 ) -> Option<(u16, u16)> {
1845 let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
1846 let v = self.host.viewport();
1847 if pos_row < v.top_row || pos_col < v.top_col {
1848 return None;
1849 }
1850 let lnum_width = if self.settings.number || self.settings.relativenumber {
1851 let needed = buf_row_count(&self.buffer).to_string().len() + 1;
1852 needed.max(self.settings.numberwidth) as u16
1853 } else {
1854 0
1855 };
1856 let dy = (pos_row - v.top_row) as u16;
1857 // Convert char column to visual column so cursor lands on the
1858 // correct cell when the line contains tabs (which the renderer
1859 // expands to TAB_WIDTH stops). Tab width must match the renderer.
1860 let line = self.buffer.line(pos_row).unwrap_or("");
1861 let tab_width = if v.tab_width == 0 {
1862 4
1863 } else {
1864 v.tab_width as usize
1865 };
1866 let visual_pos = visual_col_for_char(line, pos_col, tab_width);
1867 let visual_top = visual_col_for_char(line, v.top_col, tab_width);
1868 let dx = (visual_pos - visual_top) as u16;
1869 if dy >= area_height || dx + lnum_width >= area_width {
1870 return None;
1871 }
1872 Some((area_x + lnum_width + dx, area_y + dy))
1873 }
1874
1875 /// Ratatui [`Rect`]-flavoured wrapper around
1876 /// [`Editor::cursor_screen_pos`]. Behind the `ratatui` feature.
1877 ///
1878 /// Renamed from `cursor_screen_pos` in 0.0.32 — the unprefixed
1879 /// name now belongs to the engine-native variant.
1880 #[cfg(feature = "ratatui")]
1881 pub fn cursor_screen_pos_in_rect(&self, area: Rect) -> Option<(u16, u16)> {
1882 self.cursor_screen_pos(area.x, area.y, area.width, area.height)
1883 }
1884
1885 /// Returns the current vim mode. Phase 6.3: reads from the stable
1886 /// `current_mode` field (kept in sync by both the FSM step loop and
1887 /// the Phase 6.3 primitive bridges) rather than deriving from the
1888 /// FSM-internal `mode` field via `public_mode()`.
1889 pub fn vim_mode(&self) -> VimMode {
1890 self.vim.current_mode
1891 }
1892
1893 /// Bounds of the active visual-block rectangle as
1894 /// `(top_row, bot_row, left_col, right_col)` — all inclusive.
1895 /// `None` when we're not in VisualBlock mode.
1896 /// Read-only view of the live `/` or `?` prompt. `None` outside
1897 /// search-prompt mode.
1898 pub fn search_prompt(&self) -> Option<&crate::vim::SearchPrompt> {
1899 self.vim.search_prompt.as_ref()
1900 }
1901
1902 /// Most recent committed search pattern (persists across `n` / `N`
1903 /// and across prompt exits). `None` before the first search.
1904 pub fn last_search(&self) -> Option<&str> {
1905 self.vim.last_search.as_deref()
1906 }
1907
1908 /// Whether the last committed search was a forward `/` (`true`) or
1909 /// a backward `?` (`false`). `n` and `N` consult this to honour the
1910 /// direction the user committed.
1911 pub fn last_search_forward(&self) -> bool {
1912 self.vim.last_search_forward
1913 }
1914
1915 /// Set the most recent committed search text + direction. Used by
1916 /// host-driven prompts (e.g. apps/hjkl's `/` `?` prompt that lives
1917 /// outside the engine's vim FSM) so `n` / `N` repeat the host's
1918 /// most recent commit with the right direction. Pass `None` /
1919 /// `true` to clear.
1920 pub fn set_last_search(&mut self, text: Option<String>, forward: bool) {
1921 self.vim.last_search = text;
1922 self.vim.last_search_forward = forward;
1923 }
1924
1925 /// Start/end `(row, col)` of the active char-wise Visual selection
1926 /// (inclusive on both ends, positionally ordered). `None` when not
1927 /// in Visual mode.
1928 pub fn char_highlight(&self) -> Option<((usize, usize), (usize, usize))> {
1929 if self.vim_mode() != VimMode::Visual {
1930 return None;
1931 }
1932 let anchor = self.vim.visual_anchor;
1933 let cursor = self.cursor();
1934 let (start, end) = if anchor <= cursor {
1935 (anchor, cursor)
1936 } else {
1937 (cursor, anchor)
1938 };
1939 Some((start, end))
1940 }
1941
1942 /// Top/bottom rows of the active VisualLine selection (inclusive).
1943 /// `None` when we're not in VisualLine mode.
1944 pub fn line_highlight(&self) -> Option<(usize, usize)> {
1945 if self.vim_mode() != VimMode::VisualLine {
1946 return None;
1947 }
1948 let anchor = self.vim.visual_line_anchor;
1949 let cursor = buf_cursor_row(&self.buffer);
1950 Some((anchor.min(cursor), anchor.max(cursor)))
1951 }
1952
1953 pub fn block_highlight(&self) -> Option<(usize, usize, usize, usize)> {
1954 if self.vim_mode() != VimMode::VisualBlock {
1955 return None;
1956 }
1957 let (ar, ac) = self.vim.block_anchor;
1958 let cr = buf_cursor_row(&self.buffer);
1959 let cc = self.vim.block_vcol;
1960 let top = ar.min(cr);
1961 let bot = ar.max(cr);
1962 let left = ac.min(cc);
1963 let right = ac.max(cc);
1964 Some((top, bot, left, right))
1965 }
1966
1967 /// Active selection in `hjkl_buffer::Selection` shape. `None` when
1968 /// not in a Visual mode. Phase 7d-i wiring — the host hands this
1969 /// straight to `BufferView` once render flips off textarea
1970 /// (Phase 7d-ii drops the `paint_*_overlay` calls on the same
1971 /// switch).
1972 pub fn buffer_selection(&self) -> Option<hjkl_buffer::Selection> {
1973 use hjkl_buffer::{Position, Selection};
1974 match self.vim_mode() {
1975 VimMode::Visual => {
1976 let (ar, ac) = self.vim.visual_anchor;
1977 let head = buf_cursor_pos(&self.buffer);
1978 Some(Selection::Char {
1979 anchor: Position::new(ar, ac),
1980 head,
1981 })
1982 }
1983 VimMode::VisualLine => {
1984 let anchor_row = self.vim.visual_line_anchor;
1985 let head_row = buf_cursor_row(&self.buffer);
1986 Some(Selection::Line {
1987 anchor_row,
1988 head_row,
1989 })
1990 }
1991 VimMode::VisualBlock => {
1992 let (ar, ac) = self.vim.block_anchor;
1993 let cr = buf_cursor_row(&self.buffer);
1994 let cc = self.vim.block_vcol;
1995 Some(Selection::Block {
1996 anchor: Position::new(ar, ac),
1997 head: Position::new(cr, cc),
1998 })
1999 }
2000 _ => None,
2001 }
2002 }
2003
2004 /// Force back to normal mode (used when dismissing completions etc.)
2005 pub fn force_normal(&mut self) {
2006 self.vim.force_normal();
2007 }
2008
2009 pub fn content(&self) -> String {
2010 let n = buf_row_count(&self.buffer);
2011 let mut s = String::new();
2012 for r in 0..n {
2013 if r > 0 {
2014 s.push('\n');
2015 }
2016 s.push_str(crate::types::Query::line(&self.buffer, r as u32));
2017 }
2018 s.push('\n');
2019 s
2020 }
2021
2022 /// Same logical output as [`content`], but returns a cached
2023 /// `Arc<String>` so back-to-back reads within an un-mutated window
2024 /// are ref-count bumps instead of multi-MB joins. The cache is
2025 /// invalidated by every [`mark_content_dirty`] call.
2026 pub fn content_arc(&mut self) -> std::sync::Arc<String> {
2027 if let Some(arc) = &self.cached_content {
2028 return std::sync::Arc::clone(arc);
2029 }
2030 let arc = std::sync::Arc::new(self.content());
2031 self.cached_content = Some(std::sync::Arc::clone(&arc));
2032 arc
2033 }
2034
2035 pub fn set_content(&mut self, text: &str) {
2036 let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
2037 while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
2038 lines.pop();
2039 }
2040 if lines.is_empty() {
2041 lines.push(String::new());
2042 }
2043 let _ = lines;
2044 crate::types::BufferEdit::replace_all(&mut self.buffer, text);
2045 self.undo_stack.clear();
2046 self.redo_stack.clear();
2047 // Whole-buffer replace supersedes any queued ContentEdits.
2048 self.pending_content_edits.clear();
2049 self.pending_content_reset = true;
2050 self.mark_content_dirty();
2051 }
2052
2053 /// Drain the pending change log produced by buffer mutations.
2054 ///
2055 /// Returns a `Vec<EditOp>` covering edits applied since the last
2056 /// call. Empty when no edits ran. Pull-model, complementary to
2057 /// [`Editor::take_content_change`] which gives back the new full
2058 /// content.
2059 ///
2060 /// Mapping coverage:
2061 /// - InsertChar / InsertStr → exact `EditOp` with empty range +
2062 /// replacement.
2063 /// - DeleteRange (`Char` kind) → exact range + empty replacement.
2064 /// - Replace → exact range + new replacement.
2065 /// - DeleteRange (`Line`/`Block`), JoinLines, SplitLines,
2066 /// InsertBlock, DeleteBlockChunks → best-effort placeholder
2067 /// covering the touched range. Hosts wanting per-cell deltas
2068 /// should diff their own `lines()` snapshot.
2069 pub fn take_changes(&mut self) -> Vec<crate::types::Edit> {
2070 std::mem::take(&mut self.change_log)
2071 }
2072
2073 /// Read the engine's current settings as a SPEC
2074 /// [`crate::types::Options`].
2075 ///
2076 /// Bridges between the legacy [`Settings`] (which carries fewer
2077 /// fields than SPEC) and the planned 0.1.0 trait surface. Fields
2078 /// not present in `Settings` fall back to vim defaults (e.g.,
2079 /// `expandtab=false`, `wrapscan=true`, `timeout_len=1000ms`).
2080 /// Once trait extraction lands, this becomes the canonical config
2081 /// reader and `Settings` retires.
2082 pub fn current_options(&self) -> crate::types::Options {
2083 crate::types::Options {
2084 shiftwidth: self.settings.shiftwidth as u32,
2085 tabstop: self.settings.tabstop as u32,
2086 softtabstop: self.settings.softtabstop as u32,
2087 textwidth: self.settings.textwidth as u32,
2088 expandtab: self.settings.expandtab,
2089 ignorecase: self.settings.ignore_case,
2090 smartcase: self.settings.smartcase,
2091 wrapscan: self.settings.wrapscan,
2092 wrap: match self.settings.wrap {
2093 hjkl_buffer::Wrap::None => crate::types::WrapMode::None,
2094 hjkl_buffer::Wrap::Char => crate::types::WrapMode::Char,
2095 hjkl_buffer::Wrap::Word => crate::types::WrapMode::Word,
2096 },
2097 readonly: self.settings.readonly,
2098 autoindent: self.settings.autoindent,
2099 smartindent: self.settings.smartindent,
2100 undo_levels: self.settings.undo_levels,
2101 undo_break_on_motion: self.settings.undo_break_on_motion,
2102 iskeyword: self.settings.iskeyword.clone(),
2103 timeout_len: self.settings.timeout_len,
2104 ..crate::types::Options::default()
2105 }
2106 }
2107
2108 /// Apply a SPEC [`crate::types::Options`] to the engine's settings.
2109 /// Only the fields backed by today's [`Settings`] take effect;
2110 /// remaining options become live once trait extraction wires them
2111 /// through.
2112 pub fn apply_options(&mut self, opts: &crate::types::Options) {
2113 self.settings.shiftwidth = opts.shiftwidth as usize;
2114 self.settings.tabstop = opts.tabstop as usize;
2115 self.settings.softtabstop = opts.softtabstop as usize;
2116 self.settings.textwidth = opts.textwidth as usize;
2117 self.settings.expandtab = opts.expandtab;
2118 self.settings.ignore_case = opts.ignorecase;
2119 self.settings.smartcase = opts.smartcase;
2120 self.settings.wrapscan = opts.wrapscan;
2121 self.settings.wrap = match opts.wrap {
2122 crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
2123 crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
2124 crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
2125 };
2126 self.settings.readonly = opts.readonly;
2127 self.settings.autoindent = opts.autoindent;
2128 self.settings.smartindent = opts.smartindent;
2129 self.settings.undo_levels = opts.undo_levels;
2130 self.settings.undo_break_on_motion = opts.undo_break_on_motion;
2131 self.set_iskeyword(opts.iskeyword.clone());
2132 self.settings.timeout_len = opts.timeout_len;
2133 self.settings.number = opts.number;
2134 self.settings.relativenumber = opts.relativenumber;
2135 self.settings.numberwidth = opts.numberwidth;
2136 self.settings.cursorline = opts.cursorline;
2137 self.settings.cursorcolumn = opts.cursorcolumn;
2138 self.settings.signcolumn = opts.signcolumn;
2139 self.settings.foldcolumn = opts.foldcolumn;
2140 self.settings.colorcolumn = opts.colorcolumn.clone();
2141 }
2142
2143 /// Active visual selection as a SPEC [`crate::types::Highlight`]
2144 /// with [`crate::types::HighlightKind::Selection`].
2145 ///
2146 /// Returns `None` when the editor isn't in a Visual mode.
2147 /// Visual-line and visual-block selections collapse to the
2148 /// bounding char range of the selection — the SPEC `Selection`
2149 /// kind doesn't carry sub-line info today; hosts that need full
2150 /// line / block geometry continue to read [`buffer_selection`]
2151 /// (the legacy [`hjkl_buffer::Selection`] shape).
2152 pub fn selection_highlight(&self) -> Option<crate::types::Highlight> {
2153 use crate::types::{Highlight, HighlightKind, Pos};
2154 let sel = self.buffer_selection()?;
2155 let (start, end) = match sel {
2156 hjkl_buffer::Selection::Char { anchor, head } => {
2157 let a = (anchor.row, anchor.col);
2158 let h = (head.row, head.col);
2159 if a <= h { (a, h) } else { (h, a) }
2160 }
2161 hjkl_buffer::Selection::Line {
2162 anchor_row,
2163 head_row,
2164 } => {
2165 let (top, bot) = if anchor_row <= head_row {
2166 (anchor_row, head_row)
2167 } else {
2168 (head_row, anchor_row)
2169 };
2170 let last_col = buf_line(&self.buffer, bot).map(|l| l.len()).unwrap_or(0);
2171 ((top, 0), (bot, last_col))
2172 }
2173 hjkl_buffer::Selection::Block { anchor, head } => {
2174 let (top, bot) = if anchor.row <= head.row {
2175 (anchor.row, head.row)
2176 } else {
2177 (head.row, anchor.row)
2178 };
2179 let (left, right) = if anchor.col <= head.col {
2180 (anchor.col, head.col)
2181 } else {
2182 (head.col, anchor.col)
2183 };
2184 ((top, left), (bot, right))
2185 }
2186 };
2187 Some(Highlight {
2188 range: Pos {
2189 line: start.0 as u32,
2190 col: start.1 as u32,
2191 }..Pos {
2192 line: end.0 as u32,
2193 col: end.1 as u32,
2194 },
2195 kind: HighlightKind::Selection,
2196 })
2197 }
2198
2199 /// SPEC-typed highlights for `line`.
2200 ///
2201 /// Two emission modes:
2202 ///
2203 /// - **IncSearch**: the user is typing a `/` or `?` prompt and
2204 /// `Editor::search_prompt` is `Some`. Live-preview matches of
2205 /// the in-flight pattern surface as
2206 /// [`crate::types::HighlightKind::IncSearch`].
2207 /// - **SearchMatch**: the prompt has been committed (or absent)
2208 /// and the buffer's armed pattern is non-empty. Matches surface
2209 /// as [`crate::types::HighlightKind::SearchMatch`].
2210 ///
2211 /// Selection / MatchParen / Syntax(id) variants land once the
2212 /// trait extraction routes the FSM's selection set + the host's
2213 /// syntax pipeline through the [`crate::types::Host`] trait.
2214 ///
2215 /// Returns an empty vec when there is nothing to highlight or
2216 /// `line` is out of bounds.
2217 pub fn highlights_for_line(&mut self, line: u32) -> Vec<crate::types::Highlight> {
2218 use crate::types::{Highlight, HighlightKind, Pos};
2219 let row = line as usize;
2220 if row >= buf_row_count(&self.buffer) {
2221 return Vec::new();
2222 }
2223
2224 // Live preview while the prompt is open beats the committed
2225 // pattern.
2226 if let Some(prompt) = self.search_prompt() {
2227 if prompt.text.is_empty() {
2228 return Vec::new();
2229 }
2230 let Ok(re) = regex::Regex::new(&prompt.text) else {
2231 return Vec::new();
2232 };
2233 let Some(haystack) = buf_line(&self.buffer, row) else {
2234 return Vec::new();
2235 };
2236 return re
2237 .find_iter(haystack)
2238 .map(|m| Highlight {
2239 range: Pos {
2240 line,
2241 col: m.start() as u32,
2242 }..Pos {
2243 line,
2244 col: m.end() as u32,
2245 },
2246 kind: HighlightKind::IncSearch,
2247 })
2248 .collect();
2249 }
2250
2251 if self.search_state.pattern.is_none() {
2252 return Vec::new();
2253 }
2254 let dgen = crate::types::Query::dirty_gen(&self.buffer);
2255 crate::search::search_matches(&self.buffer, &mut self.search_state, dgen, row)
2256 .into_iter()
2257 .map(|(start, end)| Highlight {
2258 range: Pos {
2259 line,
2260 col: start as u32,
2261 }..Pos {
2262 line,
2263 col: end as u32,
2264 },
2265 kind: HighlightKind::SearchMatch,
2266 })
2267 .collect()
2268 }
2269
2270 /// Build the engine's [`crate::types::RenderFrame`] for the
2271 /// current state. Hosts call this once per redraw and diff
2272 /// across frames.
2273 ///
2274 /// Coarse today — covers mode + cursor + cursor shape + viewport
2275 /// top + line count. SPEC-target fields (selections, highlights,
2276 /// command line, search prompt, status line) land once trait
2277 /// extraction routes them through `SelectionSet` and the
2278 /// `Highlight` pipeline.
2279 pub fn render_frame(&self) -> crate::types::RenderFrame {
2280 use crate::types::{CursorShape, RenderFrame, SnapshotMode};
2281 let (cursor_row, cursor_col) = self.cursor();
2282 let (mode, shape) = match self.vim_mode() {
2283 crate::VimMode::Normal => (SnapshotMode::Normal, CursorShape::Block),
2284 crate::VimMode::Insert => (SnapshotMode::Insert, CursorShape::Bar),
2285 crate::VimMode::Visual => (SnapshotMode::Visual, CursorShape::Block),
2286 crate::VimMode::VisualLine => (SnapshotMode::VisualLine, CursorShape::Block),
2287 crate::VimMode::VisualBlock => (SnapshotMode::VisualBlock, CursorShape::Block),
2288 };
2289 RenderFrame {
2290 mode,
2291 cursor_row: cursor_row as u32,
2292 cursor_col: cursor_col as u32,
2293 cursor_shape: shape,
2294 viewport_top: self.host.viewport().top_row as u32,
2295 line_count: crate::types::Query::line_count(&self.buffer),
2296 }
2297 }
2298
2299 /// Capture the editor's coarse state into a serde-friendly
2300 /// [`crate::types::EditorSnapshot`].
2301 ///
2302 /// Today's snapshot covers mode, cursor, lines, viewport top.
2303 /// Registers, marks, jump list, undo tree, and full options arrive
2304 /// once phase 5 trait extraction lands the generic
2305 /// `Editor<B: Buffer, H: Host>` constructor — this method's surface
2306 /// stays stable; only the snapshot's internal fields grow.
2307 ///
2308 /// Distinct from the internal `snapshot` used by undo (which
2309 /// returns `(Vec<String>, (usize, usize))`); host-facing
2310 /// persistence goes through this one.
2311 pub fn take_snapshot(&self) -> crate::types::EditorSnapshot {
2312 use crate::types::{EditorSnapshot, SnapshotMode};
2313 let mode = match self.vim_mode() {
2314 crate::VimMode::Normal => SnapshotMode::Normal,
2315 crate::VimMode::Insert => SnapshotMode::Insert,
2316 crate::VimMode::Visual => SnapshotMode::Visual,
2317 crate::VimMode::VisualLine => SnapshotMode::VisualLine,
2318 crate::VimMode::VisualBlock => SnapshotMode::VisualBlock,
2319 };
2320 let cursor = self.cursor();
2321 let cursor = (cursor.0 as u32, cursor.1 as u32);
2322 let lines: Vec<String> = buf_lines_to_vec(&self.buffer);
2323 let viewport_top = self.host.viewport().top_row as u32;
2324 let marks = self
2325 .marks
2326 .iter()
2327 .map(|(c, (r, col))| (*c, (*r as u32, *col as u32)))
2328 .collect();
2329 EditorSnapshot {
2330 version: EditorSnapshot::VERSION,
2331 mode,
2332 cursor,
2333 lines,
2334 viewport_top,
2335 registers: self.registers.clone(),
2336 marks,
2337 }
2338 }
2339
2340 /// Restore editor state from an [`EditorSnapshot`]. Returns
2341 /// [`crate::EngineError::SnapshotVersion`] if the snapshot's
2342 /// `version` doesn't match [`EditorSnapshot::VERSION`].
2343 ///
2344 /// Mode is best-effort: `SnapshotMode` only round-trips the
2345 /// status-line summary, not the full FSM state. Visual / Insert
2346 /// mode entry happens through synthetic key dispatch when needed.
2347 pub fn restore_snapshot(
2348 &mut self,
2349 snap: crate::types::EditorSnapshot,
2350 ) -> Result<(), crate::EngineError> {
2351 use crate::types::EditorSnapshot;
2352 if snap.version != EditorSnapshot::VERSION {
2353 return Err(crate::EngineError::SnapshotVersion(
2354 snap.version,
2355 EditorSnapshot::VERSION,
2356 ));
2357 }
2358 let text = snap.lines.join("\n");
2359 self.set_content(&text);
2360 self.jump_cursor(snap.cursor.0 as usize, snap.cursor.1 as usize);
2361 self.host.viewport_mut().top_row = snap.viewport_top as usize;
2362 self.registers = snap.registers;
2363 self.marks = snap
2364 .marks
2365 .into_iter()
2366 .map(|(c, (r, col))| (c, (r as usize, col as usize)))
2367 .collect();
2368 Ok(())
2369 }
2370
2371 /// Install `text` as the pending yank buffer so the next `p`/`P` pastes
2372 /// it. Linewise is inferred from a trailing newline, matching how `yy`/`dd`
2373 /// shape their payload.
2374 pub fn seed_yank(&mut self, text: String) {
2375 let linewise = text.ends_with('\n');
2376 self.vim.yank_linewise = linewise;
2377 self.registers.unnamed = crate::registers::Slot { text, linewise };
2378 }
2379
2380 /// Scroll the viewport down by `rows`. The cursor stays on its
2381 /// absolute line (vim convention) unless the scroll would take it
2382 /// off-screen — in that case it's clamped to the first row still
2383 /// visible.
2384 pub fn scroll_down(&mut self, rows: i16) {
2385 self.scroll_viewport(rows);
2386 }
2387
2388 /// Scroll the viewport up by `rows`. Cursor stays unless it would
2389 /// fall off the bottom of the new viewport, then clamp to the
2390 /// bottom-most visible row.
2391 pub fn scroll_up(&mut self, rows: i16) {
2392 self.scroll_viewport(-rows);
2393 }
2394
2395 /// Vim's `scrolloff` default — keep the cursor at least this many
2396 /// rows away from the top / bottom edge of the viewport while
2397 /// scrolling. Collapses to `height / 2` for tiny viewports.
2398 const SCROLLOFF: usize = 5;
2399
2400 /// Scroll the viewport so the cursor stays at least `SCROLLOFF`
2401 /// rows from each edge. Replaces the bare
2402 /// `Buffer::ensure_cursor_visible` call at end-of-step so motions
2403 /// don't park the cursor on the very last visible row.
2404 pub fn ensure_cursor_in_scrolloff(&mut self) {
2405 let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2406 if height == 0 {
2407 // 0.0.42 (Patch C-δ.7): viewport math lifted onto engine
2408 // free fns over `B: Query [+ Cursor]` + `&dyn FoldProvider`.
2409 // Disjoint-field borrow split: `self.buffer` (immutable via
2410 // `folds` snapshot + cursor) and `self.host` (mutable
2411 // viewport ref) live on distinct struct fields, so one
2412 // statement satisfies the borrow checker.
2413 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2414 crate::viewport_math::ensure_cursor_visible(
2415 &self.buffer,
2416 &folds,
2417 self.host.viewport_mut(),
2418 );
2419 return;
2420 }
2421 // Cap margin at (height - 1) / 2 so the upper + lower bands
2422 // can't overlap on tiny windows (margin=5 + height=10 would
2423 // otherwise produce contradictory clamp ranges).
2424 let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
2425 // Soft-wrap path: scrolloff math runs in *screen rows*, not
2426 // doc rows, since a wrapped doc row spans many visual lines.
2427 if !matches!(self.host.viewport().wrap, hjkl_buffer::Wrap::None) {
2428 self.ensure_scrolloff_wrap(height, margin);
2429 return;
2430 }
2431 let cursor_row = buf_cursor_row(&self.buffer);
2432 let last_row = buf_row_count(&self.buffer).saturating_sub(1);
2433 let v = self.host.viewport_mut();
2434 // Top edge: cursor_row should sit at >= top_row + margin.
2435 if cursor_row < v.top_row + margin {
2436 v.top_row = cursor_row.saturating_sub(margin);
2437 }
2438 // Bottom edge: cursor_row should sit at <= top_row + height - 1 - margin.
2439 let max_bottom = height.saturating_sub(1).saturating_sub(margin);
2440 if cursor_row > v.top_row + max_bottom {
2441 v.top_row = cursor_row.saturating_sub(max_bottom);
2442 }
2443 // Clamp top_row so we never scroll past the buffer's bottom.
2444 let max_top = last_row.saturating_sub(height.saturating_sub(1));
2445 if v.top_row > max_top {
2446 v.top_row = max_top;
2447 }
2448 // Defer to Buffer for column-side scroll (no scrolloff for
2449 // horizontal scrolling — vim default `sidescrolloff = 0`).
2450 let cursor = buf_cursor_pos(&self.buffer);
2451 self.host.viewport_mut().ensure_visible(cursor);
2452 }
2453
2454 /// Soft-wrap-aware scrolloff. Walks `top_row` one visible doc row
2455 /// at a time so the cursor's *screen* row stays inside
2456 /// `[margin, height - 1 - margin]`, then clamps `top_row` so the
2457 /// buffer's bottom never leaves blank rows below it.
2458 fn ensure_scrolloff_wrap(&mut self, height: usize, margin: usize) {
2459 let cursor_row = buf_cursor_row(&self.buffer);
2460 // Step 1 — cursor above viewport: snap top to cursor row,
2461 // then we'll fix up the margin below.
2462 if cursor_row < self.host.viewport().top_row {
2463 let v = self.host.viewport_mut();
2464 v.top_row = cursor_row;
2465 v.top_col = 0;
2466 }
2467 // Step 2 — push top forward until cursor's screen row is
2468 // within the bottom margin (`csr <= height - 1 - margin`).
2469 // 0.0.33 (Patch C-γ): fold-iteration goes through the
2470 // [`crate::types::FoldProvider`] surface via
2471 // [`crate::buffer_impl::BufferFoldProvider`]. 0.0.34 (Patch
2472 // C-δ.1): `cursor_screen_row` / `max_top_for_height` now take
2473 // a `&Viewport` parameter; the host owns the viewport, so the
2474 // disjoint `(self.host, self.buffer)` borrows split cleanly.
2475 let max_csr = height.saturating_sub(1).saturating_sub(margin);
2476 loop {
2477 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2478 let csr =
2479 crate::viewport_math::cursor_screen_row(&self.buffer, &folds, self.host.viewport())
2480 .unwrap_or(0);
2481 if csr <= max_csr {
2482 break;
2483 }
2484 let top = self.host.viewport().top_row;
2485 let row_count = buf_row_count(&self.buffer);
2486 let next = {
2487 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2488 <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::next_visible_row(&folds, top, row_count)
2489 };
2490 let Some(next) = next else {
2491 break;
2492 };
2493 // Don't walk past the cursor's row.
2494 if next > cursor_row {
2495 self.host.viewport_mut().top_row = cursor_row;
2496 break;
2497 }
2498 self.host.viewport_mut().top_row = next;
2499 }
2500 // Step 3 — pull top backward until cursor's screen row is
2501 // past the top margin (`csr >= margin`).
2502 loop {
2503 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2504 let csr =
2505 crate::viewport_math::cursor_screen_row(&self.buffer, &folds, self.host.viewport())
2506 .unwrap_or(0);
2507 if csr >= margin {
2508 break;
2509 }
2510 let top = self.host.viewport().top_row;
2511 let prev = {
2512 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2513 <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::prev_visible_row(&folds, top)
2514 };
2515 let Some(prev) = prev else {
2516 break;
2517 };
2518 self.host.viewport_mut().top_row = prev;
2519 }
2520 // Step 4 — clamp top so the buffer's bottom doesn't leave
2521 // blank rows below it. `max_top_for_height` walks segments
2522 // backward from the last row until it accumulates `height`
2523 // screen rows.
2524 let max_top = {
2525 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2526 crate::viewport_math::max_top_for_height(
2527 &self.buffer,
2528 &folds,
2529 self.host.viewport(),
2530 height,
2531 )
2532 };
2533 if self.host.viewport().top_row > max_top {
2534 self.host.viewport_mut().top_row = max_top;
2535 }
2536 self.host.viewport_mut().top_col = 0;
2537 }
2538
2539 fn scroll_viewport(&mut self, delta: i16) {
2540 if delta == 0 {
2541 return;
2542 }
2543 // Bump the host viewport's top within bounds.
2544 let total_rows = buf_row_count(&self.buffer) as isize;
2545 let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2546 let cur_top = self.host.viewport().top_row as isize;
2547 let new_top = (cur_top + delta as isize)
2548 .max(0)
2549 .min((total_rows - 1).max(0)) as usize;
2550 self.host.viewport_mut().top_row = new_top;
2551 // Mirror to textarea so its viewport reads (still consumed by
2552 // a couple of helpers) stay accurate.
2553 let _ = cur_top;
2554 if height == 0 {
2555 return;
2556 }
2557 // Apply scrolloff: keep the cursor at least SCROLLOFF rows
2558 // from the visible viewport edges.
2559 let (cursor_row, cursor_col) = buf_cursor_rc(&self.buffer);
2560 let margin = Self::SCROLLOFF.min(height / 2);
2561 let min_row = new_top + margin;
2562 let max_row = new_top + height.saturating_sub(1).saturating_sub(margin);
2563 let target_row = cursor_row.clamp(min_row, max_row.max(min_row));
2564 if target_row != cursor_row {
2565 let line_len = buf_line(&self.buffer, target_row)
2566 .map(|l| l.chars().count())
2567 .unwrap_or(0);
2568 let target_col = cursor_col.min(line_len.saturating_sub(1));
2569 buf_set_cursor_rc(&mut self.buffer, target_row, target_col);
2570 }
2571 }
2572
2573 pub fn goto_line(&mut self, line: usize) {
2574 let row = line.saturating_sub(1);
2575 let max = buf_row_count(&self.buffer).saturating_sub(1);
2576 let target = row.min(max);
2577 buf_set_cursor_rc(&mut self.buffer, target, 0);
2578 // Vim: `:N` / `+N` jump scrolls the viewport too — without this
2579 // the cursor lands off-screen and the user has to scroll
2580 // manually to see it.
2581 self.ensure_cursor_in_scrolloff();
2582 }
2583
2584 /// Scroll so the cursor row lands at the given viewport position:
2585 /// `Center` → middle row, `Top` → first row, `Bottom` → last row.
2586 /// Cursor stays on its absolute line; only the viewport moves.
2587 pub(super) fn scroll_cursor_to(&mut self, pos: CursorScrollTarget) {
2588 let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2589 if height == 0 {
2590 return;
2591 }
2592 let cur_row = buf_cursor_row(&self.buffer);
2593 let cur_top = self.host.viewport().top_row;
2594 // Scrolloff awareness: `zt` lands the cursor at the top edge
2595 // of the viable area (top + margin), `zb` at the bottom edge
2596 // (top + height - 1 - margin). Match the cap used by
2597 // `ensure_cursor_in_scrolloff` so contradictory bounds are
2598 // impossible on tiny viewports.
2599 let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
2600 let new_top = match pos {
2601 CursorScrollTarget::Center => cur_row.saturating_sub(height / 2),
2602 CursorScrollTarget::Top => cur_row.saturating_sub(margin),
2603 CursorScrollTarget::Bottom => {
2604 cur_row.saturating_sub(height.saturating_sub(1).saturating_sub(margin))
2605 }
2606 };
2607 if new_top == cur_top {
2608 return;
2609 }
2610 self.host.viewport_mut().top_row = new_top;
2611 }
2612
2613 /// Jump the cursor to the given 1-based line/column, clamped to the document.
2614 pub fn jump_to(&mut self, line: usize, col: usize) {
2615 let r = line.saturating_sub(1);
2616 let max_row = buf_row_count(&self.buffer).saturating_sub(1);
2617 let r = r.min(max_row);
2618 let line_len = buf_line(&self.buffer, r)
2619 .map(|l| l.chars().count())
2620 .unwrap_or(0);
2621 let c = col.saturating_sub(1).min(line_len);
2622 buf_set_cursor_rc(&mut self.buffer, r, c);
2623 }
2624
2625 // ── Host-agnostic doc-coord mouse primitives (Phase 1 of issue #114) ─────
2626 //
2627 // These primitives operate on document (row, col) coordinates that the HOST
2628 // computes from its own layout knowledge (cell geometry for the TUI host,
2629 // pixel geometry for the future GUI host). The engine has no u16 terminal
2630 // assumption here — it just moves the cursor in doc-space.
2631
2632 /// Set the cursor to the given doc-space `(row, col)`, clamped to the
2633 /// document bounds. Hosts use this for programmatic cursor placement and
2634 /// as the building block for the mouse-click path.
2635 ///
2636 /// `col` may equal `line.chars().count()` (Insert-mode "one past end"
2637 /// position); values beyond that are clamped to `char_count`.
2638 pub fn set_cursor_doc(&mut self, row: usize, col: usize) {
2639 let max_row = buf_row_count(&self.buffer).saturating_sub(1);
2640 let r = row.min(max_row);
2641 let line_len = buf_line(&self.buffer, r)
2642 .map(|l| l.chars().count())
2643 .unwrap_or(0);
2644 let c = col.min(line_len);
2645 buf_set_cursor_rc(&mut self.buffer, r, c);
2646 }
2647
2648 /// Handle a left-button click at doc-space `(row, col)`.
2649 ///
2650 /// Exits Visual mode if active, breaks the insert-mode undo group (Vim
2651 /// parity for `undo_break_on_motion`), then moves the cursor. The host
2652 /// performs cell→doc or pixel→doc translation before calling this.
2653 pub fn mouse_click_doc(&mut self, row: usize, col: usize) {
2654 if self.vim.is_visual() {
2655 self.vim.force_normal();
2656 }
2657 // Mouse-position click counts as a motion — break the active
2658 // insert-mode undo group when the toggle is on (vim parity).
2659 crate::vim::break_undo_group_in_insert(self);
2660 self.set_cursor_doc(row, col);
2661 }
2662
2663 /// Begin a mouse-drag selection: anchor at the current cursor and enter
2664 /// Visual-char mode. Idempotent if already in Visual-char mode.
2665 pub fn mouse_begin_drag(&mut self) {
2666 if !self.vim.is_visual_char() {
2667 vim::enter_visual_char_bridge(self);
2668 }
2669 }
2670
2671 /// Extend an in-progress mouse drag to doc-space `(row, col)`.
2672 ///
2673 /// Moves the live cursor; the Visual anchor stays where
2674 /// [`Editor::mouse_begin_drag`] set it. Call after the host has
2675 /// translated the drag position to doc coordinates.
2676 pub fn mouse_extend_drag_doc(&mut self, row: usize, col: usize) {
2677 self.set_cursor_doc(row, col);
2678 }
2679
2680 pub fn insert_str(&mut self, text: &str) {
2681 let pos = crate::types::Cursor::cursor(&self.buffer);
2682 crate::types::BufferEdit::insert_at(&mut self.buffer, pos, text);
2683 self.push_buffer_content_to_textarea();
2684 self.mark_content_dirty();
2685 }
2686
2687 pub fn accept_completion(&mut self, completion: &str) {
2688 use crate::types::{BufferEdit, Cursor as CursorTrait, Pos};
2689 let cursor_pos = CursorTrait::cursor(&self.buffer);
2690 let cursor_row = cursor_pos.line as usize;
2691 let cursor_col = cursor_pos.col as usize;
2692 let line = buf_line(&self.buffer, cursor_row).unwrap_or("").to_string();
2693 let chars: Vec<char> = line.chars().collect();
2694 let prefix_len = chars[..cursor_col.min(chars.len())]
2695 .iter()
2696 .rev()
2697 .take_while(|c| c.is_alphanumeric() || **c == '_')
2698 .count();
2699 if prefix_len > 0 {
2700 let start = Pos {
2701 line: cursor_row as u32,
2702 col: (cursor_col - prefix_len) as u32,
2703 };
2704 BufferEdit::delete_range(&mut self.buffer, start..cursor_pos);
2705 }
2706 let cursor = CursorTrait::cursor(&self.buffer);
2707 BufferEdit::insert_at(&mut self.buffer, cursor, completion);
2708 self.push_buffer_content_to_textarea();
2709 self.mark_content_dirty();
2710 }
2711
2712 pub(super) fn snapshot(&self) -> (Vec<String>, (usize, usize)) {
2713 let rc = buf_cursor_rc(&self.buffer);
2714 (buf_lines_to_vec(&self.buffer), rc)
2715 }
2716
2717 /// Walk one step back through the undo history. Equivalent to the
2718 /// user pressing `u` in normal mode. Drains the most recent undo
2719 /// entry and pushes it onto the redo stack.
2720 pub fn undo(&mut self) {
2721 crate::vim::do_undo(self);
2722 }
2723
2724 /// Walk one step forward through the redo history. Equivalent to
2725 /// `<C-r>` in normal mode.
2726 pub fn redo(&mut self) {
2727 crate::vim::do_redo(self);
2728 }
2729
2730 /// Snapshot current buffer state onto the undo stack and clear
2731 /// the redo stack. Bounded by `settings.undo_levels` — older
2732 /// entries pruned. Call before any group of buffer mutations the
2733 /// user might want to undo as a single step.
2734 pub fn push_undo(&mut self) {
2735 let snap = self.snapshot();
2736 self.undo_stack.push(snap);
2737 self.cap_undo();
2738 self.redo_stack.clear();
2739 }
2740
2741 /// Trim the undo stack down to `settings.undo_levels`, dropping
2742 /// the oldest entries. `undo_levels == 0` is treated as
2743 /// "unlimited" (vim's 0-means-no-undo semantics intentionally
2744 /// skipped — guarding with `> 0` is one line shorter than gating
2745 /// the cap path with an explicit zero-check above the call site).
2746 pub(crate) fn cap_undo(&mut self) {
2747 let cap = self.settings.undo_levels as usize;
2748 if cap > 0 && self.undo_stack.len() > cap {
2749 let diff = self.undo_stack.len() - cap;
2750 self.undo_stack.drain(..diff);
2751 }
2752 }
2753
2754 /// Test-only accessor for the undo stack length.
2755 #[doc(hidden)]
2756 pub fn undo_stack_len(&self) -> usize {
2757 self.undo_stack.len()
2758 }
2759
2760 /// Replace the buffer with `lines` joined by `\n` and set the
2761 /// cursor to `cursor`. Used by undo / `:e!` / snapshot restore
2762 /// paths. Marks the editor dirty.
2763 pub fn restore(&mut self, lines: Vec<String>, cursor: (usize, usize)) {
2764 let text = lines.join("\n");
2765 crate::types::BufferEdit::replace_all(&mut self.buffer, &text);
2766 buf_set_cursor_rc(&mut self.buffer, cursor.0, cursor.1);
2767 // Bulk replace — supersedes any queued ContentEdits.
2768 self.pending_content_edits.clear();
2769 self.pending_content_reset = true;
2770 self.mark_content_dirty();
2771 }
2772
2773 /// Returns true if the key was consumed by the editor.
2774 /// Replace the char under the cursor with `ch`, `count` times. Matches
2775 /// vim `r<x>` semantics: cursor ends on the last replaced char, undo
2776 /// snapshot taken once at start. Promoted to public surface in 0.5.5
2777 /// so hjkl-vim's pending-state reducer can dispatch `Replace` without
2778 /// re-entering the FSM.
2779 pub fn replace_char_at(&mut self, ch: char, count: usize) {
2780 vim::replace_char(self, ch, count);
2781 }
2782
2783 /// Apply vim's `f<x>` / `F<x>` / `t<x>` / `T<x>` motion. Moves the cursor
2784 /// to the `count`-th occurrence of `ch` on the current line, respecting
2785 /// `forward` (direction) and `till` (stop one char before target).
2786 /// Records `last_find` so `;` / `,` repeat work.
2787 ///
2788 /// No-op if the target char isn't on the current line within range.
2789 /// Cursor / scroll / sticky-col semantics match `f<x>` via `execute_motion`.
2790 pub fn find_char(&mut self, ch: char, forward: bool, till: bool, count: usize) {
2791 vim::apply_find_char(self, ch, forward, till, count.max(1));
2792 }
2793
2794 /// Apply the g-chord effect for `g<ch>` with a pre-captured `count`.
2795 /// Mirrors the full `handle_after_g` dispatch table — `gg`, `gj`, `gk`,
2796 /// `gv`, `gU` / `gu` / `g~` (→ operator-pending), `gi`, `g*`, `g#`, etc.
2797 ///
2798 /// Promoted to public surface in 0.5.10 so hjkl-vim's
2799 /// `PendingState::AfterG` reducer can dispatch `AfterGChord` without
2800 /// re-entering the engine FSM.
2801 pub fn after_g(&mut self, ch: char, count: usize) {
2802 vim::apply_after_g(self, ch, count);
2803 }
2804
2805 /// Apply the z-chord effect for `z<ch>` with a pre-captured `count`.
2806 /// Mirrors the full `handle_after_z` dispatch table — `zz` / `zt` / `zb`
2807 /// (scroll-cursor), `zo` / `zc` / `za` / `zR` / `zM` / `zE` / `zd`
2808 /// (fold ops), and `zf` (fold-add over visual selection or → op-pending).
2809 ///
2810 /// Promoted to public surface in 0.5.11 so hjkl-vim's
2811 /// `PendingState::AfterZ` reducer can dispatch `AfterZChord` without
2812 /// re-entering the engine FSM.
2813 pub fn after_z(&mut self, ch: char, count: usize) {
2814 vim::apply_after_z(self, ch, count);
2815 }
2816
2817 /// Apply an operator over a single-key motion. `op` is the engine `Operator`
2818 /// and `motion_key` is the raw character (e.g. `'w'`, `'$'`, `'G'`). The
2819 /// engine resolves the char to a [`vim::Motion`] via `parse_motion`, applies
2820 /// the vim quirks (`cw` → `ce`, `cW` → `cE`, `FindRepeat` → stored find),
2821 /// then calls `apply_op_with_motion`. `total_count` is already the product of
2822 /// the prefix count and any inner count accumulated by the reducer.
2823 ///
2824 /// No-op when `motion_key` does not map to a known motion (engine silently
2825 /// cancels the operator, matching vim's behaviour on unknown motions).
2826 ///
2827 /// Promoted to the public surface in 0.5.12 so the hjkl-vim
2828 /// `PendingState::AfterOp` reducer can dispatch `ApplyOpMotion` without
2829 /// re-entering the engine FSM.
2830 pub fn apply_op_motion(
2831 &mut self,
2832 op: crate::vim::Operator,
2833 motion_key: char,
2834 total_count: usize,
2835 ) {
2836 vim::apply_op_motion_key(self, op, motion_key, total_count);
2837 }
2838
2839 /// Apply a doubled-letter line op (`dd` / `yy` / `cc` / `>>` / `<<`).
2840 /// `total_count` is the product of prefix count and inner count.
2841 ///
2842 /// Promoted to the public surface in 0.5.12 so the hjkl-vim
2843 /// `PendingState::AfterOp` reducer can dispatch `ApplyOpDouble` without
2844 /// re-entering the engine FSM.
2845 pub fn apply_op_double(&mut self, op: crate::vim::Operator, total_count: usize) {
2846 vim::apply_op_double(self, op, total_count);
2847 }
2848
2849 /// Apply an operator over a find motion (`df<x>` / `dF<x>` / `dt<x>` /
2850 /// `dT<x>`). Builds `Motion::Find { ch, forward, till }`, applies it via
2851 /// `apply_op_with_motion`, records `last_find` for `;` / `,` repeat, and
2852 /// updates `last_change` when `op` is Change (for dot-repeat).
2853 ///
2854 /// `total_count` is the product of prefix count and any inner count
2855 /// accumulated by the reducer — already folded at transition time.
2856 ///
2857 /// Promoted to the public surface in 0.5.14 so the hjkl-vim
2858 /// `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
2859 /// re-entering the engine FSM. `handle_op_find_target` (used by the
2860 /// chord-init op path) delegates here to avoid logic duplication.
2861 pub fn apply_op_find(
2862 &mut self,
2863 op: crate::vim::Operator,
2864 ch: char,
2865 forward: bool,
2866 till: bool,
2867 total_count: usize,
2868 ) {
2869 vim::apply_op_find_motion(self, op, ch, forward, till, total_count);
2870 }
2871
2872 /// Apply an operator over a text-object range (`diw` / `daw` / `di"` etc.).
2873 /// Maps `ch` to a `TextObject` per the standard vim table, calls
2874 /// `apply_op_with_text_object`, and records `last_change` when `op` is
2875 /// Change (dot-repeat). Unknown `ch` values are silently ignored (no-op),
2876 /// matching the engine FSM's behaviour on unrecognised text-object chars.
2877 ///
2878 /// `total_count` is accepted for API symmetry with `apply_op_motion` /
2879 /// `apply_op_find` but is currently unused — text objects don't repeat in
2880 /// vim's current grammar. Kept for future-proofing.
2881 ///
2882 /// Promoted to the public surface in 0.5.15 so the hjkl-vim
2883 /// `PendingState::OpTextObj` reducer can dispatch `ApplyOpTextObj` without
2884 /// re-entering the engine FSM. `handle_text_object` (chord-init op path)
2885 /// delegates to the shared `apply_op_text_obj_inner` helper to avoid logic
2886 /// duplication.
2887 pub fn apply_op_text_obj(
2888 &mut self,
2889 op: crate::vim::Operator,
2890 ch: char,
2891 inner: bool,
2892 total_count: usize,
2893 ) {
2894 vim::apply_op_text_obj_inner(self, op, ch, inner, total_count);
2895 }
2896
2897 /// Apply an operator over a g-chord motion or case-op linewise form
2898 /// (`dgg` / `dge` / `dgE` / `dgj` / `dgk` / `gUgU` etc.).
2899 ///
2900 /// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's
2901 /// letter (`U`/`u`/`~`), executes the line op (linewise form).
2902 /// - Otherwise maps `ch` to a motion:
2903 /// - `'g'` → `Motion::FileTop` (gg)
2904 /// - `'e'` → `Motion::WordEndBack` (ge)
2905 /// - `'E'` → `Motion::BigWordEndBack` (gE)
2906 /// - `'j'` → `Motion::ScreenDown` (gj)
2907 /// - `'k'` → `Motion::ScreenUp` (gk)
2908 /// - unknown → no-op (silently ignored, matching engine FSM behaviour)
2909 /// - Updates `last_change` for dot-repeat when `op` is a change operator.
2910 ///
2911 /// `total_count` is the already-folded product of prefix and inner counts.
2912 ///
2913 /// Promoted to the public surface in 0.5.16 so the hjkl-vim
2914 /// `PendingState::OpG` reducer can dispatch `ApplyOpG` without
2915 /// re-entering the engine FSM. `handle_op_after_g` (chord-init op path)
2916 /// delegates to the shared `apply_op_g_inner` helper to avoid logic
2917 /// duplication.
2918 pub fn apply_op_g(&mut self, op: crate::vim::Operator, ch: char, total_count: usize) {
2919 vim::apply_op_g_inner(self, op, ch, total_count);
2920 }
2921
2922 // ─── Phase 4a: pub range-mutation primitives (hjkl#70) ──────────────────
2923 //
2924 // These do not consume input — the caller (hjkl-vim's visual-mode operator
2925 // path, chunk 4e) has already resolved the range from the visual selection
2926 // before calling in. Normal-mode op dispatch continues to use
2927 // `apply_op_motion` / `apply_op_double` / `apply_op_find` / `apply_op_text_obj`.
2928
2929 /// Delete the region `[start, end)` and stash the removed text in
2930 /// `register`. `'"'` selects the unnamed register (vim default); `'a'`–`'z'`
2931 /// select named registers.
2932 ///
2933 /// Pure range-mutation primitive — does not consume input. Called by
2934 /// hjkl-vim's visual-mode operator path which has already resolved the range
2935 /// from the visual selection.
2936 ///
2937 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
2938 /// grammar migration (kryptic-sh/hjkl#70).
2939 pub fn delete_range(
2940 &mut self,
2941 start: (usize, usize),
2942 end: (usize, usize),
2943 kind: crate::vim::RangeKind,
2944 register: char,
2945 ) {
2946 vim::delete_range_bridge(self, start, end, kind, register);
2947 }
2948
2949 /// Yank (copy) the region `[start, end)` into `register` without mutating
2950 /// the buffer. `'"'` selects the unnamed register; `'0'` the yank-only
2951 /// register; `'a'`–`'z'` select named registers.
2952 ///
2953 /// Pure range-mutation primitive — does not consume input. Called by
2954 /// hjkl-vim's visual-mode operator path which has already resolved the range
2955 /// from the visual selection.
2956 ///
2957 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
2958 /// grammar migration (kryptic-sh/hjkl#70).
2959 pub fn yank_range(
2960 &mut self,
2961 start: (usize, usize),
2962 end: (usize, usize),
2963 kind: crate::vim::RangeKind,
2964 register: char,
2965 ) {
2966 vim::yank_range_bridge(self, start, end, kind, register);
2967 }
2968
2969 /// Delete the region `[start, end)` and transition to Insert mode (vim `c`
2970 /// operator). The deleted text is stashed in `register`. On return the
2971 /// editor is in Insert mode; the caller must not issue further normal-mode
2972 /// ops until the insert session ends.
2973 ///
2974 /// Pure range-mutation primitive — does not consume input. Called by
2975 /// hjkl-vim's visual-mode operator path which has already resolved the range
2976 /// from the visual selection.
2977 ///
2978 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
2979 /// grammar migration (kryptic-sh/hjkl#70).
2980 pub fn change_range(
2981 &mut self,
2982 start: (usize, usize),
2983 end: (usize, usize),
2984 kind: crate::vim::RangeKind,
2985 register: char,
2986 ) {
2987 vim::change_range_bridge(self, start, end, kind, register);
2988 }
2989
2990 /// Indent (`count > 0`) or outdent (`count < 0`) the row span
2991 /// `[start.0, end.0]`. Column components are ignored — indent is always
2992 /// linewise. `shiftwidth` overrides the editor's configured shiftwidth for
2993 /// this call; pass `0` to use the current editor setting. `count == 0` is a
2994 /// no-op.
2995 ///
2996 /// Pure range-mutation primitive — does not consume input. Called by
2997 /// hjkl-vim's visual-mode operator path which has already resolved the range
2998 /// from the visual selection.
2999 ///
3000 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3001 /// grammar migration (kryptic-sh/hjkl#70).
3002 pub fn indent_range(
3003 &mut self,
3004 start: (usize, usize),
3005 end: (usize, usize),
3006 count: i32,
3007 shiftwidth: u32,
3008 ) {
3009 vim::indent_range_bridge(self, start, end, count, shiftwidth);
3010 }
3011
3012 /// Apply a case transformation (`Operator::Uppercase` /
3013 /// `Operator::Lowercase` / `Operator::ToggleCase`) to the region
3014 /// `[start, end)`. Other `Operator` variants are silently ignored (no-op).
3015 /// Yanks registers are left untouched — vim's case operators do not write
3016 /// to registers.
3017 ///
3018 /// Pure range-mutation primitive — does not consume input. Called by
3019 /// hjkl-vim's visual-mode operator path which has already resolved the range
3020 /// from the visual selection.
3021 ///
3022 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3023 /// grammar migration (kryptic-sh/hjkl#70).
3024 pub fn case_range(
3025 &mut self,
3026 start: (usize, usize),
3027 end: (usize, usize),
3028 kind: crate::vim::RangeKind,
3029 op: crate::vim::Operator,
3030 ) {
3031 vim::case_range_bridge(self, start, end, kind, op);
3032 }
3033
3034 // ─── Phase 4e: pub block-shape range-mutation primitives (hjkl#70) ──────
3035 //
3036 // Rectangular VisualBlock operations. `top_row`/`bot_row` are inclusive
3037 // line indices; `left_col`/`right_col` are inclusive char-column bounds.
3038 // Ragged-edge handling (short lines not reaching `right_col`) matches the
3039 // engine FSM's `apply_block_operator` path — short lines lose only the
3040 // chars that exist.
3041 //
3042 // `register` is the target register; `'"'` selects the unnamed register.
3043
3044 /// Delete a rectangular VisualBlock selection. `top_row` / `bot_row` are
3045 /// inclusive line bounds; `left_col` / `right_col` are inclusive column
3046 /// bounds at the visual (display) column level. Ragged-edge handling
3047 /// matches engine FSM's VisualBlock op behavior — short lines that don't
3048 /// reach `right_col` lose only the chars that exist.
3049 ///
3050 /// `register` honors the user's pending register selection.
3051 ///
3052 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3053 pub fn delete_block(
3054 &mut self,
3055 top_row: usize,
3056 bot_row: usize,
3057 left_col: usize,
3058 right_col: usize,
3059 register: char,
3060 ) {
3061 vim::delete_block_bridge(self, top_row, bot_row, left_col, right_col, register);
3062 }
3063
3064 /// Yank a rectangular VisualBlock selection into `register` without
3065 /// mutating the buffer. `'"'` selects the unnamed register.
3066 ///
3067 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3068 pub fn yank_block(
3069 &mut self,
3070 top_row: usize,
3071 bot_row: usize,
3072 left_col: usize,
3073 right_col: usize,
3074 register: char,
3075 ) {
3076 vim::yank_block_bridge(self, top_row, bot_row, left_col, right_col, register);
3077 }
3078
3079 /// Delete a rectangular VisualBlock selection and enter Insert mode (`c`
3080 /// operator). The deleted text is stashed in `register`. Mode is Insert
3081 /// on return; the caller must not issue further normal-mode ops until the
3082 /// insert session ends.
3083 ///
3084 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3085 pub fn change_block(
3086 &mut self,
3087 top_row: usize,
3088 bot_row: usize,
3089 left_col: usize,
3090 right_col: usize,
3091 register: char,
3092 ) {
3093 vim::change_block_bridge(self, top_row, bot_row, left_col, right_col, register);
3094 }
3095
3096 /// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
3097 /// Column bounds are ignored — vim's block indent is always linewise.
3098 /// `count == 0` is a no-op.
3099 ///
3100 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3101 pub fn indent_block(
3102 &mut self,
3103 top_row: usize,
3104 bot_row: usize,
3105 _left_col: usize,
3106 _right_col: usize,
3107 count: i32,
3108 ) {
3109 vim::indent_block_bridge(self, top_row, bot_row, count);
3110 }
3111
3112 // ─── Phase 4b: pub text-object resolution (hjkl#70) ─────────────────────
3113 //
3114 // Pure functions — no cursor mutation, no mode change, no register write.
3115 // Each method delegates to `vim::text_object_*_bridge`, which in turn calls
3116 // the existing `word_text_object` private resolver in vim.rs.
3117 //
3118 // Called by hjkl-vim's `OpTextObj` reducer (chunk 4e) to resolve the range
3119 // before invoking a range-mutation primitive (`delete_range`, etc.).
3120 //
3121 // Return value: `Some((start, end))` where both positions are `(row, col)`
3122 // byte-column pairs and `end` is *exclusive* (one past the last byte to act
3123 // on), matching the convention used by `delete_range` / `yank_range` / etc.
3124 // Returns `None` when the cursor is on an empty line or the resolver cannot
3125 // find a word boundary.
3126
3127 /// Resolve the range of `iw` (inner word) at the current cursor position.
3128 ///
3129 /// An inner word is the contiguous run of keyword characters (or punctuation
3130 /// characters if the cursor is on punctuation) under the cursor, without any
3131 /// surrounding whitespace. Whitespace-only positions return `None`.
3132 ///
3133 /// Pure function — does not move the cursor or change any editor state.
3134 /// Called by hjkl-vim's `OpTextObj` reducer to resolve the range before
3135 /// invoking a range-mutation primitive (`delete_range`, etc.).
3136 ///
3137 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3138 /// migration (kryptic-sh/hjkl#70).
3139 pub fn text_object_inner_word(&self) -> Option<((usize, usize), (usize, usize))> {
3140 vim::text_object_inner_word_bridge(self)
3141 }
3142
3143 /// Resolve the range of `aw` (around word) at the current cursor position.
3144 ///
3145 /// Like `iw` but extends the range to include trailing whitespace after the
3146 /// word. If no trailing whitespace exists, leading whitespace before the word
3147 /// is absorbed instead (vim `:help text-objects` behaviour).
3148 ///
3149 /// Pure function — does not move the cursor or change any editor state.
3150 ///
3151 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3152 /// migration (kryptic-sh/hjkl#70).
3153 pub fn text_object_around_word(&self) -> Option<((usize, usize), (usize, usize))> {
3154 vim::text_object_around_word_bridge(self)
3155 }
3156
3157 /// Resolve the range of `iW` (inner WORD) at the current cursor position.
3158 ///
3159 /// A WORD is any contiguous run of non-whitespace characters — punctuation
3160 /// is not treated as a word boundary. Returns the span of the WORD under the
3161 /// cursor, without surrounding whitespace.
3162 ///
3163 /// Pure function — does not move the cursor or change any editor state.
3164 ///
3165 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3166 /// migration (kryptic-sh/hjkl#70).
3167 pub fn text_object_inner_big_word(&self) -> Option<((usize, usize), (usize, usize))> {
3168 vim::text_object_inner_big_word_bridge(self)
3169 }
3170
3171 /// Resolve the range of `aW` (around WORD) at the current cursor position.
3172 ///
3173 /// Like `iW` but extends the range to include trailing whitespace after the
3174 /// WORD. If no trailing whitespace exists, leading whitespace before the WORD
3175 /// is absorbed instead.
3176 ///
3177 /// Pure function — does not move the cursor or change any editor state.
3178 ///
3179 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3180 /// migration (kryptic-sh/hjkl#70).
3181 pub fn text_object_around_big_word(&self) -> Option<((usize, usize), (usize, usize))> {
3182 vim::text_object_around_big_word_bridge(self)
3183 }
3184
3185 // ─── Phase 4c: pub text-object resolution — quote + bracket (hjkl#70) ───
3186 //
3187 // Pure functions — no cursor mutation, no mode change, no register write.
3188 // Each method delegates to `vim::text_object_*_bridge`, which in turn calls
3189 // the existing private resolvers (`quote_text_object`, `bracket_text_object`)
3190 // in vim.rs.
3191 //
3192 // Quote methods take the quote char itself (`'"'`, `'\''`, `` '`' ``).
3193 // Bracket methods take the OPEN bracket char (`'('`, `'{'`, `'['`, `'<'`);
3194 // close-bracket variants (`)`, `}`, `]`, `>`) are NOT accepted here — the
3195 // hjkl-vim grammar layer normalises close→open before calling these methods.
3196 //
3197 // Return value: `Some((start, end))` where both positions are `(row, col)`
3198 // byte-column pairs and `end` is *exclusive* (one past the last byte to act
3199 // on), matching the convention used by `delete_range` / `yank_range` / etc.
3200 // `bracket_text_object` internally distinguishes Linewise vs Exclusive
3201 // ranges for multi-line pairs; that tag is stripped here — callers receive
3202 // the same flat shape as all other text-object resolvers.
3203
3204 /// Resolve the range of `i<quote>` (inner quote) at the cursor position.
3205 ///
3206 /// `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None` when the
3207 /// cursor's line contains fewer than two occurrences of `quote`, or when no
3208 /// matching pair can be found around or ahead of the cursor.
3209 ///
3210 /// Inner range excludes the quote characters themselves.
3211 ///
3212 /// Pure function — no cursor mutation.
3213 ///
3214 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3215 /// migration (kryptic-sh/hjkl#70).
3216 pub fn text_object_inner_quote(&self, quote: char) -> Option<((usize, usize), (usize, usize))> {
3217 vim::text_object_inner_quote_bridge(self, quote)
3218 }
3219
3220 /// Resolve the range of `a<quote>` (around quote) at the cursor position.
3221 ///
3222 /// Like `i<quote>` but includes the quote characters themselves plus
3223 /// surrounding whitespace on one side: trailing whitespace after the closing
3224 /// quote if any exists; otherwise leading whitespace before the opening
3225 /// quote. This matches vim `:help text-objects` behaviour.
3226 ///
3227 /// Pure function — no cursor mutation.
3228 ///
3229 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3230 /// migration (kryptic-sh/hjkl#70).
3231 pub fn text_object_around_quote(
3232 &self,
3233 quote: char,
3234 ) -> Option<((usize, usize), (usize, usize))> {
3235 vim::text_object_around_quote_bridge(self, quote)
3236 }
3237
3238 /// Resolve the range of `i<bracket>` (inner bracket pair) at the cursor.
3239 ///
3240 /// `open` must be one of `'('`, `'{'`, `'['`, `'<'` — the corresponding
3241 /// close bracket is derived automatically. Close-bracket chars (`)`, `}`,
3242 /// `]`, `>`) are **not** accepted; hjkl-vim normalises close→open before
3243 /// calling this method. Returns `None` when no enclosing pair is found.
3244 ///
3245 /// The cursor may be anywhere inside the pair or on a bracket character
3246 /// itself. When not inside any pair the resolver falls back to a forward
3247 /// scan (targets.vim-style: `ci(` works when the cursor is before `(`).
3248 ///
3249 /// Inner range excludes the bracket characters. Multi-line pairs are
3250 /// supported; the returned range spans the full content between the
3251 /// brackets.
3252 ///
3253 /// Pure function — no cursor mutation.
3254 ///
3255 /// `ib` / `iB` aliases live in the hjkl-vim grammar layer and are not
3256 /// handled here.
3257 ///
3258 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3259 /// migration (kryptic-sh/hjkl#70).
3260 pub fn text_object_inner_bracket(
3261 &self,
3262 open: char,
3263 ) -> Option<((usize, usize), (usize, usize))> {
3264 vim::text_object_inner_bracket_bridge(self, open)
3265 }
3266
3267 /// Resolve the range of `a<bracket>` (around bracket pair) at the cursor.
3268 ///
3269 /// Like `i<bracket>` but includes the bracket characters themselves.
3270 /// `open` must be one of `'('`, `'{'`, `'['`, `'<'`.
3271 ///
3272 /// Pure function — no cursor mutation.
3273 ///
3274 /// `aB` alias lives in the hjkl-vim grammar layer and is not handled here.
3275 ///
3276 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3277 /// migration (kryptic-sh/hjkl#70).
3278 pub fn text_object_around_bracket(
3279 &self,
3280 open: char,
3281 ) -> Option<((usize, usize), (usize, usize))> {
3282 vim::text_object_around_bracket_bridge(self, open)
3283 }
3284
3285 // ── Sentence text objects (is / as) ───────────────────────────────────
3286
3287 /// Resolve `is` (inner sentence) at the cursor position.
3288 ///
3289 /// Returns the range of the current sentence, excluding trailing
3290 /// whitespace. Sentence boundaries follow vim's `is` semantics (period /
3291 /// `?` / `!` followed by whitespace or end-of-paragraph).
3292 ///
3293 /// Pure function — no cursor mutation.
3294 ///
3295 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3296 /// grammar migration (kryptic-sh/hjkl#70).
3297 pub fn text_object_inner_sentence(&self) -> Option<((usize, usize), (usize, usize))> {
3298 vim::text_object_inner_sentence_bridge(self)
3299 }
3300
3301 /// Resolve `as` (around sentence) at the cursor position.
3302 ///
3303 /// Like `is` but includes trailing whitespace after the sentence
3304 /// terminator.
3305 ///
3306 /// Pure function — no cursor mutation.
3307 ///
3308 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3309 /// grammar migration (kryptic-sh/hjkl#70).
3310 pub fn text_object_around_sentence(&self) -> Option<((usize, usize), (usize, usize))> {
3311 vim::text_object_around_sentence_bridge(self)
3312 }
3313
3314 // ── Paragraph text objects (ip / ap) ──────────────────────────────────
3315
3316 /// Resolve `ip` (inner paragraph) at the cursor position.
3317 ///
3318 /// A paragraph is a block of non-blank lines bounded by blank lines or
3319 /// buffer edges. Returns `None` when the cursor is on a blank line.
3320 ///
3321 /// Pure function — no cursor mutation.
3322 ///
3323 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3324 /// grammar migration (kryptic-sh/hjkl#70).
3325 pub fn text_object_inner_paragraph(&self) -> Option<((usize, usize), (usize, usize))> {
3326 vim::text_object_inner_paragraph_bridge(self)
3327 }
3328
3329 /// Resolve `ap` (around paragraph) at the cursor position.
3330 ///
3331 /// Like `ip` but includes one trailing blank line when present.
3332 ///
3333 /// Pure function — no cursor mutation.
3334 ///
3335 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3336 /// grammar migration (kryptic-sh/hjkl#70).
3337 pub fn text_object_around_paragraph(&self) -> Option<((usize, usize), (usize, usize))> {
3338 vim::text_object_around_paragraph_bridge(self)
3339 }
3340
3341 // ── Tag text objects (it / at) ────────────────────────────────────────
3342
3343 /// Resolve `it` (inner tag) at the cursor position.
3344 ///
3345 /// Matches XML/HTML-style `<tag>...</tag>` pairs. Returns the range of
3346 /// inner content between the open and close tags (excluding the tags
3347 /// themselves).
3348 ///
3349 /// Pure function — no cursor mutation.
3350 ///
3351 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3352 /// grammar migration (kryptic-sh/hjkl#70).
3353 pub fn text_object_inner_tag(&self) -> Option<((usize, usize), (usize, usize))> {
3354 vim::text_object_inner_tag_bridge(self)
3355 }
3356
3357 /// Resolve `at` (around tag) at the cursor position.
3358 ///
3359 /// Like `it` but includes the open and close tag delimiters themselves.
3360 ///
3361 /// Pure function — no cursor mutation.
3362 ///
3363 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3364 /// grammar migration (kryptic-sh/hjkl#70).
3365 pub fn text_object_around_tag(&self) -> Option<((usize, usize), (usize, usize))> {
3366 vim::text_object_around_tag_bridge(self)
3367 }
3368
3369 /// Execute a named cursor motion `kind` repeated `count` times.
3370 ///
3371 /// Maps the keymap-layer `crate::MotionKind` to the engine's internal
3372 /// motion primitives, bypassing the engine FSM. Identical cursor semantics
3373 /// to the FSM path — sticky column, scroll sync, and big-jump tracking are
3374 /// all applied via `vim::execute_motion` (for Down/Up) or the same helpers
3375 /// used by the FSM arms.
3376 ///
3377 /// Introduced in 0.6.1 as the host entry point for Phase 3a of
3378 /// kryptic-sh/hjkl#69: the app keymap dispatches `AppAction::Motion` and
3379 /// calls this method rather than re-entering the engine FSM.
3380 ///
3381 /// Engine FSM arms for `h`/`j`/`k`/`l`/`<BS>`/`<Space>`/`+`/`-` remain
3382 /// intact for macro-replay coverage (macros re-feed raw keys through the
3383 /// FSM). This method is the keymap / controller path only.
3384 pub fn apply_motion(&mut self, kind: crate::MotionKind, count: usize) {
3385 vim::apply_motion_kind(self, kind, count);
3386 }
3387
3388 /// Set `vim.pending_register` to `Some(reg)` if `reg` is a valid register
3389 /// selector (`a`–`z`, `A`–`Z`, `0`–`9`, `"`, `+`, `*`, `_`). Invalid
3390 /// chars are silently ignored (no-op), matching the engine FSM's
3391 /// `handle_select_register` behaviour.
3392 ///
3393 /// Promoted to the public surface in 0.5.17 so the hjkl-vim
3394 /// `PendingState::SelectRegister` reducer can dispatch `SetPendingRegister`
3395 /// without re-entering the engine FSM. `handle_select_register` (engine FSM
3396 /// path for macro-replay / defensive coverage) delegates here to avoid
3397 /// logic duplication.
3398 pub fn set_pending_register(&mut self, reg: char) {
3399 if reg.is_ascii_alphanumeric() || matches!(reg, '"' | '+' | '*' | '_') {
3400 self.vim.pending_register = Some(reg);
3401 }
3402 // Invalid chars silently no-op (matches engine FSM behavior).
3403 }
3404
3405 /// Record a mark named `ch` at the current cursor position.
3406 ///
3407 /// Validates `ch` (must be `a`–`z` or `A`–`Z` to match vim's mark-name
3408 /// rules). Invalid chars are silently ignored (no-op), matching the engine
3409 /// FSM's `handle_set_mark` behaviour.
3410 ///
3411 /// Promoted to the public surface in 0.6.7 so the hjkl-vim
3412 /// `PendingState::SetMark` reducer can dispatch `EngineCmd::SetMark`
3413 /// without re-entering the engine FSM. `handle_set_mark` delegates here.
3414 pub fn set_mark_at_cursor(&mut self, ch: char) {
3415 vim::set_mark_at_cursor(self, ch);
3416 }
3417
3418 /// `.` dot-repeat: replay the last buffered change at the current cursor.
3419 /// `count` scales repeats (e.g. `3.` runs the last change 3 times). When
3420 /// `count` is 0, defaults to 1. No-op when no change has been buffered yet.
3421 ///
3422 /// Storage of `LastChange` stays inside engine for now; Phase 5c of
3423 /// kryptic-sh/hjkl#71 just lifts the `.` chord binding into the app
3424 /// keymap so the engine FSM `.` arm is no longer the entry point. Engine
3425 /// FSM `.` arm stays for macro-replay defensive coverage.
3426 pub fn replay_last_change(&mut self, count: usize) {
3427 vim::replay_last_change(self, count);
3428 }
3429
3430 /// Jump to the mark named `ch`, linewise (row only; col snaps to first
3431 /// non-blank). Pushes the pre-jump position onto the jumplist if the
3432 /// cursor actually moved.
3433 ///
3434 /// Accepts the same mark chars as vim's `'<ch>` command: `a`–`z`,
3435 /// `A`–`Z`, `'`/`` ` `` (jump-back peek), `.` (last edit), and the
3436 /// special auto-marks `[`, `]`, `<`, `>`. Unset marks and invalid chars
3437 /// are silently ignored (no-op), matching the engine FSM's
3438 /// `handle_goto_mark` behaviour.
3439 ///
3440 /// Promoted to the public surface in 0.6.7 so the hjkl-vim
3441 /// `PendingState::GotoMarkLine` reducer can dispatch
3442 /// `EngineCmd::GotoMarkLine` without re-entering the engine FSM.
3443 pub fn goto_mark_line(&mut self, ch: char) {
3444 vim::goto_mark(self, ch, true);
3445 }
3446
3447 /// Jump to the mark named `ch`, charwise (exact row + col). Pushes the
3448 /// pre-jump position onto the jumplist if the cursor actually moved.
3449 ///
3450 /// Accepts the same mark chars as vim's `` `<ch> `` command: `a`–`z`,
3451 /// `A`–`Z`, `'`/`` ` `` (jump-back peek), `.` (last edit), and the
3452 /// special auto-marks `[`, `]`, `<`, `>`. Unset marks and invalid chars
3453 /// are silently ignored (no-op), matching the engine FSM's
3454 /// `handle_goto_mark` behaviour.
3455 ///
3456 /// Promoted to the public surface in 0.6.7 so the hjkl-vim
3457 /// `PendingState::GotoMarkChar` reducer can dispatch
3458 /// `EngineCmd::GotoMarkChar` without re-entering the engine FSM.
3459 pub fn goto_mark_char(&mut self, ch: char) {
3460 vim::goto_mark(self, ch, false);
3461 }
3462
3463 // ── Macro controller API (Phase 5b) ──────────────────────────────────────
3464
3465 /// Begin recording keystrokes into register `reg`. The caller (app) is
3466 /// responsible for stopping the recording via `stop_macro_record` when the
3467 /// user presses bare `q`.
3468 ///
3469 /// - Uppercase `reg` (e.g. `'A'`) appends to the existing lowercase
3470 /// recording by pre-seeding `recording_keys` with the decoded text of the
3471 /// matching lowercase register, matching vim's capital-register append
3472 /// semantics.
3473 /// - Lowercase `reg` clears `recording_keys` (fresh recording).
3474 /// - Invalid chars (non-alphabetic, non-digit) are silently ignored.
3475 ///
3476 /// Promoted to the public surface in Phase 5b so the app's
3477 /// `route_chord_key` can start a recording without re-entering the engine
3478 /// FSM. `handle_record_macro_target` (engine FSM path for macro-replay
3479 /// defensive coverage) continues to use the same logic via delegation.
3480 pub fn start_macro_record(&mut self, reg: char) {
3481 if !(reg.is_ascii_alphabetic() || reg.is_ascii_digit()) {
3482 return;
3483 }
3484 self.vim.recording_macro = Some(reg);
3485 if reg.is_ascii_uppercase() {
3486 // Seed recording_keys with the existing lowercase register's text
3487 // decoded back to inputs so capital-register append continues from
3488 // where the previous recording left off.
3489 let lower = reg.to_ascii_lowercase();
3490 let text = self
3491 .registers
3492 .read(lower)
3493 .map(|s| s.text.clone())
3494 .unwrap_or_default();
3495 self.vim.recording_keys = crate::input::decode_macro(&text);
3496 } else {
3497 self.vim.recording_keys.clear();
3498 }
3499 }
3500
3501 /// Finalize the active recording: encode `recording_keys` as text and write
3502 /// to the matching (lowercase) named register. Clears both `recording_macro`
3503 /// and `recording_keys`. No-ops if no recording is active.
3504 ///
3505 /// Promoted to the public surface in Phase 5b so the app's `QChord` action
3506 /// can stop a recording when the user presses bare `q` without re-entering
3507 /// the engine FSM.
3508 pub fn stop_macro_record(&mut self) {
3509 let Some(reg) = self.vim.recording_macro.take() else {
3510 return;
3511 };
3512 let keys = std::mem::take(&mut self.vim.recording_keys);
3513 let text = crate::input::encode_macro(&keys);
3514 self.set_named_register_text(reg.to_ascii_lowercase(), text);
3515 }
3516
3517 /// Returns `true` while a `q{reg}` recording is in progress.
3518 /// Hosts use this to show a "recording @r" status indicator and to decide
3519 /// whether bare `q` should stop the recording or open the `RecordMacroTarget`
3520 /// chord.
3521 pub fn is_recording_macro(&self) -> bool {
3522 self.vim.recording_macro.is_some()
3523 }
3524
3525 /// Returns `true` while a macro is being replayed. The app sets this flag
3526 /// (via `play_macro`) and clears it (via `end_macro_replay`) around the
3527 /// re-feed loop so the recorder hook can skip double-capture.
3528 pub fn is_replaying_macro(&self) -> bool {
3529 self.vim.replaying_macro
3530 }
3531
3532 /// Decode the named register `reg` into a `Vec<crate::input::Input>` and
3533 /// prepare for replay, returning the inputs the app should re-feed through
3534 /// `route_chord_key`.
3535 ///
3536 /// Resolves `reg`:
3537 /// - `'@'` → use `vim.last_macro`; returns empty vec if none.
3538 /// - Any other char → lowercase it, read the register, decode.
3539 ///
3540 /// Side-effects:
3541 /// - Sets `vim.last_macro` to the resolved register.
3542 /// - Sets `vim.replaying_macro = true` so the recorder hook skips during
3543 /// replay. The app calls `end_macro_replay` after the loop finishes.
3544 ///
3545 /// Returns an empty vec (and no side-effects for `'@'`) if the register is
3546 /// unset or empty.
3547 pub fn play_macro(&mut self, reg: char, count: usize) -> Vec<crate::input::Input> {
3548 let resolved = if reg == '@' {
3549 match self.vim.last_macro {
3550 Some(r) => r,
3551 None => return vec![],
3552 }
3553 } else {
3554 reg.to_ascii_lowercase()
3555 };
3556 let text = match self.registers.read(resolved) {
3557 Some(slot) if !slot.text.is_empty() => slot.text.clone(),
3558 _ => return vec![],
3559 };
3560 let keys = crate::input::decode_macro(&text);
3561 self.vim.last_macro = Some(resolved);
3562 self.vim.replaying_macro = true;
3563 // Multiply by count (minimum 1).
3564 keys.repeat(count.max(1))
3565 }
3566
3567 /// Clear the `replaying_macro` flag. Called by the app after the
3568 /// re-feed loop in the `PlayMacro` commit arm completes (or aborts).
3569 pub fn end_macro_replay(&mut self) {
3570 self.vim.replaying_macro = false;
3571 }
3572
3573 /// Append `input` to the active recording (`recording_keys`) if and only
3574 /// if a recording is in progress AND we are not currently replaying.
3575 /// Called by the app's `route_chord_key` recorder hook so that user
3576 /// keystrokes captured through the app-level chord path are recorded
3577 /// (rather than relying solely on the engine FSM's in-step hook).
3578 pub fn record_input(&mut self, input: crate::input::Input) {
3579 if self.vim.recording_macro.is_some() && !self.vim.replaying_macro {
3580 self.vim.recording_keys.push(input);
3581 }
3582 }
3583
3584 // ─── Phase 6.1: public insert-mode primitives (kryptic-sh/hjkl#87) ────────
3585 //
3586 // Each method is the publicly callable form of one insert-mode action.
3587 // All logic lives in the corresponding `vim::*_bridge` free function;
3588 // these methods are thin delegators so the public surface stays on `Editor`.
3589 //
3590 // Invariants (enforced by the bridge fns):
3591 // - Buffer mutations go through `mutate_edit` (dirty/undo/change-list).
3592 // - Navigation keys call `break_undo_group_in_insert` when the FSM did.
3593 // - `push_buffer_cursor_to_textarea` is called after every mutation
3594 // (currently a no-op, kept for migration hygiene).
3595
3596 /// Insert `ch` at the cursor. In Replace mode, overstrike the cell under
3597 /// the cursor instead of inserting; at end-of-line, always appends. With
3598 /// `smartindent` on, closing brackets (`}`/`)`/`]`) trigger one-unit
3599 /// dedent on an otherwise-whitespace line.
3600 ///
3601 /// Callers must ensure the editor is in Insert or Replace mode before
3602 /// calling this method.
3603 pub fn insert_char(&mut self, ch: char) {
3604 let mutated = vim::insert_char_bridge(self, ch);
3605 if mutated {
3606 self.mark_content_dirty();
3607 let (row, _) = self.cursor();
3608 self.vim.widen_insert_row(row);
3609 }
3610 }
3611
3612 /// Insert a newline at the cursor, applying autoindent / smartindent to
3613 /// prefix the new line with the appropriate leading whitespace.
3614 ///
3615 /// Callers must ensure the editor is in Insert mode before calling.
3616 pub fn insert_newline(&mut self) {
3617 let mutated = vim::insert_newline_bridge(self);
3618 if mutated {
3619 self.mark_content_dirty();
3620 let (row, _) = self.cursor();
3621 self.vim.widen_insert_row(row);
3622 }
3623 }
3624
3625 /// Insert a tab character (or spaces up to the next `softtabstop` boundary
3626 /// when `expandtab` is set).
3627 ///
3628 /// Callers must ensure the editor is in Insert mode before calling.
3629 pub fn insert_tab(&mut self) {
3630 let mutated = vim::insert_tab_bridge(self);
3631 if mutated {
3632 self.mark_content_dirty();
3633 let (row, _) = self.cursor();
3634 self.vim.widen_insert_row(row);
3635 }
3636 }
3637
3638 /// Delete the character before the cursor (Backspace). With `softtabstop`
3639 /// active, deletes the entire soft-tab run at an aligned boundary. Joins
3640 /// with the previous line when at column 0.
3641 ///
3642 /// Callers must ensure the editor is in Insert mode before calling.
3643 pub fn insert_backspace(&mut self) {
3644 let mutated = vim::insert_backspace_bridge(self);
3645 if mutated {
3646 self.mark_content_dirty();
3647 let (row, _) = self.cursor();
3648 self.vim.widen_insert_row(row);
3649 }
3650 }
3651
3652 /// Delete the character under the cursor (Delete key). Joins with the
3653 /// next line when at end-of-line.
3654 ///
3655 /// Callers must ensure the editor is in Insert mode before calling.
3656 pub fn insert_delete(&mut self) {
3657 let mutated = vim::insert_delete_bridge(self);
3658 if mutated {
3659 self.mark_content_dirty();
3660 let (row, _) = self.cursor();
3661 self.vim.widen_insert_row(row);
3662 }
3663 }
3664
3665 /// Move the cursor one step in `dir` (arrow key), breaking the undo group
3666 /// per `undo_break_on_motion`.
3667 ///
3668 /// Callers must ensure the editor is in Insert mode before calling.
3669 pub fn insert_arrow(&mut self, dir: vim::InsertDir) {
3670 vim::insert_arrow_bridge(self, dir);
3671 let (row, _) = self.cursor();
3672 self.vim.widen_insert_row(row);
3673 }
3674
3675 /// Move the cursor to the start of the current line (Home key), breaking
3676 /// the undo group.
3677 ///
3678 /// Callers must ensure the editor is in Insert mode before calling.
3679 pub fn insert_home(&mut self) {
3680 vim::insert_home_bridge(self);
3681 let (row, _) = self.cursor();
3682 self.vim.widen_insert_row(row);
3683 }
3684
3685 /// Move the cursor to the end of the current line (End key), breaking the
3686 /// undo group.
3687 ///
3688 /// Callers must ensure the editor is in Insert mode before calling.
3689 pub fn insert_end(&mut self) {
3690 vim::insert_end_bridge(self);
3691 let (row, _) = self.cursor();
3692 self.vim.widen_insert_row(row);
3693 }
3694
3695 /// Scroll up one full viewport height (PageUp), moving the cursor with it.
3696 /// `viewport_h` is the current viewport height in rows; pass
3697 /// `self.viewport_height_value()` if the stored value is current.
3698 ///
3699 /// Callers must ensure the editor is in Insert mode before calling.
3700 pub fn insert_pageup(&mut self, viewport_h: u16) {
3701 vim::insert_pageup_bridge(self, viewport_h);
3702 let (row, _) = self.cursor();
3703 self.vim.widen_insert_row(row);
3704 }
3705
3706 /// Scroll down one full viewport height (PageDown), moving the cursor with
3707 /// it. `viewport_h` is the current viewport height in rows.
3708 ///
3709 /// Callers must ensure the editor is in Insert mode before calling.
3710 pub fn insert_pagedown(&mut self, viewport_h: u16) {
3711 vim::insert_pagedown_bridge(self, viewport_h);
3712 let (row, _) = self.cursor();
3713 self.vim.widen_insert_row(row);
3714 }
3715
3716 /// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
3717 /// At column 0, joins with the previous line (vim `b`-motion semantics).
3718 ///
3719 /// Callers must ensure the editor is in Insert mode before calling.
3720 pub fn insert_ctrl_w(&mut self) {
3721 let mutated = vim::insert_ctrl_w_bridge(self);
3722 if mutated {
3723 self.mark_content_dirty();
3724 let (row, _) = self.cursor();
3725 self.vim.widen_insert_row(row);
3726 }
3727 }
3728
3729 /// Delete from the cursor back to the start of the current line (`Ctrl-U`).
3730 /// No-op when already at column 0.
3731 ///
3732 /// Callers must ensure the editor is in Insert mode before calling.
3733 pub fn insert_ctrl_u(&mut self) {
3734 let mutated = vim::insert_ctrl_u_bridge(self);
3735 if mutated {
3736 self.mark_content_dirty();
3737 let (row, _) = self.cursor();
3738 self.vim.widen_insert_row(row);
3739 }
3740 }
3741
3742 /// Delete one character backwards (`Ctrl-H`) — alias for Backspace in
3743 /// insert mode. Joins with the previous line when at col 0.
3744 ///
3745 /// Callers must ensure the editor is in Insert mode before calling.
3746 pub fn insert_ctrl_h(&mut self) {
3747 let mutated = vim::insert_ctrl_h_bridge(self);
3748 if mutated {
3749 self.mark_content_dirty();
3750 let (row, _) = self.cursor();
3751 self.vim.widen_insert_row(row);
3752 }
3753 }
3754
3755 /// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
3756 /// complete normal-mode command, then return to insert automatically.
3757 ///
3758 /// Callers must ensure the editor is in Insert mode before calling.
3759 pub fn insert_ctrl_o_arm(&mut self) {
3760 vim::insert_ctrl_o_bridge(self);
3761 }
3762
3763 /// Arm the register-paste selector (`Ctrl-R`). The next call to
3764 /// `insert_paste_register(reg)` will insert the register contents.
3765 /// Alternatively, feeding a `Key::Char(c)` through the FSM will consume
3766 /// the armed state and paste register `c`.
3767 ///
3768 /// Callers must ensure the editor is in Insert mode before calling.
3769 pub fn insert_ctrl_r_arm(&mut self) {
3770 vim::insert_ctrl_r_bridge(self);
3771 }
3772
3773 /// Indent the current line by one `shiftwidth` and shift the cursor right
3774 /// by the same amount (`Ctrl-T`).
3775 ///
3776 /// Callers must ensure the editor is in Insert mode before calling.
3777 pub fn insert_ctrl_t(&mut self) {
3778 let mutated = vim::insert_ctrl_t_bridge(self);
3779 if mutated {
3780 self.mark_content_dirty();
3781 let (row, _) = self.cursor();
3782 self.vim.widen_insert_row(row);
3783 }
3784 }
3785
3786 /// Outdent the current line by up to one `shiftwidth` and shift the cursor
3787 /// left by the amount stripped (`Ctrl-D`).
3788 ///
3789 /// Callers must ensure the editor is in Insert mode before calling.
3790 pub fn insert_ctrl_d(&mut self) {
3791 let mutated = vim::insert_ctrl_d_bridge(self);
3792 if mutated {
3793 self.mark_content_dirty();
3794 let (row, _) = self.cursor();
3795 self.vim.widen_insert_row(row);
3796 }
3797 }
3798
3799 /// Paste the contents of register `reg` at the cursor (the commit arm of
3800 /// `Ctrl-R {reg}`). Unknown or empty registers are a no-op.
3801 ///
3802 /// Callers must ensure the editor is in Insert mode before calling.
3803 pub fn insert_paste_register(&mut self, reg: char) {
3804 vim::insert_paste_register_bridge(self, reg);
3805 let (row, _) = self.cursor();
3806 self.vim.widen_insert_row(row);
3807 }
3808
3809 /// Exit insert mode to Normal: finish the insert session, step the cursor
3810 /// one cell left (vim convention on Esc), record the `gi` target position,
3811 /// and update the sticky column.
3812 ///
3813 /// Callers must ensure the editor is in Insert mode before calling.
3814 pub fn leave_insert_to_normal(&mut self) {
3815 vim::leave_insert_to_normal_bridge(self);
3816 }
3817
3818 // ── Phase 6.2: normal-mode primitive controller methods ───────────────────
3819 //
3820 // Each method is a thin wrapper around a `pub(crate) fn *_bridge` in
3821 // `vim.rs` following the same pattern as Phase 6.1. The FSM's
3822 // `handle_normal_only` now calls the same bridges so both paths are
3823 // identical. See kryptic-sh/hjkl#88 for the full promotion plan.
3824
3825 /// `i` — transition to Insert mode at the current cursor position.
3826 /// `count` is stored in the insert session and replayed by dot-repeat
3827 /// as a repeat count on the inserted text.
3828 pub fn enter_insert_i(&mut self, count: usize) {
3829 vim::enter_insert_i_bridge(self, count);
3830 }
3831
3832 /// `I` — move to the first non-blank character on the line, then
3833 /// transition to Insert mode. `count` is stored for dot-repeat.
3834 pub fn enter_insert_shift_i(&mut self, count: usize) {
3835 vim::enter_insert_shift_i_bridge(self, count);
3836 }
3837
3838 /// `a` — advance the cursor one cell past the current position, then
3839 /// transition to Insert mode (append). `count` is stored for dot-repeat.
3840 pub fn enter_insert_a(&mut self, count: usize) {
3841 vim::enter_insert_a_bridge(self, count);
3842 }
3843
3844 /// `A` — move the cursor to the end of the line, then transition to
3845 /// Insert mode (append at end). `count` is stored for dot-repeat.
3846 pub fn enter_insert_shift_a(&mut self, count: usize) {
3847 vim::enter_insert_shift_a_bridge(self, count);
3848 }
3849
3850 /// `o` — open a new line below the current line with smart-indent, then
3851 /// transition to Insert mode. `count` is stored for dot-repeat replay.
3852 pub fn open_line_below(&mut self, count: usize) {
3853 vim::open_line_below_bridge(self, count);
3854 }
3855
3856 /// `O` — open a new line above the current line with smart-indent, then
3857 /// transition to Insert mode. `count` is stored for dot-repeat replay.
3858 pub fn open_line_above(&mut self, count: usize) {
3859 vim::open_line_above_bridge(self, count);
3860 }
3861
3862 /// `R` — enter Replace mode: subsequent typed characters overstrike the
3863 /// cell under the cursor rather than inserting. `count` is for replay.
3864 pub fn enter_replace_mode(&mut self, count: usize) {
3865 vim::enter_replace_mode_bridge(self, count);
3866 }
3867
3868 /// `x` — delete `count` characters forward from the cursor and write them
3869 /// to the unnamed register. No-op on an empty line. Records for `.`.
3870 pub fn delete_char_forward(&mut self, count: usize) {
3871 vim::delete_char_forward_bridge(self, count);
3872 }
3873
3874 /// `X` — delete `count` characters backward from the cursor and write
3875 /// them to the unnamed register. No-op at column 0. Records for `.`.
3876 pub fn delete_char_backward(&mut self, count: usize) {
3877 vim::delete_char_backward_bridge(self, count);
3878 }
3879
3880 /// `s` — substitute `count` characters: delete them (writing to the
3881 /// unnamed register) then enter Insert mode. Equivalent to `cl`.
3882 /// Records as `OpMotion { Change, Right }` for dot-repeat.
3883 pub fn substitute_char(&mut self, count: usize) {
3884 vim::substitute_char_bridge(self, count);
3885 }
3886
3887 /// `S` — substitute the current line: wipe its contents (writing to the
3888 /// unnamed register) then enter Insert mode. Equivalent to `cc`.
3889 /// Records as `LineOp { Change }` for dot-repeat.
3890 pub fn substitute_line(&mut self, count: usize) {
3891 vim::substitute_line_bridge(self, count);
3892 }
3893
3894 /// `D` — delete from the cursor to end-of-line, writing to the unnamed
3895 /// register. The cursor parks on the new last character. Records for `.`.
3896 pub fn delete_to_eol(&mut self) {
3897 vim::delete_to_eol_bridge(self);
3898 }
3899
3900 /// `C` — change from the cursor to end-of-line: delete to EOL then enter
3901 /// Insert mode. Equivalent to `c$`. Does not record its own `last_change`
3902 /// (the insert session records `DeleteToEol` on exit, like `c` motions).
3903 pub fn change_to_eol(&mut self) {
3904 vim::change_to_eol_bridge(self);
3905 }
3906
3907 /// `Y` — yank from the cursor to end-of-line into the unnamed register.
3908 /// Vim 8 default: equivalent to `y$`. `count` multiplies the motion.
3909 pub fn yank_to_eol(&mut self, count: usize) {
3910 vim::yank_to_eol_bridge(self, count);
3911 }
3912
3913 /// `J` — join `count` lines (default 2) onto the current line, inserting
3914 /// a single space between each non-empty pair. Records for dot-repeat.
3915 pub fn join_line(&mut self, count: usize) {
3916 vim::join_line_bridge(self, count);
3917 }
3918
3919 /// `~` — toggle the case of `count` characters from the cursor, advancing
3920 /// right after each toggle. Records `ToggleCase` for dot-repeat.
3921 pub fn toggle_case_at_cursor(&mut self, count: usize) {
3922 vim::toggle_case_at_cursor_bridge(self, count);
3923 }
3924
3925 /// `p` — paste the unnamed register (or the register selected via `"r`)
3926 /// after the cursor. Linewise content opens a new line below; charwise
3927 /// content is inserted inline. Records `Paste { before: false }` for `.`.
3928 pub fn paste_after(&mut self, count: usize) {
3929 vim::paste_after_bridge(self, count);
3930 }
3931
3932 /// `P` — paste the unnamed register (or the `"r` register) before the
3933 /// cursor. Linewise content opens a new line above; charwise is inline.
3934 /// Records `Paste { before: true }` for dot-repeat.
3935 pub fn paste_before(&mut self, count: usize) {
3936 vim::paste_before_bridge(self, count);
3937 }
3938
3939 /// `<C-o>` — jump back `count` entries in the jumplist, saving the
3940 /// current position on the forward stack so `<C-i>` can return.
3941 pub fn jump_back(&mut self, count: usize) {
3942 vim::jump_back_bridge(self, count);
3943 }
3944
3945 /// `<C-i>` / `Tab` — redo `count` entries on the forward jumplist stack,
3946 /// saving the current position on the backward stack.
3947 pub fn jump_forward(&mut self, count: usize) {
3948 vim::jump_forward_bridge(self, count);
3949 }
3950
3951 /// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
3952 /// (height − 2 rows, preserving two-line overlap). `count` multiplies.
3953 /// `dir = Down` for `<C-f>`, `Up` for `<C-b>`.
3954 pub fn scroll_full_page(&mut self, dir: vim::ScrollDir, count: usize) {
3955 vim::scroll_full_page_bridge(self, dir, count);
3956 }
3957
3958 /// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
3959 /// `count` multiplies the step. `dir = Down` for `<C-d>`, `Up` for `<C-u>`.
3960 pub fn scroll_half_page(&mut self, dir: vim::ScrollDir, count: usize) {
3961 vim::scroll_half_page_bridge(self, dir, count);
3962 }
3963
3964 /// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving
3965 /// the cursor (cursor is clamped to the new visible region if necessary).
3966 /// `dir = Down` for `<C-e>` (scroll text up), `Up` for `<C-y>`.
3967 pub fn scroll_line(&mut self, dir: vim::ScrollDir, count: usize) {
3968 vim::scroll_line_bridge(self, dir, count);
3969 }
3970
3971 /// `n` — repeat the last `/` or `?` search `count` times in its original
3972 /// direction. `forward = true` keeps the direction; `false` inverts (`N`).
3973 pub fn search_repeat(&mut self, forward: bool, count: usize) {
3974 vim::search_repeat_bridge(self, forward, count);
3975 }
3976
3977 /// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
3978 /// `forward` chooses direction; `whole_word` wraps the pattern in `\b`
3979 /// anchors (true for `*` / `#`, false for `g*` / `g#`). `count` repeats.
3980 pub fn word_search(&mut self, forward: bool, whole_word: bool, count: usize) {
3981 vim::word_search_bridge(self, forward, whole_word, count);
3982 }
3983
3984 // ── Phase 6.3: visual-mode primitive controller methods ──────────────────
3985 //
3986 // Each method is a thin wrapper around a `pub(crate) fn *_bridge` in
3987 // `vim.rs` following the same pattern as Phase 6.1 / 6.2. Both the FSM
3988 // and these wrappers write `current_mode` so `vim_mode()` returns correct
3989 // values regardless of which path performed the transition.
3990 // See kryptic-sh/hjkl#89 for the full promotion plan.
3991
3992 /// `v` from Normal — enter charwise Visual mode, anchoring the selection
3993 /// at the current cursor position.
3994 pub fn enter_visual_char(&mut self) {
3995 vim::enter_visual_char_bridge(self);
3996 }
3997
3998 /// `V` from Normal — enter linewise Visual mode, anchoring on the current
3999 /// line. Motions extend the selection by whole lines.
4000 pub fn enter_visual_line(&mut self) {
4001 vim::enter_visual_line_bridge(self);
4002 }
4003
4004 /// `<C-v>` from Normal — enter Visual-block mode. The selection is a
4005 /// rectangle whose corners are the anchor and the live cursor.
4006 pub fn enter_visual_block(&mut self) {
4007 vim::enter_visual_block_bridge(self);
4008 }
4009
4010 /// Esc from any visual mode — set `<` / `>` marks, stash the selection
4011 /// for `gv` re-entry, then return to Normal mode.
4012 pub fn exit_visual_to_normal(&mut self) {
4013 vim::exit_visual_to_normal_bridge(self);
4014 }
4015
4016 /// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
4017 /// so the user can extend the other end of the selection. Does NOT
4018 /// mutate the selection range; only the active endpoint changes.
4019 pub fn visual_o_toggle(&mut self) {
4020 vim::visual_o_toggle_bridge(self);
4021 }
4022
4023 /// `gv` — restore the last visual selection (mode + anchor + cursor
4024 /// position). No-op when no visual selection has been exited yet.
4025 pub fn reenter_last_visual(&mut self) {
4026 vim::reenter_last_visual_bridge(self);
4027 }
4028
4029 /// Direct mode-transition entry point. Sets both the internal FSM mode
4030 /// and the stable `current_mode` field read by [`Editor::vim_mode`].
4031 ///
4032 /// Prefer the semantic primitives (`enter_visual_char`, `enter_insert_i`,
4033 /// …) which also set up required bookkeeping (anchors, sessions, …).
4034 /// Use `set_mode` only when you need a raw mode flip without side-effects.
4035 pub fn set_mode(&mut self, mode: VimMode) {
4036 vim::set_mode_bridge(self, mode);
4037 }
4038}
4039
4040// ── Phase 6.6b: FSM state accessors (for hjkl-vim ownership) ─────────────────
4041//
4042// The FSM (now in hjkl-vim) reads/writes `VimState` fields through public
4043// `Editor` accessors and mutators defined in this block. Each method gets a
4044// one-line `///` rustdoc. Fields mutated as a unit get a combined action method
4045// rather than individual getters + setters (e.g. `accumulate_count_digit`).
4046
4047/// State carried between [`Editor::begin_step`] and [`Editor::end_step`].
4048///
4049/// Treat as opaque — construct by calling `begin_step` and pass the
4050/// returned value directly into `end_step` without modification.
4051/// The fields capture per-step pre-dispatch state that the epilogue
4052/// needs to run its invariants correctly.
4053pub struct StepBookkeeping {
4054 /// True when the pending chord before this step was a macro-chord
4055 /// (`q{reg}` or `@{reg}`). The recorder hook skips these bookkeeping
4056 /// keys so that only the *payload* keys enter `recording_keys`.
4057 pub pending_was_macro_chord: bool,
4058 /// True when the mode was Insert *before* the FSM body ran. Used by
4059 /// the Ctrl-o one-shot-normal epilogue to decide whether to bounce
4060 /// back into Insert.
4061 pub was_insert: bool,
4062 /// Pre-dispatch visual snapshot. When the FSM body transitions out of
4063 /// a visual mode the epilogue uses this to set the `<`/`>` marks and
4064 /// store `last_visual` for `gv`.
4065 pub pre_visual_snapshot: Option<vim::LastVisual>,
4066}
4067
4068impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
4069 // ── Pending chord ─────────────────────────────────────────────────────────
4070
4071 /// Return a clone of the current pending chord state.
4072 pub fn pending(&self) -> vim::Pending {
4073 self.vim.pending.clone()
4074 }
4075
4076 /// Overwrite the pending chord state.
4077 pub fn set_pending(&mut self, p: vim::Pending) {
4078 self.vim.pending = p;
4079 }
4080
4081 /// Atomically take the pending chord, replacing it with `Pending::None`.
4082 pub fn take_pending(&mut self) -> vim::Pending {
4083 std::mem::take(&mut self.vim.pending)
4084 }
4085
4086 // ── Count prefix ──────────────────────────────────────────────────────────
4087
4088 /// Return the raw digit-prefix count (`0` = no prefix typed yet).
4089 pub fn count(&self) -> usize {
4090 self.vim.count
4091 }
4092
4093 /// Overwrite the digit-prefix count directly.
4094 pub fn set_count(&mut self, c: usize) {
4095 self.vim.count = c;
4096 }
4097
4098 /// Accumulate one more digit into the count prefix (mirrors `count * 10 + digit`).
4099 pub fn accumulate_count_digit(&mut self, digit: usize) {
4100 self.vim.count = self.vim.count.saturating_mul(10) + digit;
4101 }
4102
4103 /// Reset the count prefix to zero (no pending count).
4104 pub fn reset_count(&mut self) {
4105 self.vim.count = 0;
4106 }
4107
4108 /// Consume the count and return it; resets to zero. Returns `1` when no
4109 /// prefix was typed (mirrors `take_count` in vim.rs).
4110 pub fn take_count(&mut self) -> usize {
4111 if self.vim.count > 0 {
4112 let n = self.vim.count;
4113 self.vim.count = 0;
4114 n
4115 } else {
4116 1
4117 }
4118 }
4119
4120 // ── Internal FSM mode ─────────────────────────────────────────────────────
4121
4122 /// Return the FSM-internal mode (Normal / Insert / Visual / …).
4123 pub fn fsm_mode(&self) -> vim::Mode {
4124 self.vim.mode
4125 }
4126
4127 /// Overwrite the FSM-internal mode without side-effects. Prefer the
4128 /// semantic primitives (`enter_insert_i`, `enter_visual_char`, …).
4129 pub fn set_fsm_mode(&mut self, m: vim::Mode) {
4130 self.vim.mode = m;
4131 self.vim.current_mode = self.vim.public_mode();
4132 }
4133
4134 // ── Replaying flag ────────────────────────────────────────────────────────
4135
4136 /// `true` while the `.` dot-repeat replay is running.
4137 pub fn is_replaying(&self) -> bool {
4138 self.vim.replaying
4139 }
4140
4141 /// Set or clear the dot-replay flag.
4142 pub fn set_replaying(&mut self, v: bool) {
4143 self.vim.replaying = v;
4144 }
4145
4146 // ── One-shot normal (Ctrl-o) ──────────────────────────────────────────────
4147
4148 /// `true` when we entered Normal from Insert via `Ctrl-o` and will return
4149 /// to Insert after the next complete command.
4150 pub fn is_one_shot_normal(&self) -> bool {
4151 self.vim.one_shot_normal
4152 }
4153
4154 /// Set or clear the Ctrl-o one-shot-normal flag.
4155 pub fn set_one_shot_normal(&mut self, v: bool) {
4156 self.vim.one_shot_normal = v;
4157 }
4158
4159 // ── Last find (f/F/t/T target) ────────────────────────────────────────────
4160
4161 /// Return the last `f`/`F`/`t`/`T` target as `(char, forward, till)`, or
4162 /// `None` before any find command was executed.
4163 pub fn last_find(&self) -> Option<(char, bool, bool)> {
4164 self.vim.last_find
4165 }
4166
4167 /// Overwrite the stored last-find target.
4168 pub fn set_last_find(&mut self, target: Option<(char, bool, bool)>) {
4169 self.vim.last_find = target;
4170 }
4171
4172 // ── Last change (dot-repeat payload) ─────────────────────────────────────
4173
4174 /// Return a clone of the last recorded mutating change, or `None` before
4175 /// any change has been made.
4176 pub fn last_change(&self) -> Option<vim::LastChange> {
4177 self.vim.last_change.clone()
4178 }
4179
4180 /// Overwrite the stored last-change record.
4181 pub fn set_last_change(&mut self, lc: Option<vim::LastChange>) {
4182 self.vim.last_change = lc;
4183 }
4184
4185 /// Borrow the last-change record mutably (e.g. to fill in an `inserted`
4186 /// field after the insert session completes).
4187 pub fn last_change_mut(&mut self) -> Option<&mut vim::LastChange> {
4188 self.vim.last_change.as_mut()
4189 }
4190
4191 // ── Insert session ────────────────────────────────────────────────────────
4192
4193 /// Borrow the active insert session, or `None` when not in Insert mode.
4194 pub fn insert_session(&self) -> Option<&vim::InsertSession> {
4195 self.vim.insert_session.as_ref()
4196 }
4197
4198 /// Borrow the active insert session mutably.
4199 pub fn insert_session_mut(&mut self) -> Option<&mut vim::InsertSession> {
4200 self.vim.insert_session.as_mut()
4201 }
4202
4203 /// Atomically take the insert session out, leaving `None`.
4204 pub fn take_insert_session(&mut self) -> Option<vim::InsertSession> {
4205 self.vim.insert_session.take()
4206 }
4207
4208 /// Install a new insert session, replacing any existing one.
4209 pub fn set_insert_session(&mut self, s: Option<vim::InsertSession>) {
4210 self.vim.insert_session = s;
4211 }
4212
4213 // ── Visual anchors ────────────────────────────────────────────────────────
4214
4215 /// Return the charwise Visual-mode anchor `(row, col)`.
4216 pub fn visual_anchor(&self) -> (usize, usize) {
4217 self.vim.visual_anchor
4218 }
4219
4220 /// Overwrite the charwise Visual-mode anchor.
4221 pub fn set_visual_anchor(&mut self, anchor: (usize, usize)) {
4222 self.vim.visual_anchor = anchor;
4223 }
4224
4225 /// Return the VisualLine anchor row.
4226 pub fn visual_line_anchor(&self) -> usize {
4227 self.vim.visual_line_anchor
4228 }
4229
4230 /// Overwrite the VisualLine anchor row.
4231 pub fn set_visual_line_anchor(&mut self, row: usize) {
4232 self.vim.visual_line_anchor = row;
4233 }
4234
4235 /// Return the VisualBlock anchor `(row, col)`.
4236 pub fn block_anchor(&self) -> (usize, usize) {
4237 self.vim.block_anchor
4238 }
4239
4240 /// Overwrite the VisualBlock anchor.
4241 pub fn set_block_anchor(&mut self, anchor: (usize, usize)) {
4242 self.vim.block_anchor = anchor;
4243 }
4244
4245 /// Return the VisualBlock virtual column used to survive j/k row clamping.
4246 pub fn block_vcol(&self) -> usize {
4247 self.vim.block_vcol
4248 }
4249
4250 /// Overwrite the VisualBlock virtual column.
4251 pub fn set_block_vcol(&mut self, vcol: usize) {
4252 self.vim.block_vcol = vcol;
4253 }
4254
4255 // ── Yank linewise flag ────────────────────────────────────────────────────
4256
4257 /// `true` when the last yank/cut was linewise (affects `p`/`P` layout).
4258 pub fn yank_linewise(&self) -> bool {
4259 self.vim.yank_linewise
4260 }
4261
4262 /// Set or clear the linewise-yank flag.
4263 pub fn set_yank_linewise(&mut self, v: bool) {
4264 self.vim.yank_linewise = v;
4265 }
4266
4267 // ── Pending register selector ─────────────────────────────────────────────
4268 // Note: `pending_register()` getter already exists at line ~1254 (Phase 4e).
4269 // Only the mutators are new here.
4270
4271 /// Overwrite the pending register selector (Phase 6.6b mutator companion to
4272 /// the existing `pending_register()` getter).
4273 pub fn set_pending_register_raw(&mut self, reg: Option<char>) {
4274 self.vim.pending_register = reg;
4275 }
4276
4277 /// Atomically take the pending register, returning `None` afterward.
4278 pub fn take_pending_register_raw(&mut self) -> Option<char> {
4279 self.vim.pending_register.take()
4280 }
4281
4282 // ── Macro recording ───────────────────────────────────────────────────────
4283
4284 /// Return the register currently being recorded into, or `None`.
4285 pub fn recording_macro(&self) -> Option<char> {
4286 self.vim.recording_macro
4287 }
4288
4289 /// Overwrite the recording-macro target register.
4290 pub fn set_recording_macro(&mut self, reg: Option<char>) {
4291 self.vim.recording_macro = reg;
4292 }
4293
4294 /// Append one input to the in-progress macro recording buffer.
4295 pub fn push_recording_key(&mut self, input: crate::input::Input) {
4296 self.vim.recording_keys.push(input);
4297 }
4298
4299 /// Atomically take the recorded key sequence, leaving an empty vec.
4300 pub fn take_recording_keys(&mut self) -> Vec<crate::input::Input> {
4301 std::mem::take(&mut self.vim.recording_keys)
4302 }
4303
4304 /// Overwrite the recording-keys buffer (e.g. to seed from a register).
4305 pub fn set_recording_keys(&mut self, keys: Vec<crate::input::Input>) {
4306 self.vim.recording_keys = keys;
4307 }
4308
4309 // ── Macro replay flag ─────────────────────────────────────────────────────
4310
4311 /// `true` while `@reg` macro replay is running (suppresses re-recording).
4312 pub fn is_replaying_macro_raw(&self) -> bool {
4313 self.vim.replaying_macro
4314 }
4315
4316 /// Set or clear the macro-replay-in-progress flag.
4317 pub fn set_replaying_macro_raw(&mut self, v: bool) {
4318 self.vim.replaying_macro = v;
4319 }
4320
4321 // ── Last macro register ───────────────────────────────────────────────────
4322
4323 /// Return the register of the most recently played macro (`@@` source).
4324 pub fn last_macro(&self) -> Option<char> {
4325 self.vim.last_macro
4326 }
4327
4328 /// Overwrite the last-played-macro register.
4329 pub fn set_last_macro(&mut self, reg: Option<char>) {
4330 self.vim.last_macro = reg;
4331 }
4332
4333 // ── Last insert position ──────────────────────────────────────────────────
4334
4335 /// Return the cursor position when Insert mode was last exited (for `gi`).
4336 pub fn last_insert_pos(&self) -> Option<(usize, usize)> {
4337 self.vim.last_insert_pos
4338 }
4339
4340 /// Overwrite the stored last-insert position.
4341 pub fn set_last_insert_pos(&mut self, pos: Option<(usize, usize)>) {
4342 self.vim.last_insert_pos = pos;
4343 }
4344
4345 // ── Last visual selection ─────────────────────────────────────────────────
4346
4347 /// Return the saved visual selection snapshot for `gv`, or `None`.
4348 pub fn last_visual(&self) -> Option<vim::LastVisual> {
4349 self.vim.last_visual
4350 }
4351
4352 /// Overwrite the saved visual selection snapshot.
4353 pub fn set_last_visual(&mut self, snap: Option<vim::LastVisual>) {
4354 self.vim.last_visual = snap;
4355 }
4356
4357 // ── Viewport-pinned flag ──────────────────────────────────────────────────
4358
4359 /// `true` when `zz`/`zt`/`zb` pinned the viewport this step (suppresses
4360 /// the end-of-step scrolloff pass).
4361 pub fn viewport_pinned(&self) -> bool {
4362 self.vim.viewport_pinned
4363 }
4364
4365 /// Set or clear the viewport-pinned flag.
4366 pub fn set_viewport_pinned(&mut self, v: bool) {
4367 self.vim.viewport_pinned = v;
4368 }
4369
4370 // ── Insert pending register (Ctrl-R wait) ─────────────────────────────────
4371
4372 /// `true` while waiting for the register-name key after `Ctrl-R` in
4373 /// Insert mode.
4374 pub fn insert_pending_register(&self) -> bool {
4375 self.vim.insert_pending_register
4376 }
4377
4378 /// Set or clear the `Ctrl-R` register-wait flag.
4379 pub fn set_insert_pending_register(&mut self, v: bool) {
4380 self.vim.insert_pending_register = v;
4381 }
4382
4383 // ── Change-mark start ─────────────────────────────────────────────────────
4384
4385 /// Return the stashed `[` mark start for a Change operation, or `None`.
4386 pub fn change_mark_start(&self) -> Option<(usize, usize)> {
4387 self.vim.change_mark_start
4388 }
4389
4390 /// Atomically take the change-mark start, leaving `None`.
4391 pub fn take_change_mark_start(&mut self) -> Option<(usize, usize)> {
4392 self.vim.change_mark_start.take()
4393 }
4394
4395 /// Overwrite the change-mark start.
4396 pub fn set_change_mark_start(&mut self, pos: Option<(usize, usize)>) {
4397 self.vim.change_mark_start = pos;
4398 }
4399
4400 // ── Timeout tracking ──────────────────────────────────────────────────────
4401
4402 /// Return the wall-clock `Instant` of the last keystroke.
4403 pub fn last_input_at(&self) -> Option<std::time::Instant> {
4404 self.vim.last_input_at
4405 }
4406
4407 /// Overwrite the wall-clock last-input timestamp.
4408 pub fn set_last_input_at(&mut self, t: Option<std::time::Instant>) {
4409 self.vim.last_input_at = t;
4410 }
4411
4412 /// Return the `Host::now()` duration at the last keystroke.
4413 pub fn last_input_host_at(&self) -> Option<core::time::Duration> {
4414 self.vim.last_input_host_at
4415 }
4416
4417 /// Overwrite the host-clock last-input timestamp.
4418 pub fn set_last_input_host_at(&mut self, d: Option<core::time::Duration>) {
4419 self.vim.last_input_host_at = d;
4420 }
4421
4422 // ── Search prompt ──────────────────────────────────────────────────────────
4423
4424 /// Borrow the live search prompt, or `None` when not in search-prompt mode.
4425 pub fn search_prompt_state(&self) -> Option<&vim::SearchPrompt> {
4426 self.vim.search_prompt.as_ref()
4427 }
4428
4429 /// Borrow the live search prompt mutably.
4430 pub fn search_prompt_state_mut(&mut self) -> Option<&mut vim::SearchPrompt> {
4431 self.vim.search_prompt.as_mut()
4432 }
4433
4434 /// Atomically take the search prompt, leaving `None`.
4435 pub fn take_search_prompt_state(&mut self) -> Option<vim::SearchPrompt> {
4436 self.vim.search_prompt.take()
4437 }
4438
4439 /// Install a new search prompt (entering search-prompt mode).
4440 pub fn set_search_prompt_state(&mut self, prompt: Option<vim::SearchPrompt>) {
4441 self.vim.search_prompt = prompt;
4442 }
4443
4444 // ── Last search pattern / direction ───────────────────────────────────────
4445 // Note: `last_search_forward()` getter already exists at line ~1909.
4446 // `set_last_search()` combined mutator exists at line ~1918.
4447 // Only new / complementary accessors are added here.
4448
4449 /// Return the most recently committed search pattern, or `None`.
4450 pub fn last_search_pattern(&self) -> Option<&str> {
4451 self.vim.last_search.as_deref()
4452 }
4453
4454 /// Overwrite the stored last-search pattern without changing direction
4455 /// (use the existing `set_last_search` for the combined update).
4456 pub fn set_last_search_pattern_only(&mut self, pattern: Option<String>) {
4457 self.vim.last_search = pattern;
4458 }
4459
4460 /// Overwrite only the last-search direction flag.
4461 pub fn set_last_search_forward_only(&mut self, forward: bool) {
4462 self.vim.last_search_forward = forward;
4463 }
4464
4465 // ── Search history ────────────────────────────────────────────────────────
4466
4467 /// Borrow the committed search-pattern history (oldest first).
4468 pub fn search_history(&self) -> &[String] {
4469 &self.vim.search_history
4470 }
4471
4472 /// Borrow the search history mutably (e.g. to push a new entry).
4473 pub fn search_history_mut(&mut self) -> &mut Vec<String> {
4474 &mut self.vim.search_history
4475 }
4476
4477 /// Return the current search-history navigation cursor index.
4478 pub fn search_history_cursor(&self) -> Option<usize> {
4479 self.vim.search_history_cursor
4480 }
4481
4482 /// Overwrite the search-history navigation cursor.
4483 pub fn set_search_history_cursor(&mut self, idx: Option<usize>) {
4484 self.vim.search_history_cursor = idx;
4485 }
4486
4487 // ── Jump lists ────────────────────────────────────────────────────────────
4488
4489 /// Borrow the back half of the jump list (entries Ctrl-o pops from).
4490 pub fn jump_back_list(&self) -> &[(usize, usize)] {
4491 &self.vim.jump_back
4492 }
4493
4494 /// Borrow the back jump list mutably (push / pop).
4495 pub fn jump_back_list_mut(&mut self) -> &mut Vec<(usize, usize)> {
4496 &mut self.vim.jump_back
4497 }
4498
4499 /// Borrow the forward half of the jump list (entries Ctrl-i pops from).
4500 pub fn jump_fwd_list(&self) -> &[(usize, usize)] {
4501 &self.vim.jump_fwd
4502 }
4503
4504 /// Borrow the forward jump list mutably (push / pop / clear).
4505 pub fn jump_fwd_list_mut(&mut self) -> &mut Vec<(usize, usize)> {
4506 &mut self.vim.jump_fwd
4507 }
4508
4509 // ── Phase 6.6c: search + jump helpers (public Editor API) ───────────────
4510 //
4511 // `push_search_pattern`, `push_jump`, `record_search_history`, and
4512 // `walk_search_history` are public `Editor` methods so that `hjkl-vim`'s
4513 // search-prompt and normal-mode FSM can call them via the public API.
4514
4515 /// Compile `pattern` into a regex and install it as the active search
4516 /// pattern. Respects `:set ignorecase` / `:set smartcase`. An empty or
4517 /// invalid pattern clears the highlight without raising an error.
4518 pub fn push_search_pattern(&mut self, pattern: &str) {
4519 let compiled = if pattern.is_empty() {
4520 None
4521 } else {
4522 let case_insensitive = self.settings().ignore_case
4523 && !(self.settings().smartcase && pattern.chars().any(|c| c.is_uppercase()));
4524 let effective: std::borrow::Cow<'_, str> = if case_insensitive {
4525 std::borrow::Cow::Owned(format!("(?i){pattern}"))
4526 } else {
4527 std::borrow::Cow::Borrowed(pattern)
4528 };
4529 regex::Regex::new(&effective).ok()
4530 };
4531 let wrap = self.settings().wrapscan;
4532 self.set_search_pattern(compiled);
4533 self.search_state_mut().wrap_around = wrap;
4534 }
4535
4536 /// Record a pre-jump cursor position onto the back jumplist. Called
4537 /// before any "big jump" motion (`gg`/`G`, `%`, `*`/`#`, `n`/`N`,
4538 /// committed `/` or `?`, …). Branching off the history clears the
4539 /// forward half, matching vim's "redo-is-lost" semantics.
4540 pub fn push_jump(&mut self, from: (usize, usize)) {
4541 self.vim.jump_back.push(from);
4542 if self.vim.jump_back.len() > vim::JUMPLIST_MAX {
4543 self.vim.jump_back.remove(0);
4544 }
4545 self.vim.jump_fwd.clear();
4546 }
4547
4548 /// Push `pattern` onto the committed search history. Skips if the
4549 /// most recent entry already matches (consecutive dedupe) and trims
4550 /// the oldest entries beyond the history cap.
4551 pub fn record_search_history(&mut self, pattern: &str) {
4552 if pattern.is_empty() {
4553 return;
4554 }
4555 if self.vim.search_history.last().map(String::as_str) == Some(pattern) {
4556 return;
4557 }
4558 self.vim.search_history.push(pattern.to_string());
4559 let len = self.vim.search_history.len();
4560 if len > vim::SEARCH_HISTORY_MAX {
4561 self.vim
4562 .search_history
4563 .drain(0..len - vim::SEARCH_HISTORY_MAX);
4564 }
4565 }
4566
4567 /// Walk the search-prompt history by `dir` steps. `dir = -1` moves
4568 /// toward older entries (Ctrl-P / Up); `dir = 1` toward newer ones
4569 /// (Ctrl-N / Down). Stops at the ends; does nothing if there is no
4570 /// active search prompt.
4571 pub fn walk_search_history(&mut self, dir: isize) {
4572 if self.vim.search_history.is_empty() || self.vim.search_prompt.is_none() {
4573 return;
4574 }
4575 let len = self.vim.search_history.len();
4576 let next_idx = match (self.vim.search_history_cursor, dir) {
4577 (None, -1) => Some(len - 1),
4578 (None, 1) => return,
4579 (Some(i), -1) => i.checked_sub(1),
4580 (Some(i), 1) if i + 1 < len => Some(i + 1),
4581 _ => None,
4582 };
4583 let Some(idx) = next_idx else {
4584 return;
4585 };
4586 self.vim.search_history_cursor = Some(idx);
4587 let text = self.vim.search_history[idx].clone();
4588 if let Some(prompt) = self.vim.search_prompt.as_mut() {
4589 prompt.cursor = text.chars().count();
4590 prompt.text = text.clone();
4591 }
4592 self.push_search_pattern(&text);
4593 }
4594
4595 // ── Phase 6.6d: pre/post FSM bookkeeping ────────────────────────────────
4596 //
4597 // `begin_step` and `end_step` are the bookkeeping prelude/epilogue that
4598 // `hjkl_vim::dispatch_input` wraps around its per-mode FSM dispatch.
4599
4600 /// Pre-dispatch bookkeeping that must run before every per-mode FSM step.
4601 ///
4602 /// Call this at the start of every step; pass the returned
4603 /// [`StepBookkeeping`] to [`end_step`] after the FSM body finishes.
4604 ///
4605 /// Returns `Ok(bk)` when the caller should proceed with FSM dispatch.
4606 /// Returns `Err(consumed)` when the prelude itself handled the input
4607 /// (macro-stop chord); in that case skip the FSM body and do NOT call
4608 /// `end_step` — the macro-stop path is a true short-circuit with no
4609 /// epilogue needed.
4610 ///
4611 /// This method does NOT handle the search-prompt intercept — callers
4612 /// must check `search_prompt_state().is_some()` before calling `begin_step`
4613 /// and dispatch to the search-prompt FSM body directly.
4614 pub fn begin_step(&mut self, input: Input) -> Result<StepBookkeeping, bool> {
4615 use crate::input::Key;
4616 use vim::{Mode, Pending};
4617 // ── Timestamps ───────────────────────────────────────────────────────
4618 // Phase 7f: sync buffer before motion handlers see it.
4619 self.sync_buffer_content_from_textarea();
4620 // `:set timeoutlen` chord-timeout handling.
4621 let now = std::time::Instant::now();
4622 let host_now = self.host.now();
4623 let timed_out = match self.vim.last_input_host_at {
4624 Some(prev) => host_now.saturating_sub(prev) > self.settings.timeout_len,
4625 None => false,
4626 };
4627 if timed_out {
4628 let chord_in_flight = !matches!(self.vim.pending, Pending::None)
4629 || self.vim.count != 0
4630 || self.vim.pending_register.is_some()
4631 || self.vim.insert_pending_register;
4632 if chord_in_flight {
4633 self.vim.clear_pending_prefix();
4634 }
4635 }
4636 self.vim.last_input_at = Some(now);
4637 self.vim.last_input_host_at = Some(host_now);
4638 // ── Macro-stop: bare `q` outside Insert ends the recording ───────────
4639 if self.vim.recording_macro.is_some()
4640 && !self.vim.replaying_macro
4641 && matches!(self.vim.pending, Pending::None)
4642 && self.vim.mode != Mode::Insert
4643 && input.key == Key::Char('q')
4644 && !input.ctrl
4645 && !input.alt
4646 {
4647 let reg = self.vim.recording_macro.take().unwrap();
4648 let keys = std::mem::take(&mut self.vim.recording_keys);
4649 let text = crate::input::encode_macro(&keys);
4650 self.set_named_register_text(reg.to_ascii_lowercase(), text);
4651 return Err(true);
4652 }
4653 // ── Snapshots for epilogue ────────────────────────────────────────────
4654 let pending_was_macro_chord = matches!(
4655 self.vim.pending,
4656 Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
4657 );
4658 let was_insert = self.vim.mode == Mode::Insert;
4659 let pre_visual_snapshot = match self.vim.mode {
4660 Mode::Visual => Some(vim::LastVisual {
4661 mode: Mode::Visual,
4662 anchor: self.vim.visual_anchor,
4663 cursor: self.cursor(),
4664 block_vcol: 0,
4665 }),
4666 Mode::VisualLine => Some(vim::LastVisual {
4667 mode: Mode::VisualLine,
4668 anchor: (self.vim.visual_line_anchor, 0),
4669 cursor: self.cursor(),
4670 block_vcol: 0,
4671 }),
4672 Mode::VisualBlock => Some(vim::LastVisual {
4673 mode: Mode::VisualBlock,
4674 anchor: self.vim.block_anchor,
4675 cursor: self.cursor(),
4676 block_vcol: self.vim.block_vcol,
4677 }),
4678 _ => None,
4679 };
4680 Ok(StepBookkeeping {
4681 pending_was_macro_chord,
4682 was_insert,
4683 pre_visual_snapshot,
4684 })
4685 }
4686
4687 /// Post-dispatch bookkeeping that must run after every per-mode FSM step.
4688 ///
4689 /// `input` is the same input that was passed to `begin_step`.
4690 /// `bk` is the [`StepBookkeeping`] returned by `begin_step`.
4691 /// `consumed` is the return value of the FSM body; this method returns
4692 /// it after running all epilogue invariants.
4693 ///
4694 /// Must NOT be called when `begin_step` returned `Err(...)`.
4695 pub fn end_step(&mut self, input: Input, bk: StepBookkeeping, consumed: bool) -> bool {
4696 use crate::input::Key;
4697 use vim::{Mode, Pending};
4698 let StepBookkeeping {
4699 pending_was_macro_chord,
4700 was_insert,
4701 pre_visual_snapshot,
4702 } = bk;
4703 // ── Visual-exit: set `<`/`>` marks and stash `last_visual` ───────────
4704 if let Some(snap) = pre_visual_snapshot
4705 && !matches!(
4706 self.vim.mode,
4707 Mode::Visual | Mode::VisualLine | Mode::VisualBlock
4708 )
4709 {
4710 let (lo, hi) = match snap.mode {
4711 Mode::Visual => {
4712 if snap.anchor <= snap.cursor {
4713 (snap.anchor, snap.cursor)
4714 } else {
4715 (snap.cursor, snap.anchor)
4716 }
4717 }
4718 Mode::VisualLine => {
4719 let r_lo = snap.anchor.0.min(snap.cursor.0);
4720 let r_hi = snap.anchor.0.max(snap.cursor.0);
4721 let last_col = self
4722 .buffer()
4723 .lines()
4724 .get(r_hi)
4725 .map(|l| l.chars().count().saturating_sub(1))
4726 .unwrap_or(0);
4727 ((r_lo, 0), (r_hi, last_col))
4728 }
4729 Mode::VisualBlock => {
4730 let (r1, c1) = snap.anchor;
4731 let (r2, c2) = snap.cursor;
4732 ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
4733 }
4734 _ => {
4735 if snap.anchor <= snap.cursor {
4736 (snap.anchor, snap.cursor)
4737 } else {
4738 (snap.cursor, snap.anchor)
4739 }
4740 }
4741 };
4742 self.set_mark('<', lo);
4743 self.set_mark('>', hi);
4744 self.vim.last_visual = Some(snap);
4745 }
4746 // ── Ctrl-o one-shot-normal return to Insert ───────────────────────────
4747 if !was_insert
4748 && self.vim.one_shot_normal
4749 && self.vim.mode == Mode::Normal
4750 && matches!(self.vim.pending, Pending::None)
4751 {
4752 self.vim.one_shot_normal = false;
4753 self.vim.mode = Mode::Insert;
4754 }
4755 // ── Content + viewport sync ───────────────────────────────────────────
4756 self.sync_buffer_content_from_textarea();
4757 if !self.vim.viewport_pinned {
4758 self.ensure_cursor_in_scrolloff();
4759 }
4760 self.vim.viewport_pinned = false;
4761 // ── Recorder hook ─────────────────────────────────────────────────────
4762 if self.vim.recording_macro.is_some()
4763 && !self.vim.replaying_macro
4764 && input.key != Key::Char('q')
4765 && !pending_was_macro_chord
4766 {
4767 self.vim.recording_keys.push(input);
4768 }
4769 // ── Phase 6.3: current_mode sync ─────────────────────────────────────
4770 self.vim.current_mode = self.vim.public_mode();
4771 consumed
4772 }
4773
4774 // ── Phase 6.6e: additional public primitives for hjkl-vim::normal ─────────
4775
4776 /// `true` when the editor is in any visual mode (Visual / VisualLine /
4777 /// VisualBlock). Convenience wrapper around `vim_mode()` for hjkl-vim.
4778 pub fn is_visual(&self) -> bool {
4779 matches!(
4780 self.vim.mode,
4781 vim::Mode::Visual | vim::Mode::VisualLine | vim::Mode::VisualBlock
4782 )
4783 }
4784
4785 /// Compute the VisualBlock rectangle corners: `(top_row, bot_row,
4786 /// left_col, right_col)`. Uses `block_anchor` and `block_vcol` (the
4787 /// virtual column, which survives j/k clamping to shorter rows).
4788 ///
4789 /// Promoted in Phase 6.6e so `hjkl-vim::normal` can compute the block
4790 /// extents needed for VisualBlock `I` / `A` / `r` without accessing
4791 /// engine-private helpers.
4792 pub fn visual_block_bounds(&self) -> (usize, usize, usize, usize) {
4793 let (ar, ac) = self.vim.block_anchor;
4794 let (cr, _) = self.cursor();
4795 let cc = self.vim.block_vcol;
4796 let top = ar.min(cr);
4797 let bot = ar.max(cr);
4798 let left = ac.min(cc);
4799 let right = ac.max(cc);
4800 (top, bot, left, right)
4801 }
4802
4803 /// Return the character count (code-point count) of line `row`, or `0`
4804 /// when `row` is out of range. Used by hjkl-vim::normal for VisualBlock
4805 /// I / A column computations.
4806 pub fn line_char_count(&self, row: usize) -> usize {
4807 buf_line_chars(&self.buffer, row)
4808 }
4809
4810 /// Apply operator over `motion` with `count` repetitions. The full
4811 /// vim-quirks path (operator context for `l`, clamping, etc.) is applied.
4812 ///
4813 /// Promoted to the public surface in Phase 6.6e so `hjkl-vim::normal`'s
4814 /// relocated `handle_after_op` can call it directly with a parsed `Motion`
4815 /// without re-entering the engine FSM.
4816 pub fn apply_op_with_motion_direct(
4817 &mut self,
4818 op: crate::vim::Operator,
4819 motion: &crate::vim::Motion,
4820 count: usize,
4821 ) {
4822 vim::apply_op_with_motion(self, op, motion, count);
4823 }
4824
4825 /// `Ctrl-a` / `Ctrl-x` — adjust the number under or after the cursor.
4826 /// `delta = 1` increments; `delta = -1` decrements; larger deltas
4827 /// multiply as in vim's `5<C-a>`. Promoted in Phase 6.6e so
4828 /// `hjkl-vim::normal` can dispatch `Ctrl-a` / `Ctrl-x`.
4829 pub fn adjust_number(&mut self, delta: i64) {
4830 vim::adjust_number(self, delta);
4831 }
4832
4833 /// Open the `/` or `?` search prompt. `forward = true` for `/`,
4834 /// `false` for `?`. Promoted in Phase 6.6e so `hjkl-vim::normal` can
4835 /// dispatch `/` and `?` without re-entering the engine FSM.
4836 pub fn enter_search(&mut self, forward: bool) {
4837 vim::enter_search(self, forward);
4838 }
4839
4840 /// Enter Insert mode at the left edge of a VisualBlock selection for
4841 /// `I`. Moves the cursor to `(top, col)`, resets to Normal internally,
4842 /// then begins an insert session with `InsertReason::BlockEdge`.
4843 ///
4844 /// Promoted in Phase 6.6e so `hjkl-vim::normal` can dispatch the
4845 /// VisualBlock `I` command without accessing engine-private helpers.
4846 pub fn visual_block_insert_at_left(&mut self, top: usize, bot: usize, col: usize) {
4847 self.jump_cursor(top, col);
4848 self.vim.mode = vim::Mode::Normal;
4849 vim::begin_insert(self, 1, vim::InsertReason::BlockEdge { top, bot, col });
4850 }
4851
4852 /// Enter Insert mode at the right edge of a VisualBlock selection for
4853 /// `A`. Moves the cursor to `(top, col)`, resets to Normal internally,
4854 /// then begins an insert session with `InsertReason::BlockEdge`.
4855 ///
4856 /// Promoted in Phase 6.6e so `hjkl-vim::normal` can dispatch the
4857 /// VisualBlock `A` command without accessing engine-private helpers.
4858 pub fn visual_block_append_at_right(&mut self, top: usize, bot: usize, col: usize) {
4859 self.jump_cursor(top, col);
4860 self.vim.mode = vim::Mode::Normal;
4861 vim::begin_insert(self, 1, vim::InsertReason::BlockEdge { top, bot, col });
4862 }
4863
4864 /// Execute a motion (cursor movement), push to the jumplist for big jumps,
4865 /// and update the sticky column. Mirrors the engine FSM's `execute_motion`
4866 /// free function. Promoted in Phase 6.6e for `hjkl-vim::normal`.
4867 pub fn execute_motion(&mut self, motion: crate::vim::Motion, count: usize) {
4868 vim::execute_motion(self, motion, count);
4869 }
4870
4871 /// Update the VisualBlock virtual column after a motion in VisualBlock mode.
4872 /// Horizontal motions sync `block_vcol` to the cursor column; vertical /
4873 /// non-h/l motions leave it alone so the intended column survives clamping
4874 /// to shorter rows. Promoted in Phase 6.6e for `hjkl-vim::normal`.
4875 pub fn update_block_vcol(&mut self, motion: &crate::vim::Motion) {
4876 vim::update_block_vcol(self, motion);
4877 }
4878
4879 /// Apply `op` over the current visual selection (char-wise, linewise, or
4880 /// block). Mirrors the engine's internal `apply_visual_operator` free fn.
4881 /// Promoted in Phase 6.6e for `hjkl-vim::normal`.
4882 pub fn apply_visual_operator(&mut self, op: crate::vim::Operator) {
4883 vim::apply_visual_operator(self, op);
4884 }
4885
4886 /// Replace each character cell in the current VisualBlock selection with
4887 /// `ch`. Mirrors the engine's `block_replace` free fn. Promoted in Phase
4888 /// 6.6e for the VisualBlock `r<ch>` command in `hjkl-vim::normal`.
4889 pub fn replace_block_char(&mut self, ch: char) {
4890 vim::block_replace(self, ch);
4891 }
4892
4893 /// Extend the current visual selection to cover the text object identified
4894 /// by `ch` and `inner`. Maps `ch` to a `TextObject`, resolves its range
4895 /// via `text_object_range`, then updates the visual anchor and cursor.
4896 ///
4897 /// Promoted in Phase 6.6e for the visual-mode `i<ch>` / `a<ch>` commands
4898 /// in `hjkl-vim::normal::handle_visual_text_obj`.
4899 pub fn visual_text_obj_extend(&mut self, ch: char, inner: bool) {
4900 use crate::vim::{Mode, TextObject};
4901 let obj = match ch {
4902 'w' => TextObject::Word { big: false },
4903 'W' => TextObject::Word { big: true },
4904 '"' | '\'' | '`' => TextObject::Quote(ch),
4905 '(' | ')' | 'b' => TextObject::Bracket('('),
4906 '[' | ']' => TextObject::Bracket('['),
4907 '{' | '}' | 'B' => TextObject::Bracket('{'),
4908 '<' | '>' => TextObject::Bracket('<'),
4909 'p' => TextObject::Paragraph,
4910 't' => TextObject::XmlTag,
4911 's' => TextObject::Sentence,
4912 _ => return,
4913 };
4914 let Some((start, end, kind)) = vim::text_object_range(self, obj, inner) else {
4915 return;
4916 };
4917 match kind {
4918 crate::vim::RangeKind::Linewise => {
4919 self.vim.visual_line_anchor = start.0;
4920 self.vim.mode = Mode::VisualLine;
4921 self.vim.current_mode = VimMode::VisualLine;
4922 self.jump_cursor(end.0, 0);
4923 }
4924 _ => {
4925 self.vim.mode = Mode::Visual;
4926 self.vim.current_mode = VimMode::Visual;
4927 self.vim.visual_anchor = (start.0, start.1);
4928 let (er, ec) = vim::retreat_one(self, end);
4929 self.jump_cursor(er, ec);
4930 }
4931 }
4932 }
4933}
4934
4935/// Visual column of the character at `char_col` in `line`, treating `\t`
4936/// as expansion to the next `tab_width` stop and every other char as
4937/// 1 cell wide. Wide-char support (CJK, emoji) is a separate concern —
4938/// the cursor math elsewhere also assumes single-cell chars.
4939fn visual_col_for_char(line: &str, char_col: usize, tab_width: usize) -> usize {
4940 let mut visual = 0usize;
4941 for (i, ch) in line.chars().enumerate() {
4942 if i >= char_col {
4943 break;
4944 }
4945 if ch == '\t' {
4946 visual += tab_width - (visual % tab_width);
4947 } else {
4948 visual += 1;
4949 }
4950 }
4951 visual
4952}
4953
4954#[cfg(feature = "crossterm")]
4955impl From<KeyEvent> for Input {
4956 fn from(key: KeyEvent) -> Self {
4957 let k = match key.code {
4958 KeyCode::Char(c) => Key::Char(c),
4959 KeyCode::Backspace => Key::Backspace,
4960 KeyCode::Delete => Key::Delete,
4961 KeyCode::Enter => Key::Enter,
4962 KeyCode::Left => Key::Left,
4963 KeyCode::Right => Key::Right,
4964 KeyCode::Up => Key::Up,
4965 KeyCode::Down => Key::Down,
4966 KeyCode::Home => Key::Home,
4967 KeyCode::End => Key::End,
4968 KeyCode::Tab => Key::Tab,
4969 KeyCode::Esc => Key::Esc,
4970 _ => Key::Null,
4971 };
4972 Input {
4973 key: k,
4974 ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
4975 alt: key.modifiers.contains(KeyModifiers::ALT),
4976 shift: key.modifiers.contains(KeyModifiers::SHIFT),
4977 }
4978 }
4979}
4980
4981/// Crossterm `KeyEvent` → engine `Input`. Thin wrapper that delegates
4982/// to the [`From`] impl above; kept as a free fn for the in-tree
4983/// callers in the legacy ratatui-coupled paths.
4984#[cfg(feature = "crossterm")]
4985pub fn crossterm_to_input(key: KeyEvent) -> Input {
4986 Input::from(key)
4987}
4988
4989#[cfg(all(test, feature = "crossterm", feature = "ratatui"))]
4990mod tests {
4991 use super::*;
4992 use crate::types::Host;
4993 use crossterm::event::KeyEvent;
4994
4995 #[allow(dead_code)]
4996 fn key(code: KeyCode) -> KeyEvent {
4997 KeyEvent::new(code, KeyModifiers::NONE)
4998 }
4999 #[allow(dead_code)]
5000 fn shift_key(code: KeyCode) -> KeyEvent {
5001 KeyEvent::new(code, KeyModifiers::SHIFT)
5002 }
5003 #[allow(dead_code)]
5004 fn ctrl_key(code: KeyCode) -> KeyEvent {
5005 KeyEvent::new(code, KeyModifiers::CONTROL)
5006 }
5007
5008 #[test]
5009 fn intern_style_dedups_engine_native_styles() {
5010 use crate::types::{Attrs, Color, Style};
5011 let mut e = Editor::new(
5012 hjkl_buffer::Buffer::new(),
5013 crate::types::DefaultHost::new(),
5014 crate::types::Options::default(),
5015 );
5016 let s = Style {
5017 fg: Some(Color(255, 0, 0)),
5018 bg: None,
5019 attrs: Attrs::BOLD,
5020 };
5021 let id_a = e.intern_style(s);
5022 // Re-interning the same engine style returns the same id.
5023 let id_b = e.intern_style(s);
5024 assert_eq!(id_a, id_b);
5025 // Engine accessor returns the same style back.
5026 let back = e.engine_style_at(id_a).expect("interned");
5027 assert_eq!(back, s);
5028 }
5029
5030 #[test]
5031 fn engine_style_at_out_of_range_returns_none() {
5032 let e = Editor::new(
5033 hjkl_buffer::Buffer::new(),
5034 crate::types::DefaultHost::new(),
5035 crate::types::Options::default(),
5036 );
5037 assert!(e.engine_style_at(99).is_none());
5038 }
5039
5040 #[test]
5041 fn options_bridge_roundtrip() {
5042 let mut e = Editor::new(
5043 hjkl_buffer::Buffer::new(),
5044 crate::types::DefaultHost::new(),
5045 crate::types::Options::default(),
5046 );
5047 let opts = e.current_options();
5048 // 0.2.0: defaults flipped to modern editor norms — 4-space soft tabs.
5049 assert_eq!(opts.shiftwidth, 4);
5050 assert_eq!(opts.tabstop, 4);
5051
5052 let new_opts = crate::types::Options {
5053 shiftwidth: 4,
5054 tabstop: 2,
5055 ignorecase: true,
5056 ..crate::types::Options::default()
5057 };
5058 e.apply_options(&new_opts);
5059
5060 let after = e.current_options();
5061 assert_eq!(after.shiftwidth, 4);
5062 assert_eq!(after.tabstop, 2);
5063 assert!(after.ignorecase);
5064 }
5065
5066 #[test]
5067 fn selection_highlight_none_in_normal() {
5068 let mut e = Editor::new(
5069 hjkl_buffer::Buffer::new(),
5070 crate::types::DefaultHost::new(),
5071 crate::types::Options::default(),
5072 );
5073 e.set_content("hello");
5074 assert!(e.selection_highlight().is_none());
5075 }
5076
5077 #[test]
5078 fn highlights_emit_search_matches() {
5079 use crate::types::HighlightKind;
5080 let mut e = Editor::new(
5081 hjkl_buffer::Buffer::new(),
5082 crate::types::DefaultHost::new(),
5083 crate::types::Options::default(),
5084 );
5085 e.set_content("foo bar foo\nbaz qux\n");
5086 // 0.0.35: arm via the engine search state. The buffer
5087 // accessor still works (deprecated) but new code goes
5088 // through Editor.
5089 e.set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
5090 let hs = e.highlights_for_line(0);
5091 assert_eq!(hs.len(), 2);
5092 for h in &hs {
5093 assert_eq!(h.kind, HighlightKind::SearchMatch);
5094 assert_eq!(h.range.start.line, 0);
5095 assert_eq!(h.range.end.line, 0);
5096 }
5097 }
5098
5099 #[test]
5100 fn highlights_empty_without_pattern() {
5101 let mut e = Editor::new(
5102 hjkl_buffer::Buffer::new(),
5103 crate::types::DefaultHost::new(),
5104 crate::types::Options::default(),
5105 );
5106 e.set_content("foo bar");
5107 assert!(e.highlights_for_line(0).is_empty());
5108 }
5109
5110 #[test]
5111 fn highlights_empty_for_out_of_range_line() {
5112 let mut e = Editor::new(
5113 hjkl_buffer::Buffer::new(),
5114 crate::types::DefaultHost::new(),
5115 crate::types::Options::default(),
5116 );
5117 e.set_content("foo");
5118 e.set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
5119 assert!(e.highlights_for_line(99).is_empty());
5120 }
5121
5122 #[test]
5123 fn snapshot_roundtrips_through_restore() {
5124 use crate::types::SnapshotMode;
5125 let mut e = Editor::new(
5126 hjkl_buffer::Buffer::new(),
5127 crate::types::DefaultHost::new(),
5128 crate::types::Options::default(),
5129 );
5130 e.set_content("alpha\nbeta\ngamma");
5131 e.jump_cursor(2, 3);
5132 let snap = e.take_snapshot();
5133 assert_eq!(snap.mode, SnapshotMode::Normal);
5134 assert_eq!(snap.cursor, (2, 3));
5135 assert_eq!(snap.lines.len(), 3);
5136
5137 let mut other = Editor::new(
5138 hjkl_buffer::Buffer::new(),
5139 crate::types::DefaultHost::new(),
5140 crate::types::Options::default(),
5141 );
5142 other.restore_snapshot(snap).expect("restore");
5143 assert_eq!(other.cursor(), (2, 3));
5144 assert_eq!(other.buffer().lines().len(), 3);
5145 }
5146
5147 #[test]
5148 fn restore_snapshot_rejects_version_mismatch() {
5149 let mut e = Editor::new(
5150 hjkl_buffer::Buffer::new(),
5151 crate::types::DefaultHost::new(),
5152 crate::types::Options::default(),
5153 );
5154 let mut snap = e.take_snapshot();
5155 snap.version = 9999;
5156 match e.restore_snapshot(snap) {
5157 Err(crate::EngineError::SnapshotVersion(got, want)) => {
5158 assert_eq!(got, 9999);
5159 assert_eq!(want, crate::types::EditorSnapshot::VERSION);
5160 }
5161 other => panic!("expected SnapshotVersion err, got {other:?}"),
5162 }
5163 }
5164
5165 #[test]
5166 fn take_content_change_returns_some_on_first_dirty() {
5167 let mut e = Editor::new(
5168 hjkl_buffer::Buffer::new(),
5169 crate::types::DefaultHost::new(),
5170 crate::types::Options::default(),
5171 );
5172 e.set_content("hello");
5173 let first = e.take_content_change();
5174 assert!(first.is_some());
5175 let second = e.take_content_change();
5176 assert!(second.is_none());
5177 }
5178
5179 fn many_lines(n: usize) -> String {
5180 (0..n)
5181 .map(|i| format!("line{i}"))
5182 .collect::<Vec<_>>()
5183 .join("\n")
5184 }
5185
5186 #[allow(dead_code)]
5187 fn prime_viewport<H: Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, height: u16) {
5188 e.set_viewport_height(height);
5189 }
5190
5191 /// Contract that the TUI drain relies on: `set_content` flags the
5192 /// editor dirty (so the next `take_dirty` call reports the change),
5193 /// and a second `take_dirty` returns `false` after consumption. The
5194 /// TUI drains this flag after every programmatic content load so
5195 /// opening a tab doesn't get mistaken for a user edit and mark the
5196 /// tab dirty (which would then trigger the quit-prompt on `:q`).
5197 #[test]
5198 fn set_content_dirties_then_take_dirty_clears() {
5199 let mut e = Editor::new(
5200 hjkl_buffer::Buffer::new(),
5201 crate::types::DefaultHost::new(),
5202 crate::types::Options::default(),
5203 );
5204 e.set_content("hello");
5205 assert!(
5206 e.take_dirty(),
5207 "set_content should leave content_dirty=true"
5208 );
5209 assert!(!e.take_dirty(), "take_dirty should clear the flag");
5210 }
5211
5212 #[test]
5213 fn content_arc_cache_invalidated_by_set_content() {
5214 let mut e = Editor::new(
5215 hjkl_buffer::Buffer::new(),
5216 crate::types::DefaultHost::new(),
5217 crate::types::Options::default(),
5218 );
5219 e.set_content("one");
5220 let a = e.content_arc();
5221 e.set_content("two");
5222 let b = e.content_arc();
5223 assert!(!std::sync::Arc::ptr_eq(&a, &b));
5224 assert!(b.starts_with("two"));
5225 }
5226
5227 // ── doc-coord mouse primitives (Phase 1 — issue #114) ──────────────────
5228
5229 #[test]
5230 fn mouse_click_doc_moves_cursor_to_doc_coords() {
5231 let mut e = Editor::new(
5232 hjkl_buffer::Buffer::new(),
5233 crate::types::DefaultHost::new(),
5234 crate::types::Options::default(),
5235 );
5236 e.set_content("hello\nworld");
5237 e.mouse_click_doc(1, 2);
5238 assert_eq!(e.cursor(), (1, 2));
5239 }
5240
5241 #[test]
5242 fn mouse_click_doc_clamps_past_eol_to_char_count() {
5243 let mut e = Editor::new(
5244 hjkl_buffer::Buffer::new(),
5245 crate::types::DefaultHost::new(),
5246 crate::types::Options::default(),
5247 );
5248 e.set_content("hello");
5249 // "hello" has 5 chars; past-EOL click clamps to 5 (Insert-mode sentinel).
5250 e.mouse_click_doc(0, 99);
5251 assert_eq!(e.cursor(), (0, 5));
5252 }
5253
5254 #[test]
5255 fn mouse_click_doc_clamps_past_eol_multibyte() {
5256 let mut e = Editor::new(
5257 hjkl_buffer::Buffer::new(),
5258 crate::types::DefaultHost::new(),
5259 crate::types::Options::default(),
5260 );
5261 // 5 chars, 6 bytes — clamping must be char-counted, not byte-counted.
5262 e.set_content("héllo");
5263 e.mouse_click_doc(0, 99);
5264 assert_eq!(e.cursor(), (0, 5));
5265 }
5266
5267 #[test]
5268 fn mouse_click_doc_exits_visual_mode() {
5269 use crate::VimMode;
5270 let mut e = Editor::new(
5271 hjkl_buffer::Buffer::new(),
5272 crate::types::DefaultHost::new(),
5273 crate::types::Options::default(),
5274 );
5275 e.set_content("hello");
5276 e.enter_visual_char();
5277 assert_eq!(e.vim_mode(), VimMode::Visual);
5278 e.mouse_click_doc(0, 2);
5279 assert_eq!(e.vim_mode(), VimMode::Normal);
5280 assert_eq!(e.cursor(), (0, 2));
5281 }
5282
5283 #[test]
5284 fn set_cursor_doc_clamps_past_last_row() {
5285 let mut e = Editor::new(
5286 hjkl_buffer::Buffer::new(),
5287 crate::types::DefaultHost::new(),
5288 crate::types::Options::default(),
5289 );
5290 e.set_content("one\ntwo");
5291 // doc has 2 rows (0 and 1); row 99 clamps to 1.
5292 e.set_cursor_doc(99, 0);
5293 assert_eq!(e.cursor(), (1, 0));
5294 }
5295
5296 #[test]
5297 fn mouse_begin_drag_enters_visual_char() {
5298 use crate::VimMode;
5299 let mut e = Editor::new(
5300 hjkl_buffer::Buffer::new(),
5301 crate::types::DefaultHost::new(),
5302 crate::types::Options::default(),
5303 );
5304 e.set_content("hello");
5305 e.mouse_begin_drag();
5306 assert_eq!(e.vim_mode(), VimMode::Visual);
5307 }
5308
5309 #[test]
5310 fn mouse_extend_drag_doc_moves_cursor_leaving_visual_anchor() {
5311 use crate::VimMode;
5312 let mut e = Editor::new(
5313 hjkl_buffer::Buffer::new(),
5314 crate::types::DefaultHost::new(),
5315 crate::types::Options::default(),
5316 );
5317 e.set_content("hello world");
5318 e.mouse_begin_drag(); // anchor at (0,0)
5319 e.mouse_extend_drag_doc(0, 5);
5320 assert_eq!(e.vim_mode(), VimMode::Visual);
5321 assert_eq!(e.cursor(), (0, 5));
5322 }
5323
5324 // ── Patch B (0.0.29): Host trait wired into Editor ──
5325
5326 #[test]
5327 fn host_clipboard_round_trip_via_default_host() {
5328 // DefaultHost stores write_clipboard in-memory; read_clipboard
5329 // returns the most recent payload.
5330 let mut e = Editor::new(
5331 hjkl_buffer::Buffer::new(),
5332 crate::types::DefaultHost::new(),
5333 crate::types::Options::default(),
5334 );
5335 e.host_mut().write_clipboard("payload".to_string());
5336 assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("payload"));
5337 }
5338
5339 // ── ContentEdit emission ─────────────────────────────────────────
5340
5341 fn fresh_editor(initial: &str) -> Editor {
5342 let buffer = hjkl_buffer::Buffer::from_str(initial);
5343 Editor::new(
5344 buffer,
5345 crate::types::DefaultHost::new(),
5346 crate::types::Options::default(),
5347 )
5348 }
5349
5350 #[test]
5351 fn content_edit_insert_char_at_origin() {
5352 let mut e = fresh_editor("");
5353 let _ = e.mutate_edit(hjkl_buffer::Edit::InsertChar {
5354 at: hjkl_buffer::Position::new(0, 0),
5355 ch: 'a',
5356 });
5357 let edits = e.take_content_edits();
5358 assert_eq!(edits.len(), 1);
5359 let ce = &edits[0];
5360 assert_eq!(ce.start_byte, 0);
5361 assert_eq!(ce.old_end_byte, 0);
5362 assert_eq!(ce.new_end_byte, 1);
5363 assert_eq!(ce.start_position, (0, 0));
5364 assert_eq!(ce.old_end_position, (0, 0));
5365 assert_eq!(ce.new_end_position, (0, 1));
5366 }
5367
5368 #[test]
5369 fn content_edit_insert_str_multiline() {
5370 // Buffer "x\ny" — insert "ab\ncd" at end of row 0.
5371 let mut e = fresh_editor("x\ny");
5372 let _ = e.mutate_edit(hjkl_buffer::Edit::InsertStr {
5373 at: hjkl_buffer::Position::new(0, 1),
5374 text: "ab\ncd".into(),
5375 });
5376 let edits = e.take_content_edits();
5377 assert_eq!(edits.len(), 1);
5378 let ce = &edits[0];
5379 assert_eq!(ce.start_byte, 1);
5380 assert_eq!(ce.old_end_byte, 1);
5381 assert_eq!(ce.new_end_byte, 1 + 5);
5382 assert_eq!(ce.start_position, (0, 1));
5383 // Insertion contains one '\n', so row+1, col = bytes after last '\n' = 2.
5384 assert_eq!(ce.new_end_position, (1, 2));
5385 }
5386
5387 #[test]
5388 fn content_edit_delete_range_charwise() {
5389 // "abcdef" — delete chars 1..4 ("bcd").
5390 let mut e = fresh_editor("abcdef");
5391 let _ = e.mutate_edit(hjkl_buffer::Edit::DeleteRange {
5392 start: hjkl_buffer::Position::new(0, 1),
5393 end: hjkl_buffer::Position::new(0, 4),
5394 kind: hjkl_buffer::MotionKind::Char,
5395 });
5396 let edits = e.take_content_edits();
5397 assert_eq!(edits.len(), 1);
5398 let ce = &edits[0];
5399 assert_eq!(ce.start_byte, 1);
5400 assert_eq!(ce.old_end_byte, 4);
5401 assert_eq!(ce.new_end_byte, 1);
5402 assert!(ce.old_end_byte > ce.new_end_byte);
5403 }
5404
5405 #[test]
5406 fn content_edit_set_content_resets() {
5407 let mut e = fresh_editor("foo");
5408 let _ = e.mutate_edit(hjkl_buffer::Edit::InsertChar {
5409 at: hjkl_buffer::Position::new(0, 0),
5410 ch: 'X',
5411 });
5412 // set_content should clear queued edits and raise the reset
5413 // flag on the next take_content_reset.
5414 e.set_content("brand new");
5415 assert!(e.take_content_reset());
5416 // Subsequent call clears the flag.
5417 assert!(!e.take_content_reset());
5418 // Edits cleared on reset.
5419 assert!(e.take_content_edits().is_empty());
5420 }
5421
5422 #[test]
5423 fn content_edit_multiple_replaces_in_order() {
5424 // Three Replace edits applied left-to-right (mimics the
5425 // substitute path's per-match Replace fan-out). Verify each
5426 // mutation queues exactly one ContentEdit and they're drained
5427 // in source-order with structurally valid byte spans.
5428 let mut e = fresh_editor("xax xbx xcx");
5429 let _ = e.take_content_edits();
5430 let _ = e.take_content_reset();
5431 // Replace each "x" with "yy", left to right. After each replace,
5432 // the next match's char-col shifts by +1 (since "yy" is 1 char
5433 // longer than "x" but they're both ASCII so byte = char here).
5434 let positions = [(0usize, 0usize), (0, 4), (0, 8)];
5435 for (row, col) in positions {
5436 let _ = e.mutate_edit(hjkl_buffer::Edit::Replace {
5437 start: hjkl_buffer::Position::new(row, col),
5438 end: hjkl_buffer::Position::new(row, col + 1),
5439 with: "yy".into(),
5440 });
5441 }
5442 let edits = e.take_content_edits();
5443 assert_eq!(edits.len(), 3);
5444 for ce in &edits {
5445 assert!(ce.start_byte <= ce.old_end_byte);
5446 assert!(ce.start_byte <= ce.new_end_byte);
5447 }
5448 // Document order.
5449 for w in edits.windows(2) {
5450 assert!(w[0].start_byte <= w[1].start_byte);
5451 }
5452 }
5453
5454 #[test]
5455 fn replace_char_at_replaces_single_char_under_cursor() {
5456 // Matches vim's `rx` semantics: replace char under cursor.
5457 let mut e = fresh_editor("abc");
5458 e.jump_cursor(0, 1); // cursor on 'b'
5459 e.replace_char_at('X', 1);
5460 let got = e.content();
5461 let got = got.trim_end_matches('\n');
5462 assert_eq!(
5463 got, "aXc",
5464 "replace_char_at(X, 1) must replace 'b' with 'X'"
5465 );
5466 // Cursor stays on the replaced char.
5467 assert_eq!(e.cursor(), (0, 1));
5468 }
5469
5470 #[test]
5471 fn replace_char_at_count_replaces_multiple_chars() {
5472 // `3rx` in vim replaces 3 chars starting at cursor.
5473 let mut e = fresh_editor("abcde");
5474 e.jump_cursor(0, 0);
5475 e.replace_char_at('Z', 3);
5476 let got = e.content();
5477 let got = got.trim_end_matches('\n');
5478 assert_eq!(
5479 got, "ZZZde",
5480 "replace_char_at(Z, 3) must replace first 3 chars"
5481 );
5482 }
5483
5484 #[test]
5485 fn find_char_method_moves_to_target() {
5486 // buffer "abcabc", cursor (0,0), f<c> → cursor (0,2).
5487 let mut e = fresh_editor("abcabc");
5488 e.jump_cursor(0, 0);
5489 e.find_char('c', true, false, 1);
5490 assert_eq!(
5491 e.cursor(),
5492 (0, 2),
5493 "find_char('c', forward=true, till=false, count=1) must land on 'c' at col 2"
5494 );
5495 }
5496
5497 // ── after_g unit tests (Phase 2b-ii) ────────────────────────────────────
5498
5499 #[test]
5500 fn after_g_gg_jumps_to_top() {
5501 let content: String = (0..20).map(|i| format!("line {i}\n")).collect();
5502 let mut e = fresh_editor(&content);
5503 e.jump_cursor(15, 0);
5504 e.after_g('g', 1);
5505 assert_eq!(e.cursor().0, 0, "gg must move cursor to row 0");
5506 }
5507
5508 #[test]
5509 fn after_g_gg_with_count_jumps_line() {
5510 // 5gg → row 4 (0-indexed).
5511 let content: String = (0..20).map(|i| format!("line {i}\n")).collect();
5512 let mut e = fresh_editor(&content);
5513 e.jump_cursor(0, 0);
5514 e.after_g('g', 5);
5515 assert_eq!(e.cursor().0, 4, "5gg must land on row 4");
5516 }
5517
5518 #[test]
5519 fn after_g_gj_moves_down() {
5520 let mut e = fresh_editor("line0\nline1\nline2\n");
5521 e.jump_cursor(0, 0);
5522 e.after_g('j', 1);
5523 assert_eq!(e.cursor().0, 1, "gj must move down one display row");
5524 }
5525
5526 #[test]
5527 fn after_g_gu_sets_operator_pending() {
5528 // gU enters operator-pending with Uppercase op; next key applies it.
5529 let mut e = fresh_editor("hello\n");
5530 e.after_g('U', 1);
5531 // The engine should now be chord-pending (Pending::Op set).
5532 assert!(
5533 e.is_chord_pending(),
5534 "gU must set engine chord-pending (Pending::Op)"
5535 );
5536 }
5537
5538 #[test]
5539 fn after_g_g_star_searches_forward_non_whole_word() {
5540 // g* on word "foo" in "foobar" should find the match.
5541 let mut e = fresh_editor("foo foobar\n");
5542 e.jump_cursor(0, 0); // cursor on 'f' of "foo"
5543 e.after_g('*', 1);
5544 // After g* the cursor should have moved (ScreenDown motion is
5545 // not applicable here; WordAtCursor forward moves to next match).
5546 // At minimum: no panic and mode stays Normal.
5547 assert_eq!(e.vim_mode(), VimMode::Normal, "g* must stay in Normal mode");
5548 }
5549
5550 // ── apply_motion controller tests (Phase 3a) ────────────────────────────
5551
5552 #[test]
5553 fn apply_motion_char_left_moves_cursor() {
5554 let mut e = fresh_editor("hello\n");
5555 e.jump_cursor(0, 3);
5556 e.apply_motion(crate::MotionKind::CharLeft, 1);
5557 assert_eq!(e.cursor(), (0, 2), "CharLeft moves one col left");
5558 }
5559
5560 #[test]
5561 fn apply_motion_char_left_clamps_at_col_zero() {
5562 let mut e = fresh_editor("hello\n");
5563 e.jump_cursor(0, 0);
5564 e.apply_motion(crate::MotionKind::CharLeft, 1);
5565 assert_eq!(e.cursor(), (0, 0), "CharLeft at col 0 must not wrap");
5566 }
5567
5568 #[test]
5569 fn apply_motion_char_left_with_count() {
5570 let mut e = fresh_editor("hello\n");
5571 e.jump_cursor(0, 4);
5572 e.apply_motion(crate::MotionKind::CharLeft, 3);
5573 assert_eq!(e.cursor(), (0, 1), "CharLeft count=3 moves three cols left");
5574 }
5575
5576 #[test]
5577 fn apply_motion_char_right_moves_cursor() {
5578 let mut e = fresh_editor("hello\n");
5579 e.jump_cursor(0, 0);
5580 e.apply_motion(crate::MotionKind::CharRight, 1);
5581 assert_eq!(e.cursor(), (0, 1), "CharRight moves one col right");
5582 }
5583
5584 #[test]
5585 fn apply_motion_char_right_clamps_at_last_char() {
5586 let mut e = fresh_editor("hello\n");
5587 // "hello" has chars at 0..=4; normal mode clamps at 4.
5588 e.jump_cursor(0, 4);
5589 e.apply_motion(crate::MotionKind::CharRight, 1);
5590 assert_eq!(
5591 e.cursor(),
5592 (0, 4),
5593 "CharRight at end must not go past last char"
5594 );
5595 }
5596
5597 #[test]
5598 fn apply_motion_line_down_moves_cursor() {
5599 let mut e = fresh_editor("line0\nline1\nline2\n");
5600 e.jump_cursor(0, 0);
5601 e.apply_motion(crate::MotionKind::LineDown, 1);
5602 assert_eq!(e.cursor().0, 1, "LineDown moves one row down");
5603 }
5604
5605 #[test]
5606 fn apply_motion_line_down_with_count() {
5607 let mut e = fresh_editor("line0\nline1\nline2\n");
5608 e.jump_cursor(0, 0);
5609 e.apply_motion(crate::MotionKind::LineDown, 2);
5610 assert_eq!(e.cursor().0, 2, "LineDown count=2 moves two rows down");
5611 }
5612
5613 #[test]
5614 fn apply_motion_line_up_moves_cursor() {
5615 let mut e = fresh_editor("line0\nline1\nline2\n");
5616 e.jump_cursor(2, 0);
5617 e.apply_motion(crate::MotionKind::LineUp, 1);
5618 assert_eq!(e.cursor().0, 1, "LineUp moves one row up");
5619 }
5620
5621 #[test]
5622 fn apply_motion_line_up_clamps_at_top() {
5623 let mut e = fresh_editor("line0\nline1\n");
5624 e.jump_cursor(0, 0);
5625 e.apply_motion(crate::MotionKind::LineUp, 1);
5626 assert_eq!(e.cursor().0, 0, "LineUp at top must not go negative");
5627 }
5628
5629 #[test]
5630 fn apply_motion_first_non_blank_down_moves_and_lands_on_non_blank() {
5631 // Line 0: " hello" (indent 2), line 1: " world" (indent 2).
5632 let mut e = fresh_editor(" hello\n world\n");
5633 e.jump_cursor(0, 0);
5634 e.apply_motion(crate::MotionKind::FirstNonBlankDown, 1);
5635 assert_eq!(e.cursor().0, 1, "FirstNonBlankDown must move to next row");
5636 assert_eq!(
5637 e.cursor().1,
5638 2,
5639 "FirstNonBlankDown must land on first non-blank col"
5640 );
5641 }
5642
5643 #[test]
5644 fn apply_motion_first_non_blank_up_moves_and_lands_on_non_blank() {
5645 let mut e = fresh_editor(" hello\n world\n");
5646 e.jump_cursor(1, 4);
5647 e.apply_motion(crate::MotionKind::FirstNonBlankUp, 1);
5648 assert_eq!(e.cursor().0, 0, "FirstNonBlankUp must move to prev row");
5649 assert_eq!(
5650 e.cursor().1,
5651 2,
5652 "FirstNonBlankUp must land on first non-blank col"
5653 );
5654 }
5655
5656 #[test]
5657 fn apply_motion_count_zero_treated_as_one() {
5658 // count=0 must be normalised to 1 (count.max(1) in apply_motion_kind).
5659 let mut e = fresh_editor("hello\n");
5660 e.jump_cursor(0, 3);
5661 e.apply_motion(crate::MotionKind::CharLeft, 0);
5662 assert_eq!(e.cursor(), (0, 2), "count=0 treated as 1 for CharLeft");
5663 }
5664
5665 // ── apply_motion controller tests (Phase 3b) — word motions ─────────────
5666
5667 #[test]
5668 fn apply_motion_word_forward_moves_to_next_word() {
5669 // "hello world\n": 'w' from col 0 lands on 'w' of "world" at col 6.
5670 let mut e = fresh_editor("hello world\n");
5671 e.jump_cursor(0, 0);
5672 e.apply_motion(crate::MotionKind::WordForward, 1);
5673 assert_eq!(
5674 e.cursor(),
5675 (0, 6),
5676 "WordForward moves to start of next word"
5677 );
5678 }
5679
5680 #[test]
5681 fn apply_motion_word_forward_with_count() {
5682 // "one two three\n": 2w from col 0 → start of "three" at col 8.
5683 let mut e = fresh_editor("one two three\n");
5684 e.jump_cursor(0, 0);
5685 e.apply_motion(crate::MotionKind::WordForward, 2);
5686 assert_eq!(e.cursor(), (0, 8), "WordForward count=2 skips two words");
5687 }
5688
5689 #[test]
5690 fn apply_motion_big_word_forward_moves_to_next_big_word() {
5691 // "foo.bar baz\n": W from col 0 skips entire "foo.bar" (one WORD) to 'b' at col 8.
5692 let mut e = fresh_editor("foo.bar baz\n");
5693 e.jump_cursor(0, 0);
5694 e.apply_motion(crate::MotionKind::BigWordForward, 1);
5695 assert_eq!(e.cursor(), (0, 8), "BigWordForward skips the whole WORD");
5696 }
5697
5698 #[test]
5699 fn apply_motion_big_word_forward_with_count() {
5700 // "aa bb cc\n": 2W from col 0 → start of "cc" at col 6.
5701 let mut e = fresh_editor("aa bb cc\n");
5702 e.jump_cursor(0, 0);
5703 e.apply_motion(crate::MotionKind::BigWordForward, 2);
5704 assert_eq!(e.cursor(), (0, 6), "BigWordForward count=2 skips two WORDs");
5705 }
5706
5707 #[test]
5708 fn apply_motion_word_backward_moves_to_prev_word() {
5709 // "hello world\n": 'b' from col 6 ('w') lands back at col 0 ('h').
5710 let mut e = fresh_editor("hello world\n");
5711 e.jump_cursor(0, 6);
5712 e.apply_motion(crate::MotionKind::WordBackward, 1);
5713 assert_eq!(
5714 e.cursor(),
5715 (0, 0),
5716 "WordBackward moves to start of prev word"
5717 );
5718 }
5719
5720 #[test]
5721 fn apply_motion_word_backward_with_count() {
5722 // "one two three\n": 2b from col 8 ('t' of "three") → col 0 ('o' of "one").
5723 let mut e = fresh_editor("one two three\n");
5724 e.jump_cursor(0, 8);
5725 e.apply_motion(crate::MotionKind::WordBackward, 2);
5726 assert_eq!(
5727 e.cursor(),
5728 (0, 0),
5729 "WordBackward count=2 skips two words back"
5730 );
5731 }
5732
5733 #[test]
5734 fn apply_motion_big_word_backward_moves_to_prev_big_word() {
5735 // "foo.bar baz\n": B from col 8 ('b' of "baz") → col 0 (start of "foo.bar" WORD).
5736 let mut e = fresh_editor("foo.bar baz\n");
5737 e.jump_cursor(0, 8);
5738 e.apply_motion(crate::MotionKind::BigWordBackward, 1);
5739 assert_eq!(
5740 e.cursor(),
5741 (0, 0),
5742 "BigWordBackward jumps to start of prev WORD"
5743 );
5744 }
5745
5746 #[test]
5747 fn apply_motion_big_word_backward_with_count() {
5748 // "aa bb cc\n": 2B from col 6 ('c') → col 0 ('a').
5749 let mut e = fresh_editor("aa bb cc\n");
5750 e.jump_cursor(0, 6);
5751 e.apply_motion(crate::MotionKind::BigWordBackward, 2);
5752 assert_eq!(
5753 e.cursor(),
5754 (0, 0),
5755 "BigWordBackward count=2 skips two WORDs back"
5756 );
5757 }
5758
5759 #[test]
5760 fn apply_motion_word_end_moves_to_end_of_word() {
5761 // "hello world\n": 'e' from col 0 lands on 'o' of "hello" at col 4.
5762 let mut e = fresh_editor("hello world\n");
5763 e.jump_cursor(0, 0);
5764 e.apply_motion(crate::MotionKind::WordEnd, 1);
5765 assert_eq!(e.cursor(), (0, 4), "WordEnd moves to end of current word");
5766 }
5767
5768 #[test]
5769 fn apply_motion_word_end_with_count() {
5770 // "one two three\n": 2e from col 0 → end of "two" at col 6.
5771 let mut e = fresh_editor("one two three\n");
5772 e.jump_cursor(0, 0);
5773 e.apply_motion(crate::MotionKind::WordEnd, 2);
5774 assert_eq!(
5775 e.cursor(),
5776 (0, 6),
5777 "WordEnd count=2 lands on end of second word"
5778 );
5779 }
5780
5781 #[test]
5782 fn apply_motion_big_word_end_moves_to_end_of_big_word() {
5783 // "foo.bar baz\n": E from col 0 → end of "foo.bar" WORD at col 6.
5784 let mut e = fresh_editor("foo.bar baz\n");
5785 e.jump_cursor(0, 0);
5786 e.apply_motion(crate::MotionKind::BigWordEnd, 1);
5787 assert_eq!(e.cursor(), (0, 6), "BigWordEnd lands on end of WORD");
5788 }
5789
5790 #[test]
5791 fn apply_motion_big_word_end_with_count() {
5792 // "aa bb cc\n": 2E from col 0 → end of "bb" at col 4.
5793 let mut e = fresh_editor("aa bb cc\n");
5794 e.jump_cursor(0, 0);
5795 e.apply_motion(crate::MotionKind::BigWordEnd, 2);
5796 assert_eq!(
5797 e.cursor(),
5798 (0, 4),
5799 "BigWordEnd count=2 lands on end of second WORD"
5800 );
5801 }
5802
5803 // ── apply_motion controller tests (Phase 3c) — line-anchor motions ────────
5804
5805 #[test]
5806 fn apply_motion_line_start_lands_at_col_zero() {
5807 // " foo bar \n": `0` from col 5 → col 0 unconditionally.
5808 let mut e = fresh_editor(" foo bar \n");
5809 e.jump_cursor(0, 5);
5810 e.apply_motion(crate::MotionKind::LineStart, 1);
5811 assert_eq!(e.cursor(), (0, 0), "LineStart lands at col 0");
5812 }
5813
5814 #[test]
5815 fn apply_motion_line_start_from_beginning_stays_at_col_zero() {
5816 // Already at col 0 — motion is a no-op but must not panic.
5817 let mut e = fresh_editor(" foo bar \n");
5818 e.jump_cursor(0, 0);
5819 e.apply_motion(crate::MotionKind::LineStart, 1);
5820 assert_eq!(e.cursor(), (0, 0), "LineStart from col 0 stays at col 0");
5821 }
5822
5823 #[test]
5824 fn apply_motion_first_non_blank_lands_on_first_non_blank() {
5825 // " foo bar \n": `^` from col 0 → col 2 ('f').
5826 let mut e = fresh_editor(" foo bar \n");
5827 e.jump_cursor(0, 0);
5828 e.apply_motion(crate::MotionKind::FirstNonBlank, 1);
5829 assert_eq!(
5830 e.cursor(),
5831 (0, 2),
5832 "FirstNonBlank lands on first non-blank char"
5833 );
5834 }
5835
5836 #[test]
5837 fn apply_motion_first_non_blank_on_blank_line_lands_at_zero() {
5838 // " \n": all whitespace — `^` must land at col 0.
5839 let mut e = fresh_editor(" \n");
5840 e.jump_cursor(0, 2);
5841 e.apply_motion(crate::MotionKind::FirstNonBlank, 1);
5842 assert_eq!(
5843 e.cursor(),
5844 (0, 0),
5845 "FirstNonBlank on blank line stays at col 0"
5846 );
5847 }
5848
5849 #[test]
5850 fn apply_motion_line_end_lands_on_last_char() {
5851 // " foo bar \n": last char is the second space at col 10.
5852 let mut e = fresh_editor(" foo bar \n");
5853 e.jump_cursor(0, 0);
5854 e.apply_motion(crate::MotionKind::LineEnd, 1);
5855 assert_eq!(e.cursor(), (0, 10), "LineEnd lands on last char of line");
5856 }
5857
5858 #[test]
5859 fn apply_motion_line_end_on_empty_line_stays_at_zero() {
5860 // "\n": empty line — `$` must stay at col 0.
5861 let mut e = fresh_editor("\n");
5862 e.jump_cursor(0, 0);
5863 e.apply_motion(crate::MotionKind::LineEnd, 1);
5864 assert_eq!(e.cursor(), (0, 0), "LineEnd on empty line stays at col 0");
5865 }
5866
5867 // ── apply_motion controller tests (Phase 3d) — doc-level motion ───────────
5868
5869 #[test]
5870 fn goto_line_count_1_lands_on_last_line() {
5871 // "foo\nbar\nbaz\n": bare `G` (count=1) → last content line (row 2).
5872 // Count convention: apply_motion_kind normalises 1 → execute_motion
5873 // with count=1 → FileBottom arm sees count <= 1 → move_bottom(0) =
5874 // last content row.
5875 let mut e = fresh_editor("foo\nbar\nbaz\n");
5876 e.jump_cursor(0, 0);
5877 e.apply_motion(crate::MotionKind::GotoLine, 1);
5878 assert_eq!(e.cursor(), (2, 0), "bare G lands on last content row");
5879 }
5880
5881 #[test]
5882 fn goto_line_count_5_lands_on_line_5() {
5883 // 6-line buffer (rows 0-5); `5G` → row 4 (1-based line 5).
5884 let mut e = fresh_editor("a\nb\nc\nd\ne\nf\n");
5885 e.jump_cursor(0, 0);
5886 e.apply_motion(crate::MotionKind::GotoLine, 5);
5887 assert_eq!(e.cursor(), (4, 0), "5G lands on row 4 (1-based line 5)");
5888 }
5889
5890 #[test]
5891 fn goto_line_count_past_buffer_clamps_to_last_line() {
5892 // "foo\nbar\nbaz\n": `100G` → last content line (row 2), clamped.
5893 let mut e = fresh_editor("foo\nbar\nbaz\n");
5894 e.jump_cursor(0, 0);
5895 e.apply_motion(crate::MotionKind::GotoLine, 100);
5896 assert_eq!(e.cursor(), (2, 0), "100G clamps to last content row");
5897 }
5898
5899 // ── FindRepeat / FindRepeatReverse controller tests (Phase 3e) ────────────
5900
5901 #[test]
5902 fn find_repeat_after_f_finds_next_occurrence() {
5903 // "abcabc", cursor at (0,0). `fc` lands on (0,2). `;` repeats → (0,5).
5904 let mut e = fresh_editor("abcabc");
5905 e.jump_cursor(0, 0);
5906 e.find_char('c', true, false, 1);
5907 assert_eq!(e.cursor(), (0, 2), "fc must land on first 'c'");
5908 e.apply_motion(crate::MotionKind::FindRepeat, 1);
5909 assert_eq!(
5910 e.cursor(),
5911 (0, 5),
5912 "find_repeat (;) must advance to second 'c'"
5913 );
5914 }
5915
5916 #[test]
5917 fn find_repeat_reverse_after_f_finds_prev_occurrence() {
5918 // "abcabc", cursor at (0,0). `fc` lands on (0,2). `;` → (0,5). `,` back → (0,2).
5919 let mut e = fresh_editor("abcabc");
5920 e.jump_cursor(0, 0);
5921 e.find_char('c', true, false, 1);
5922 assert_eq!(e.cursor(), (0, 2), "fc must land on first 'c'");
5923 e.apply_motion(crate::MotionKind::FindRepeat, 1);
5924 assert_eq!(e.cursor(), (0, 5), "; must advance to second 'c'");
5925 e.apply_motion(crate::MotionKind::FindRepeatReverse, 1);
5926 assert_eq!(
5927 e.cursor(),
5928 (0, 2),
5929 "find_repeat_reverse (,) must go back to first 'c'"
5930 );
5931 }
5932
5933 #[test]
5934 fn find_repeat_with_no_prior_find_is_noop() {
5935 // Fresh editor, no prior find — `;` must not move cursor.
5936 let mut e = fresh_editor("abcabc");
5937 e.jump_cursor(0, 3);
5938 e.apply_motion(crate::MotionKind::FindRepeat, 1);
5939 assert_eq!(
5940 e.cursor(),
5941 (0, 3),
5942 "find_repeat with no prior find must be a no-op"
5943 );
5944 }
5945
5946 #[test]
5947 fn find_repeat_with_count_advances_count_times() {
5948 // "aXaXaX", cursor (0,0). `fX` → (0,1). `3;` → repeats 3× → (0,5).
5949 let mut e = fresh_editor("aXaXaX");
5950 e.jump_cursor(0, 0);
5951 e.find_char('X', true, false, 1);
5952 assert_eq!(e.cursor(), (0, 1), "fX must land on first 'X' at col 1");
5953 e.apply_motion(crate::MotionKind::FindRepeat, 3);
5954 assert_eq!(
5955 e.cursor(),
5956 (0, 5),
5957 "3; must advance 3 times from col 1 to col 5"
5958 );
5959 }
5960
5961 // ── BracketMatch controller tests (Phase 3f) ───────────────────────────────
5962
5963 #[test]
5964 fn bracket_match_jumps_to_matching_close_paren() {
5965 // "(abc)", cursor at (0,0) on `(` — `%` must jump to `)` at (0,4).
5966 let mut e = fresh_editor("(abc)");
5967 e.jump_cursor(0, 0);
5968 e.apply_motion(crate::MotionKind::BracketMatch, 1);
5969 assert_eq!(
5970 e.cursor(),
5971 (0, 4),
5972 "% on '(' must land on matching ')' at col 4"
5973 );
5974 }
5975
5976 #[test]
5977 fn bracket_match_jumps_to_matching_open_paren() {
5978 // "(abc)", cursor at (0,4) on `)` — `%` must jump back to `(` at (0,0).
5979 let mut e = fresh_editor("(abc)");
5980 e.jump_cursor(0, 4);
5981 e.apply_motion(crate::MotionKind::BracketMatch, 1);
5982 assert_eq!(
5983 e.cursor(),
5984 (0, 0),
5985 "% on ')' must land on matching '(' at col 0"
5986 );
5987 }
5988
5989 #[test]
5990 fn bracket_match_with_no_match_on_line_is_noop_or_engine_behaviour() {
5991 // "abcd", cursor at (0,2) — no bracket under cursor; engine returns
5992 // false from matching_bracket, cursor must not move.
5993 let mut e = fresh_editor("abcd");
5994 e.jump_cursor(0, 2);
5995 e.apply_motion(crate::MotionKind::BracketMatch, 1);
5996 assert_eq!(
5997 e.cursor(),
5998 (0, 2),
5999 "% with no bracket under cursor must be a no-op"
6000 );
6001 }
6002
6003 // ── Scroll / viewport motion controller tests (Phase 3g) ──────────────────
6004
6005 /// Helper: build a 20-line buffer, set viewport to rows [5..14] (height=10).
6006 fn fresh_viewport_editor() -> Editor {
6007 let content = many_lines(20);
6008 let mut e = Editor::new(
6009 hjkl_buffer::Buffer::from_str(&content),
6010 crate::types::DefaultHost::new(),
6011 crate::types::Options::default(),
6012 );
6013 // height=10, top_row=5 → visible rows 5..14.
6014 // set_viewport_height stores to the atomic; sync_buffer_from_textarea
6015 // propagates it to host.viewport_mut().height so motion helpers see it.
6016 e.set_viewport_height(10);
6017 e.sync_buffer_from_textarea();
6018 e.host_mut().viewport_mut().top_row = 5;
6019 e
6020 }
6021
6022 #[test]
6023 fn viewport_top_lands_on_first_visible_row() {
6024 // Viewport top=5, height=10. H (count=1) should land on row 5
6025 // (the first visible row, offset = count-1 = 0).
6026 let mut e = fresh_viewport_editor();
6027 e.jump_cursor(10, 0);
6028 e.apply_motion(crate::MotionKind::ViewportTop, 1);
6029 assert_eq!(
6030 e.cursor().0,
6031 5,
6032 "H (count=1) must land on viewport top row (5)"
6033 );
6034 }
6035
6036 #[test]
6037 fn viewport_top_with_count_offsets_down() {
6038 // H with count=3 → viewport top + (3-1) = 5 + 2 = row 7.
6039 let mut e = fresh_viewport_editor();
6040 e.jump_cursor(12, 0);
6041 e.apply_motion(crate::MotionKind::ViewportTop, 3);
6042 assert_eq!(e.cursor().0, 7, "3H must land at viewport top + 2 = row 7");
6043 }
6044
6045 #[test]
6046 fn viewport_middle_lands_on_middle_visible_row() {
6047 // Viewport top=5, height=10 → last visible = 14, mid = 5 + (14-5)/2 = 9.
6048 let mut e = fresh_viewport_editor();
6049 e.jump_cursor(0, 0);
6050 e.apply_motion(crate::MotionKind::ViewportMiddle, 1);
6051 assert_eq!(e.cursor().0, 9, "M must land on middle visible row (9)");
6052 }
6053
6054 #[test]
6055 fn viewport_bottom_lands_on_last_visible_row() {
6056 // L (count=1) → viewport bottom, offset = count-1 = 0 → row 14.
6057 let mut e = fresh_viewport_editor();
6058 e.jump_cursor(5, 0);
6059 e.apply_motion(crate::MotionKind::ViewportBottom, 1);
6060 assert_eq!(
6061 e.cursor().0,
6062 14,
6063 "L (count=1) must land on viewport bottom row (14)"
6064 );
6065 }
6066
6067 #[test]
6068 fn half_page_down_moves_cursor_by_half_window() {
6069 // viewport height=10, so half=5. Cursor at row 0 → row 5 after C-d.
6070 let mut e = Editor::new(
6071 hjkl_buffer::Buffer::from_str(&many_lines(30)),
6072 crate::types::DefaultHost::new(),
6073 crate::types::Options::default(),
6074 );
6075 e.set_viewport_height(10);
6076 e.jump_cursor(0, 0);
6077 e.apply_motion(crate::MotionKind::HalfPageDown, 1);
6078 assert_eq!(
6079 e.cursor().0,
6080 5,
6081 "<C-d> from row 0 with viewport height=10 must land on row 5"
6082 );
6083 }
6084
6085 #[test]
6086 fn half_page_up_moves_cursor_by_half_window_reverse() {
6087 // viewport height=10, half=5. Cursor at row 10 → row 5 after C-u.
6088 let mut e = Editor::new(
6089 hjkl_buffer::Buffer::from_str(&many_lines(30)),
6090 crate::types::DefaultHost::new(),
6091 crate::types::Options::default(),
6092 );
6093 e.set_viewport_height(10);
6094 e.jump_cursor(10, 0);
6095 e.apply_motion(crate::MotionKind::HalfPageUp, 1);
6096 assert_eq!(
6097 e.cursor().0,
6098 5,
6099 "<C-u> from row 10 with viewport height=10 must land on row 5"
6100 );
6101 }
6102
6103 #[test]
6104 fn full_page_down_moves_cursor_by_full_window() {
6105 // viewport height=10, full = 10 - 2 = 8. Cursor at row 0 → row 8.
6106 let mut e = Editor::new(
6107 hjkl_buffer::Buffer::from_str(&many_lines(30)),
6108 crate::types::DefaultHost::new(),
6109 crate::types::Options::default(),
6110 );
6111 e.set_viewport_height(10);
6112 e.jump_cursor(0, 0);
6113 e.apply_motion(crate::MotionKind::FullPageDown, 1);
6114 assert_eq!(
6115 e.cursor().0,
6116 8,
6117 "<C-f> from row 0 with viewport height=10 must land on row 8"
6118 );
6119 }
6120
6121 #[test]
6122 fn full_page_up_moves_cursor_by_full_window_reverse() {
6123 // viewport height=10, full=8. Cursor at row 10 → row 2.
6124 let mut e = Editor::new(
6125 hjkl_buffer::Buffer::from_str(&many_lines(30)),
6126 crate::types::DefaultHost::new(),
6127 crate::types::Options::default(),
6128 );
6129 e.set_viewport_height(10);
6130 e.jump_cursor(10, 0);
6131 e.apply_motion(crate::MotionKind::FullPageUp, 1);
6132 assert_eq!(
6133 e.cursor().0,
6134 2,
6135 "<C-b> from row 10 with viewport height=10 must land on row 2"
6136 );
6137 }
6138
6139 // ── set_mark_at_cursor unit tests ─────────────────────────────────────────
6140
6141 #[test]
6142 fn set_mark_at_cursor_alphabetic_records() {
6143 // `ma` at (0, 2) — mark 'a' must store (0, 2).
6144 let mut e = fresh_editor("hello");
6145 e.jump_cursor(0, 2);
6146 e.set_mark_at_cursor('a');
6147 assert_eq!(
6148 e.mark('a'),
6149 Some((0, 2)),
6150 "mark 'a' must record current pos"
6151 );
6152 }
6153
6154 #[test]
6155 fn set_mark_at_cursor_invalid_char_no_op() {
6156 // Invalid chars (digits, special) must not store a mark.
6157 let mut e = fresh_editor("hello");
6158 e.jump_cursor(0, 1);
6159 e.set_mark_at_cursor('1'); // digit — not alphanumeric in vim mark sense
6160 assert_eq!(e.mark('1'), None, "digit mark must be a no-op");
6161 e.set_mark_at_cursor('['); // special — only goto uses '[', not set_mark
6162 assert_eq!(
6163 e.mark('['),
6164 None,
6165 "bracket char must be a no-op for set_mark"
6166 );
6167 }
6168
6169 #[test]
6170 fn set_mark_at_cursor_special_left_bracket() {
6171 // Confirm '[' is NOT stored by set_mark_at_cursor (vim's `m[` is invalid).
6172 // The `[` mark is only set automatically by operator paths, not `m[`.
6173 let mut e = fresh_editor("hello");
6174 e.jump_cursor(0, 3);
6175 e.set_mark_at_cursor('[');
6176 assert_eq!(
6177 e.mark('['),
6178 None,
6179 "set_mark_at_cursor must reject '[' (vim: m[ is invalid)"
6180 );
6181 }
6182
6183 // ── goto_mark_line unit tests ─────────────────────────────────────────────
6184
6185 #[test]
6186 fn goto_mark_line_jumps_to_first_non_blank() {
6187 // Set mark 'a' at (1, 3), then jump back to (0, 0).
6188 // `'a` (linewise) must land on row 1, first non-blank column.
6189 let mut e = fresh_editor("hello\n world\n");
6190 e.jump_cursor(1, 3);
6191 e.set_mark_at_cursor('a');
6192 e.jump_cursor(0, 0);
6193 e.goto_mark_line('a');
6194 assert_eq!(e.cursor().0, 1, "goto_mark_line must jump to mark row");
6195 // " world" — first non-blank is col 2.
6196 assert_eq!(
6197 e.cursor().1,
6198 2,
6199 "goto_mark_line must land on first non-blank column"
6200 );
6201 }
6202
6203 #[test]
6204 fn goto_mark_line_unset_mark_no_op() {
6205 // Jumping to an unset mark must not move the cursor.
6206 let mut e = fresh_editor("hello\nworld\n");
6207 e.jump_cursor(1, 2);
6208 e.goto_mark_line('z'); // 'z' not set
6209 assert_eq!(e.cursor(), (1, 2), "unset mark jump must be a no-op");
6210 }
6211
6212 #[test]
6213 fn goto_mark_line_invalid_char_no_op() {
6214 // '!' is not a valid mark char — must not move cursor.
6215 let mut e = fresh_editor("hello\nworld\n");
6216 e.jump_cursor(0, 0);
6217 e.goto_mark_line('!');
6218 assert_eq!(e.cursor(), (0, 0), "invalid mark char must be a no-op");
6219 }
6220
6221 // ── goto_mark_char unit tests ─────────────────────────────────────────────
6222
6223 #[test]
6224 fn goto_mark_char_jumps_to_exact_pos() {
6225 // Set mark 'b' at (1, 4), then jump back to (0, 0).
6226 // `` `b `` (charwise) must land on (1, 4) exactly.
6227 let mut e = fresh_editor("hello\nworld\n");
6228 e.jump_cursor(1, 4);
6229 e.set_mark_at_cursor('b');
6230 e.jump_cursor(0, 0);
6231 e.goto_mark_char('b');
6232 assert_eq!(
6233 e.cursor(),
6234 (1, 4),
6235 "goto_mark_char must jump to exact mark position"
6236 );
6237 }
6238
6239 #[test]
6240 fn goto_mark_char_unset_mark_no_op() {
6241 // Jumping to an unset mark must not move the cursor.
6242 let mut e = fresh_editor("hello\nworld\n");
6243 e.jump_cursor(1, 1);
6244 e.goto_mark_char('x'); // 'x' not set
6245 assert_eq!(
6246 e.cursor(),
6247 (1, 1),
6248 "unset charwise mark jump must be a no-op"
6249 );
6250 }
6251
6252 #[test]
6253 fn goto_mark_char_invalid_char_no_op() {
6254 // '#' is not a valid mark char — must not move cursor.
6255 let mut e = fresh_editor("hello\nworld\n");
6256 e.jump_cursor(0, 2);
6257 e.goto_mark_char('#');
6258 assert_eq!(
6259 e.cursor(),
6260 (0, 2),
6261 "invalid charwise mark char must be a no-op"
6262 );
6263 }
6264
6265 // ── Macro controller API tests (Phase 5b) ─────────────────────────────────
6266
6267 #[test]
6268 fn start_macro_record_records_register() {
6269 let mut e = fresh_editor("hello");
6270 assert!(!e.is_recording_macro());
6271 e.start_macro_record('a');
6272 assert!(e.is_recording_macro());
6273 assert_eq!(e.recording_register(), Some('a'));
6274 }
6275
6276 #[test]
6277 fn start_macro_record_capital_seeds_existing() {
6278 // `qa` records "h", stop. Then `qA` should seed from existing 'a' reg.
6279 let mut e = fresh_editor("hello");
6280 e.start_macro_record('a');
6281 e.record_input(crate::input::Input {
6282 key: crate::input::Key::Char('h'),
6283 ..Default::default()
6284 });
6285 e.stop_macro_record();
6286 // Start capital 'A' — should seed from existing 'a' register.
6287 e.start_macro_record('A');
6288 // recording_keys should now contain 1 input (the seeded 'h').
6289 assert_eq!(
6290 e.vim.recording_keys.len(),
6291 1,
6292 "capital record must seed from existing lowercase reg"
6293 );
6294 }
6295
6296 #[test]
6297 fn stop_macro_record_writes_register() {
6298 let mut e = fresh_editor("hello");
6299 e.start_macro_record('a');
6300 e.record_input(crate::input::Input {
6301 key: crate::input::Key::Char('h'),
6302 ..Default::default()
6303 });
6304 e.record_input(crate::input::Input {
6305 key: crate::input::Key::Char('l'),
6306 ..Default::default()
6307 });
6308 e.stop_macro_record();
6309 assert!(!e.is_recording_macro());
6310 // Register 'a' should contain "hl".
6311 let text = e
6312 .registers()
6313 .read('a')
6314 .map(|s| s.text.clone())
6315 .unwrap_or_default();
6316 assert_eq!(
6317 text, "hl",
6318 "stop_macro_record must write encoded keys to register"
6319 );
6320 }
6321
6322 #[test]
6323 fn is_recording_macro_reflects_state() {
6324 let mut e = fresh_editor("hello");
6325 assert!(!e.is_recording_macro());
6326 e.start_macro_record('b');
6327 assert!(e.is_recording_macro());
6328 e.stop_macro_record();
6329 assert!(!e.is_recording_macro());
6330 }
6331
6332 #[test]
6333 fn play_macro_returns_decoded_inputs() {
6334 let mut e = fresh_editor("hello");
6335 // Write "jj" into register 'a'.
6336 e.set_named_register_text('a', "jj".to_string());
6337 let inputs = e.play_macro('a', 1);
6338 assert_eq!(inputs.len(), 2);
6339 assert_eq!(inputs[0].key, crate::input::Key::Char('j'));
6340 assert_eq!(inputs[1].key, crate::input::Key::Char('j'));
6341 assert!(e.is_replaying_macro(), "play_macro must set replaying flag");
6342 e.end_macro_replay();
6343 assert!(!e.is_replaying_macro());
6344 }
6345
6346 #[test]
6347 fn play_macro_at_uses_last_macro() {
6348 let mut e = fresh_editor("hello");
6349 e.set_named_register_text('a', "k".to_string());
6350 // Play 'a' first to set last_macro.
6351 let _ = e.play_macro('a', 1);
6352 e.end_macro_replay();
6353 // Now `@@` should replay 'a' again.
6354 let inputs = e.play_macro('@', 1);
6355 assert_eq!(inputs.len(), 1);
6356 assert_eq!(inputs[0].key, crate::input::Key::Char('k'));
6357 e.end_macro_replay();
6358 }
6359
6360 #[test]
6361 fn play_macro_with_count_repeats() {
6362 let mut e = fresh_editor("hello");
6363 e.set_named_register_text('a', "j".to_string());
6364 let inputs = e.play_macro('a', 3);
6365 assert_eq!(inputs.len(), 3, "3@a must produce 3 inputs");
6366 e.end_macro_replay();
6367 }
6368
6369 #[test]
6370 fn record_input_appends_when_recording() {
6371 let mut e = fresh_editor("hello");
6372 // Not recording: record_input is a no-op.
6373 e.record_input(crate::input::Input {
6374 key: crate::input::Key::Char('j'),
6375 ..Default::default()
6376 });
6377 assert_eq!(e.vim.recording_keys.len(), 0);
6378 // Start recording: record_input appends.
6379 e.start_macro_record('a');
6380 e.record_input(crate::input::Input {
6381 key: crate::input::Key::Char('j'),
6382 ..Default::default()
6383 });
6384 e.record_input(crate::input::Input {
6385 key: crate::input::Key::Char('k'),
6386 ..Default::default()
6387 });
6388 assert_eq!(e.vim.recording_keys.len(), 2);
6389 // During replay: record_input must NOT append.
6390 e.vim.replaying_macro = true;
6391 e.record_input(crate::input::Input {
6392 key: crate::input::Key::Char('l'),
6393 ..Default::default()
6394 });
6395 assert_eq!(
6396 e.vim.recording_keys.len(),
6397 2,
6398 "record_input must skip during replay"
6399 );
6400 e.vim.replaying_macro = false;
6401 e.stop_macro_record();
6402 }
6403
6404 // ── Phase 6.1 insert-mode primitive tests (kryptic-sh/hjkl#87) ────────────
6405
6406 /// Helper: enter insert mode via the public bridge, then call the method under test.
6407 fn enter_insert(e: &mut Editor) {
6408 e.enter_insert_i(1);
6409 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6410 }
6411
6412 #[test]
6413 fn insert_char_basic() {
6414 let mut e = fresh_editor("hello");
6415 enter_insert(&mut e);
6416 e.insert_char('X');
6417 assert_eq!(e.buffer().lines()[0], "Xhello");
6418 assert!(e.take_dirty());
6419 }
6420
6421 #[test]
6422 fn insert_newline_splits_line() {
6423 let mut e = fresh_editor("hello");
6424 // Move to col 3 so we split "hel" | "lo".
6425 e.jump_cursor(0, 3);
6426 enter_insert(&mut e);
6427 e.insert_newline();
6428 let lines = e.buffer().lines().to_vec();
6429 assert_eq!(lines[0], "hel");
6430 assert_eq!(lines[1], "lo");
6431 }
6432
6433 #[test]
6434 fn insert_tab_expandtab_inserts_spaces() {
6435 let mut e = fresh_editor("");
6436 // Default options: expandtab=true, softtabstop=4, tabstop=4.
6437 enter_insert(&mut e);
6438 e.insert_tab();
6439 // At col 0 with sts=4: 4 spaces inserted.
6440 assert_eq!(e.buffer().lines()[0], " ");
6441 }
6442
6443 #[test]
6444 fn insert_tab_real_tab_when_noexpandtab() {
6445 let opts = crate::types::Options {
6446 expandtab: false,
6447 ..crate::types::Options::default()
6448 };
6449 let mut e = Editor::new(
6450 hjkl_buffer::Buffer::new(),
6451 crate::types::DefaultHost::new(),
6452 opts,
6453 );
6454 e.set_content("");
6455 enter_insert(&mut e);
6456 e.insert_tab();
6457 assert_eq!(e.buffer().lines()[0], "\t");
6458 }
6459
6460 #[test]
6461 fn insert_backspace_single_char() {
6462 // Cursor at col 3 in "hello", backspace deletes 'l'.
6463 let mut e = fresh_editor("hello");
6464 e.jump_cursor(0, 3);
6465 enter_insert(&mut e);
6466 e.insert_backspace();
6467 assert_eq!(e.buffer().lines()[0], "helo");
6468 }
6469
6470 #[test]
6471 fn insert_backspace_softtabstop() {
6472 // With sts=4, expandtab: 4 spaces at col 4 → one backspace deletes all 4.
6473 let mut e = fresh_editor(" hello");
6474 e.jump_cursor(0, 4);
6475 enter_insert(&mut e);
6476 e.insert_backspace();
6477 assert_eq!(e.buffer().lines()[0], "hello");
6478 }
6479
6480 #[test]
6481 fn insert_backspace_join_up() {
6482 // At col 0 on row 1, backspace joins with the previous line.
6483 let mut e = fresh_editor("foo\nbar");
6484 e.jump_cursor(1, 0);
6485 enter_insert(&mut e);
6486 e.insert_backspace();
6487 // Two rows merged into one.
6488 assert_eq!(e.buffer().lines().len(), 1);
6489 assert_eq!(e.buffer().lines()[0], "foobar");
6490 }
6491
6492 #[test]
6493 fn leave_insert_steps_back_col() {
6494 // Esc in insert mode should move the cursor one cell left (vim convention).
6495 let mut e = fresh_editor("hello");
6496 e.jump_cursor(0, 3);
6497 enter_insert(&mut e);
6498 // Type one char so cursor is at col 4, then call leave_insert_to_normal.
6499 e.insert_char('X');
6500 // cursor is now at col 4 (after the inserted 'X').
6501 let pre_col = e.cursor().1;
6502 e.leave_insert_to_normal();
6503 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
6504 // Cursor stepped back one.
6505 assert_eq!(e.cursor().1, pre_col - 1);
6506 }
6507
6508 #[test]
6509 fn insert_ctrl_w_word_back() {
6510 // Ctrl-W deletes from cursor back to word start.
6511 // "hello world" — cursor at end of "world" (col 11).
6512 let mut e = fresh_editor("hello world");
6513 // Normal mode clamps cursor to col 10 (last char); jump_cursor doesn't clamp.
6514 e.jump_cursor(0, 11);
6515 enter_insert(&mut e);
6516 e.insert_ctrl_w();
6517 // "world" (5 chars) deleted, leaving "hello ".
6518 assert_eq!(e.buffer().lines()[0], "hello ");
6519 }
6520
6521 #[test]
6522 fn insert_ctrl_u_deletes_to_line_start() {
6523 let mut e = fresh_editor("hello world");
6524 e.jump_cursor(0, 5);
6525 enter_insert(&mut e);
6526 e.insert_ctrl_u();
6527 assert_eq!(e.buffer().lines()[0], " world");
6528 }
6529
6530 #[test]
6531 fn insert_ctrl_h_single_backspace() {
6532 // Ctrl-H is an alias for Backspace in insert mode.
6533 let mut e = fresh_editor("hello");
6534 e.jump_cursor(0, 3);
6535 enter_insert(&mut e);
6536 e.insert_ctrl_h();
6537 assert_eq!(e.buffer().lines()[0], "helo");
6538 }
6539
6540 #[test]
6541 fn insert_ctrl_h_join_up() {
6542 let mut e = fresh_editor("foo\nbar");
6543 e.jump_cursor(1, 0);
6544 enter_insert(&mut e);
6545 e.insert_ctrl_h();
6546 assert_eq!(e.buffer().lines().len(), 1);
6547 assert_eq!(e.buffer().lines()[0], "foobar");
6548 }
6549
6550 #[test]
6551 fn insert_ctrl_t_indents_current_line() {
6552 let mut e = Editor::new(
6553 hjkl_buffer::Buffer::new(),
6554 crate::types::DefaultHost::new(),
6555 crate::types::Options {
6556 shiftwidth: 4,
6557 ..crate::types::Options::default()
6558 },
6559 );
6560 e.set_content("hello");
6561 enter_insert(&mut e);
6562 e.insert_ctrl_t();
6563 assert_eq!(e.buffer().lines()[0], " hello");
6564 }
6565
6566 #[test]
6567 fn insert_ctrl_d_outdents_current_line() {
6568 let mut e = Editor::new(
6569 hjkl_buffer::Buffer::new(),
6570 crate::types::DefaultHost::new(),
6571 crate::types::Options {
6572 shiftwidth: 4,
6573 ..crate::types::Options::default()
6574 },
6575 );
6576 e.set_content(" hello");
6577 enter_insert(&mut e);
6578 e.insert_ctrl_d();
6579 assert_eq!(e.buffer().lines()[0], "hello");
6580 }
6581
6582 #[test]
6583 fn insert_ctrl_o_arm_sets_one_shot_normal() {
6584 let mut e = fresh_editor("hello");
6585 enter_insert(&mut e);
6586 e.insert_ctrl_o_arm();
6587 // Mode should flip to Normal (one-shot).
6588 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
6589 }
6590
6591 #[test]
6592 fn insert_ctrl_r_arm_sets_pending_register() {
6593 let mut e = fresh_editor("hello");
6594 enter_insert(&mut e);
6595 e.insert_ctrl_r_arm();
6596 // pending register flag set; mode stays Insert.
6597 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6598 assert!(e.vim.insert_pending_register);
6599 }
6600
6601 #[test]
6602 fn insert_delete_removes_char_under_cursor() {
6603 let mut e = fresh_editor("hello");
6604 e.jump_cursor(0, 2);
6605 enter_insert(&mut e);
6606 e.insert_delete();
6607 assert_eq!(e.buffer().lines()[0], "helo");
6608 }
6609
6610 #[test]
6611 fn insert_delete_joins_lines_at_eol() {
6612 let mut e = fresh_editor("foo\nbar");
6613 // Position at end of row 0 (col 3 = past last char).
6614 e.jump_cursor(0, 3);
6615 enter_insert(&mut e);
6616 e.insert_delete();
6617 assert_eq!(e.buffer().lines().len(), 1);
6618 assert_eq!(e.buffer().lines()[0], "foobar");
6619 }
6620
6621 #[test]
6622 fn insert_arrow_left_moves_cursor() {
6623 let mut e = fresh_editor("hello");
6624 e.jump_cursor(0, 3);
6625 enter_insert(&mut e);
6626 e.insert_arrow(crate::vim::InsertDir::Left);
6627 assert_eq!(e.cursor().1, 2);
6628 }
6629
6630 #[test]
6631 fn insert_arrow_right_moves_cursor() {
6632 let mut e = fresh_editor("hello");
6633 e.jump_cursor(0, 2);
6634 enter_insert(&mut e);
6635 e.insert_arrow(crate::vim::InsertDir::Right);
6636 assert_eq!(e.cursor().1, 3);
6637 }
6638
6639 #[test]
6640 fn insert_arrow_up_moves_cursor() {
6641 let mut e = fresh_editor("foo\nbar");
6642 e.jump_cursor(1, 0);
6643 enter_insert(&mut e);
6644 e.insert_arrow(crate::vim::InsertDir::Up);
6645 assert_eq!(e.cursor().0, 0);
6646 }
6647
6648 #[test]
6649 fn insert_arrow_down_moves_cursor() {
6650 let mut e = fresh_editor("foo\nbar");
6651 e.jump_cursor(0, 0);
6652 enter_insert(&mut e);
6653 e.insert_arrow(crate::vim::InsertDir::Down);
6654 assert_eq!(e.cursor().0, 1);
6655 }
6656
6657 #[test]
6658 fn insert_home_moves_to_line_start() {
6659 let mut e = fresh_editor("hello");
6660 e.jump_cursor(0, 4);
6661 enter_insert(&mut e);
6662 e.insert_home();
6663 assert_eq!(e.cursor().1, 0);
6664 }
6665
6666 #[test]
6667 fn insert_end_moves_to_line_end() {
6668 let mut e = fresh_editor("hello");
6669 e.jump_cursor(0, 0);
6670 enter_insert(&mut e);
6671 e.insert_end();
6672 // move_line_end lands on the last char (col 4) for "hello".
6673 assert_eq!(e.cursor().1, 4);
6674 }
6675
6676 #[test]
6677 fn insert_pageup_does_not_panic() {
6678 let mut e = fresh_editor("line1\nline2\nline3");
6679 e.jump_cursor(2, 0);
6680 enter_insert(&mut e);
6681 // Viewport height 0 → no crash (viewport_h saturates to 1 row effectively).
6682 e.insert_pageup(24);
6683 }
6684
6685 #[test]
6686 fn insert_pagedown_does_not_panic() {
6687 let mut e = fresh_editor("line1\nline2\nline3");
6688 e.jump_cursor(0, 0);
6689 enter_insert(&mut e);
6690 e.insert_pagedown(24);
6691 }
6692
6693 #[test]
6694 fn leave_insert_to_normal_exits_mode() {
6695 let mut e = fresh_editor("hello");
6696 enter_insert(&mut e);
6697 e.leave_insert_to_normal();
6698 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
6699 }
6700
6701 #[test]
6702 fn insert_backspace_at_buffer_start_is_noop() {
6703 let mut e = fresh_editor("hello");
6704 e.jump_cursor(0, 0);
6705 enter_insert(&mut e);
6706 // No previous char and no previous row — should not panic.
6707 e.insert_backspace();
6708 assert_eq!(e.buffer().lines()[0], "hello");
6709 }
6710
6711 #[test]
6712 fn insert_delete_at_buffer_end_is_noop() {
6713 let mut e = fresh_editor("hello");
6714 // Cursor at col 5 (past last char index of 4), no next row.
6715 e.jump_cursor(0, 5);
6716 enter_insert(&mut e);
6717 // col 5 >= line_chars (5), no next row → no-op.
6718 e.insert_delete();
6719 assert_eq!(e.buffer().lines()[0], "hello");
6720 }
6721
6722 // ── Phase 6.2: normal-mode primitive tests (kryptic-sh/hjkl#88) ─────────
6723
6724 // Helper: set content and ensure we are in Normal mode.
6725 fn normal_editor(initial: &str) -> Editor {
6726 let e = fresh_editor(initial);
6727 // fresh_editor starts in Normal; this is just a readability alias.
6728 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
6729 e
6730 }
6731
6732 // ── Insert-mode entry ────────────────────────────────────────────────────
6733
6734 #[test]
6735 fn enter_insert_i_lands_in_insert_at_cursor() {
6736 let mut e = normal_editor("hello");
6737 e.jump_cursor(0, 2);
6738 e.enter_insert_i(1);
6739 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6740 assert_eq!(e.cursor(), (0, 2));
6741 }
6742
6743 #[test]
6744 fn enter_insert_shift_i_moves_to_first_non_blank_then_insert() {
6745 let mut e = normal_editor(" hello");
6746 e.jump_cursor(0, 5);
6747 e.enter_insert_shift_i(1);
6748 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6749 // First non-blank of " hello" is col 2.
6750 assert_eq!(e.cursor().1, 2);
6751 }
6752
6753 #[test]
6754 fn enter_insert_a_advances_one_then_insert() {
6755 let mut e = normal_editor("hello");
6756 e.jump_cursor(0, 0);
6757 e.enter_insert_a(1);
6758 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6759 assert_eq!(e.cursor().1, 1);
6760 }
6761
6762 #[test]
6763 fn enter_insert_shift_a_lands_at_eol() {
6764 let mut e = normal_editor("hello");
6765 e.enter_insert_shift_a(1);
6766 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6767 assert_eq!(e.cursor().1, 5);
6768 }
6769
6770 #[test]
6771 fn open_line_below_creates_new_line_and_insert() {
6772 let mut e = normal_editor("hello\nworld");
6773 e.open_line_below(1);
6774 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6775 assert_eq!(e.buffer().lines().len(), 3);
6776 }
6777
6778 #[test]
6779 fn open_line_above_creates_line_before_cursor() {
6780 let mut e = normal_editor("hello\nworld");
6781 e.jump_cursor(1, 0);
6782 e.open_line_above(1);
6783 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6784 assert_eq!(e.buffer().lines().len(), 3);
6785 assert_eq!(e.cursor().0, 1);
6786 }
6787
6788 #[test]
6789 fn open_line_above_at_row_0_creates_blank_first_line() {
6790 let mut e = normal_editor("hello");
6791 e.open_line_above(1);
6792 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6793 // New blank line is row 0; old "hello" is row 1.
6794 assert_eq!(e.cursor().0, 0);
6795 assert_eq!(e.buffer().lines()[1], "hello");
6796 }
6797
6798 #[test]
6799 fn enter_replace_mode_sets_insert_mode() {
6800 let mut e = normal_editor("hello");
6801 e.enter_replace_mode(1);
6802 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6803 }
6804
6805 // ── Char / line ops ──────────────────────────────────────────────────────
6806
6807 #[test]
6808 fn delete_char_forward_removes_one_char() {
6809 let mut e = normal_editor("hello");
6810 e.jump_cursor(0, 1);
6811 e.delete_char_forward(1);
6812 assert_eq!(e.buffer().lines()[0], "hllo");
6813 }
6814
6815 #[test]
6816 fn delete_char_forward_count_5_removes_five() {
6817 let mut e = normal_editor("hello world");
6818 e.delete_char_forward(5);
6819 assert_eq!(e.buffer().lines()[0], " world");
6820 }
6821
6822 #[test]
6823 fn delete_char_forward_noop_on_empty_line() {
6824 let mut e = normal_editor("");
6825 let before = e.content().to_string();
6826 e.delete_char_forward(1);
6827 // Empty buffer: no chars to delete, content unchanged.
6828 assert_eq!(e.content(), before.as_str());
6829 }
6830
6831 #[test]
6832 fn delete_char_backward_removes_char_before_cursor() {
6833 let mut e = normal_editor("hello");
6834 e.jump_cursor(0, 3);
6835 e.delete_char_backward(1);
6836 assert_eq!(e.buffer().lines()[0], "helo");
6837 }
6838
6839 #[test]
6840 fn delete_char_backward_noop_at_col_0() {
6841 let mut e = normal_editor("hello");
6842 e.jump_cursor(0, 0);
6843 e.delete_char_backward(1);
6844 assert_eq!(e.buffer().lines()[0], "hello");
6845 }
6846
6847 #[test]
6848 fn substitute_char_deletes_and_enters_insert() {
6849 let mut e = normal_editor("hello");
6850 e.jump_cursor(0, 0);
6851 e.substitute_char(1);
6852 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6853 assert_eq!(e.buffer().lines()[0], "ello");
6854 }
6855
6856 #[test]
6857 fn substitute_char_count_3_deletes_three() {
6858 let mut e = normal_editor("hello");
6859 e.substitute_char(3);
6860 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6861 assert_eq!(e.buffer().lines()[0], "lo");
6862 }
6863
6864 #[test]
6865 fn substitute_line_clears_content_and_enters_insert() {
6866 let mut e = normal_editor("hello world");
6867 e.substitute_line(1);
6868 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6869 assert_eq!(e.buffer().lines()[0], "");
6870 }
6871
6872 #[test]
6873 fn delete_to_eol_removes_from_cursor_to_end() {
6874 let mut e = normal_editor("hello world");
6875 e.jump_cursor(0, 5);
6876 e.delete_to_eol();
6877 // col 5 is ' ' — deletes " world", leaving "hello".
6878 assert_eq!(e.buffer().lines()[0], "hello");
6879 }
6880
6881 #[test]
6882 fn delete_to_eol_noop_when_cursor_past_end() {
6883 let mut e = normal_editor("hi");
6884 e.jump_cursor(0, 2);
6885 e.delete_to_eol();
6886 assert_eq!(e.buffer().lines()[0], "hi");
6887 }
6888
6889 #[test]
6890 fn change_to_eol_enters_insert() {
6891 let mut e = normal_editor("hello world");
6892 e.jump_cursor(0, 5);
6893 e.change_to_eol();
6894 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
6895 // col 5 is ' ' — deletes " world", leaving "hello".
6896 assert_eq!(e.buffer().lines()[0], "hello");
6897 }
6898
6899 #[test]
6900 fn yank_to_eol_fills_register() {
6901 let mut e = normal_editor("hello world");
6902 e.jump_cursor(0, 6);
6903 e.yank_to_eol(1);
6904 // Yank does not change mode.
6905 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
6906 // Unnamed register holds the yanked text (col 6 is 'w' in "world").
6907 assert!(
6908 e.registers().unnamed.text.starts_with("world")
6909 || e.registers().unnamed.text.contains("world")
6910 );
6911 }
6912
6913 #[test]
6914 fn join_line_merges_next_line_with_space() {
6915 let mut e = normal_editor("foo\nbar");
6916 e.join_line(1);
6917 assert_eq!(e.buffer().lines()[0], "foo bar");
6918 }
6919
6920 #[test]
6921 fn join_line_count_2_merges_three_lines() {
6922 let mut e = normal_editor("a\nb\nc");
6923 e.join_line(2);
6924 // Our bridge calls join_line() `count` times, each joining the
6925 // current line with the next → 2 iterations: "a b c".
6926 assert_eq!(e.buffer().lines()[0], "a b c");
6927 }
6928
6929 #[test]
6930 fn join_line_noop_on_last_line() {
6931 let mut e = normal_editor("only");
6932 e.join_line(1);
6933 assert_eq!(e.buffer().lines()[0], "only");
6934 }
6935
6936 #[test]
6937 fn toggle_case_at_cursor_flips_letter() {
6938 let mut e = normal_editor("hello");
6939 e.toggle_case_at_cursor(1);
6940 assert_eq!(e.buffer().lines()[0], "Hello");
6941 }
6942
6943 #[test]
6944 fn toggle_case_at_cursor_count_3_flips_three() {
6945 let mut e = normal_editor("hello");
6946 e.toggle_case_at_cursor(3);
6947 assert_eq!(e.buffer().lines()[0], "HELlo");
6948 }
6949
6950 // ── Undo / redo round-trip ───────────────────────────────────────────────
6951
6952 #[test]
6953 fn undo_redo_roundtrip_via_public_methods() {
6954 let mut e = normal_editor("hello");
6955 e.delete_char_forward(1);
6956 assert_eq!(e.buffer().lines()[0], "ello");
6957 e.undo();
6958 assert_eq!(e.buffer().lines()[0], "hello");
6959 e.redo();
6960 assert_eq!(e.buffer().lines()[0], "ello");
6961 }
6962
6963 // ── Jump / scroll ────────────────────────────────────────────────────────
6964
6965 #[test]
6966 fn jump_back_and_forward_roundtrip() {
6967 let mut e = fresh_editor("a\nb\nc\nd");
6968 e.set_viewport_height(10);
6969 e.jump_cursor(3, 0);
6970 // Push current pos onto jumplist (big motion done externally; use
6971 // `run_keys` shortcut: `gg` pushes jump then `G` jumps).
6972 // Simpler: just call jump_back with empty stack → no-op (shouldn't panic).
6973 e.jump_back(1);
6974 e.jump_forward(1);
6975 }
6976
6977 #[test]
6978 fn scroll_full_page_down_moves_cursor() {
6979 use crate::vim::ScrollDir;
6980 let lines = (0..30)
6981 .map(|i| format!("line{i}"))
6982 .collect::<Vec<_>>()
6983 .join("\n");
6984 let mut e = fresh_editor(&lines);
6985 e.set_viewport_height(10);
6986 let before = e.cursor().0;
6987 e.scroll_full_page(ScrollDir::Down, 1);
6988 assert!(e.cursor().0 > before);
6989 }
6990
6991 #[test]
6992 fn scroll_full_page_up_moves_cursor() {
6993 use crate::vim::ScrollDir;
6994 let lines = (0..30)
6995 .map(|i| format!("line{i}"))
6996 .collect::<Vec<_>>()
6997 .join("\n");
6998 let mut e = fresh_editor(&lines);
6999 e.set_viewport_height(10);
7000 e.jump_cursor(25, 0);
7001 let before = e.cursor().0;
7002 e.scroll_full_page(ScrollDir::Up, 1);
7003 assert!(e.cursor().0 < before);
7004 }
7005
7006 #[test]
7007 fn scroll_half_page_down_moves_cursor() {
7008 use crate::vim::ScrollDir;
7009 let lines = (0..30)
7010 .map(|i| format!("line{i}"))
7011 .collect::<Vec<_>>()
7012 .join("\n");
7013 let mut e = fresh_editor(&lines);
7014 e.set_viewport_height(10);
7015 let before = e.cursor().0;
7016 e.scroll_half_page(ScrollDir::Down, 1);
7017 assert!(e.cursor().0 > before);
7018 }
7019
7020 #[test]
7021 fn scroll_half_page_up_at_top_is_noop() {
7022 use crate::vim::ScrollDir;
7023 let lines = (0..30)
7024 .map(|i| format!("line{i}"))
7025 .collect::<Vec<_>>()
7026 .join("\n");
7027 let mut e = fresh_editor(&lines);
7028 e.set_viewport_height(10);
7029 // Already at top, scrolling up should not panic and cursor stays at 0.
7030 e.scroll_half_page(ScrollDir::Up, 1);
7031 assert_eq!(e.cursor().0, 0);
7032 }
7033
7034 #[test]
7035 fn scroll_line_down_shifts_viewport_without_moving_cursor() {
7036 use crate::vim::ScrollDir;
7037 let lines = (0..30)
7038 .map(|i| format!("line{i}"))
7039 .collect::<Vec<_>>()
7040 .join("\n");
7041 let mut e = fresh_editor(&lines);
7042 e.set_viewport_height(10);
7043 // Park cursor in the middle of a large buffer.
7044 e.jump_cursor(15, 0);
7045 e.set_viewport_top(10);
7046 let cursor_before = e.cursor().0;
7047 e.scroll_line(ScrollDir::Down, 1);
7048 // Viewport top advances; cursor stays.
7049 assert_eq!(e.cursor().0, cursor_before);
7050 assert_eq!(e.host().viewport().top_row, 11);
7051 }
7052
7053 #[test]
7054 fn scroll_line_up_shifts_viewport() {
7055 use crate::vim::ScrollDir;
7056 let lines = (0..30)
7057 .map(|i| format!("line{i}"))
7058 .collect::<Vec<_>>()
7059 .join("\n");
7060 let mut e = fresh_editor(&lines);
7061 e.set_viewport_height(10);
7062 e.jump_cursor(15, 0);
7063 e.set_viewport_top(10);
7064 let cursor_before = e.cursor().0;
7065 e.scroll_line(ScrollDir::Up, 1);
7066 assert_eq!(e.cursor().0, cursor_before);
7067 assert_eq!(e.host().viewport().top_row, 9);
7068 }
7069
7070 #[test]
7071 fn scroll_line_clamps_cursor_when_off_screen() {
7072 use crate::vim::ScrollDir;
7073 let lines = (0..30)
7074 .map(|i| format!("line{i}"))
7075 .collect::<Vec<_>>()
7076 .join("\n");
7077 let mut e = fresh_editor(&lines);
7078 e.set_viewport_height(10);
7079 // Cursor at viewport top; scrolling down pushes it off — must clamp.
7080 e.jump_cursor(5, 0);
7081 e.set_viewport_top(5);
7082 e.scroll_line(ScrollDir::Down, 3);
7083 // New top = 8; cursor was at 5, which is now off-screen (< 8).
7084 // Cursor clamped to new top.
7085 assert!(e.cursor().0 >= 8);
7086 }
7087
7088 #[test]
7089 fn scroll_doesnt_crash_at_buffer_edges() {
7090 use crate::vim::ScrollDir;
7091 let mut e = normal_editor("single line");
7092 e.set_viewport_height(10);
7093 // Should not panic on any of these at-the-edge scrolls.
7094 e.scroll_full_page(ScrollDir::Down, 99);
7095 e.scroll_full_page(ScrollDir::Up, 99);
7096 e.scroll_half_page(ScrollDir::Down, 99);
7097 e.scroll_half_page(ScrollDir::Up, 99);
7098 e.scroll_line(ScrollDir::Down, 99);
7099 e.scroll_line(ScrollDir::Up, 99);
7100 }
7101
7102 // ── Search ───────────────────────────────────────────────────────────────
7103
7104 #[test]
7105 fn search_repeat_advances_to_next_match() {
7106 let mut e = fresh_editor("foo bar foo baz");
7107 // Use word_search to seed the search state (no search prompt needed).
7108 // `*` on "foo" at col 0 finds the second "foo" and sets last_search.
7109 e.word_search(true, true, 1);
7110 // Repeating forward wraps and finds the first "foo" again at col 0.
7111 e.search_repeat(true, 1);
7112 // Just ensure no panic and search state is valid.
7113 assert!(e.cursor().0 < e.buffer().lines().len());
7114 }
7115
7116 #[test]
7117 fn search_repeat_no_pattern_is_noop() {
7118 let mut e = normal_editor("hello world");
7119 let before = e.cursor();
7120 // No search pattern loaded — should not panic.
7121 e.search_repeat(true, 1);
7122 assert_eq!(e.cursor(), before);
7123 }
7124
7125 #[test]
7126 fn word_search_finds_word_under_cursor() {
7127 let mut e = fresh_editor("foo bar foo");
7128 // cursor starts at col 0 on "foo"
7129 e.word_search(true, true, 1);
7130 // Should jump to the second "foo" at col 8.
7131 assert_eq!(e.cursor().1, 8);
7132 }
7133
7134 #[test]
7135 fn word_search_whole_word_false_extracts_word_under_cursor() {
7136 // `g*` on "foo" (no `\b`) — use two lines so wrap can find the next match.
7137 let mut e = fresh_editor("foobar\nfoo baz");
7138 // Cursor on second line "foo" at col 0.
7139 e.jump_cursor(1, 0);
7140 // g* with whole_word=false: pattern = "foo", advance forward (skip current).
7141 // Starting at (1, 0), skip "foo" at (1,0), wrap to (0, 0) which matches "foo"
7142 // inside "foobar".
7143 e.word_search(true, false, 1);
7144 // Cursor should land on "foo" at row 0, col 0.
7145 assert_eq!(e.cursor(), (0, 0));
7146 }
7147
7148 #[test]
7149 fn word_search_backward_finds_previous_match() {
7150 let mut e = fresh_editor("foo bar foo");
7151 e.jump_cursor(0, 8); // on second "foo"
7152 e.word_search(false, true, 1);
7153 // Cursor should land on col 0 (first "foo").
7154 assert_eq!(e.cursor().1, 0);
7155 }
7156
7157 // ── Edge cases ───────────────────────────────────────────────────────────
7158
7159 #[test]
7160 fn delete_char_forward_on_single_char_line() {
7161 let mut e = normal_editor("x");
7162 e.delete_char_forward(1);
7163 assert_eq!(e.buffer().lines()[0], "");
7164 }
7165
7166 #[test]
7167 fn substitute_char_on_empty_line_is_noop_for_delete() {
7168 let mut e = normal_editor("");
7169 e.substitute_char(1);
7170 // Nothing to delete — but should enter Insert mode.
7171 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
7172 }
7173
7174 #[test]
7175 fn join_line_10_iterations_clamps_gracefully() {
7176 let mut e = normal_editor("a\nb");
7177 // Joining 10 times on a 2-line buffer should not panic.
7178 e.join_line(10);
7179 // After the first join succeeds, the rest are no-ops.
7180 assert_eq!(e.buffer().lines()[0], "a b");
7181 }
7182
7183 #[test]
7184 fn toggle_case_past_line_end_is_noop() {
7185 let mut e = normal_editor("ab");
7186 e.jump_cursor(0, 5); // way past end
7187 e.toggle_case_at_cursor(1);
7188 // Should not panic.
7189 assert_eq!(e.buffer().lines()[0], "ab");
7190 }
7191
7192 // ── Phase 6.3: visual-mode primitive tests (kryptic-sh/hjkl#89) ──────────
7193
7194 // ── Visual entry ─────────────────────────────────────────────────────────
7195
7196 #[test]
7197 fn enter_visual_char_lands_in_visual_at_cursor() {
7198 let mut e = normal_editor("hello world");
7199 e.jump_cursor(0, 3);
7200 e.enter_visual_char();
7201 assert_eq!(e.vim_mode(), crate::VimMode::Visual);
7202 // Anchor should be at the cursor position we entered from.
7203 assert_eq!(e.vim.visual_anchor, (0, 3));
7204 }
7205
7206 #[test]
7207 fn enter_visual_line_lands_in_visual_line() {
7208 let mut e = normal_editor("hello\nworld");
7209 e.jump_cursor(1, 2);
7210 e.enter_visual_line();
7211 assert_eq!(e.vim_mode(), crate::VimMode::VisualLine);
7212 // Line anchor should be the current row.
7213 assert_eq!(e.vim.visual_line_anchor, 1);
7214 }
7215
7216 #[test]
7217 fn enter_visual_block_lands_in_visual_block() {
7218 let mut e = normal_editor("hello\nworld");
7219 e.jump_cursor(0, 2);
7220 e.enter_visual_block();
7221 assert_eq!(e.vim_mode(), crate::VimMode::VisualBlock);
7222 // Block anchor and vcol should match the cursor column.
7223 assert_eq!(e.vim.block_anchor, (0, 2));
7224 assert_eq!(e.vim.block_vcol, 2);
7225 }
7226
7227 // ── Visual exit ──────────────────────────────────────────────────────────
7228
7229 #[test]
7230 fn exit_visual_to_normal_sets_marks_and_returns_to_normal() {
7231 let mut e = normal_editor("hello world");
7232 // Enter charwise visual at col 2, extend to col 5.
7233 e.jump_cursor(0, 2);
7234 e.enter_visual_char();
7235 e.jump_cursor(0, 5);
7236 e.exit_visual_to_normal();
7237 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
7238 // `<` = (0, 2), `>` = (0, 5).
7239 assert_eq!(e.mark('<'), Some((0, 2)));
7240 assert_eq!(e.mark('>'), Some((0, 5)));
7241 }
7242
7243 #[test]
7244 fn exit_visual_to_normal_stores_last_visual() {
7245 let mut e = normal_editor("hello world");
7246 e.jump_cursor(0, 1);
7247 e.enter_visual_char();
7248 e.jump_cursor(0, 4);
7249 e.exit_visual_to_normal();
7250 // last_visual should be set so gv can restore it.
7251 assert!(e.vim.last_visual.is_some());
7252 let lv = e.vim.last_visual.unwrap();
7253 assert_eq!(lv.anchor, (0, 1));
7254 assert_eq!(lv.cursor, (0, 4));
7255 }
7256
7257 #[test]
7258 fn exit_visual_line_sets_marks_at_line_boundaries() {
7259 let mut e = normal_editor("alpha\nbeta\ngamma");
7260 e.enter_visual_line(); // row 0
7261 e.jump_cursor(1, 3);
7262 e.exit_visual_to_normal();
7263 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
7264 // `<` snaps to (min_row, 0), `>` snaps to (max_row, last_col).
7265 assert_eq!(e.mark('<'), Some((0, 0)));
7266 let last_col_of_beta = "beta".chars().count() - 1;
7267 assert_eq!(e.mark('>'), Some((1, last_col_of_beta)));
7268 }
7269
7270 // ── visual_o_toggle ───────────────────────────────────────────────────────
7271
7272 #[test]
7273 fn visual_o_toggle_swaps_anchor_and_cursor_charwise() {
7274 let mut e = normal_editor("hello world");
7275 // Enter visual at col 0, extend to col 4.
7276 e.enter_visual_char(); // anchor = (0,0)
7277 e.jump_cursor(0, 4); // cursor at col 4
7278 // Selection bounds before toggle: anchor=0, cursor=4.
7279 let pre_anchor = e.vim.visual_anchor;
7280 let pre_cursor = e.cursor();
7281 e.visual_o_toggle();
7282 // After toggle: cursor jumps to old anchor, anchor = old cursor.
7283 assert_eq!(e.cursor(), pre_anchor, "cursor should move to old anchor");
7284 assert_eq!(
7285 e.vim.visual_anchor, pre_cursor,
7286 "anchor should take old cursor position"
7287 );
7288 // Mode is unchanged.
7289 assert_eq!(e.vim_mode(), crate::VimMode::Visual);
7290 }
7291
7292 #[test]
7293 fn visual_o_toggle_double_returns_to_start() {
7294 let mut e = normal_editor("hello world");
7295 e.enter_visual_char();
7296 e.jump_cursor(0, 4);
7297 let anchor0 = e.vim.visual_anchor;
7298 let cursor0 = e.cursor();
7299 e.visual_o_toggle();
7300 e.visual_o_toggle();
7301 // Two toggles restore original positions.
7302 assert_eq!(e.vim.visual_anchor, anchor0);
7303 assert_eq!(e.cursor(), cursor0);
7304 }
7305
7306 #[test]
7307 fn visual_o_toggle_linewise_swaps_anchor_row() {
7308 let mut e = normal_editor("alpha\nbeta\ngamma");
7309 e.enter_visual_line(); // anchor row = 0
7310 e.jump_cursor(2, 0); // cursor on row 2
7311 e.visual_o_toggle();
7312 // Cursor should jump to old anchor row.
7313 assert_eq!(e.cursor().0, 0, "cursor row should be old anchor row");
7314 // Anchor row should now be the old cursor row.
7315 assert_eq!(e.vim.visual_line_anchor, 2);
7316 }
7317
7318 // ── reenter_last_visual ───────────────────────────────────────────────────
7319
7320 #[test]
7321 fn reenter_last_visual_after_vdollar_esc_restores() {
7322 let mut e = normal_editor("hello world");
7323 // v$ then Esc via FSM to store a real last_visual.
7324 e.enter_visual_char(); // anchor = (0,0)
7325 e.jump_cursor(0, 5); // move cursor to col 5 to create a range
7326 e.exit_visual_to_normal();
7327 // Should be back to Normal.
7328 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
7329 // gv — should restore Visual mode.
7330 e.reenter_last_visual();
7331 assert_eq!(e.vim_mode(), crate::VimMode::Visual);
7332 // Cursor should be at the stored last position (col 5).
7333 assert_eq!(e.cursor().1, 5);
7334 }
7335
7336 #[test]
7337 fn reenter_last_visual_noop_when_no_history() {
7338 let mut e = normal_editor("hello");
7339 // No prior visual — should be a no-op, not a panic.
7340 e.reenter_last_visual();
7341 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
7342 }
7343
7344 // ── set_mode ─────────────────────────────────────────────────────────────
7345
7346 #[test]
7347 fn set_mode_insert_flips_vim_mode_to_insert() {
7348 let mut e = normal_editor("hello");
7349 e.set_mode(crate::VimMode::Insert);
7350 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
7351 }
7352
7353 #[test]
7354 fn set_mode_roundtrip_normal_insert_normal() {
7355 let mut e = normal_editor("hello");
7356 e.set_mode(crate::VimMode::Insert);
7357 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
7358 e.set_mode(crate::VimMode::Normal);
7359 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
7360 }
7361
7362 #[test]
7363 fn set_mode_visual_variants() {
7364 let mut e = normal_editor("hello");
7365 e.set_mode(crate::VimMode::Visual);
7366 assert_eq!(e.vim_mode(), crate::VimMode::Visual);
7367 e.set_mode(crate::VimMode::VisualLine);
7368 assert_eq!(e.vim_mode(), crate::VimMode::VisualLine);
7369 e.set_mode(crate::VimMode::VisualBlock);
7370 assert_eq!(e.vim_mode(), crate::VimMode::VisualBlock);
7371 e.set_mode(crate::VimMode::Normal);
7372 assert_eq!(e.vim_mode(), crate::VimMode::Normal);
7373 }
7374
7375 // ── current_mode / vim_mode consistency ───────────────────────────────────
7376
7377 // ── Phase 6.6b: FSM state accessor smoke tests ────────────────────────────
7378
7379 #[test]
7380 fn pending_round_trips() {
7381 let mut e = normal_editor("hello");
7382 assert!(matches!(e.pending(), crate::vim::Pending::None));
7383 e.set_pending(crate::vim::Pending::G);
7384 assert!(matches!(e.pending(), crate::vim::Pending::G));
7385 let taken = e.take_pending();
7386 assert!(matches!(taken, crate::vim::Pending::G));
7387 assert!(matches!(e.pending(), crate::vim::Pending::None));
7388 }
7389
7390 #[test]
7391 fn count_round_trips() {
7392 let mut e = normal_editor("hello");
7393 assert_eq!(e.count(), 0);
7394 e.set_count(5);
7395 assert_eq!(e.count(), 5);
7396 e.accumulate_count_digit(3);
7397 assert_eq!(e.count(), 53);
7398 e.reset_count();
7399 assert_eq!(e.count(), 0);
7400 }
7401
7402 #[test]
7403 fn take_count_returns_one_when_zero() {
7404 let mut e = normal_editor("hello");
7405 assert_eq!(e.take_count(), 1);
7406 }
7407
7408 #[test]
7409 fn take_count_returns_value_and_resets() {
7410 let mut e = normal_editor("hello");
7411 e.set_count(7);
7412 assert_eq!(e.take_count(), 7);
7413 assert_eq!(e.count(), 0);
7414 }
7415
7416 #[test]
7417 fn fsm_mode_round_trips() {
7418 let mut e = normal_editor("hello");
7419 assert_eq!(e.fsm_mode(), crate::vim::Mode::Normal);
7420 e.set_fsm_mode(crate::vim::Mode::Insert);
7421 assert_eq!(e.fsm_mode(), crate::vim::Mode::Insert);
7422 assert_eq!(e.vim_mode(), crate::VimMode::Insert);
7423 e.set_fsm_mode(crate::vim::Mode::Normal);
7424 assert_eq!(e.fsm_mode(), crate::vim::Mode::Normal);
7425 }
7426
7427 #[test]
7428 fn replaying_flag_round_trips() {
7429 let mut e = normal_editor("hello");
7430 assert!(!e.is_replaying());
7431 e.set_replaying(true);
7432 assert!(e.is_replaying());
7433 e.set_replaying(false);
7434 assert!(!e.is_replaying());
7435 }
7436
7437 #[test]
7438 fn one_shot_normal_flag_round_trips() {
7439 let mut e = normal_editor("hello");
7440 assert!(!e.is_one_shot_normal());
7441 e.set_one_shot_normal(true);
7442 assert!(e.is_one_shot_normal());
7443 e.set_one_shot_normal(false);
7444 assert!(!e.is_one_shot_normal());
7445 }
7446
7447 #[test]
7448 fn last_find_round_trips() {
7449 let mut e = normal_editor("hello");
7450 assert_eq!(e.last_find(), None);
7451 e.set_last_find(Some(('x', true, false)));
7452 assert_eq!(e.last_find(), Some(('x', true, false)));
7453 e.set_last_find(None);
7454 assert_eq!(e.last_find(), None);
7455 }
7456
7457 #[test]
7458 fn last_change_round_trips() {
7459 let mut e = normal_editor("hello");
7460 assert!(e.last_change().is_none());
7461 e.set_last_change(Some(crate::vim::LastChange::ToggleCase { count: 2 }));
7462 let lc = e.last_change();
7463 assert!(matches!(
7464 lc,
7465 Some(crate::vim::LastChange::ToggleCase { count: 2 })
7466 ));
7467 e.set_last_change(None);
7468 assert!(e.last_change().is_none());
7469 }
7470
7471 #[test]
7472 fn last_change_mut_allows_in_place_edit() {
7473 let mut e = normal_editor("hello");
7474 e.set_last_change(Some(crate::vim::LastChange::ToggleCase { count: 1 }));
7475 if let Some(crate::vim::LastChange::ToggleCase { count }) = e.last_change_mut() {
7476 *count = 42;
7477 }
7478 assert!(matches!(
7479 e.last_change(),
7480 Some(crate::vim::LastChange::ToggleCase { count: 42 })
7481 ));
7482 }
7483
7484 #[test]
7485 fn insert_session_round_trips() {
7486 let mut e = normal_editor("hello");
7487 assert!(e.insert_session().is_none());
7488 e.set_insert_session(Some(crate::vim::InsertSession {
7489 count: 3,
7490 row_min: 0,
7491 row_max: 0,
7492 before_lines: vec!["hello".to_string()],
7493 reason: crate::vim::InsertReason::Enter(crate::vim::InsertEntry::I),
7494 }));
7495 assert_eq!(e.insert_session().map(|s| s.count), Some(3));
7496 let taken = e.take_insert_session();
7497 assert!(taken.is_some());
7498 assert!(e.insert_session().is_none());
7499 }
7500
7501 #[test]
7502 fn visual_anchor_round_trips() {
7503 let mut e = normal_editor("hello");
7504 e.set_visual_anchor((1, 3));
7505 assert_eq!(e.visual_anchor(), (1, 3));
7506 }
7507
7508 #[test]
7509 fn visual_line_anchor_round_trips() {
7510 let mut e = normal_editor("hello\nworld");
7511 e.set_visual_line_anchor(1);
7512 assert_eq!(e.visual_line_anchor(), 1);
7513 }
7514
7515 #[test]
7516 fn block_anchor_and_vcol_round_trip() {
7517 let mut e = normal_editor("hello");
7518 e.set_block_anchor((0, 2));
7519 e.set_block_vcol(4);
7520 assert_eq!(e.block_anchor(), (0, 2));
7521 assert_eq!(e.block_vcol(), 4);
7522 }
7523
7524 #[test]
7525 fn yank_linewise_round_trips() {
7526 let mut e = normal_editor("hello");
7527 assert!(!e.yank_linewise());
7528 e.set_yank_linewise(true);
7529 assert!(e.yank_linewise());
7530 }
7531
7532 #[test]
7533 fn pending_register_raw_round_trips() {
7534 let mut e = normal_editor("hello");
7535 assert_eq!(e.pending_register(), None);
7536 e.set_pending_register_raw(Some('a'));
7537 assert_eq!(e.pending_register(), Some('a'));
7538 let taken = e.take_pending_register_raw();
7539 assert_eq!(taken, Some('a'));
7540 assert_eq!(e.pending_register(), None);
7541 }
7542
7543 #[test]
7544 fn recording_macro_round_trips() {
7545 let mut e = normal_editor("hello");
7546 assert_eq!(e.recording_macro(), None);
7547 e.set_recording_macro(Some('q'));
7548 assert_eq!(e.recording_macro(), Some('q'));
7549 e.set_recording_macro(None);
7550 assert_eq!(e.recording_macro(), None);
7551 }
7552
7553 #[test]
7554 fn recording_keys_round_trips() {
7555 let mut e = normal_editor("hello");
7556 let input = crate::Input {
7557 key: crate::Key::Char('j'),
7558 ctrl: false,
7559 alt: false,
7560 shift: false,
7561 };
7562 e.push_recording_key(input);
7563 assert_eq!(e.take_recording_keys(), vec![input]);
7564 assert!(e.take_recording_keys().is_empty());
7565 }
7566
7567 #[test]
7568 fn replaying_macro_raw_round_trips() {
7569 let mut e = normal_editor("hello");
7570 assert!(!e.is_replaying_macro_raw());
7571 e.set_replaying_macro_raw(true);
7572 assert!(e.is_replaying_macro_raw());
7573 e.set_replaying_macro_raw(false);
7574 assert!(!e.is_replaying_macro_raw());
7575 }
7576
7577 #[test]
7578 fn last_macro_round_trips() {
7579 let mut e = normal_editor("hello");
7580 assert_eq!(e.last_macro(), None);
7581 e.set_last_macro(Some('m'));
7582 assert_eq!(e.last_macro(), Some('m'));
7583 }
7584
7585 #[test]
7586 fn last_insert_pos_round_trips() {
7587 let mut e = normal_editor("hello");
7588 assert_eq!(e.last_insert_pos(), None);
7589 e.set_last_insert_pos(Some((1, 2)));
7590 assert_eq!(e.last_insert_pos(), Some((1, 2)));
7591 }
7592
7593 #[test]
7594 fn last_visual_round_trips() {
7595 let mut e = normal_editor("hello");
7596 assert!(e.last_visual().is_none());
7597 let snap = crate::vim::LastVisual {
7598 mode: crate::vim::Mode::Visual,
7599 anchor: (0, 0),
7600 cursor: (0, 3),
7601 block_vcol: 0,
7602 };
7603 e.set_last_visual(Some(snap));
7604 assert!(e.last_visual().is_some());
7605 e.set_last_visual(None);
7606 assert!(e.last_visual().is_none());
7607 }
7608
7609 #[test]
7610 fn viewport_pinned_round_trips() {
7611 let mut e = normal_editor("hello");
7612 assert!(!e.viewport_pinned());
7613 e.set_viewport_pinned(true);
7614 assert!(e.viewport_pinned());
7615 e.set_viewport_pinned(false);
7616 assert!(!e.viewport_pinned());
7617 }
7618
7619 #[test]
7620 fn insert_pending_register_round_trips() {
7621 let mut e = normal_editor("hello");
7622 assert!(!e.insert_pending_register());
7623 e.set_insert_pending_register(true);
7624 assert!(e.insert_pending_register());
7625 }
7626
7627 #[test]
7628 fn change_mark_start_round_trips() {
7629 let mut e = normal_editor("hello");
7630 assert_eq!(e.change_mark_start(), None);
7631 e.set_change_mark_start(Some((2, 5)));
7632 assert_eq!(e.change_mark_start(), Some((2, 5)));
7633 let taken = e.take_change_mark_start();
7634 assert_eq!(taken, Some((2, 5)));
7635 assert_eq!(e.change_mark_start(), None);
7636 }
7637
7638 #[test]
7639 fn search_prompt_state_round_trips() {
7640 let mut e = normal_editor("hello");
7641 assert!(e.search_prompt_state().is_none());
7642 e.set_search_prompt_state(Some(crate::vim::SearchPrompt {
7643 text: "foo".to_string(),
7644 cursor: 3,
7645 forward: true,
7646 }));
7647 assert_eq!(
7648 e.search_prompt_state().map(|p| p.text.as_str()),
7649 Some("foo")
7650 );
7651 let taken = e.take_search_prompt_state();
7652 assert!(taken.is_some());
7653 assert!(e.search_prompt_state().is_none());
7654 }
7655
7656 #[test]
7657 fn last_search_pattern_and_direction_round_trips() {
7658 let mut e = normal_editor("hello");
7659 assert_eq!(e.last_search_pattern(), None);
7660 e.set_last_search_pattern_only(Some("world".to_string()));
7661 assert_eq!(e.last_search_pattern(), Some("world"));
7662 e.set_last_search_forward_only(false);
7663 assert!(!e.last_search_forward());
7664 }
7665
7666 #[test]
7667 fn search_history_round_trips() {
7668 let mut e = normal_editor("hello");
7669 assert!(e.search_history().is_empty());
7670 e.search_history_mut().push("pattern1".to_string());
7671 assert_eq!(e.search_history(), &["pattern1"]);
7672 e.set_search_history_cursor(Some(0));
7673 assert_eq!(e.search_history_cursor(), Some(0));
7674 e.set_search_history_cursor(None);
7675 assert_eq!(e.search_history_cursor(), None);
7676 }
7677
7678 #[test]
7679 fn jump_lists_round_trips() {
7680 let mut e = normal_editor("hello");
7681 assert!(e.jump_back_list().is_empty());
7682 assert!(e.jump_fwd_list().is_empty());
7683 e.jump_back_list_mut().push((1, 2));
7684 e.jump_fwd_list_mut().push((3, 4));
7685 assert_eq!(e.jump_back_list(), &[(1, 2)]);
7686 assert_eq!(e.jump_fwd_list(), &[(3, 4)]);
7687 }
7688
7689 #[test]
7690 fn last_input_timing_round_trips() {
7691 let mut e = normal_editor("hello");
7692 assert!(e.last_input_at().is_none());
7693 assert!(e.last_input_host_at().is_none());
7694 let now = std::time::Instant::now();
7695 e.set_last_input_at(Some(now));
7696 assert!(e.last_input_at().is_some());
7697 let dur = core::time::Duration::from_millis(100);
7698 e.set_last_input_host_at(Some(dur));
7699 assert_eq!(e.last_input_host_at(), Some(dur));
7700 }
7701}