hjkl_engine/editor.rs
1//! Editor — the public sqeel-vim type, layered over `hjkl_buffer::Buffer`.
2//!
3//! This file owns the public Editor API — construction, content access,
4//! mouse and goto helpers, the (buffer-level) undo stack, and insert-mode
5//! session bookkeeping. All vim-specific keyboard handling lives in
6//! [`vim`] and communicates with Editor through a small internal API
7//! exposed via `pub(super)` fields and helper methods.
8
9use crate::input::{Input, Key};
10use crate::vim::{self, VimState};
11use crate::{KeybindingMode, VimMode};
12#[cfg(feature = "crossterm")]
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14#[cfg(feature = "ratatui")]
15use ratatui::layout::Rect;
16use std::sync::atomic::{AtomicU16, Ordering};
17
18/// Convert a SPEC [`crate::types::Style`] to a [`ratatui::style::Style`].
19///
20/// Lossless within the styles each library represents. Lives behind the
21/// `ratatui` feature so wasm / no_std consumers that opt out don't pay
22/// for the dep. Use the engine-native [`crate::types::Style`] +
23/// [`Editor::intern_engine_style`] surface from feature-disabled hosts.
24#[cfg(feature = "ratatui")]
25pub(crate) fn engine_style_to_ratatui(s: crate::types::Style) -> ratatui::style::Style {
26 use crate::types::Attrs;
27 use ratatui::style::{Color as RColor, Modifier as RMod, Style as RStyle};
28 let mut out = RStyle::default();
29 if let Some(c) = s.fg {
30 out = out.fg(RColor::Rgb(c.0, c.1, c.2));
31 }
32 if let Some(c) = s.bg {
33 out = out.bg(RColor::Rgb(c.0, c.1, c.2));
34 }
35 let mut m = RMod::empty();
36 if s.attrs.contains(Attrs::BOLD) {
37 m |= RMod::BOLD;
38 }
39 if s.attrs.contains(Attrs::ITALIC) {
40 m |= RMod::ITALIC;
41 }
42 if s.attrs.contains(Attrs::UNDERLINE) {
43 m |= RMod::UNDERLINED;
44 }
45 if s.attrs.contains(Attrs::REVERSE) {
46 m |= RMod::REVERSED;
47 }
48 if s.attrs.contains(Attrs::DIM) {
49 m |= RMod::DIM;
50 }
51 if s.attrs.contains(Attrs::STRIKE) {
52 m |= RMod::CROSSED_OUT;
53 }
54 out.add_modifier(m)
55}
56
57/// Inverse of [`engine_style_to_ratatui`]. Lossy for ratatui colors
58/// the engine doesn't model (Indexed, named ANSI) — flattens to
59/// nearest RGB. Behind the `ratatui` feature.
60#[cfg(feature = "ratatui")]
61pub(crate) fn ratatui_style_to_engine(s: ratatui::style::Style) -> crate::types::Style {
62 use crate::types::{Attrs, Color, Style};
63 use ratatui::style::{Color as RColor, Modifier as RMod};
64 fn c(rc: RColor) -> Color {
65 match rc {
66 RColor::Rgb(r, g, b) => Color(r, g, b),
67 RColor::Black => Color(0, 0, 0),
68 RColor::Red => Color(205, 49, 49),
69 RColor::Green => Color(13, 188, 121),
70 RColor::Yellow => Color(229, 229, 16),
71 RColor::Blue => Color(36, 114, 200),
72 RColor::Magenta => Color(188, 63, 188),
73 RColor::Cyan => Color(17, 168, 205),
74 RColor::Gray => Color(229, 229, 229),
75 RColor::DarkGray => Color(102, 102, 102),
76 RColor::LightRed => Color(241, 76, 76),
77 RColor::LightGreen => Color(35, 209, 139),
78 RColor::LightYellow => Color(245, 245, 67),
79 RColor::LightBlue => Color(59, 142, 234),
80 RColor::LightMagenta => Color(214, 112, 214),
81 RColor::LightCyan => Color(41, 184, 219),
82 RColor::White => Color(255, 255, 255),
83 _ => Color(0, 0, 0),
84 }
85 }
86 let mut attrs = Attrs::empty();
87 if s.add_modifier.contains(RMod::BOLD) {
88 attrs |= Attrs::BOLD;
89 }
90 if s.add_modifier.contains(RMod::ITALIC) {
91 attrs |= Attrs::ITALIC;
92 }
93 if s.add_modifier.contains(RMod::UNDERLINED) {
94 attrs |= Attrs::UNDERLINE;
95 }
96 if s.add_modifier.contains(RMod::REVERSED) {
97 attrs |= Attrs::REVERSE;
98 }
99 if s.add_modifier.contains(RMod::DIM) {
100 attrs |= Attrs::DIM;
101 }
102 if s.add_modifier.contains(RMod::CROSSED_OUT) {
103 attrs |= Attrs::STRIKE;
104 }
105 Style {
106 fg: s.fg.map(c),
107 bg: s.bg.map(c),
108 attrs,
109 }
110}
111
112/// Map a [`hjkl_buffer::Edit`] to one or more SPEC
113/// [`crate::types::Edit`] (`EditOp`) records.
114///
115/// Most buffer edits map to a single EditOp. Block ops
116/// ([`hjkl_buffer::Edit::InsertBlock`] /
117/// [`hjkl_buffer::Edit::DeleteBlockChunks`]) emit one EditOp per row
118/// touched — they edit non-contiguous cells and a single
119/// `range..range` can't represent the rectangle.
120///
121/// Returns an empty vec when the edit isn't representable (no buffer
122/// variant currently fails this check).
123fn edit_to_editops(edit: &hjkl_buffer::Edit) -> Vec<crate::types::Edit> {
124 use crate::types::{Edit as Op, Pos};
125 use hjkl_buffer::Edit as B;
126 let to_pos = |p: hjkl_buffer::Position| Pos {
127 line: p.row as u32,
128 col: p.col as u32,
129 };
130 match edit {
131 B::InsertChar { at, ch } => vec![Op {
132 range: to_pos(*at)..to_pos(*at),
133 replacement: ch.to_string(),
134 }],
135 B::InsertStr { at, text } => vec![Op {
136 range: to_pos(*at)..to_pos(*at),
137 replacement: text.clone(),
138 }],
139 B::DeleteRange { start, end, .. } => vec![Op {
140 range: to_pos(*start)..to_pos(*end),
141 replacement: String::new(),
142 }],
143 B::Replace { start, end, with } => vec![Op {
144 range: to_pos(*start)..to_pos(*end),
145 replacement: with.clone(),
146 }],
147 B::JoinLines {
148 row,
149 count,
150 with_space,
151 } => {
152 // Joining `count` rows after `row` collapses
153 // [(row+1, 0) .. (row+count, EOL)] into the joined
154 // sentinel. The replacement is either an empty string
155 // (gJ) or " " between segments (J).
156 let start = Pos {
157 line: *row as u32 + 1,
158 col: 0,
159 };
160 let end = Pos {
161 line: (*row + *count) as u32,
162 col: u32::MAX, // covers to EOL of the last source row
163 };
164 vec![Op {
165 range: start..end,
166 replacement: if *with_space {
167 " ".into()
168 } else {
169 String::new()
170 },
171 }]
172 }
173 B::SplitLines {
174 row,
175 cols,
176 inserted_space: _,
177 } => {
178 // SplitLines reverses a JoinLines: insert a `\n`
179 // (and optional dropped space) at each col on `row`.
180 cols.iter()
181 .map(|c| {
182 let p = Pos {
183 line: *row as u32,
184 col: *c as u32,
185 };
186 Op {
187 range: p..p,
188 replacement: "\n".into(),
189 }
190 })
191 .collect()
192 }
193 B::InsertBlock { at, chunks } => {
194 // One EditOp per row in the block — non-contiguous edits.
195 chunks
196 .iter()
197 .enumerate()
198 .map(|(i, chunk)| {
199 let p = Pos {
200 line: at.row as u32 + i as u32,
201 col: at.col as u32,
202 };
203 Op {
204 range: p..p,
205 replacement: chunk.clone(),
206 }
207 })
208 .collect()
209 }
210 B::DeleteBlockChunks { at, widths } => {
211 // One EditOp per row, deleting `widths[i]` chars at
212 // `(at.row + i, at.col)`.
213 widths
214 .iter()
215 .enumerate()
216 .map(|(i, w)| {
217 let start = Pos {
218 line: at.row as u32 + i as u32,
219 col: at.col as u32,
220 };
221 let end = Pos {
222 line: at.row as u32 + i as u32,
223 col: at.col as u32 + *w as u32,
224 };
225 Op {
226 range: start..end,
227 replacement: String::new(),
228 }
229 })
230 .collect()
231 }
232 }
233}
234
235/// Sum of bytes from the start of the buffer to the start of `row`.
236/// Walks lines + their separating `\n` bytes — matches the canonical
237/// `lines().join("\n")` byte rendering used by syntax tooling.
238#[inline]
239fn buffer_byte_of_row(buf: &hjkl_buffer::Buffer, row: usize) -> usize {
240 let n = buf.row_count();
241 let row = row.min(n);
242 let mut acc = 0usize;
243 for r in 0..row {
244 acc += buf.line(r).map(str::len).unwrap_or(0);
245 if r + 1 < n {
246 acc += 1; // separator '\n'
247 }
248 }
249 acc
250}
251
252/// Convert an `hjkl_buffer::Position` (char-indexed col) into byte
253/// coordinates `(byte_within_buffer, (row, col_byte))` against the
254/// **pre-edit** buffer.
255fn position_to_byte_coords(
256 buf: &hjkl_buffer::Buffer,
257 pos: hjkl_buffer::Position,
258) -> (usize, (u32, u32)) {
259 let row = pos.row.min(buf.row_count().saturating_sub(1));
260 let line = buf.line(row).unwrap_or("");
261 let col_byte = pos.byte_offset(line);
262 let byte = buffer_byte_of_row(buf, row) + col_byte;
263 (byte, (row as u32, col_byte as u32))
264}
265
266/// Compute the byte position after inserting `text` starting at
267/// `start_byte` / `start_pos`. Returns `(end_byte, end_position)`.
268fn advance_by_text(text: &str, start_byte: usize, start_pos: (u32, u32)) -> (usize, (u32, u32)) {
269 let new_end_byte = start_byte + text.len();
270 let newlines = text.bytes().filter(|&b| b == b'\n').count();
271 let end_pos = if newlines == 0 {
272 (start_pos.0, start_pos.1 + text.len() as u32)
273 } else {
274 // Bytes after the last newline determine the trailing column.
275 let last_nl = text.rfind('\n').unwrap();
276 let tail_bytes = (text.len() - last_nl - 1) as u32;
277 (start_pos.0 + newlines as u32, tail_bytes)
278 };
279 (new_end_byte, end_pos)
280}
281
282/// Translate a single `hjkl_buffer::Edit` into one or more
283/// [`crate::types::ContentEdit`] records using the **pre-edit** buffer
284/// state for byte/position lookups. Block ops fan out to one entry per
285/// touched row (matches `edit_to_editops`).
286fn content_edits_from_buffer_edit(
287 buf: &hjkl_buffer::Buffer,
288 edit: &hjkl_buffer::Edit,
289) -> Vec<crate::types::ContentEdit> {
290 use hjkl_buffer::Edit as B;
291 use hjkl_buffer::Position;
292
293 let mut out: Vec<crate::types::ContentEdit> = Vec::new();
294
295 match edit {
296 B::InsertChar { at, ch } => {
297 let (start_byte, start_pos) = position_to_byte_coords(buf, *at);
298 let new_end_byte = start_byte + ch.len_utf8();
299 let new_end_pos = (start_pos.0, start_pos.1 + ch.len_utf8() as u32);
300 out.push(crate::types::ContentEdit {
301 start_byte,
302 old_end_byte: start_byte,
303 new_end_byte,
304 start_position: start_pos,
305 old_end_position: start_pos,
306 new_end_position: new_end_pos,
307 });
308 }
309 B::InsertStr { at, text } => {
310 let (start_byte, start_pos) = position_to_byte_coords(buf, *at);
311 let (new_end_byte, new_end_pos) = advance_by_text(text, start_byte, start_pos);
312 out.push(crate::types::ContentEdit {
313 start_byte,
314 old_end_byte: start_byte,
315 new_end_byte,
316 start_position: start_pos,
317 old_end_position: start_pos,
318 new_end_position: new_end_pos,
319 });
320 }
321 B::DeleteRange { start, end, kind } => {
322 let (start, end) = if start <= end {
323 (*start, *end)
324 } else {
325 (*end, *start)
326 };
327 match kind {
328 hjkl_buffer::MotionKind::Char => {
329 let (start_byte, start_pos) = position_to_byte_coords(buf, start);
330 let (old_end_byte, old_end_pos) = position_to_byte_coords(buf, end);
331 out.push(crate::types::ContentEdit {
332 start_byte,
333 old_end_byte,
334 new_end_byte: start_byte,
335 start_position: start_pos,
336 old_end_position: old_end_pos,
337 new_end_position: start_pos,
338 });
339 }
340 hjkl_buffer::MotionKind::Line => {
341 // Linewise delete drops rows [start.row..=end.row]. Map
342 // to a span from start of `start.row` through start of
343 // (end.row + 1). The buffer's own `do_delete_range`
344 // collapses to row `start.row` after dropping.
345 let lo = start.row;
346 let hi = end.row.min(buf.row_count().saturating_sub(1));
347 let start_byte = buffer_byte_of_row(buf, lo);
348 let next_row_byte = if hi + 1 < buf.row_count() {
349 buffer_byte_of_row(buf, hi + 1)
350 } else {
351 // No row after; clamp to end-of-buffer byte.
352 buffer_byte_of_row(buf, buf.row_count())
353 + buf
354 .line(buf.row_count().saturating_sub(1))
355 .map(str::len)
356 .unwrap_or(0)
357 };
358 out.push(crate::types::ContentEdit {
359 start_byte,
360 old_end_byte: next_row_byte,
361 new_end_byte: start_byte,
362 start_position: (lo as u32, 0),
363 old_end_position: ((hi + 1) as u32, 0),
364 new_end_position: (lo as u32, 0),
365 });
366 }
367 hjkl_buffer::MotionKind::Block => {
368 // Block delete removes a rectangle of chars per row.
369 // Fan out to one ContentEdit per row.
370 let (left_col, right_col) = (start.col.min(end.col), start.col.max(end.col));
371 for row in start.row..=end.row {
372 let row_start_pos = Position::new(row, left_col);
373 let row_end_pos = Position::new(row, right_col + 1);
374 let (sb, sp) = position_to_byte_coords(buf, row_start_pos);
375 let (eb, ep) = position_to_byte_coords(buf, row_end_pos);
376 if eb <= sb {
377 continue;
378 }
379 out.push(crate::types::ContentEdit {
380 start_byte: sb,
381 old_end_byte: eb,
382 new_end_byte: sb,
383 start_position: sp,
384 old_end_position: ep,
385 new_end_position: sp,
386 });
387 }
388 }
389 }
390 }
391 B::Replace { start, end, with } => {
392 let (start, end) = if start <= end {
393 (*start, *end)
394 } else {
395 (*end, *start)
396 };
397 let (start_byte, start_pos) = position_to_byte_coords(buf, start);
398 let (old_end_byte, old_end_pos) = position_to_byte_coords(buf, end);
399 let (new_end_byte, new_end_pos) = advance_by_text(with, start_byte, start_pos);
400 out.push(crate::types::ContentEdit {
401 start_byte,
402 old_end_byte,
403 new_end_byte,
404 start_position: start_pos,
405 old_end_position: old_end_pos,
406 new_end_position: new_end_pos,
407 });
408 }
409 B::JoinLines {
410 row,
411 count,
412 with_space,
413 } => {
414 // Joining `count` rows after `row` collapses the bytes
415 // between EOL of `row` and EOL of `row + count` into either
416 // an empty string (gJ) or a single space per join (J — but
417 // only when both sides are non-empty; we approximate with
418 // a single space for simplicity).
419 let row = (*row).min(buf.row_count().saturating_sub(1));
420 let last_join_row = (row + count).min(buf.row_count().saturating_sub(1));
421 let line = buf.line(row).unwrap_or("");
422 let row_eol_byte = buffer_byte_of_row(buf, row) + line.len();
423 let row_eol_col = line.len() as u32;
424 let next_row_after = last_join_row + 1;
425 let old_end_byte = if next_row_after < buf.row_count() {
426 buffer_byte_of_row(buf, next_row_after).saturating_sub(1)
427 } else {
428 buffer_byte_of_row(buf, buf.row_count())
429 + buf
430 .line(buf.row_count().saturating_sub(1))
431 .map(str::len)
432 .unwrap_or(0)
433 };
434 let last_line = buf.line(last_join_row).unwrap_or("");
435 let old_end_pos = (last_join_row as u32, last_line.len() as u32);
436 let replacement_len = if *with_space { 1 } else { 0 };
437 let new_end_byte = row_eol_byte + replacement_len;
438 let new_end_pos = (row as u32, row_eol_col + replacement_len as u32);
439 out.push(crate::types::ContentEdit {
440 start_byte: row_eol_byte,
441 old_end_byte,
442 new_end_byte,
443 start_position: (row as u32, row_eol_col),
444 old_end_position: old_end_pos,
445 new_end_position: new_end_pos,
446 });
447 }
448 B::SplitLines {
449 row,
450 cols,
451 inserted_space,
452 } => {
453 // Splits insert "\n" (or "\n " inverse) at each col on `row`.
454 // The buffer applies all splits left-to-right via the
455 // do_split_lines path; we emit one ContentEdit per col,
456 // each treated as an insert at that col on `row`. Note: the
457 // buffer state during emission is *pre-edit*, so all cols
458 // index into the same pre-edit row.
459 let row = (*row).min(buf.row_count().saturating_sub(1));
460 let line = buf.line(row).unwrap_or("");
461 let row_byte = buffer_byte_of_row(buf, row);
462 let insert = if *inserted_space { "\n " } else { "\n" };
463 for &c in cols {
464 let pos = Position::new(row, c);
465 let col_byte = pos.byte_offset(line);
466 let start_byte = row_byte + col_byte;
467 let start_pos = (row as u32, col_byte as u32);
468 let (new_end_byte, new_end_pos) = advance_by_text(insert, start_byte, start_pos);
469 out.push(crate::types::ContentEdit {
470 start_byte,
471 old_end_byte: start_byte,
472 new_end_byte,
473 start_position: start_pos,
474 old_end_position: start_pos,
475 new_end_position: new_end_pos,
476 });
477 }
478 }
479 B::InsertBlock { at, chunks } => {
480 // One ContentEdit per chunk; each lands at `(at.row + i,
481 // at.col)` in the pre-edit buffer.
482 for (i, chunk) in chunks.iter().enumerate() {
483 let pos = Position::new(at.row + i, at.col);
484 let (start_byte, start_pos) = position_to_byte_coords(buf, pos);
485 let (new_end_byte, new_end_pos) = advance_by_text(chunk, start_byte, start_pos);
486 out.push(crate::types::ContentEdit {
487 start_byte,
488 old_end_byte: start_byte,
489 new_end_byte,
490 start_position: start_pos,
491 old_end_position: start_pos,
492 new_end_position: new_end_pos,
493 });
494 }
495 }
496 B::DeleteBlockChunks { at, widths } => {
497 for (i, w) in widths.iter().enumerate() {
498 let row = at.row + i;
499 let start_pos = Position::new(row, at.col);
500 let end_pos = Position::new(row, at.col + *w);
501 let (sb, sp) = position_to_byte_coords(buf, start_pos);
502 let (eb, ep) = position_to_byte_coords(buf, end_pos);
503 if eb <= sb {
504 continue;
505 }
506 out.push(crate::types::ContentEdit {
507 start_byte: sb,
508 old_end_byte: eb,
509 new_end_byte: sb,
510 start_position: sp,
511 old_end_position: ep,
512 new_end_position: sp,
513 });
514 }
515 }
516 }
517
518 out
519}
520
521/// Where the cursor should land in the viewport after a `z`-family
522/// scroll (`zz` / `zt` / `zb`).
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524pub(super) enum CursorScrollTarget {
525 Center,
526 Top,
527 Bottom,
528}
529
530// ── Trait-surface cast helpers ────────────────────────────────────
531//
532// 0.0.42 (Patch C-δ.7): the helpers introduced in 0.0.41 were
533// promoted to [`crate::buf_helpers`] so `vim.rs` free fns can route
534// their reaches through the same primitives. Re-import via
535// `use` so the editor body keeps its terse call shape.
536
537use crate::buf_helpers::{
538 apply_buffer_edit, buf_cursor_pos, buf_cursor_rc, buf_cursor_row, buf_line, buf_lines_to_vec,
539 buf_row_count, buf_set_cursor_rc,
540};
541
542pub struct Editor<
543 B: crate::types::Buffer = hjkl_buffer::Buffer,
544 H: crate::types::Host = crate::types::DefaultHost,
545> {
546 pub keybinding_mode: KeybindingMode,
547 /// Set when the user yanks/cuts; caller drains this to write to OS clipboard.
548 pub last_yank: Option<String>,
549 /// All vim-specific state (mode, pending operator, count, dot-repeat, ...).
550 /// Internal — exposed via Editor accessor methods
551 /// ([`Editor::buffer_mark`], [`Editor::last_jump_back`],
552 /// [`Editor::last_edit_pos`], [`Editor::take_lsp_intent`], …).
553 pub(crate) vim: VimState,
554 /// Undo history: each entry is (lines, cursor) before the edit.
555 /// Internal — managed by [`Editor::push_undo`] / [`Editor::restore`]
556 /// / [`Editor::pop_last_undo`].
557 pub(crate) undo_stack: Vec<(Vec<String>, (usize, usize))>,
558 /// Redo history: entries pushed when undoing.
559 pub(super) redo_stack: Vec<(Vec<String>, (usize, usize))>,
560 /// Set whenever the buffer content changes; cleared by `take_dirty`.
561 pub(super) content_dirty: bool,
562 /// Cached snapshot of `lines().join("\n") + "\n"` wrapped in an Arc
563 /// so repeated `content_arc()` calls within the same un-mutated
564 /// window are free (ref-count bump instead of a full-buffer join).
565 /// Invalidated by every [`mark_content_dirty`] call.
566 pub(super) cached_content: Option<std::sync::Arc<String>>,
567 /// Last rendered viewport height (text rows only, no chrome). Written
568 /// by the draw path via [`set_viewport_height`] so the scroll helpers
569 /// can clamp the cursor to stay visible without plumbing the height
570 /// through every call.
571 pub(super) viewport_height: AtomicU16,
572 /// Pending LSP intent set by a normal-mode chord (e.g. `gd` for
573 /// goto-definition). The host app drains this each step and fires
574 /// the matching request against its own LSP client.
575 pub(super) pending_lsp: Option<LspIntent>,
576 /// Pending [`crate::types::FoldOp`]s raised by `z…` keystrokes,
577 /// the `:fold*` Ex commands, or the edit pipeline's
578 /// "edits-inside-a-fold open it" invalidation. Drained by hosts
579 /// via [`Editor::take_fold_ops`]; the engine also applies each op
580 /// locally through [`crate::buffer_impl::BufferFoldProviderMut`]
581 /// so the in-tree buffer fold storage stays in sync without host
582 /// cooperation. Introduced in 0.0.38 (Patch C-δ.4).
583 pub(super) pending_fold_ops: Vec<crate::types::FoldOp>,
584 /// Buffer storage.
585 ///
586 /// 0.1.0 (Patch C-δ): generic over `B: Buffer` per SPEC §"Editor
587 /// surface". Default `B = hjkl_buffer::Buffer`. The vim FSM body
588 /// and `Editor::mutate_edit` are concrete on `hjkl_buffer::Buffer`
589 /// for 0.1.0 — see `crate::buf_helpers::apply_buffer_edit`.
590 pub(super) buffer: B,
591 /// Style intern table for the migration buffer's opaque
592 /// `Span::style` ids. Phase 7d-ii-a wiring — `apply_window_spans`
593 /// produces `(start, end, Style)` tuples for the textarea; we
594 /// translate those to `hjkl_buffer::Span` by interning the
595 /// `Style` here and storing the table index. The render path's
596 /// `StyleResolver` looks the style back up by id.
597 ///
598 /// Behind the `ratatui` feature; non-ratatui hosts use the
599 /// engine-native [`crate::types::Style`] surface via
600 /// [`Editor::intern_engine_style`] (which lives on a parallel
601 /// engine-side table when ratatui is off).
602 #[cfg(feature = "ratatui")]
603 pub(super) style_table: Vec<ratatui::style::Style>,
604 /// Engine-native style intern table. Used directly by
605 /// [`Editor::intern_engine_style`] when the `ratatui` feature is
606 /// off; when it's on, the table is derived from `style_table` via
607 /// [`ratatui_style_to_engine`] / [`engine_style_to_ratatui`].
608 #[cfg(not(feature = "ratatui"))]
609 pub(super) engine_style_table: Vec<crate::types::Style>,
610 /// Vim-style register bank — `"`, `"0`–`"9`, `"a`–`"z`. Sources
611 /// every `p` / `P` via the active selector (default unnamed).
612 /// Internal — read via [`Editor::registers`]; mutated by yank /
613 /// delete / paste FSM paths and by [`Editor::seed_yank`].
614 pub(crate) registers: crate::registers::Registers,
615 /// Per-row syntax styling, kept here so the host can do
616 /// incremental window updates (see `apply_window_spans` in
617 /// the host). Same `(start_byte, end_byte, Style)` tuple shape
618 /// the textarea used to host. The Buffer-side opaque-id spans are
619 /// derived from this on every install. Behind the `ratatui`
620 /// feature.
621 #[cfg(feature = "ratatui")]
622 pub styled_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
623 /// Per-editor settings tweakable via `:set`. Exposed by reference
624 /// so handlers (indent, search) read the live value rather than a
625 /// snapshot taken at startup. Read via [`Editor::settings`];
626 /// mutate via [`Editor::settings_mut`].
627 pub(crate) settings: Settings,
628 /// Unified named-marks map. Lowercase letters (`'a`–`'z`) are
629 /// per-Editor / "buffer-scope-equivalent" — set by `m{a-z}`, read
630 /// by `'{a-z}` / `` `{a-z} ``. Uppercase letters (`'A`–`'Z`) are
631 /// "file marks" that survive [`Editor::set_content`] calls so
632 /// they persist across tab swaps within the same Editor.
633 ///
634 /// 0.0.36: consolidated from three former storages:
635 /// - `hjkl_buffer::Buffer::marks` (deleted; was unused dead code).
636 /// - `vim::VimState::marks` (lowercase) (deleted).
637 /// - `Editor::file_marks` (uppercase) (replaced by this map).
638 ///
639 /// `BTreeMap` so iteration is deterministic for snapshot tests
640 /// and the `:marks` ex command. Mark-shift on edits is handled
641 /// by [`Editor::shift_marks_after_edit`].
642 pub(crate) marks: std::collections::BTreeMap<char, (usize, usize)>,
643 /// Block ranges (`(start_row, end_row)` inclusive) the host has
644 /// extracted from a syntax tree. `:foldsyntax` reads these to
645 /// populate folds. The host refreshes them on every re-parse via
646 /// [`Editor::set_syntax_fold_ranges`]; ex commands read them via
647 /// [`Editor::syntax_fold_ranges`].
648 pub(crate) syntax_fold_ranges: Vec<(usize, usize)>,
649 /// Pending edit log drained by [`Editor::take_changes`]. Each entry
650 /// is a SPEC [`crate::types::Edit`] mapped from the underlying
651 /// `hjkl_buffer::Edit` operation. Compound ops (JoinLines,
652 /// SplitLines, InsertBlock, DeleteBlockChunks) emit a single
653 /// best-effort EditOp covering the touched range; hosts wanting
654 /// per-cell deltas should diff their own snapshot of `lines()`.
655 /// Sealed at 0.1.0 trait extraction.
656 /// Drained by [`Editor::take_changes`].
657 pub(crate) change_log: Vec<crate::types::Edit>,
658 /// Vim's "sticky column" (curswant). `None` before the first
659 /// motion — the next vertical motion bootstraps from the live
660 /// cursor column. Horizontal motions refresh this to the new
661 /// column; vertical motions read it back so bouncing through a
662 /// shorter row doesn't drag the cursor to col 0. Hoisted out of
663 /// `hjkl_buffer::Buffer` (and `VimState`) in 0.0.28 — Editor is
664 /// the single owner now. Buffer motion methods that need it
665 /// take a `&mut Option<usize>` parameter.
666 pub(crate) sticky_col: Option<usize>,
667 /// Host adapter for clipboard, cursor-shape, time, viewport, and
668 /// search-prompt / cancellation side-channels.
669 ///
670 /// 0.1.0 (Patch C-δ): generic over `H: Host` per SPEC §"Editor
671 /// surface". Default `H = DefaultHost`. The pre-0.1.0 `EngineHost`
672 /// dyn-shim is gone — every method now dispatches through `H`'s
673 /// `Host` trait surface directly.
674 pub(crate) host: H,
675 /// Last public mode the cursor-shape emitter saw. Drives
676 /// [`Editor::emit_cursor_shape_if_changed`] so `Host::emit_cursor_shape`
677 /// fires exactly once per mode transition without sprinkling the
678 /// call across every `vim.mode = ...` site.
679 pub(crate) last_emitted_mode: crate::VimMode,
680 /// Search FSM state (pattern + per-row match cache + wrapscan).
681 /// 0.0.35: relocated out of `hjkl_buffer::Buffer` per
682 /// `DESIGN_33_METHOD_CLASSIFICATION.md` step 1.
683 /// 0.0.37: the buffer-side bridge (`Buffer::search_pattern`) is
684 /// gone; `BufferView` now takes the active regex as a `&Regex`
685 /// parameter, sourced from `Editor::search_state().pattern`.
686 pub(crate) search_state: crate::search::SearchState,
687 /// Per-row syntax span overlay. Source of truth for the host's
688 /// renderer ([`hjkl_buffer::BufferView::spans`]). Populated by
689 /// [`Editor::install_syntax_spans`] /
690 /// [`Editor::install_ratatui_syntax_spans`] (and, in due course,
691 /// by `Host::syntax_highlights` once the engine drives that path
692 /// directly).
693 ///
694 /// 0.0.37: lifted out of `hjkl_buffer::Buffer` per step 3 of
695 /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer-side cache +
696 /// `Buffer::set_spans` / `Buffer::spans` accessors are gone.
697 pub(crate) buffer_spans: Vec<Vec<hjkl_buffer::Span>>,
698 /// Pending `ContentEdit` records emitted by `mutate_edit`. Drained by
699 /// hosts via [`Editor::take_content_edits`] for fan-in to a syntax
700 /// tree (or any other content-change observer that needs byte-level
701 /// position deltas). Edges are byte-indexed and `(row, col_byte)`.
702 pub(crate) pending_content_edits: Vec<crate::types::ContentEdit>,
703 /// Pending "reset" flag set when the entire buffer is replaced
704 /// (e.g. `set_content` / `restore`). Supersedes any queued
705 /// `pending_content_edits` on the same frame: hosts call
706 /// [`Editor::take_content_reset`] before draining edits.
707 pub(crate) pending_content_reset: bool,
708}
709
710/// Vim-style options surfaced by `:set`. New fields land here as
711/// individual ex commands gain `:set` plumbing.
712#[derive(Debug, Clone)]
713pub struct Settings {
714 /// Spaces per shift step for `>>` / `<<` / `Ctrl-T` / `Ctrl-D`.
715 pub shiftwidth: usize,
716 /// Visual width of a `\t` character. Stored for future render
717 /// hookup; not yet consumed by the buffer renderer.
718 pub tabstop: usize,
719 /// When true, `/` / `?` patterns and `:s/.../.../` ignore case
720 /// without an explicit `i` flag.
721 pub ignore_case: bool,
722 /// When true *and* `ignore_case` is true, an uppercase letter in
723 /// the pattern flips that search back to case-sensitive. Matches
724 /// vim's `:set smartcase`. Default `false`.
725 pub smartcase: bool,
726 /// Wrap searches past buffer ends. Matches vim's `:set wrapscan`.
727 /// Default `true`.
728 pub wrapscan: bool,
729 /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
730 pub textwidth: usize,
731 /// When `true`, the Tab key in insert mode inserts `tabstop` spaces
732 /// instead of a literal `\t`. Matches vim's `:set expandtab`.
733 /// Default `false`.
734 pub expandtab: bool,
735 /// Soft tab stop in spaces. When `> 0`, Tab inserts spaces to the
736 /// next softtabstop boundary (when `expandtab`), and Backspace at the
737 /// end of a softtabstop-aligned space run deletes the entire run as
738 /// if it were one tab. `0` disables. Matches vim's `:set softtabstop`.
739 pub softtabstop: usize,
740 /// Soft-wrap mode the renderer + scroll math + `gj` / `gk` use.
741 /// Default is [`hjkl_buffer::Wrap::None`] — long lines extend
742 /// past the right edge and `top_col` clips the left side.
743 /// `:set wrap` flips to char-break wrap; `:set linebreak` flips
744 /// to word-break wrap; `:set nowrap` resets.
745 pub wrap: hjkl_buffer::Wrap,
746 /// When true, the engine drops every edit before it touches the
747 /// buffer — undo, dirty flag, and change log all stay clean.
748 /// Matches vim's `:set readonly` / `:set ro`. Default `false`.
749 pub readonly: bool,
750 /// When `true`, pressing Enter in insert mode copies the leading
751 /// whitespace of the current line onto the new line. Matches vim's
752 /// `:set autoindent`. Default `true` (vim parity).
753 pub autoindent: bool,
754 /// When `true`, bumps indent by one `shiftwidth` after a line ending
755 /// in `{` / `(` / `[`, and strips one indent unit when the user types
756 /// `}` / `)` / `]` on a whitespace-only line. See `compute_enter_indent`
757 /// in `vim.rs` for the tree-sitter plug-in seam. Default `true`.
758 pub smartindent: bool,
759 /// Cap on undo-stack length. Older entries are pruned past this
760 /// bound. `0` means unlimited. Matches vim's `:set undolevels`.
761 /// Default `1000`.
762 pub undo_levels: u32,
763 /// When `true`, cursor motions inside insert mode break the
764 /// current undo group (so a single `u` only reverses the run of
765 /// keystrokes that preceded the motion). Default `true`.
766 /// Currently a no-op — engine doesn't yet break the undo group
767 /// on insert-mode motions; field is wired through `:set
768 /// undobreak` for forward compatibility.
769 pub undo_break_on_motion: bool,
770 /// Vim-flavoured "what counts as a word" character class.
771 /// Comma-separated tokens: `@` = `is_alphabetic()`, `_` = literal
772 /// `_`, `48-57` = decimal char range, bare integer = single char
773 /// code, single ASCII punctuation = literal. Default
774 /// `"@,48-57,_,192-255"` matches vim.
775 pub iskeyword: String,
776 /// Multi-key sequence timeout (e.g. `gg`, `dd`). When the user
777 /// pauses longer than this between keys, any pending prefix is
778 /// abandoned and the next key starts a fresh sequence. Matches
779 /// vim's `:set timeoutlen` / `:set tm` (millis). Default 1000ms.
780 pub timeout_len: core::time::Duration,
781 /// When true, render absolute line numbers in the gutter. Matches
782 /// vim's `:set number` / `:set nu`. Default `true`.
783 pub number: bool,
784 /// When true, render line numbers as offsets from the cursor row.
785 /// Combined with `number`, the cursor row shows its absolute number
786 /// while other rows show the relative offset (vim's `nu+rnu` hybrid).
787 /// Matches vim's `:set relativenumber` / `:set rnu`. Default `false`.
788 pub relativenumber: bool,
789 /// Minimum gutter width in cells for the line-number column.
790 /// Width grows past this to fit the largest displayed number.
791 /// Matches vim's `:set numberwidth` / `:set nuw`. Default `4`.
792 /// Range 1..=20.
793 pub numberwidth: usize,
794 /// Highlight the row where the cursor sits. Matches vim's `:set cursorline`.
795 /// Default `false`.
796 pub cursorline: bool,
797 /// Highlight the column where the cursor sits. Matches vim's `:set cursorcolumn`.
798 /// Default `false`.
799 pub cursorcolumn: bool,
800 /// Sign-column display mode. Matches vim's `:set signcolumn`.
801 /// Default [`crate::types::SignColumnMode::Auto`].
802 pub signcolumn: crate::types::SignColumnMode,
803 /// Number of cells reserved for a fold-marker gutter.
804 /// Matches vim's `:set foldcolumn`. Default `0`.
805 pub foldcolumn: u32,
806 /// Comma-separated 1-based column indices for vertical rulers.
807 /// Matches vim's `:set colorcolumn`. Default `""`.
808 pub colorcolumn: String,
809}
810
811impl Default for Settings {
812 fn default() -> Self {
813 Self {
814 shiftwidth: 4,
815 tabstop: 4,
816 softtabstop: 4,
817 ignore_case: false,
818 smartcase: false,
819 wrapscan: true,
820 textwidth: 79,
821 expandtab: true,
822 wrap: hjkl_buffer::Wrap::None,
823 readonly: false,
824 autoindent: true,
825 smartindent: true,
826 undo_levels: 1000,
827 undo_break_on_motion: true,
828 iskeyword: "@,48-57,_,192-255".to_string(),
829 timeout_len: core::time::Duration::from_millis(1000),
830 number: true,
831 relativenumber: false,
832 numberwidth: 4,
833 cursorline: false,
834 cursorcolumn: false,
835 signcolumn: crate::types::SignColumnMode::Auto,
836 foldcolumn: 0,
837 colorcolumn: String::new(),
838 }
839 }
840}
841
842/// Translate a SPEC [`crate::types::Options`] into the engine's
843/// internal [`Settings`] representation. Field-by-field map; the
844/// shapes are isomorphic except for type widths
845/// (`u32` vs `usize`, [`crate::types::WrapMode`] vs
846/// [`hjkl_buffer::Wrap`]). 0.1.0 (Patch C-δ) collapses both into one
847/// type once the `Editor<B, H>::new(buffer, host, options)` constructor
848/// is the canonical entry point.
849fn settings_from_options(o: &crate::types::Options) -> Settings {
850 Settings {
851 shiftwidth: o.shiftwidth as usize,
852 tabstop: o.tabstop as usize,
853 softtabstop: o.softtabstop as usize,
854 ignore_case: o.ignorecase,
855 smartcase: o.smartcase,
856 wrapscan: o.wrapscan,
857 textwidth: o.textwidth as usize,
858 expandtab: o.expandtab,
859 wrap: match o.wrap {
860 crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
861 crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
862 crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
863 },
864 readonly: o.readonly,
865 autoindent: o.autoindent,
866 smartindent: o.smartindent,
867 undo_levels: o.undo_levels,
868 undo_break_on_motion: o.undo_break_on_motion,
869 iskeyword: o.iskeyword.clone(),
870 timeout_len: o.timeout_len,
871 number: o.number,
872 relativenumber: o.relativenumber,
873 numberwidth: o.numberwidth,
874 cursorline: o.cursorline,
875 cursorcolumn: o.cursorcolumn,
876 signcolumn: o.signcolumn,
877 foldcolumn: o.foldcolumn,
878 colorcolumn: o.colorcolumn.clone(),
879 }
880}
881
882/// Host-observable LSP requests triggered by editor bindings. The
883/// hjkl-engine crate doesn't talk to an LSP itself — it just raises an
884/// intent that the TUI layer picks up and routes to `sqls`.
885#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub enum LspIntent {
887 /// `gd` — textDocument/definition at the cursor.
888 GotoDefinition,
889}
890
891impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
892 /// Build an [`Editor`] from a buffer, host adapter, and SPEC options.
893 ///
894 /// 0.1.0 (Patch C-δ): canonical, frozen constructor per SPEC §"Editor
895 /// surface". Replaces the pre-0.1.0 `Editor::new(KeybindingMode)` /
896 /// `with_host` / `with_options` triad — there is no shim.
897 ///
898 /// Consumers that don't need a custom host pass
899 /// [`crate::types::DefaultHost::new()`]; consumers that don't need
900 /// custom options pass [`crate::types::Options::default()`].
901 pub fn new(buffer: hjkl_buffer::Buffer, host: H, options: crate::types::Options) -> Self {
902 let settings = settings_from_options(&options);
903 Self {
904 keybinding_mode: KeybindingMode::Vim,
905 last_yank: None,
906 vim: VimState::default(),
907 undo_stack: Vec::new(),
908 redo_stack: Vec::new(),
909 content_dirty: false,
910 cached_content: None,
911 viewport_height: AtomicU16::new(0),
912 pending_lsp: None,
913 pending_fold_ops: Vec::new(),
914 buffer,
915 #[cfg(feature = "ratatui")]
916 style_table: Vec::new(),
917 #[cfg(not(feature = "ratatui"))]
918 engine_style_table: Vec::new(),
919 registers: crate::registers::Registers::default(),
920 #[cfg(feature = "ratatui")]
921 styled_spans: Vec::new(),
922 settings,
923 marks: std::collections::BTreeMap::new(),
924 syntax_fold_ranges: Vec::new(),
925 change_log: Vec::new(),
926 sticky_col: None,
927 host,
928 last_emitted_mode: crate::VimMode::Normal,
929 search_state: crate::search::SearchState::new(),
930 buffer_spans: Vec::new(),
931 pending_content_edits: Vec::new(),
932 pending_content_reset: false,
933 }
934 }
935}
936
937impl<B: crate::types::Buffer, H: crate::types::Host> Editor<B, H> {
938 /// Borrow the buffer (typed `&B`). Host renders through this via
939 /// `hjkl_buffer::BufferView` when `B = hjkl_buffer::Buffer`.
940 pub fn buffer(&self) -> &B {
941 &self.buffer
942 }
943
944 /// Mutably borrow the buffer (typed `&mut B`).
945 pub fn buffer_mut(&mut self) -> &mut B {
946 &mut self.buffer
947 }
948
949 /// Borrow the host adapter directly (typed `&H`).
950 pub fn host(&self) -> &H {
951 &self.host
952 }
953
954 /// Mutably borrow the host adapter (typed `&mut H`).
955 pub fn host_mut(&mut self) -> &mut H {
956 &mut self.host
957 }
958}
959
960impl<H: crate::types::Host> Editor<hjkl_buffer::Buffer, H> {
961 /// Update the active `iskeyword` spec for word motions
962 /// (`w`/`b`/`e`/`ge` and engine-side `*`/`#` pickup). 0.0.28
963 /// hoisted iskeyword storage out of `Buffer` — `Editor` is the
964 /// single owner now. Equivalent to assigning
965 /// `settings_mut().iskeyword` directly; the dedicated setter is
966 /// retained for source-compatibility with 0.0.27 callers.
967 pub fn set_iskeyword(&mut self, spec: impl Into<String>) {
968 self.settings.iskeyword = spec.into();
969 }
970
971 /// Emit `Host::emit_cursor_shape` if the public mode has changed
972 /// since the last emit. Engine calls this at the end of every input
973 /// step so mode transitions surface to the host without sprinkling
974 /// the call across every `vim.mode = ...` site.
975 pub(crate) fn emit_cursor_shape_if_changed(&mut self) {
976 let mode = self.vim_mode();
977 if mode == self.last_emitted_mode {
978 return;
979 }
980 let shape = match mode {
981 crate::VimMode::Insert => crate::types::CursorShape::Bar,
982 _ => crate::types::CursorShape::Block,
983 };
984 self.host.emit_cursor_shape(shape);
985 self.last_emitted_mode = mode;
986 }
987
988 /// Record a yank/cut payload. Writes both the legacy
989 /// [`Editor::last_yank`] field (drained directly by 0.0.28-era
990 /// hosts) and the new [`crate::types::Host::write_clipboard`]
991 /// side-channel (Patch B). Consumers should migrate to a `Host`
992 /// impl whose `write_clipboard` queues the platform-clipboard
993 /// write; the `last_yank` mirror will be removed at 0.1.0.
994 pub(crate) fn record_yank_to_host(&mut self, text: String) {
995 self.host.write_clipboard(text.clone());
996 self.last_yank = Some(text);
997 }
998
999 /// Vim's sticky column (curswant). `None` before the first motion;
1000 /// hosts shouldn't normally need to read this directly — it's
1001 /// surfaced for migration off `Buffer::sticky_col` and for
1002 /// snapshot tests.
1003 pub fn sticky_col(&self) -> Option<usize> {
1004 self.sticky_col
1005 }
1006
1007 /// Replace the sticky column. Hosts should rarely touch this —
1008 /// motion code maintains it through the standard horizontal /
1009 /// vertical motion paths.
1010 pub fn set_sticky_col(&mut self, col: Option<usize>) {
1011 self.sticky_col = col;
1012 }
1013
1014 /// Host hook: replace the cached syntax-derived block ranges that
1015 /// `:foldsyntax` consumes. the host calls this on every re-parse;
1016 /// the cost is just a `Vec` swap.
1017 /// Look up a named mark by character. Returns `(row, col)` if
1018 /// set; `None` otherwise. Both lowercase (`'a`–`'z`) and
1019 /// uppercase (`'A`–`'Z`) marks live in the same unified
1020 /// [`Editor::marks`] map as of 0.0.36.
1021 pub fn mark(&self, c: char) -> Option<(usize, usize)> {
1022 self.marks.get(&c).copied()
1023 }
1024
1025 /// Set the named mark `c` to `(row, col)`. Used by the FSM's
1026 /// `m{a-zA-Z}` keystroke and by [`Editor::restore_snapshot`].
1027 pub fn set_mark(&mut self, c: char, pos: (usize, usize)) {
1028 self.marks.insert(c, pos);
1029 }
1030
1031 /// Remove the named mark `c` (no-op if unset).
1032 pub fn clear_mark(&mut self, c: char) {
1033 self.marks.remove(&c);
1034 }
1035
1036 /// Look up a buffer-local lowercase mark (`'a`–`'z`). Kept as a
1037 /// thin wrapper over [`Editor::mark`] for source compatibility
1038 /// with pre-0.0.36 callers; new code should call
1039 /// [`Editor::mark`] directly.
1040 #[deprecated(
1041 since = "0.0.36",
1042 note = "use Editor::mark — lowercase + uppercase marks now live in a single map"
1043 )]
1044 pub fn buffer_mark(&self, c: char) -> Option<(usize, usize)> {
1045 self.mark(c)
1046 }
1047
1048 /// Discard the most recent undo entry. Used by ex commands that
1049 /// pre-emptively pushed an undo state (`:s`, `:r`) but ended up
1050 /// matching nothing — popping prevents a no-op undo step from
1051 /// polluting the user's history.
1052 ///
1053 /// Returns `true` if an entry was discarded.
1054 pub fn pop_last_undo(&mut self) -> bool {
1055 self.undo_stack.pop().is_some()
1056 }
1057
1058 /// Read all named marks set this session — both lowercase
1059 /// (`'a`–`'z`) and uppercase (`'A`–`'Z`). Iteration is
1060 /// deterministic (BTreeMap-ordered) so snapshot / `:marks`
1061 /// output is stable.
1062 pub fn marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
1063 self.marks.iter().map(|(c, p)| (*c, *p))
1064 }
1065
1066 /// Read all buffer-local lowercase marks. Kept for source
1067 /// compatibility with pre-0.0.36 callers (e.g. `:marks` ex
1068 /// command); new code should use [`Editor::marks`] which
1069 /// iterates the unified map.
1070 #[deprecated(
1071 since = "0.0.36",
1072 note = "use Editor::marks — lowercase + uppercase marks now live in a single map"
1073 )]
1074 pub fn buffer_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
1075 self.marks
1076 .iter()
1077 .filter(|(c, _)| c.is_ascii_lowercase())
1078 .map(|(c, p)| (*c, *p))
1079 }
1080
1081 /// Position the cursor was at when the user last jumped via
1082 /// `<C-o>` / `g;` / similar. `None` before any jump.
1083 pub fn last_jump_back(&self) -> Option<(usize, usize)> {
1084 self.vim.jump_back.last().copied()
1085 }
1086
1087 /// Position of the last edit (where `.` would replay). `None` if
1088 /// no edit has happened yet in this session.
1089 pub fn last_edit_pos(&self) -> Option<(usize, usize)> {
1090 self.vim.last_edit_pos
1091 }
1092
1093 /// Read-only view of the file-marks table — uppercase / "file"
1094 /// marks (`'A`–`'Z`) the host has set this session. Returns an
1095 /// iterator of `(mark_char, (row, col))` pairs.
1096 ///
1097 /// Mutate via the FSM (`m{A-Z}` keystroke) or via
1098 /// [`Editor::restore_snapshot`].
1099 ///
1100 /// 0.0.36: file marks now live in the unified [`Editor::marks`]
1101 /// map; this accessor is kept for source compatibility and
1102 /// filters the unified map to uppercase entries.
1103 pub fn file_marks(&self) -> impl Iterator<Item = (char, (usize, usize))> + '_ {
1104 self.marks
1105 .iter()
1106 .filter(|(c, _)| c.is_ascii_uppercase())
1107 .map(|(c, p)| (*c, *p))
1108 }
1109
1110 /// Read-only view of the cached syntax-derived block ranges that
1111 /// `:foldsyntax` consumes. Returns the slice the host last
1112 /// installed via [`Editor::set_syntax_fold_ranges`]; empty when
1113 /// no syntax integration is active.
1114 pub fn syntax_fold_ranges(&self) -> &[(usize, usize)] {
1115 &self.syntax_fold_ranges
1116 }
1117
1118 pub fn set_syntax_fold_ranges(&mut self, ranges: Vec<(usize, usize)>) {
1119 self.syntax_fold_ranges = ranges;
1120 }
1121
1122 /// Live settings (read-only). `:set` mutates these via
1123 /// [`Editor::settings_mut`].
1124 pub fn settings(&self) -> &Settings {
1125 &self.settings
1126 }
1127
1128 /// Live settings (mutable). `:set` flows through here to mutate
1129 /// shiftwidth / tabstop / textwidth / ignore_case / wrap. Hosts
1130 /// configuring at startup typically construct a [`Settings`]
1131 /// snapshot and overwrite via `*editor.settings_mut() = …`.
1132 pub fn settings_mut(&mut self) -> &mut Settings {
1133 &mut self.settings
1134 }
1135
1136 /// Returns `true` when `:set readonly` is active. Convenience
1137 /// accessor for hosts that cannot import the internal [`Settings`]
1138 /// type. Phase 5 binary uses this to gate `:w` writes.
1139 pub fn is_readonly(&self) -> bool {
1140 self.settings.readonly
1141 }
1142
1143 /// Borrow the engine search state. Hosts inspecting the
1144 /// committed `/` / `?` pattern (e.g. for status-line display) or
1145 /// feeding the active regex into `BufferView::search_pattern`
1146 /// read it from here.
1147 pub fn search_state(&self) -> &crate::search::SearchState {
1148 &self.search_state
1149 }
1150
1151 /// Mutable engine search state. Hosts driving search
1152 /// programmatically (test fixtures, scripted demos) write the
1153 /// pattern through here.
1154 pub fn search_state_mut(&mut self) -> &mut crate::search::SearchState {
1155 &mut self.search_state
1156 }
1157
1158 /// Install `pattern` as the active search regex on the engine
1159 /// state and clear the cached row matches. Pass `None` to clear.
1160 /// 0.0.37: dropped the buffer-side mirror that 0.0.35 introduced
1161 /// — `BufferView` now takes the regex through its `search_pattern`
1162 /// field per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`.
1163 pub fn set_search_pattern(&mut self, pattern: Option<regex::Regex>) {
1164 self.search_state.set_pattern(pattern);
1165 }
1166
1167 /// Drive `n` (or the `/` commit equivalent) — advance the cursor
1168 /// to the next match of `search_state.pattern` from the cursor's
1169 /// current position. Returns `true` when a match was found.
1170 /// `skip_current = true` excludes a match the cursor sits on.
1171 pub fn search_advance_forward(&mut self, skip_current: bool) -> bool {
1172 crate::search::search_forward(&mut self.buffer, &mut self.search_state, skip_current)
1173 }
1174
1175 /// Drive `N` — symmetric counterpart of [`Editor::search_advance_forward`].
1176 pub fn search_advance_backward(&mut self, skip_current: bool) -> bool {
1177 crate::search::search_backward(&mut self.buffer, &mut self.search_state, skip_current)
1178 }
1179
1180 /// Install styled syntax spans using `ratatui::style::Style`. The
1181 /// ratatui-flavoured variant of [`Editor::install_syntax_spans`].
1182 /// Drops zero-width runs and clamps `end` to the line's char length
1183 /// so the buffer cache doesn't see runaway ranges. Behind the
1184 /// `ratatui` feature; non-ratatui hosts use the unprefixed
1185 /// [`Editor::install_syntax_spans`] (engine-native `Style`).
1186 ///
1187 /// Renamed from `install_syntax_spans` in 0.0.32 — the unprefixed
1188 /// name now belongs to the engine-native variant per SPEC 0.1.0
1189 /// freeze ("engine never imports ratatui").
1190 #[cfg(feature = "ratatui")]
1191 pub fn install_ratatui_syntax_spans(
1192 &mut self,
1193 spans: Vec<Vec<(usize, usize, ratatui::style::Style)>>,
1194 ) {
1195 // Look up `line_byte_lens` lazily — only fetch a row's length
1196 // when it has at least one span. On a 100k-line file with
1197 // ~50 visible rows, this avoids an O(N) buffer walk per frame.
1198 let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
1199 for (row, row_spans) in spans.iter().enumerate() {
1200 if row_spans.is_empty() {
1201 by_row.push(Vec::new());
1202 continue;
1203 }
1204 let line_len = buf_line(&self.buffer, row).map(str::len).unwrap_or(0);
1205 let mut translated = Vec::with_capacity(row_spans.len());
1206 for (start, end, style) in row_spans {
1207 let end_clamped = (*end).min(line_len);
1208 if end_clamped <= *start {
1209 continue;
1210 }
1211 let id = self.intern_ratatui_style(*style);
1212 translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
1213 }
1214 by_row.push(translated);
1215 }
1216 self.buffer_spans = by_row;
1217 self.styled_spans = spans;
1218 }
1219
1220 /// Snapshot of the unnamed register (the default `p` / `P` source).
1221 pub fn yank(&self) -> &str {
1222 &self.registers.unnamed.text
1223 }
1224
1225 /// Borrow the full register bank — `"`, `"0`–`"9`, `"a`–`"z`.
1226 pub fn registers(&self) -> &crate::registers::Registers {
1227 &self.registers
1228 }
1229
1230 /// Mutably borrow the full register bank. Hosts that share registers
1231 /// across multiple editors (e.g. multi-buffer `yy` / `p`) overwrite
1232 /// the slots here on buffer switch.
1233 pub fn registers_mut(&mut self) -> &mut crate::registers::Registers {
1234 &mut self.registers
1235 }
1236
1237 /// Host hook: load the OS clipboard's contents into the `"+` / `"*`
1238 /// register slot. the host calls this before letting vim consume a
1239 /// paste so `"*p` / `"+p` reflect the live clipboard rather than a
1240 /// stale snapshot from the last yank.
1241 pub fn sync_clipboard_register(&mut self, text: String, linewise: bool) {
1242 self.registers.set_clipboard(text, linewise);
1243 }
1244
1245 /// Return the user's pending register selection (set via `"<reg>` chord
1246 /// before an operator). `None` if no register was selected — caller should
1247 /// use the unnamed register `"`.
1248 ///
1249 /// Read-only — does not consume / clear the pending selection. The
1250 /// register is cleared by the engine after the next operator fires.
1251 ///
1252 /// Promoted in 0.6.X for Phase 4e to let the App's visual-op dispatch arm
1253 /// honor `"a` + visual op chord sequences.
1254 pub fn pending_register(&self) -> Option<char> {
1255 self.vim.pending_register
1256 }
1257
1258 /// True when the user's pending register selector is `+` or `*`.
1259 /// the host peeks this so it can refresh `sync_clipboard_register`
1260 /// only when a clipboard read is actually about to happen.
1261 pub fn pending_register_is_clipboard(&self) -> bool {
1262 matches!(self.vim.pending_register, Some('+') | Some('*'))
1263 }
1264
1265 /// Register currently being recorded into via `q{reg}`. `None` when
1266 /// no recording is active. Hosts use this to surface a "recording @r"
1267 /// indicator in the status line.
1268 pub fn recording_register(&self) -> Option<char> {
1269 self.vim.recording_macro
1270 }
1271
1272 /// Pending repeat count the user has typed but not yet resolved
1273 /// (e.g. pressing `5` before `d`). `None` when nothing is pending.
1274 /// Hosts surface this in a "showcmd" area.
1275 pub fn pending_count(&self) -> Option<u32> {
1276 self.vim.pending_count_val()
1277 }
1278
1279 /// The operator character for any in-flight operator that is waiting
1280 /// for a motion (e.g. `d` after the user types `d` but before a
1281 /// motion). Returns `None` when no operator is pending.
1282 pub fn pending_op(&self) -> Option<char> {
1283 self.vim.pending_op_char()
1284 }
1285
1286 /// `true` when the engine is in any pending chord state — waiting for
1287 /// the next key to complete a command (e.g. `r<char>` replace,
1288 /// `f<char>` find, `m<a>` set-mark, `'<a>` goto-mark, operator-pending
1289 /// after `d` / `c` / `y`, `g`-prefix continuation, `z`-prefix continuation,
1290 /// register selection `"<reg>`, macro recording target, etc).
1291 ///
1292 /// Hosts use this to bypass their own chord dispatch (keymap tries, etc.)
1293 /// and forward keys directly to the engine so in-flight commands can
1294 /// complete without the host eating their continuation keys.
1295 pub fn is_chord_pending(&self) -> bool {
1296 self.vim.is_chord_pending()
1297 }
1298
1299 /// Read-only view of the jump-back list (positions pushed on "big"
1300 /// motions). Newest entry is at the back — `Ctrl-o` pops from there.
1301 #[allow(clippy::type_complexity)]
1302 pub fn jump_list(&self) -> (&[(usize, usize)], &[(usize, usize)]) {
1303 (&self.vim.jump_back, &self.vim.jump_fwd)
1304 }
1305
1306 /// Read-only view of the change list (positions of recent edits) plus
1307 /// the current walk cursor. Newest entry is at the back.
1308 pub fn change_list(&self) -> (&[(usize, usize)], Option<usize>) {
1309 (&self.vim.change_list, self.vim.change_list_cursor)
1310 }
1311
1312 /// Replace the unnamed register without touching any other slot.
1313 /// For host-driven imports (e.g. system clipboard); operator
1314 /// code uses [`record_yank`] / [`record_delete`].
1315 pub fn set_yank(&mut self, text: impl Into<String>) {
1316 let text = text.into();
1317 let linewise = self.vim.yank_linewise;
1318 self.registers.unnamed = crate::registers::Slot { text, linewise };
1319 }
1320
1321 /// Record a yank into `"` and `"0`, plus the named target if the
1322 /// user prefixed `"reg`. Updates `vim.yank_linewise` for the
1323 /// paste path.
1324 pub(crate) fn record_yank(&mut self, text: String, linewise: bool) {
1325 self.vim.yank_linewise = linewise;
1326 let target = self.vim.pending_register.take();
1327 self.registers.record_yank(text, linewise, target);
1328 }
1329
1330 /// Direct write to a named register slot — bypasses the unnamed
1331 /// `"` and `"0` updates that `record_yank` does. Used by the
1332 /// macro recorder so finishing a `q{reg}` recording doesn't
1333 /// pollute the user's last yank.
1334 pub(crate) fn set_named_register_text(&mut self, reg: char, text: String) {
1335 if let Some(slot) = match reg {
1336 'a'..='z' => Some(&mut self.registers.named[(reg as u8 - b'a') as usize]),
1337 'A'..='Z' => {
1338 Some(&mut self.registers.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize])
1339 }
1340 _ => None,
1341 } {
1342 slot.text = text;
1343 slot.linewise = false;
1344 }
1345 }
1346
1347 /// Record a delete / change into `"` and the `"1`–`"9` ring.
1348 /// Honours the active named-register prefix.
1349 pub(crate) fn record_delete(&mut self, text: String, linewise: bool) {
1350 self.vim.yank_linewise = linewise;
1351 let target = self.vim.pending_register.take();
1352 self.registers.record_delete(text, linewise, target);
1353 }
1354
1355 /// Install styled syntax spans using the engine-native
1356 /// [`crate::types::Style`]. Always available, regardless of the
1357 /// `ratatui` feature. Hosts depending on ratatui can use the
1358 /// ratatui-flavoured [`Editor::install_ratatui_syntax_spans`].
1359 ///
1360 /// Renamed from `install_engine_syntax_spans` in 0.0.32 — at the
1361 /// 0.1.0 freeze the unprefixed name is the universally-available
1362 /// engine-native variant ("engine never imports ratatui").
1363 pub fn install_syntax_spans(&mut self, spans: Vec<Vec<(usize, usize, crate::types::Style)>>) {
1364 let line_byte_lens: Vec<usize> = (0..buf_row_count(&self.buffer))
1365 .map(|r| buf_line(&self.buffer, r).map(str::len).unwrap_or(0))
1366 .collect();
1367 let mut by_row: Vec<Vec<hjkl_buffer::Span>> = Vec::with_capacity(spans.len());
1368 #[cfg(feature = "ratatui")]
1369 let mut ratatui_spans: Vec<Vec<(usize, usize, ratatui::style::Style)>> =
1370 Vec::with_capacity(spans.len());
1371 for (row, row_spans) in spans.iter().enumerate() {
1372 let line_len = line_byte_lens.get(row).copied().unwrap_or(0);
1373 let mut translated = Vec::with_capacity(row_spans.len());
1374 #[cfg(feature = "ratatui")]
1375 let mut translated_r = Vec::with_capacity(row_spans.len());
1376 for (start, end, style) in row_spans {
1377 let end_clamped = (*end).min(line_len);
1378 if end_clamped <= *start {
1379 continue;
1380 }
1381 let id = self.intern_style(*style);
1382 translated.push(hjkl_buffer::Span::new(*start, end_clamped, id));
1383 #[cfg(feature = "ratatui")]
1384 translated_r.push((*start, end_clamped, engine_style_to_ratatui(*style)));
1385 }
1386 by_row.push(translated);
1387 #[cfg(feature = "ratatui")]
1388 ratatui_spans.push(translated_r);
1389 }
1390 self.buffer_spans = by_row;
1391 #[cfg(feature = "ratatui")]
1392 {
1393 self.styled_spans = ratatui_spans;
1394 }
1395 }
1396
1397 /// Intern a `ratatui::style::Style` and return the opaque id used
1398 /// in `hjkl_buffer::Span::style`. The ratatui-flavoured variant of
1399 /// [`Editor::intern_style`]. Linear-scan dedup — the table grows
1400 /// only as new tree-sitter token kinds appear, so it stays tiny.
1401 /// Behind the `ratatui` feature.
1402 ///
1403 /// Renamed from `intern_style` in 0.0.32 — at 0.1.0 freeze the
1404 /// unprefixed name belongs to the engine-native variant.
1405 #[cfg(feature = "ratatui")]
1406 pub fn intern_ratatui_style(&mut self, style: ratatui::style::Style) -> u32 {
1407 if let Some(idx) = self.style_table.iter().position(|s| *s == style) {
1408 return idx as u32;
1409 }
1410 self.style_table.push(style);
1411 (self.style_table.len() - 1) as u32
1412 }
1413
1414 /// Read-only view of the style table — id `i` → `style_table[i]`.
1415 /// The render path passes a closure backed by this slice as the
1416 /// `StyleResolver` for `BufferView`. Behind the `ratatui` feature.
1417 #[cfg(feature = "ratatui")]
1418 pub fn style_table(&self) -> &[ratatui::style::Style] {
1419 &self.style_table
1420 }
1421
1422 /// Per-row syntax span overlay, one `Vec<Span>` per buffer row.
1423 /// Hosts feed this slice into [`hjkl_buffer::BufferView::spans`]
1424 /// per draw frame.
1425 ///
1426 /// 0.0.37: replaces `editor.buffer().spans()` per step 3 of
1427 /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The buffer no longer
1428 /// caches spans; they live on the engine and route through the
1429 /// `Host::syntax_highlights` pipeline.
1430 pub fn buffer_spans(&self) -> &[Vec<hjkl_buffer::Span>] {
1431 &self.buffer_spans
1432 }
1433
1434 /// Intern a SPEC [`crate::types::Style`] and return its opaque id.
1435 /// With the `ratatui` feature on, the id matches the one
1436 /// [`Editor::intern_ratatui_style`] would return for the equivalent
1437 /// `ratatui::Style` (both share the underlying table). With it off,
1438 /// the engine keeps a parallel `crate::types::Style`-keyed table
1439 /// — ids are still stable per-editor.
1440 ///
1441 /// Hosts that don't depend on ratatui (buffr, future GUI shells)
1442 /// reach this method to populate the table during syntax span
1443 /// installation.
1444 ///
1445 /// Renamed from `intern_engine_style` in 0.0.32 — at 0.1.0 freeze
1446 /// the unprefixed name is the universally-available engine-native
1447 /// variant.
1448 pub fn intern_style(&mut self, style: crate::types::Style) -> u32 {
1449 #[cfg(feature = "ratatui")]
1450 {
1451 let r = engine_style_to_ratatui(style);
1452 self.intern_ratatui_style(r)
1453 }
1454 #[cfg(not(feature = "ratatui"))]
1455 {
1456 if let Some(idx) = self.engine_style_table.iter().position(|s| *s == style) {
1457 return idx as u32;
1458 }
1459 self.engine_style_table.push(style);
1460 (self.engine_style_table.len() - 1) as u32
1461 }
1462 }
1463
1464 /// Look up an interned style by id and return it as a SPEC
1465 /// [`crate::types::Style`]. Returns `None` for ids past the end
1466 /// of the table.
1467 pub fn engine_style_at(&self, id: u32) -> Option<crate::types::Style> {
1468 #[cfg(feature = "ratatui")]
1469 {
1470 let r = self.style_table.get(id as usize).copied()?;
1471 Some(ratatui_style_to_engine(r))
1472 }
1473 #[cfg(not(feature = "ratatui"))]
1474 {
1475 self.engine_style_table.get(id as usize).copied()
1476 }
1477 }
1478
1479 /// Historical reverse-sync hook from when the textarea mirrored
1480 /// the buffer. Now that Buffer is the cursor authority this is a
1481 /// no-op; call sites can remain in place during the migration.
1482 pub(crate) fn push_buffer_cursor_to_textarea(&mut self) {}
1483
1484 /// Force the host viewport's top row without touching the
1485 /// cursor. Used by tests that simulate a scroll without the
1486 /// SCROLLOFF cursor adjustment that `scroll_down` / `scroll_up`
1487 /// apply.
1488 ///
1489 /// 0.0.34 (Patch C-δ.1): writes through `Host::viewport_mut`
1490 /// instead of the (now-deleted) `Buffer::viewport_mut`.
1491 pub fn set_viewport_top(&mut self, row: usize) {
1492 let last = buf_row_count(&self.buffer).saturating_sub(1);
1493 let target = row.min(last);
1494 self.host.viewport_mut().top_row = target;
1495 }
1496
1497 /// Set the cursor to `(row, col)`, clamped to the buffer's
1498 /// content. Hosts use this for goto-line, jump-to-mark, and
1499 /// programmatic cursor placement.
1500 pub fn jump_cursor(&mut self, row: usize, col: usize) {
1501 buf_set_cursor_rc(&mut self.buffer, row, col);
1502 }
1503
1504 /// `(row, col)` cursor read sourced from the migration buffer.
1505 /// Equivalent to `self.textarea.cursor()` when the two are in
1506 /// sync — which is the steady state during Phase 7f because
1507 /// every step opens with `sync_buffer_content_from_textarea` and
1508 /// every ported motion pushes the result back. Prefer this over
1509 /// `self.textarea.cursor()` so call sites keep working unchanged
1510 /// once the textarea field is ripped.
1511 pub fn cursor(&self) -> (usize, usize) {
1512 buf_cursor_rc(&self.buffer)
1513 }
1514
1515 /// Drain any pending LSP intent raised by the last key. Returns
1516 /// `None` when no intent is armed.
1517 pub fn take_lsp_intent(&mut self) -> Option<LspIntent> {
1518 self.pending_lsp.take()
1519 }
1520
1521 /// Drain every [`crate::types::FoldOp`] raised since the last
1522 /// call. Hosts that mirror the engine's fold storage (or that
1523 /// project folds onto a separate fold tree, LSP folding ranges,
1524 /// …) drain this each step and dispatch as their own
1525 /// [`crate::types::Host::Intent`] requires.
1526 ///
1527 /// The engine has already applied every op locally against the
1528 /// in-tree [`hjkl_buffer::Buffer`] fold storage via
1529 /// [`crate::buffer_impl::BufferFoldProviderMut`], so hosts that
1530 /// don't track folds independently can ignore the queue
1531 /// (or simply never call this drain).
1532 ///
1533 /// Introduced in 0.0.38 (Patch C-δ.4).
1534 pub fn take_fold_ops(&mut self) -> Vec<crate::types::FoldOp> {
1535 std::mem::take(&mut self.pending_fold_ops)
1536 }
1537
1538 /// Dispatch a [`crate::types::FoldOp`] through the canonical fold
1539 /// surface: queue it for host observation (drained by
1540 /// [`Editor::take_fold_ops`]) and apply it locally against the
1541 /// in-tree buffer fold storage via
1542 /// [`crate::buffer_impl::BufferFoldProviderMut`]. Engine call sites
1543 /// (vim FSM `z…` chords, `:fold*` Ex commands, edit-pipeline
1544 /// invalidation) route every fold mutation through this method.
1545 ///
1546 /// Introduced in 0.0.38 (Patch C-δ.4).
1547 pub fn apply_fold_op(&mut self, op: crate::types::FoldOp) {
1548 use crate::types::FoldProvider;
1549 self.pending_fold_ops.push(op);
1550 let mut provider = crate::buffer_impl::BufferFoldProviderMut::new(&mut self.buffer);
1551 provider.apply(op);
1552 }
1553
1554 /// Refresh the host viewport's height from the cached
1555 /// `viewport_height_value()`. Called from the per-step
1556 /// boilerplate; was the textarea → buffer mirror before Phase 7f
1557 /// put Buffer in charge. 0.0.28 hoisted sticky_col out of
1558 /// `Buffer`. 0.0.34 (Patch C-δ.1) routes the height write through
1559 /// `Host::viewport_mut`.
1560 pub(crate) fn sync_buffer_from_textarea(&mut self) {
1561 let height = self.viewport_height_value();
1562 self.host.viewport_mut().height = height;
1563 }
1564
1565 /// Was the full textarea → buffer content sync. Buffer is the
1566 /// content authority now; this remains as a no-op so the per-step
1567 /// call sites don't have to be ripped in the same patch.
1568 pub(crate) fn sync_buffer_content_from_textarea(&mut self) {
1569 self.sync_buffer_from_textarea();
1570 }
1571
1572 /// Push a `(row, col)` onto the back-jumplist so `Ctrl-o` returns
1573 /// to it later. Used by host-driven jumps (e.g. `gd`) that move
1574 /// the cursor without going through the vim engine's motion
1575 /// machinery, where push_jump fires automatically.
1576 pub fn record_jump(&mut self, pos: (usize, usize)) {
1577 const JUMPLIST_MAX: usize = 100;
1578 self.vim.jump_back.push(pos);
1579 if self.vim.jump_back.len() > JUMPLIST_MAX {
1580 self.vim.jump_back.remove(0);
1581 }
1582 self.vim.jump_fwd.clear();
1583 }
1584
1585 /// Host apps call this each draw with the current text area height so
1586 /// scroll helpers can clamp the cursor without recomputing layout.
1587 pub fn set_viewport_height(&self, height: u16) {
1588 self.viewport_height.store(height, Ordering::Relaxed);
1589 }
1590
1591 /// Last height published by `set_viewport_height` (in rows).
1592 pub fn viewport_height_value(&self) -> u16 {
1593 self.viewport_height.load(Ordering::Relaxed)
1594 }
1595
1596 /// Apply `edit` against the buffer and return the inverse so the
1597 /// host can push it onto an undo stack. Side effects: dirty
1598 /// flag, change-list ring, mark / jump-list shifts, change_log
1599 /// append, fold invalidation around the touched rows.
1600 ///
1601 /// The primary edit funnel — both FSM operators and ex commands
1602 /// route mutations through here so the side effects fire
1603 /// uniformly.
1604 pub fn mutate_edit(&mut self, edit: hjkl_buffer::Edit) -> hjkl_buffer::Edit {
1605 // `:set readonly` short-circuits every mutation funnel: no
1606 // buffer change, no dirty flag, no undo entry, no change-log
1607 // emission. We swallow the requested `edit` and hand back a
1608 // self-inverse no-op (`InsertStr` of an empty string at the
1609 // current cursor) so callers that push the return value onto
1610 // an undo stack still get a structurally valid round trip.
1611 if self.settings.readonly {
1612 let _ = edit;
1613 return hjkl_buffer::Edit::InsertStr {
1614 at: buf_cursor_pos(&self.buffer),
1615 text: String::new(),
1616 };
1617 }
1618 let pre_row = buf_cursor_row(&self.buffer);
1619 let pre_rows = buf_row_count(&self.buffer);
1620 // Capture the pre-edit cursor for the dot mark (`'.` / `` `. ``).
1621 // Vim's `:h '.` says "the position where the last change was made",
1622 // meaning the change-start, not the post-insert cursor. We snap it
1623 // here before `apply_buffer_edit` moves the cursor.
1624 let (pre_edit_row, pre_edit_col) = buf_cursor_rc(&self.buffer);
1625 // Map the underlying buffer edit to a SPEC EditOp for
1626 // change-log emission before consuming it. Coarse — see
1627 // change_log field doc on the struct.
1628 self.change_log.extend(edit_to_editops(&edit));
1629 // Compute ContentEdit fan-out from the pre-edit buffer state.
1630 // Done before `apply_buffer_edit` consumes `edit` so we can
1631 // inspect the operation's fields and the buffer's pre-edit row
1632 // bytes (needed for byte_of_row / col_byte conversion). Edits
1633 // are pushed onto `pending_content_edits` for host drain.
1634 let content_edits = content_edits_from_buffer_edit(&self.buffer, &edit);
1635 self.pending_content_edits.extend(content_edits);
1636 // 0.0.42 (Patch C-δ.7): the `apply_edit` reach is centralized
1637 // in [`crate::buf_helpers::apply_buffer_edit`] (option (c) of
1638 // the 0.0.42 plan — see that fn's doc comment). The free fn
1639 // takes `&mut hjkl_buffer::Buffer` so the editor body itself
1640 // no longer carries a `self.buffer.<inherent>` hop.
1641 let inverse = apply_buffer_edit(&mut self.buffer, edit);
1642 let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
1643 // Drop any folds the edit's range overlapped — vim opens the
1644 // surrounding fold automatically when you edit inside it. The
1645 // approximation here invalidates folds covering either the
1646 // pre-edit cursor row or the post-edit cursor row, which
1647 // catches the common single-line / multi-line edit shapes.
1648 let lo = pre_row.min(pos_row);
1649 let hi = pre_row.max(pos_row);
1650 self.apply_fold_op(crate::types::FoldOp::Invalidate {
1651 start_row: lo,
1652 end_row: hi,
1653 });
1654 // Dot mark records the PRE-edit position (change start), matching
1655 // vim's `:h '.` semantics. Previously this stored the post-edit
1656 // cursor, which diverged from nvim on `iX<Esc>j`.
1657 self.vim.last_edit_pos = Some((pre_edit_row, pre_edit_col));
1658 // Append to the change-list ring (skip when the cursor sits on
1659 // the same cell as the last entry — back-to-back keystrokes on
1660 // one column shouldn't pollute the ring). A new edit while
1661 // walking the ring trims the forward half, vim style.
1662 let entry = (pos_row, pos_col);
1663 if self.vim.change_list.last() != Some(&entry) {
1664 if let Some(idx) = self.vim.change_list_cursor.take() {
1665 self.vim.change_list.truncate(idx + 1);
1666 }
1667 self.vim.change_list.push(entry);
1668 let len = self.vim.change_list.len();
1669 if len > crate::vim::CHANGE_LIST_MAX {
1670 self.vim
1671 .change_list
1672 .drain(0..len - crate::vim::CHANGE_LIST_MAX);
1673 }
1674 }
1675 self.vim.change_list_cursor = None;
1676 // Shift / drop marks + jump-list entries to track the row
1677 // delta the edit produced. Without this, every line-changing
1678 // edit silently invalidates `'a`-style positions.
1679 let post_rows = buf_row_count(&self.buffer);
1680 let delta = post_rows as isize - pre_rows as isize;
1681 if delta != 0 {
1682 self.shift_marks_after_edit(pre_row, delta);
1683 }
1684 self.push_buffer_content_to_textarea();
1685 self.mark_content_dirty();
1686 inverse
1687 }
1688
1689 /// Migrate user marks + jumplist entries when an edit at row
1690 /// `edit_start` changes the buffer's row count by `delta` (positive
1691 /// for inserts, negative for deletes). Marks tied to a deleted row
1692 /// are dropped; marks past the affected band shift by `delta`.
1693 fn shift_marks_after_edit(&mut self, edit_start: usize, delta: isize) {
1694 if delta == 0 {
1695 return;
1696 }
1697 // Deleted-row band (only meaningful for delta < 0). Inclusive
1698 // start, exclusive end.
1699 let drop_end = if delta < 0 {
1700 edit_start.saturating_add((-delta) as usize)
1701 } else {
1702 edit_start
1703 };
1704 let shift_threshold = drop_end.max(edit_start.saturating_add(1));
1705
1706 // 0.0.36: lowercase + uppercase marks share the unified
1707 // `marks` map; one pass migrates both.
1708 let mut to_drop: Vec<char> = Vec::new();
1709 for (c, (row, _col)) in self.marks.iter_mut() {
1710 if (edit_start..drop_end).contains(row) {
1711 to_drop.push(*c);
1712 } else if *row >= shift_threshold {
1713 *row = ((*row as isize) + delta).max(0) as usize;
1714 }
1715 }
1716 for c in to_drop {
1717 self.marks.remove(&c);
1718 }
1719
1720 let shift_jumps = |entries: &mut Vec<(usize, usize)>| {
1721 entries.retain(|(row, _)| !(edit_start..drop_end).contains(row));
1722 for (row, _) in entries.iter_mut() {
1723 if *row >= shift_threshold {
1724 *row = ((*row as isize) + delta).max(0) as usize;
1725 }
1726 }
1727 };
1728 shift_jumps(&mut self.vim.jump_back);
1729 shift_jumps(&mut self.vim.jump_fwd);
1730 }
1731
1732 /// Reverse-sync helper paired with [`Editor::mutate_edit`]: rebuild
1733 /// the textarea from the buffer's lines + cursor, preserving yank
1734 /// text. Heavy (allocates a fresh `TextArea`) but correct; the
1735 /// textarea field disappears at the end of Phase 7f anyway.
1736 /// No-op since Buffer is the content authority. Retained as a
1737 /// shim so call sites in `mutate_edit` and friends don't have to
1738 /// be ripped in lockstep with the field removal.
1739 pub(crate) fn push_buffer_content_to_textarea(&mut self) {}
1740
1741 /// Single choke-point for "the buffer just changed". Sets the
1742 /// dirty flag and drops the cached `content_arc` snapshot so
1743 /// subsequent reads rebuild from the live textarea. Callers
1744 /// mutating `textarea` directly (e.g. the TUI's bracketed-paste
1745 /// path) must invoke this to keep the cache honest.
1746 pub fn mark_content_dirty(&mut self) {
1747 self.content_dirty = true;
1748 self.cached_content = None;
1749 }
1750
1751 /// Returns true if content changed since the last call, then clears the flag.
1752 pub fn take_dirty(&mut self) -> bool {
1753 let dirty = self.content_dirty;
1754 self.content_dirty = false;
1755 dirty
1756 }
1757
1758 /// Drain the queue of [`crate::types::ContentEdit`]s emitted since
1759 /// the last call. Each entry corresponds to a single buffer
1760 /// mutation funnelled through [`Editor::mutate_edit`]; block edits
1761 /// fan out to one entry per row touched.
1762 ///
1763 /// Hosts call this each frame (after [`Editor::take_content_reset`])
1764 /// to fan edits into a tree-sitter parser via `Tree::edit`.
1765 pub fn take_content_edits(&mut self) -> Vec<crate::types::ContentEdit> {
1766 std::mem::take(&mut self.pending_content_edits)
1767 }
1768
1769 /// Returns `true` if a bulk buffer replacement happened since the
1770 /// last call (e.g. `set_content` / `restore` / undo restore), then
1771 /// clears the flag. When this returns `true`, hosts should drop
1772 /// any retained syntax tree before consuming
1773 /// [`Editor::take_content_edits`].
1774 pub fn take_content_reset(&mut self) -> bool {
1775 let r = self.pending_content_reset;
1776 self.pending_content_reset = false;
1777 r
1778 }
1779
1780 /// Pull-model coarse change observation. If content changed since
1781 /// the last call, returns `Some(Arc<String>)` with the new content
1782 /// and clears the dirty flag; otherwise returns `None`.
1783 ///
1784 /// Hosts that need fine-grained edit deltas (e.g., DOM patching at
1785 /// the character level) should diff against their own previous
1786 /// snapshot. The SPEC `take_changes() -> Vec<EditOp>` API lands
1787 /// once every edit path inside the engine is instrumented; this
1788 /// coarse form covers the pull-model use case in the meantime.
1789 pub fn take_content_change(&mut self) -> Option<std::sync::Arc<String>> {
1790 if !self.content_dirty {
1791 return None;
1792 }
1793 let arc = self.content_arc();
1794 self.content_dirty = false;
1795 Some(arc)
1796 }
1797
1798 /// Returns the cursor's row within the visible textarea (0-based), updating
1799 /// the stored viewport top so subsequent calls remain accurate.
1800 pub fn cursor_screen_row(&mut self, height: u16) -> u16 {
1801 let cursor = buf_cursor_row(&self.buffer);
1802 let top = self.host.viewport().top_row;
1803 cursor.saturating_sub(top).min(height as usize - 1) as u16
1804 }
1805
1806 /// Returns the cursor's screen position `(x, y)` for the textarea
1807 /// described by `(area_x, area_y, area_width, area_height)`.
1808 /// Accounts for line-number gutter and viewport scroll. Returns
1809 /// `None` if the cursor is outside the visible viewport. Always
1810 /// available (engine-native; no ratatui dependency).
1811 ///
1812 /// Renamed from `cursor_screen_pos_xywh` in 0.0.32 — the
1813 /// ratatui-flavoured `Rect` variant is now
1814 /// [`Editor::cursor_screen_pos_in_rect`] (cfg `ratatui`).
1815 pub fn cursor_screen_pos(
1816 &self,
1817 area_x: u16,
1818 area_y: u16,
1819 area_width: u16,
1820 area_height: u16,
1821 ) -> Option<(u16, u16)> {
1822 let (pos_row, pos_col) = buf_cursor_rc(&self.buffer);
1823 let v = self.host.viewport();
1824 if pos_row < v.top_row || pos_col < v.top_col {
1825 return None;
1826 }
1827 let lnum_width = if self.settings.number || self.settings.relativenumber {
1828 let needed = buf_row_count(&self.buffer).to_string().len() + 1;
1829 needed.max(self.settings.numberwidth) as u16
1830 } else {
1831 0
1832 };
1833 let dy = (pos_row - v.top_row) as u16;
1834 // Convert char column to visual column so cursor lands on the
1835 // correct cell when the line contains tabs (which the renderer
1836 // expands to TAB_WIDTH stops). Tab width must match the renderer.
1837 let line = self.buffer.line(pos_row).unwrap_or("");
1838 let tab_width = if v.tab_width == 0 {
1839 4
1840 } else {
1841 v.tab_width as usize
1842 };
1843 let visual_pos = visual_col_for_char(line, pos_col, tab_width);
1844 let visual_top = visual_col_for_char(line, v.top_col, tab_width);
1845 let dx = (visual_pos - visual_top) as u16;
1846 if dy >= area_height || dx + lnum_width >= area_width {
1847 return None;
1848 }
1849 Some((area_x + lnum_width + dx, area_y + dy))
1850 }
1851
1852 /// Ratatui [`Rect`]-flavoured wrapper around
1853 /// [`Editor::cursor_screen_pos`]. Behind the `ratatui` feature.
1854 ///
1855 /// Renamed from `cursor_screen_pos` in 0.0.32 — the unprefixed
1856 /// name now belongs to the engine-native variant.
1857 #[cfg(feature = "ratatui")]
1858 pub fn cursor_screen_pos_in_rect(&self, area: Rect) -> Option<(u16, u16)> {
1859 self.cursor_screen_pos(area.x, area.y, area.width, area.height)
1860 }
1861
1862 pub fn vim_mode(&self) -> VimMode {
1863 self.vim.public_mode()
1864 }
1865
1866 /// Bounds of the active visual-block rectangle as
1867 /// `(top_row, bot_row, left_col, right_col)` — all inclusive.
1868 /// `None` when we're not in VisualBlock mode.
1869 /// Read-only view of the live `/` or `?` prompt. `None` outside
1870 /// search-prompt mode.
1871 pub fn search_prompt(&self) -> Option<&crate::vim::SearchPrompt> {
1872 self.vim.search_prompt.as_ref()
1873 }
1874
1875 /// Most recent committed search pattern (persists across `n` / `N`
1876 /// and across prompt exits). `None` before the first search.
1877 pub fn last_search(&self) -> Option<&str> {
1878 self.vim.last_search.as_deref()
1879 }
1880
1881 /// Whether the last committed search was a forward `/` (`true`) or
1882 /// a backward `?` (`false`). `n` and `N` consult this to honour the
1883 /// direction the user committed.
1884 pub fn last_search_forward(&self) -> bool {
1885 self.vim.last_search_forward
1886 }
1887
1888 /// Set the most recent committed search text + direction. Used by
1889 /// host-driven prompts (e.g. apps/hjkl's `/` `?` prompt that lives
1890 /// outside the engine's vim FSM) so `n` / `N` repeat the host's
1891 /// most recent commit with the right direction. Pass `None` /
1892 /// `true` to clear.
1893 pub fn set_last_search(&mut self, text: Option<String>, forward: bool) {
1894 self.vim.last_search = text;
1895 self.vim.last_search_forward = forward;
1896 }
1897
1898 /// Start/end `(row, col)` of the active char-wise Visual selection
1899 /// (inclusive on both ends, positionally ordered). `None` when not
1900 /// in Visual mode.
1901 pub fn char_highlight(&self) -> Option<((usize, usize), (usize, usize))> {
1902 if self.vim_mode() != VimMode::Visual {
1903 return None;
1904 }
1905 let anchor = self.vim.visual_anchor;
1906 let cursor = self.cursor();
1907 let (start, end) = if anchor <= cursor {
1908 (anchor, cursor)
1909 } else {
1910 (cursor, anchor)
1911 };
1912 Some((start, end))
1913 }
1914
1915 /// Top/bottom rows of the active VisualLine selection (inclusive).
1916 /// `None` when we're not in VisualLine mode.
1917 pub fn line_highlight(&self) -> Option<(usize, usize)> {
1918 if self.vim_mode() != VimMode::VisualLine {
1919 return None;
1920 }
1921 let anchor = self.vim.visual_line_anchor;
1922 let cursor = buf_cursor_row(&self.buffer);
1923 Some((anchor.min(cursor), anchor.max(cursor)))
1924 }
1925
1926 pub fn block_highlight(&self) -> Option<(usize, usize, usize, usize)> {
1927 if self.vim_mode() != VimMode::VisualBlock {
1928 return None;
1929 }
1930 let (ar, ac) = self.vim.block_anchor;
1931 let cr = buf_cursor_row(&self.buffer);
1932 let cc = self.vim.block_vcol;
1933 let top = ar.min(cr);
1934 let bot = ar.max(cr);
1935 let left = ac.min(cc);
1936 let right = ac.max(cc);
1937 Some((top, bot, left, right))
1938 }
1939
1940 /// Active selection in `hjkl_buffer::Selection` shape. `None` when
1941 /// not in a Visual mode. Phase 7d-i wiring — the host hands this
1942 /// straight to `BufferView` once render flips off textarea
1943 /// (Phase 7d-ii drops the `paint_*_overlay` calls on the same
1944 /// switch).
1945 pub fn buffer_selection(&self) -> Option<hjkl_buffer::Selection> {
1946 use hjkl_buffer::{Position, Selection};
1947 match self.vim_mode() {
1948 VimMode::Visual => {
1949 let (ar, ac) = self.vim.visual_anchor;
1950 let head = buf_cursor_pos(&self.buffer);
1951 Some(Selection::Char {
1952 anchor: Position::new(ar, ac),
1953 head,
1954 })
1955 }
1956 VimMode::VisualLine => {
1957 let anchor_row = self.vim.visual_line_anchor;
1958 let head_row = buf_cursor_row(&self.buffer);
1959 Some(Selection::Line {
1960 anchor_row,
1961 head_row,
1962 })
1963 }
1964 VimMode::VisualBlock => {
1965 let (ar, ac) = self.vim.block_anchor;
1966 let cr = buf_cursor_row(&self.buffer);
1967 let cc = self.vim.block_vcol;
1968 Some(Selection::Block {
1969 anchor: Position::new(ar, ac),
1970 head: Position::new(cr, cc),
1971 })
1972 }
1973 _ => None,
1974 }
1975 }
1976
1977 /// Force back to normal mode (used when dismissing completions etc.)
1978 pub fn force_normal(&mut self) {
1979 self.vim.force_normal();
1980 }
1981
1982 pub fn content(&self) -> String {
1983 let n = buf_row_count(&self.buffer);
1984 let mut s = String::new();
1985 for r in 0..n {
1986 if r > 0 {
1987 s.push('\n');
1988 }
1989 s.push_str(crate::types::Query::line(&self.buffer, r as u32));
1990 }
1991 s.push('\n');
1992 s
1993 }
1994
1995 /// Same logical output as [`content`], but returns a cached
1996 /// `Arc<String>` so back-to-back reads within an un-mutated window
1997 /// are ref-count bumps instead of multi-MB joins. The cache is
1998 /// invalidated by every [`mark_content_dirty`] call.
1999 pub fn content_arc(&mut self) -> std::sync::Arc<String> {
2000 if let Some(arc) = &self.cached_content {
2001 return std::sync::Arc::clone(arc);
2002 }
2003 let arc = std::sync::Arc::new(self.content());
2004 self.cached_content = Some(std::sync::Arc::clone(&arc));
2005 arc
2006 }
2007
2008 pub fn set_content(&mut self, text: &str) {
2009 let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
2010 while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
2011 lines.pop();
2012 }
2013 if lines.is_empty() {
2014 lines.push(String::new());
2015 }
2016 let _ = lines;
2017 crate::types::BufferEdit::replace_all(&mut self.buffer, text);
2018 self.undo_stack.clear();
2019 self.redo_stack.clear();
2020 // Whole-buffer replace supersedes any queued ContentEdits.
2021 self.pending_content_edits.clear();
2022 self.pending_content_reset = true;
2023 self.mark_content_dirty();
2024 }
2025
2026 /// Feed an SPEC [`crate::PlannedInput`] into the engine.
2027 ///
2028 /// Bridge for hosts that don't carry crossterm — buffr's CEF
2029 /// shell, future GUI frontends. Converts directly to the engine's
2030 /// internal [`Input`] type and dispatches through the vim FSM,
2031 /// bypassing crossterm entirely so this entry point is always
2032 /// available regardless of the `crossterm` feature.
2033 ///
2034 /// `Input::Mouse`, `Input::Paste`, `Input::FocusGained`,
2035 /// `Input::FocusLost`, and `Input::Resize` currently fall through
2036 /// without effect — the legacy FSM doesn't dispatch them. They're
2037 /// accepted so the host can pump them into the engine without
2038 /// special-casing.
2039 ///
2040 /// Returns `true` when the keystroke was consumed.
2041 pub fn feed_input(&mut self, input: crate::PlannedInput) -> bool {
2042 use crate::{PlannedInput, SpecialKey};
2043 let (key, mods) = match input {
2044 PlannedInput::Char(c, m) => (Key::Char(c), m),
2045 PlannedInput::Key(k, m) => {
2046 let key = match k {
2047 SpecialKey::Esc => Key::Esc,
2048 SpecialKey::Enter => Key::Enter,
2049 SpecialKey::Backspace => Key::Backspace,
2050 SpecialKey::Tab => Key::Tab,
2051 // Engine's internal `Key` doesn't model BackTab as a
2052 // distinct variant — fall through to the FSM as
2053 // shift+Tab, matching crossterm semantics.
2054 SpecialKey::BackTab => Key::Tab,
2055 SpecialKey::Up => Key::Up,
2056 SpecialKey::Down => Key::Down,
2057 SpecialKey::Left => Key::Left,
2058 SpecialKey::Right => Key::Right,
2059 SpecialKey::Home => Key::Home,
2060 SpecialKey::End => Key::End,
2061 SpecialKey::PageUp => Key::PageUp,
2062 SpecialKey::PageDown => Key::PageDown,
2063 // Engine's `Key` has no Insert / F(n) — drop to Null
2064 // (FSM ignores it) which matches the crossterm path
2065 // (`crossterm_to_input` mapped these to Null too).
2066 SpecialKey::Insert => Key::Null,
2067 SpecialKey::Delete => Key::Delete,
2068 SpecialKey::F(_) => Key::Null,
2069 };
2070 let m = if matches!(k, SpecialKey::BackTab) {
2071 crate::Modifiers { shift: true, ..m }
2072 } else {
2073 m
2074 };
2075 (key, m)
2076 }
2077 // Variants the legacy FSM doesn't consume yet.
2078 PlannedInput::Mouse(_)
2079 | PlannedInput::Paste(_)
2080 | PlannedInput::FocusGained
2081 | PlannedInput::FocusLost
2082 | PlannedInput::Resize(_, _) => return false,
2083 };
2084 if key == Key::Null {
2085 return false;
2086 }
2087 let event = Input {
2088 key,
2089 ctrl: mods.ctrl,
2090 alt: mods.alt,
2091 shift: mods.shift,
2092 };
2093 let consumed = vim::step(self, event);
2094 self.emit_cursor_shape_if_changed();
2095 consumed
2096 }
2097
2098 /// Drain the pending change log produced by buffer mutations.
2099 ///
2100 /// Returns a `Vec<EditOp>` covering edits applied since the last
2101 /// call. Empty when no edits ran. Pull-model, complementary to
2102 /// [`Editor::take_content_change`] which gives back the new full
2103 /// content.
2104 ///
2105 /// Mapping coverage:
2106 /// - InsertChar / InsertStr → exact `EditOp` with empty range +
2107 /// replacement.
2108 /// - DeleteRange (`Char` kind) → exact range + empty replacement.
2109 /// - Replace → exact range + new replacement.
2110 /// - DeleteRange (`Line`/`Block`), JoinLines, SplitLines,
2111 /// InsertBlock, DeleteBlockChunks → best-effort placeholder
2112 /// covering the touched range. Hosts wanting per-cell deltas
2113 /// should diff their own `lines()` snapshot.
2114 pub fn take_changes(&mut self) -> Vec<crate::types::Edit> {
2115 std::mem::take(&mut self.change_log)
2116 }
2117
2118 /// Read the engine's current settings as a SPEC
2119 /// [`crate::types::Options`].
2120 ///
2121 /// Bridges between the legacy [`Settings`] (which carries fewer
2122 /// fields than SPEC) and the planned 0.1.0 trait surface. Fields
2123 /// not present in `Settings` fall back to vim defaults (e.g.,
2124 /// `expandtab=false`, `wrapscan=true`, `timeout_len=1000ms`).
2125 /// Once trait extraction lands, this becomes the canonical config
2126 /// reader and `Settings` retires.
2127 pub fn current_options(&self) -> crate::types::Options {
2128 crate::types::Options {
2129 shiftwidth: self.settings.shiftwidth as u32,
2130 tabstop: self.settings.tabstop as u32,
2131 softtabstop: self.settings.softtabstop as u32,
2132 textwidth: self.settings.textwidth as u32,
2133 expandtab: self.settings.expandtab,
2134 ignorecase: self.settings.ignore_case,
2135 smartcase: self.settings.smartcase,
2136 wrapscan: self.settings.wrapscan,
2137 wrap: match self.settings.wrap {
2138 hjkl_buffer::Wrap::None => crate::types::WrapMode::None,
2139 hjkl_buffer::Wrap::Char => crate::types::WrapMode::Char,
2140 hjkl_buffer::Wrap::Word => crate::types::WrapMode::Word,
2141 },
2142 readonly: self.settings.readonly,
2143 autoindent: self.settings.autoindent,
2144 smartindent: self.settings.smartindent,
2145 undo_levels: self.settings.undo_levels,
2146 undo_break_on_motion: self.settings.undo_break_on_motion,
2147 iskeyword: self.settings.iskeyword.clone(),
2148 timeout_len: self.settings.timeout_len,
2149 ..crate::types::Options::default()
2150 }
2151 }
2152
2153 /// Apply a SPEC [`crate::types::Options`] to the engine's settings.
2154 /// Only the fields backed by today's [`Settings`] take effect;
2155 /// remaining options become live once trait extraction wires them
2156 /// through.
2157 pub fn apply_options(&mut self, opts: &crate::types::Options) {
2158 self.settings.shiftwidth = opts.shiftwidth as usize;
2159 self.settings.tabstop = opts.tabstop as usize;
2160 self.settings.softtabstop = opts.softtabstop as usize;
2161 self.settings.textwidth = opts.textwidth as usize;
2162 self.settings.expandtab = opts.expandtab;
2163 self.settings.ignore_case = opts.ignorecase;
2164 self.settings.smartcase = opts.smartcase;
2165 self.settings.wrapscan = opts.wrapscan;
2166 self.settings.wrap = match opts.wrap {
2167 crate::types::WrapMode::None => hjkl_buffer::Wrap::None,
2168 crate::types::WrapMode::Char => hjkl_buffer::Wrap::Char,
2169 crate::types::WrapMode::Word => hjkl_buffer::Wrap::Word,
2170 };
2171 self.settings.readonly = opts.readonly;
2172 self.settings.autoindent = opts.autoindent;
2173 self.settings.smartindent = opts.smartindent;
2174 self.settings.undo_levels = opts.undo_levels;
2175 self.settings.undo_break_on_motion = opts.undo_break_on_motion;
2176 self.set_iskeyword(opts.iskeyword.clone());
2177 self.settings.timeout_len = opts.timeout_len;
2178 self.settings.number = opts.number;
2179 self.settings.relativenumber = opts.relativenumber;
2180 self.settings.numberwidth = opts.numberwidth;
2181 self.settings.cursorline = opts.cursorline;
2182 self.settings.cursorcolumn = opts.cursorcolumn;
2183 self.settings.signcolumn = opts.signcolumn;
2184 self.settings.foldcolumn = opts.foldcolumn;
2185 self.settings.colorcolumn = opts.colorcolumn.clone();
2186 }
2187
2188 /// Active visual selection as a SPEC [`crate::types::Highlight`]
2189 /// with [`crate::types::HighlightKind::Selection`].
2190 ///
2191 /// Returns `None` when the editor isn't in a Visual mode.
2192 /// Visual-line and visual-block selections collapse to the
2193 /// bounding char range of the selection — the SPEC `Selection`
2194 /// kind doesn't carry sub-line info today; hosts that need full
2195 /// line / block geometry continue to read [`buffer_selection`]
2196 /// (the legacy [`hjkl_buffer::Selection`] shape).
2197 pub fn selection_highlight(&self) -> Option<crate::types::Highlight> {
2198 use crate::types::{Highlight, HighlightKind, Pos};
2199 let sel = self.buffer_selection()?;
2200 let (start, end) = match sel {
2201 hjkl_buffer::Selection::Char { anchor, head } => {
2202 let a = (anchor.row, anchor.col);
2203 let h = (head.row, head.col);
2204 if a <= h { (a, h) } else { (h, a) }
2205 }
2206 hjkl_buffer::Selection::Line {
2207 anchor_row,
2208 head_row,
2209 } => {
2210 let (top, bot) = if anchor_row <= head_row {
2211 (anchor_row, head_row)
2212 } else {
2213 (head_row, anchor_row)
2214 };
2215 let last_col = buf_line(&self.buffer, bot).map(|l| l.len()).unwrap_or(0);
2216 ((top, 0), (bot, last_col))
2217 }
2218 hjkl_buffer::Selection::Block { anchor, head } => {
2219 let (top, bot) = if anchor.row <= head.row {
2220 (anchor.row, head.row)
2221 } else {
2222 (head.row, anchor.row)
2223 };
2224 let (left, right) = if anchor.col <= head.col {
2225 (anchor.col, head.col)
2226 } else {
2227 (head.col, anchor.col)
2228 };
2229 ((top, left), (bot, right))
2230 }
2231 };
2232 Some(Highlight {
2233 range: Pos {
2234 line: start.0 as u32,
2235 col: start.1 as u32,
2236 }..Pos {
2237 line: end.0 as u32,
2238 col: end.1 as u32,
2239 },
2240 kind: HighlightKind::Selection,
2241 })
2242 }
2243
2244 /// SPEC-typed highlights for `line`.
2245 ///
2246 /// Two emission modes:
2247 ///
2248 /// - **IncSearch**: the user is typing a `/` or `?` prompt and
2249 /// `Editor::search_prompt` is `Some`. Live-preview matches of
2250 /// the in-flight pattern surface as
2251 /// [`crate::types::HighlightKind::IncSearch`].
2252 /// - **SearchMatch**: the prompt has been committed (or absent)
2253 /// and the buffer's armed pattern is non-empty. Matches surface
2254 /// as [`crate::types::HighlightKind::SearchMatch`].
2255 ///
2256 /// Selection / MatchParen / Syntax(id) variants land once the
2257 /// trait extraction routes the FSM's selection set + the host's
2258 /// syntax pipeline through the [`crate::types::Host`] trait.
2259 ///
2260 /// Returns an empty vec when there is nothing to highlight or
2261 /// `line` is out of bounds.
2262 pub fn highlights_for_line(&mut self, line: u32) -> Vec<crate::types::Highlight> {
2263 use crate::types::{Highlight, HighlightKind, Pos};
2264 let row = line as usize;
2265 if row >= buf_row_count(&self.buffer) {
2266 return Vec::new();
2267 }
2268
2269 // Live preview while the prompt is open beats the committed
2270 // pattern.
2271 if let Some(prompt) = self.search_prompt() {
2272 if prompt.text.is_empty() {
2273 return Vec::new();
2274 }
2275 let Ok(re) = regex::Regex::new(&prompt.text) else {
2276 return Vec::new();
2277 };
2278 let Some(haystack) = buf_line(&self.buffer, row) else {
2279 return Vec::new();
2280 };
2281 return re
2282 .find_iter(haystack)
2283 .map(|m| Highlight {
2284 range: Pos {
2285 line,
2286 col: m.start() as u32,
2287 }..Pos {
2288 line,
2289 col: m.end() as u32,
2290 },
2291 kind: HighlightKind::IncSearch,
2292 })
2293 .collect();
2294 }
2295
2296 if self.search_state.pattern.is_none() {
2297 return Vec::new();
2298 }
2299 let dgen = crate::types::Query::dirty_gen(&self.buffer);
2300 crate::search::search_matches(&self.buffer, &mut self.search_state, dgen, row)
2301 .into_iter()
2302 .map(|(start, end)| Highlight {
2303 range: Pos {
2304 line,
2305 col: start as u32,
2306 }..Pos {
2307 line,
2308 col: end as u32,
2309 },
2310 kind: HighlightKind::SearchMatch,
2311 })
2312 .collect()
2313 }
2314
2315 /// Build the engine's [`crate::types::RenderFrame`] for the
2316 /// current state. Hosts call this once per redraw and diff
2317 /// across frames.
2318 ///
2319 /// Coarse today — covers mode + cursor + cursor shape + viewport
2320 /// top + line count. SPEC-target fields (selections, highlights,
2321 /// command line, search prompt, status line) land once trait
2322 /// extraction routes them through `SelectionSet` and the
2323 /// `Highlight` pipeline.
2324 pub fn render_frame(&self) -> crate::types::RenderFrame {
2325 use crate::types::{CursorShape, RenderFrame, SnapshotMode};
2326 let (cursor_row, cursor_col) = self.cursor();
2327 let (mode, shape) = match self.vim_mode() {
2328 crate::VimMode::Normal => (SnapshotMode::Normal, CursorShape::Block),
2329 crate::VimMode::Insert => (SnapshotMode::Insert, CursorShape::Bar),
2330 crate::VimMode::Visual => (SnapshotMode::Visual, CursorShape::Block),
2331 crate::VimMode::VisualLine => (SnapshotMode::VisualLine, CursorShape::Block),
2332 crate::VimMode::VisualBlock => (SnapshotMode::VisualBlock, CursorShape::Block),
2333 };
2334 RenderFrame {
2335 mode,
2336 cursor_row: cursor_row as u32,
2337 cursor_col: cursor_col as u32,
2338 cursor_shape: shape,
2339 viewport_top: self.host.viewport().top_row as u32,
2340 line_count: crate::types::Query::line_count(&self.buffer),
2341 }
2342 }
2343
2344 /// Capture the editor's coarse state into a serde-friendly
2345 /// [`crate::types::EditorSnapshot`].
2346 ///
2347 /// Today's snapshot covers mode, cursor, lines, viewport top.
2348 /// Registers, marks, jump list, undo tree, and full options arrive
2349 /// once phase 5 trait extraction lands the generic
2350 /// `Editor<B: Buffer, H: Host>` constructor — this method's surface
2351 /// stays stable; only the snapshot's internal fields grow.
2352 ///
2353 /// Distinct from the internal `snapshot` used by undo (which
2354 /// returns `(Vec<String>, (usize, usize))`); host-facing
2355 /// persistence goes through this one.
2356 pub fn take_snapshot(&self) -> crate::types::EditorSnapshot {
2357 use crate::types::{EditorSnapshot, SnapshotMode};
2358 let mode = match self.vim_mode() {
2359 crate::VimMode::Normal => SnapshotMode::Normal,
2360 crate::VimMode::Insert => SnapshotMode::Insert,
2361 crate::VimMode::Visual => SnapshotMode::Visual,
2362 crate::VimMode::VisualLine => SnapshotMode::VisualLine,
2363 crate::VimMode::VisualBlock => SnapshotMode::VisualBlock,
2364 };
2365 let cursor = self.cursor();
2366 let cursor = (cursor.0 as u32, cursor.1 as u32);
2367 let lines: Vec<String> = buf_lines_to_vec(&self.buffer);
2368 let viewport_top = self.host.viewport().top_row as u32;
2369 let marks = self
2370 .marks
2371 .iter()
2372 .map(|(c, (r, col))| (*c, (*r as u32, *col as u32)))
2373 .collect();
2374 EditorSnapshot {
2375 version: EditorSnapshot::VERSION,
2376 mode,
2377 cursor,
2378 lines,
2379 viewport_top,
2380 registers: self.registers.clone(),
2381 marks,
2382 }
2383 }
2384
2385 /// Restore editor state from an [`EditorSnapshot`]. Returns
2386 /// [`crate::EngineError::SnapshotVersion`] if the snapshot's
2387 /// `version` doesn't match [`EditorSnapshot::VERSION`].
2388 ///
2389 /// Mode is best-effort: `SnapshotMode` only round-trips the
2390 /// status-line summary, not the full FSM state. Visual / Insert
2391 /// mode entry happens through synthetic key dispatch when needed.
2392 pub fn restore_snapshot(
2393 &mut self,
2394 snap: crate::types::EditorSnapshot,
2395 ) -> Result<(), crate::EngineError> {
2396 use crate::types::EditorSnapshot;
2397 if snap.version != EditorSnapshot::VERSION {
2398 return Err(crate::EngineError::SnapshotVersion(
2399 snap.version,
2400 EditorSnapshot::VERSION,
2401 ));
2402 }
2403 let text = snap.lines.join("\n");
2404 self.set_content(&text);
2405 self.jump_cursor(snap.cursor.0 as usize, snap.cursor.1 as usize);
2406 self.host.viewport_mut().top_row = snap.viewport_top as usize;
2407 self.registers = snap.registers;
2408 self.marks = snap
2409 .marks
2410 .into_iter()
2411 .map(|(c, (r, col))| (c, (r as usize, col as usize)))
2412 .collect();
2413 Ok(())
2414 }
2415
2416 /// Install `text` as the pending yank buffer so the next `p`/`P` pastes
2417 /// it. Linewise is inferred from a trailing newline, matching how `yy`/`dd`
2418 /// shape their payload.
2419 pub fn seed_yank(&mut self, text: String) {
2420 let linewise = text.ends_with('\n');
2421 self.vim.yank_linewise = linewise;
2422 self.registers.unnamed = crate::registers::Slot { text, linewise };
2423 }
2424
2425 /// Scroll the viewport down by `rows`. The cursor stays on its
2426 /// absolute line (vim convention) unless the scroll would take it
2427 /// off-screen — in that case it's clamped to the first row still
2428 /// visible.
2429 pub fn scroll_down(&mut self, rows: i16) {
2430 self.scroll_viewport(rows);
2431 }
2432
2433 /// Scroll the viewport up by `rows`. Cursor stays unless it would
2434 /// fall off the bottom of the new viewport, then clamp to the
2435 /// bottom-most visible row.
2436 pub fn scroll_up(&mut self, rows: i16) {
2437 self.scroll_viewport(-rows);
2438 }
2439
2440 /// Vim's `scrolloff` default — keep the cursor at least this many
2441 /// rows away from the top / bottom edge of the viewport while
2442 /// scrolling. Collapses to `height / 2` for tiny viewports.
2443 const SCROLLOFF: usize = 5;
2444
2445 /// Scroll the viewport so the cursor stays at least `SCROLLOFF`
2446 /// rows from each edge. Replaces the bare
2447 /// `Buffer::ensure_cursor_visible` call at end-of-step so motions
2448 /// don't park the cursor on the very last visible row.
2449 pub fn ensure_cursor_in_scrolloff(&mut self) {
2450 let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2451 if height == 0 {
2452 // 0.0.42 (Patch C-δ.7): viewport math lifted onto engine
2453 // free fns over `B: Query [+ Cursor]` + `&dyn FoldProvider`.
2454 // Disjoint-field borrow split: `self.buffer` (immutable via
2455 // `folds` snapshot + cursor) and `self.host` (mutable
2456 // viewport ref) live on distinct struct fields, so one
2457 // statement satisfies the borrow checker.
2458 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2459 crate::viewport_math::ensure_cursor_visible(
2460 &self.buffer,
2461 &folds,
2462 self.host.viewport_mut(),
2463 );
2464 return;
2465 }
2466 // Cap margin at (height - 1) / 2 so the upper + lower bands
2467 // can't overlap on tiny windows (margin=5 + height=10 would
2468 // otherwise produce contradictory clamp ranges).
2469 let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
2470 // Soft-wrap path: scrolloff math runs in *screen rows*, not
2471 // doc rows, since a wrapped doc row spans many visual lines.
2472 if !matches!(self.host.viewport().wrap, hjkl_buffer::Wrap::None) {
2473 self.ensure_scrolloff_wrap(height, margin);
2474 return;
2475 }
2476 let cursor_row = buf_cursor_row(&self.buffer);
2477 let last_row = buf_row_count(&self.buffer).saturating_sub(1);
2478 let v = self.host.viewport_mut();
2479 // Top edge: cursor_row should sit at >= top_row + margin.
2480 if cursor_row < v.top_row + margin {
2481 v.top_row = cursor_row.saturating_sub(margin);
2482 }
2483 // Bottom edge: cursor_row should sit at <= top_row + height - 1 - margin.
2484 let max_bottom = height.saturating_sub(1).saturating_sub(margin);
2485 if cursor_row > v.top_row + max_bottom {
2486 v.top_row = cursor_row.saturating_sub(max_bottom);
2487 }
2488 // Clamp top_row so we never scroll past the buffer's bottom.
2489 let max_top = last_row.saturating_sub(height.saturating_sub(1));
2490 if v.top_row > max_top {
2491 v.top_row = max_top;
2492 }
2493 // Defer to Buffer for column-side scroll (no scrolloff for
2494 // horizontal scrolling — vim default `sidescrolloff = 0`).
2495 let cursor = buf_cursor_pos(&self.buffer);
2496 self.host.viewport_mut().ensure_visible(cursor);
2497 }
2498
2499 /// Soft-wrap-aware scrolloff. Walks `top_row` one visible doc row
2500 /// at a time so the cursor's *screen* row stays inside
2501 /// `[margin, height - 1 - margin]`, then clamps `top_row` so the
2502 /// buffer's bottom never leaves blank rows below it.
2503 fn ensure_scrolloff_wrap(&mut self, height: usize, margin: usize) {
2504 let cursor_row = buf_cursor_row(&self.buffer);
2505 // Step 1 — cursor above viewport: snap top to cursor row,
2506 // then we'll fix up the margin below.
2507 if cursor_row < self.host.viewport().top_row {
2508 let v = self.host.viewport_mut();
2509 v.top_row = cursor_row;
2510 v.top_col = 0;
2511 }
2512 // Step 2 — push top forward until cursor's screen row is
2513 // within the bottom margin (`csr <= height - 1 - margin`).
2514 // 0.0.33 (Patch C-γ): fold-iteration goes through the
2515 // [`crate::types::FoldProvider`] surface via
2516 // [`crate::buffer_impl::BufferFoldProvider`]. 0.0.34 (Patch
2517 // C-δ.1): `cursor_screen_row` / `max_top_for_height` now take
2518 // a `&Viewport` parameter; the host owns the viewport, so the
2519 // disjoint `(self.host, self.buffer)` borrows split cleanly.
2520 let max_csr = height.saturating_sub(1).saturating_sub(margin);
2521 loop {
2522 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2523 let csr =
2524 crate::viewport_math::cursor_screen_row(&self.buffer, &folds, self.host.viewport())
2525 .unwrap_or(0);
2526 if csr <= max_csr {
2527 break;
2528 }
2529 let top = self.host.viewport().top_row;
2530 let row_count = buf_row_count(&self.buffer);
2531 let next = {
2532 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2533 <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::next_visible_row(&folds, top, row_count)
2534 };
2535 let Some(next) = next else {
2536 break;
2537 };
2538 // Don't walk past the cursor's row.
2539 if next > cursor_row {
2540 self.host.viewport_mut().top_row = cursor_row;
2541 break;
2542 }
2543 self.host.viewport_mut().top_row = next;
2544 }
2545 // Step 3 — pull top backward until cursor's screen row is
2546 // past the top margin (`csr >= margin`).
2547 loop {
2548 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2549 let csr =
2550 crate::viewport_math::cursor_screen_row(&self.buffer, &folds, self.host.viewport())
2551 .unwrap_or(0);
2552 if csr >= margin {
2553 break;
2554 }
2555 let top = self.host.viewport().top_row;
2556 let prev = {
2557 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2558 <crate::buffer_impl::BufferFoldProvider<'_> as crate::types::FoldProvider>::prev_visible_row(&folds, top)
2559 };
2560 let Some(prev) = prev else {
2561 break;
2562 };
2563 self.host.viewport_mut().top_row = prev;
2564 }
2565 // Step 4 — clamp top so the buffer's bottom doesn't leave
2566 // blank rows below it. `max_top_for_height` walks segments
2567 // backward from the last row until it accumulates `height`
2568 // screen rows.
2569 let max_top = {
2570 let folds = crate::buffer_impl::BufferFoldProvider::new(&self.buffer);
2571 crate::viewport_math::max_top_for_height(
2572 &self.buffer,
2573 &folds,
2574 self.host.viewport(),
2575 height,
2576 )
2577 };
2578 if self.host.viewport().top_row > max_top {
2579 self.host.viewport_mut().top_row = max_top;
2580 }
2581 self.host.viewport_mut().top_col = 0;
2582 }
2583
2584 fn scroll_viewport(&mut self, delta: i16) {
2585 if delta == 0 {
2586 return;
2587 }
2588 // Bump the host viewport's top within bounds.
2589 let total_rows = buf_row_count(&self.buffer) as isize;
2590 let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2591 let cur_top = self.host.viewport().top_row as isize;
2592 let new_top = (cur_top + delta as isize)
2593 .max(0)
2594 .min((total_rows - 1).max(0)) as usize;
2595 self.host.viewport_mut().top_row = new_top;
2596 // Mirror to textarea so its viewport reads (still consumed by
2597 // a couple of helpers) stay accurate.
2598 let _ = cur_top;
2599 if height == 0 {
2600 return;
2601 }
2602 // Apply scrolloff: keep the cursor at least SCROLLOFF rows
2603 // from the visible viewport edges.
2604 let (cursor_row, cursor_col) = buf_cursor_rc(&self.buffer);
2605 let margin = Self::SCROLLOFF.min(height / 2);
2606 let min_row = new_top + margin;
2607 let max_row = new_top + height.saturating_sub(1).saturating_sub(margin);
2608 let target_row = cursor_row.clamp(min_row, max_row.max(min_row));
2609 if target_row != cursor_row {
2610 let line_len = buf_line(&self.buffer, target_row)
2611 .map(|l| l.chars().count())
2612 .unwrap_or(0);
2613 let target_col = cursor_col.min(line_len.saturating_sub(1));
2614 buf_set_cursor_rc(&mut self.buffer, target_row, target_col);
2615 }
2616 }
2617
2618 pub fn goto_line(&mut self, line: usize) {
2619 let row = line.saturating_sub(1);
2620 let max = buf_row_count(&self.buffer).saturating_sub(1);
2621 let target = row.min(max);
2622 buf_set_cursor_rc(&mut self.buffer, target, 0);
2623 // Vim: `:N` / `+N` jump scrolls the viewport too — without this
2624 // the cursor lands off-screen and the user has to scroll
2625 // manually to see it.
2626 self.ensure_cursor_in_scrolloff();
2627 }
2628
2629 /// Scroll so the cursor row lands at the given viewport position:
2630 /// `Center` → middle row, `Top` → first row, `Bottom` → last row.
2631 /// Cursor stays on its absolute line; only the viewport moves.
2632 pub(super) fn scroll_cursor_to(&mut self, pos: CursorScrollTarget) {
2633 let height = self.viewport_height.load(Ordering::Relaxed) as usize;
2634 if height == 0 {
2635 return;
2636 }
2637 let cur_row = buf_cursor_row(&self.buffer);
2638 let cur_top = self.host.viewport().top_row;
2639 // Scrolloff awareness: `zt` lands the cursor at the top edge
2640 // of the viable area (top + margin), `zb` at the bottom edge
2641 // (top + height - 1 - margin). Match the cap used by
2642 // `ensure_cursor_in_scrolloff` so contradictory bounds are
2643 // impossible on tiny viewports.
2644 let margin = Self::SCROLLOFF.min(height.saturating_sub(1) / 2);
2645 let new_top = match pos {
2646 CursorScrollTarget::Center => cur_row.saturating_sub(height / 2),
2647 CursorScrollTarget::Top => cur_row.saturating_sub(margin),
2648 CursorScrollTarget::Bottom => {
2649 cur_row.saturating_sub(height.saturating_sub(1).saturating_sub(margin))
2650 }
2651 };
2652 if new_top == cur_top {
2653 return;
2654 }
2655 self.host.viewport_mut().top_row = new_top;
2656 }
2657
2658 /// Translate a terminal mouse position into a (row, col) inside
2659 /// the document. The outer editor area is described by `(area_x,
2660 /// area_y, area_width)` (height is unused). 1-row tab bar at the
2661 /// top, then the textarea with 1 cell of horizontal pane padding
2662 /// on each side. Clicks past the line's last character clamp to
2663 /// the last char (Normal-mode invariant) — never past it.
2664 /// Char-counted, not byte-counted.
2665 ///
2666 /// Ratatui-free; [`Editor::mouse_to_doc_pos`] (behind the
2667 /// `ratatui` feature) is a thin `Rect`-flavoured wrapper.
2668 fn mouse_to_doc_pos_xy(&self, area_x: u16, area_y: u16, col: u16, row: u16) -> (usize, usize) {
2669 let n = buf_row_count(&self.buffer);
2670 let inner_top = area_y.saturating_add(1); // tab bar row
2671 let lnum_width = if self.settings.number || self.settings.relativenumber {
2672 let needed = n.to_string().len() + 1;
2673 needed.max(self.settings.numberwidth) as u16
2674 } else {
2675 0
2676 };
2677 let content_x = area_x.saturating_add(1).saturating_add(lnum_width);
2678 let rel_row = row.saturating_sub(inner_top) as usize;
2679 let top = self.host.viewport().top_row;
2680 let doc_row = (top + rel_row).min(n.saturating_sub(1));
2681 let rel_col = col.saturating_sub(content_x) as usize;
2682 let line_chars = buf_line(&self.buffer, doc_row)
2683 .map(|l| l.chars().count())
2684 .unwrap_or(0);
2685 let last_col = line_chars.saturating_sub(1);
2686 (doc_row, rel_col.min(last_col))
2687 }
2688
2689 /// Jump the cursor to the given 1-based line/column, clamped to the document.
2690 pub fn jump_to(&mut self, line: usize, col: usize) {
2691 let r = line.saturating_sub(1);
2692 let max_row = buf_row_count(&self.buffer).saturating_sub(1);
2693 let r = r.min(max_row);
2694 let line_len = buf_line(&self.buffer, r)
2695 .map(|l| l.chars().count())
2696 .unwrap_or(0);
2697 let c = col.saturating_sub(1).min(line_len);
2698 buf_set_cursor_rc(&mut self.buffer, r, c);
2699 }
2700
2701 /// Jump cursor to the terminal-space mouse position; exits Visual
2702 /// modes if active. Engine-native coordinate flavour — pass the
2703 /// outer editor rect's `(x, y)` plus the click `(col, row)`.
2704 /// Always available (no ratatui dependency).
2705 ///
2706 /// Renamed from `mouse_click_xy` in 0.0.32 — at 0.1.0 freeze the
2707 /// unprefixed name belongs to the universally-available variant.
2708 pub fn mouse_click(&mut self, area_x: u16, area_y: u16, col: u16, row: u16) {
2709 if self.vim.is_visual() {
2710 self.vim.force_normal();
2711 }
2712 // Mouse-position click counts as a motion — break the active
2713 // insert-mode undo group when the toggle is on (vim parity).
2714 crate::vim::break_undo_group_in_insert(self);
2715 let (r, c) = self.mouse_to_doc_pos_xy(area_x, area_y, col, row);
2716 buf_set_cursor_rc(&mut self.buffer, r, c);
2717 }
2718
2719 /// Ratatui [`Rect`]-flavoured wrapper around
2720 /// [`Editor::mouse_click`]. Behind the `ratatui` feature.
2721 ///
2722 /// Renamed from `mouse_click` in 0.0.32 — the unprefixed name now
2723 /// belongs to the engine-native variant.
2724 #[cfg(feature = "ratatui")]
2725 pub fn mouse_click_in_rect(&mut self, area: Rect, col: u16, row: u16) {
2726 self.mouse_click(area.x, area.y, col, row);
2727 }
2728
2729 /// Begin a mouse-drag selection: anchor at current cursor and enter Visual mode.
2730 pub fn mouse_begin_drag(&mut self) {
2731 if !self.vim.is_visual_char() {
2732 let cursor = self.cursor();
2733 self.vim.enter_visual(cursor);
2734 }
2735 }
2736
2737 /// Extend an in-progress mouse drag to the given terminal-space
2738 /// position. Engine-native coordinate flavour. Always available.
2739 ///
2740 /// Renamed from `mouse_extend_drag_xy` in 0.0.32 — at 0.1.0 freeze
2741 /// the unprefixed name belongs to the universally-available variant.
2742 pub fn mouse_extend_drag(&mut self, area_x: u16, area_y: u16, col: u16, row: u16) {
2743 let (r, c) = self.mouse_to_doc_pos_xy(area_x, area_y, col, row);
2744 buf_set_cursor_rc(&mut self.buffer, r, c);
2745 }
2746
2747 /// Ratatui [`Rect`]-flavoured wrapper around
2748 /// [`Editor::mouse_extend_drag`]. Behind the `ratatui` feature.
2749 ///
2750 /// Renamed from `mouse_extend_drag` in 0.0.32 — the unprefixed
2751 /// name now belongs to the engine-native variant.
2752 #[cfg(feature = "ratatui")]
2753 pub fn mouse_extend_drag_in_rect(&mut self, area: Rect, col: u16, row: u16) {
2754 self.mouse_extend_drag(area.x, area.y, col, row);
2755 }
2756
2757 pub fn insert_str(&mut self, text: &str) {
2758 let pos = crate::types::Cursor::cursor(&self.buffer);
2759 crate::types::BufferEdit::insert_at(&mut self.buffer, pos, text);
2760 self.push_buffer_content_to_textarea();
2761 self.mark_content_dirty();
2762 }
2763
2764 pub fn accept_completion(&mut self, completion: &str) {
2765 use crate::types::{BufferEdit, Cursor as CursorTrait, Pos};
2766 let cursor_pos = CursorTrait::cursor(&self.buffer);
2767 let cursor_row = cursor_pos.line as usize;
2768 let cursor_col = cursor_pos.col as usize;
2769 let line = buf_line(&self.buffer, cursor_row).unwrap_or("").to_string();
2770 let chars: Vec<char> = line.chars().collect();
2771 let prefix_len = chars[..cursor_col.min(chars.len())]
2772 .iter()
2773 .rev()
2774 .take_while(|c| c.is_alphanumeric() || **c == '_')
2775 .count();
2776 if prefix_len > 0 {
2777 let start = Pos {
2778 line: cursor_row as u32,
2779 col: (cursor_col - prefix_len) as u32,
2780 };
2781 BufferEdit::delete_range(&mut self.buffer, start..cursor_pos);
2782 }
2783 let cursor = CursorTrait::cursor(&self.buffer);
2784 BufferEdit::insert_at(&mut self.buffer, cursor, completion);
2785 self.push_buffer_content_to_textarea();
2786 self.mark_content_dirty();
2787 }
2788
2789 pub(super) fn snapshot(&self) -> (Vec<String>, (usize, usize)) {
2790 let rc = buf_cursor_rc(&self.buffer);
2791 (buf_lines_to_vec(&self.buffer), rc)
2792 }
2793
2794 /// Walk one step back through the undo history. Equivalent to the
2795 /// user pressing `u` in normal mode. Drains the most recent undo
2796 /// entry and pushes it onto the redo stack.
2797 pub fn undo(&mut self) {
2798 crate::vim::do_undo(self);
2799 }
2800
2801 /// Walk one step forward through the redo history. Equivalent to
2802 /// `<C-r>` in normal mode.
2803 pub fn redo(&mut self) {
2804 crate::vim::do_redo(self);
2805 }
2806
2807 /// Snapshot current buffer state onto the undo stack and clear
2808 /// the redo stack. Bounded by `settings.undo_levels` — older
2809 /// entries pruned. Call before any group of buffer mutations the
2810 /// user might want to undo as a single step.
2811 pub fn push_undo(&mut self) {
2812 let snap = self.snapshot();
2813 self.undo_stack.push(snap);
2814 self.cap_undo();
2815 self.redo_stack.clear();
2816 }
2817
2818 /// Trim the undo stack down to `settings.undo_levels`, dropping
2819 /// the oldest entries. `undo_levels == 0` is treated as
2820 /// "unlimited" (vim's 0-means-no-undo semantics intentionally
2821 /// skipped — guarding with `> 0` is one line shorter than gating
2822 /// the cap path with an explicit zero-check above the call site).
2823 pub(crate) fn cap_undo(&mut self) {
2824 let cap = self.settings.undo_levels as usize;
2825 if cap > 0 && self.undo_stack.len() > cap {
2826 let diff = self.undo_stack.len() - cap;
2827 self.undo_stack.drain(..diff);
2828 }
2829 }
2830
2831 /// Test-only accessor for the undo stack length.
2832 #[doc(hidden)]
2833 pub fn undo_stack_len(&self) -> usize {
2834 self.undo_stack.len()
2835 }
2836
2837 /// Replace the buffer with `lines` joined by `\n` and set the
2838 /// cursor to `cursor`. Used by undo / `:e!` / snapshot restore
2839 /// paths. Marks the editor dirty.
2840 pub fn restore(&mut self, lines: Vec<String>, cursor: (usize, usize)) {
2841 let text = lines.join("\n");
2842 crate::types::BufferEdit::replace_all(&mut self.buffer, &text);
2843 buf_set_cursor_rc(&mut self.buffer, cursor.0, cursor.1);
2844 // Bulk replace — supersedes any queued ContentEdits.
2845 self.pending_content_edits.clear();
2846 self.pending_content_reset = true;
2847 self.mark_content_dirty();
2848 }
2849
2850 /// Returns true if the key was consumed by the editor.
2851 /// Replace the char under the cursor with `ch`, `count` times. Matches
2852 /// vim `r<x>` semantics: cursor ends on the last replaced char, undo
2853 /// snapshot taken once at start. Promoted to public surface in 0.5.5
2854 /// so hjkl-vim's pending-state reducer can dispatch `Replace` without
2855 /// re-entering the FSM.
2856 pub fn replace_char_at(&mut self, ch: char, count: usize) {
2857 vim::replace_char(self, ch, count);
2858 }
2859
2860 /// Apply vim's `f<x>` / `F<x>` / `t<x>` / `T<x>` motion. Moves the cursor
2861 /// to the `count`-th occurrence of `ch` on the current line, respecting
2862 /// `forward` (direction) and `till` (stop one char before target).
2863 /// Records `last_find` so `;` / `,` repeat work.
2864 ///
2865 /// No-op if the target char isn't on the current line within range.
2866 /// Cursor / scroll / sticky-col semantics match `f<x>` via `execute_motion`.
2867 pub fn find_char(&mut self, ch: char, forward: bool, till: bool, count: usize) {
2868 vim::apply_find_char(self, ch, forward, till, count.max(1));
2869 }
2870
2871 /// Apply the g-chord effect for `g<ch>` with a pre-captured `count`.
2872 /// Mirrors the full `handle_after_g` dispatch table — `gg`, `gj`, `gk`,
2873 /// `gv`, `gU` / `gu` / `g~` (→ operator-pending), `gi`, `g*`, `g#`, etc.
2874 ///
2875 /// Promoted to public surface in 0.5.10 so hjkl-vim's
2876 /// `PendingState::AfterG` reducer can dispatch `AfterGChord` without
2877 /// re-entering the engine FSM.
2878 pub fn after_g(&mut self, ch: char, count: usize) {
2879 vim::apply_after_g(self, ch, count);
2880 }
2881
2882 /// Apply the z-chord effect for `z<ch>` with a pre-captured `count`.
2883 /// Mirrors the full `handle_after_z` dispatch table — `zz` / `zt` / `zb`
2884 /// (scroll-cursor), `zo` / `zc` / `za` / `zR` / `zM` / `zE` / `zd`
2885 /// (fold ops), and `zf` (fold-add over visual selection or → op-pending).
2886 ///
2887 /// Promoted to public surface in 0.5.11 so hjkl-vim's
2888 /// `PendingState::AfterZ` reducer can dispatch `AfterZChord` without
2889 /// re-entering the engine FSM.
2890 pub fn after_z(&mut self, ch: char, count: usize) {
2891 vim::apply_after_z(self, ch, count);
2892 }
2893
2894 /// Apply an operator over a single-key motion. `op` is the engine `Operator`
2895 /// and `motion_key` is the raw character (e.g. `'w'`, `'$'`, `'G'`). The
2896 /// engine resolves the char to a [`vim::Motion`] via `parse_motion`, applies
2897 /// the vim quirks (`cw` → `ce`, `cW` → `cE`, `FindRepeat` → stored find),
2898 /// then calls `apply_op_with_motion`. `total_count` is already the product of
2899 /// the prefix count and any inner count accumulated by the reducer.
2900 ///
2901 /// No-op when `motion_key` does not map to a known motion (engine silently
2902 /// cancels the operator, matching vim's behaviour on unknown motions).
2903 ///
2904 /// Promoted to the public surface in 0.5.12 so the hjkl-vim
2905 /// `PendingState::AfterOp` reducer can dispatch `ApplyOpMotion` without
2906 /// re-entering the engine FSM.
2907 pub fn apply_op_motion(
2908 &mut self,
2909 op: crate::vim::Operator,
2910 motion_key: char,
2911 total_count: usize,
2912 ) {
2913 vim::apply_op_motion_key(self, op, motion_key, total_count);
2914 }
2915
2916 /// Apply a doubled-letter line op (`dd` / `yy` / `cc` / `>>` / `<<`).
2917 /// `total_count` is the product of prefix count and inner count.
2918 ///
2919 /// Promoted to the public surface in 0.5.12 so the hjkl-vim
2920 /// `PendingState::AfterOp` reducer can dispatch `ApplyOpDouble` without
2921 /// re-entering the engine FSM.
2922 pub fn apply_op_double(&mut self, op: crate::vim::Operator, total_count: usize) {
2923 vim::apply_op_double(self, op, total_count);
2924 }
2925
2926 /// Apply an operator over a find motion (`df<x>` / `dF<x>` / `dt<x>` /
2927 /// `dT<x>`). Builds `Motion::Find { ch, forward, till }`, applies it via
2928 /// `apply_op_with_motion`, records `last_find` for `;` / `,` repeat, and
2929 /// updates `last_change` when `op` is Change (for dot-repeat).
2930 ///
2931 /// `total_count` is the product of prefix count and any inner count
2932 /// accumulated by the reducer — already folded at transition time.
2933 ///
2934 /// Promoted to the public surface in 0.5.14 so the hjkl-vim
2935 /// `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
2936 /// re-entering the engine FSM. `handle_op_find_target` (used by the
2937 /// chord-init op path) delegates here to avoid logic duplication.
2938 pub fn apply_op_find(
2939 &mut self,
2940 op: crate::vim::Operator,
2941 ch: char,
2942 forward: bool,
2943 till: bool,
2944 total_count: usize,
2945 ) {
2946 vim::apply_op_find_motion(self, op, ch, forward, till, total_count);
2947 }
2948
2949 /// Apply an operator over a text-object range (`diw` / `daw` / `di"` etc.).
2950 /// Maps `ch` to a `TextObject` per the standard vim table, calls
2951 /// `apply_op_with_text_object`, and records `last_change` when `op` is
2952 /// Change (dot-repeat). Unknown `ch` values are silently ignored (no-op),
2953 /// matching the engine FSM's behaviour on unrecognised text-object chars.
2954 ///
2955 /// `total_count` is accepted for API symmetry with `apply_op_motion` /
2956 /// `apply_op_find` but is currently unused — text objects don't repeat in
2957 /// vim's current grammar. Kept for future-proofing.
2958 ///
2959 /// Promoted to the public surface in 0.5.15 so the hjkl-vim
2960 /// `PendingState::OpTextObj` reducer can dispatch `ApplyOpTextObj` without
2961 /// re-entering the engine FSM. `handle_text_object` (chord-init op path)
2962 /// delegates to the shared `apply_op_text_obj_inner` helper to avoid logic
2963 /// duplication.
2964 pub fn apply_op_text_obj(
2965 &mut self,
2966 op: crate::vim::Operator,
2967 ch: char,
2968 inner: bool,
2969 total_count: usize,
2970 ) {
2971 vim::apply_op_text_obj_inner(self, op, ch, inner, total_count);
2972 }
2973
2974 /// Apply an operator over a g-chord motion or case-op linewise form
2975 /// (`dgg` / `dge` / `dgE` / `dgj` / `dgk` / `gUgU` etc.).
2976 ///
2977 /// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's
2978 /// letter (`U`/`u`/`~`), executes the line op (linewise form).
2979 /// - Otherwise maps `ch` to a motion:
2980 /// - `'g'` → `Motion::FileTop` (gg)
2981 /// - `'e'` → `Motion::WordEndBack` (ge)
2982 /// - `'E'` → `Motion::BigWordEndBack` (gE)
2983 /// - `'j'` → `Motion::ScreenDown` (gj)
2984 /// - `'k'` → `Motion::ScreenUp` (gk)
2985 /// - unknown → no-op (silently ignored, matching engine FSM behaviour)
2986 /// - Updates `last_change` for dot-repeat when `op` is a change operator.
2987 ///
2988 /// `total_count` is the already-folded product of prefix and inner counts.
2989 ///
2990 /// Promoted to the public surface in 0.5.16 so the hjkl-vim
2991 /// `PendingState::OpG` reducer can dispatch `ApplyOpG` without
2992 /// re-entering the engine FSM. `handle_op_after_g` (chord-init op path)
2993 /// delegates to the shared `apply_op_g_inner` helper to avoid logic
2994 /// duplication.
2995 pub fn apply_op_g(&mut self, op: crate::vim::Operator, ch: char, total_count: usize) {
2996 vim::apply_op_g_inner(self, op, ch, total_count);
2997 }
2998
2999 // ─── Phase 4a: pub range-mutation primitives (hjkl#70) ──────────────────
3000 //
3001 // These do not consume input — the caller (hjkl-vim's visual-mode operator
3002 // path, chunk 4e) has already resolved the range from the visual selection
3003 // before calling in. Normal-mode op dispatch continues to use
3004 // `apply_op_motion` / `apply_op_double` / `apply_op_find` / `apply_op_text_obj`.
3005
3006 /// Delete the region `[start, end)` and stash the removed text in
3007 /// `register`. `'"'` selects the unnamed register (vim default); `'a'`–`'z'`
3008 /// select named registers.
3009 ///
3010 /// Pure range-mutation primitive — does not consume input. Called by
3011 /// hjkl-vim's visual-mode operator path which has already resolved the range
3012 /// from the visual selection.
3013 ///
3014 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3015 /// grammar migration (kryptic-sh/hjkl#70).
3016 pub fn delete_range(
3017 &mut self,
3018 start: (usize, usize),
3019 end: (usize, usize),
3020 kind: crate::vim::MotionKind,
3021 register: char,
3022 ) {
3023 vim::delete_range_bridge(self, start, end, kind, register);
3024 }
3025
3026 /// Yank (copy) the region `[start, end)` into `register` without mutating
3027 /// the buffer. `'"'` selects the unnamed register; `'0'` the yank-only
3028 /// register; `'a'`–`'z'` select named registers.
3029 ///
3030 /// Pure range-mutation primitive — does not consume input. Called by
3031 /// hjkl-vim's visual-mode operator path which has already resolved the range
3032 /// from the visual selection.
3033 ///
3034 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3035 /// grammar migration (kryptic-sh/hjkl#70).
3036 pub fn yank_range(
3037 &mut self,
3038 start: (usize, usize),
3039 end: (usize, usize),
3040 kind: crate::vim::MotionKind,
3041 register: char,
3042 ) {
3043 vim::yank_range_bridge(self, start, end, kind, register);
3044 }
3045
3046 /// Delete the region `[start, end)` and transition to Insert mode (vim `c`
3047 /// operator). The deleted text is stashed in `register`. On return the
3048 /// editor is in Insert mode; the caller must not issue further normal-mode
3049 /// ops until the insert session ends.
3050 ///
3051 /// Pure range-mutation primitive — does not consume input. Called by
3052 /// hjkl-vim's visual-mode operator path which has already resolved the range
3053 /// from the visual selection.
3054 ///
3055 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3056 /// grammar migration (kryptic-sh/hjkl#70).
3057 pub fn change_range(
3058 &mut self,
3059 start: (usize, usize),
3060 end: (usize, usize),
3061 kind: crate::vim::MotionKind,
3062 register: char,
3063 ) {
3064 vim::change_range_bridge(self, start, end, kind, register);
3065 }
3066
3067 /// Indent (`count > 0`) or outdent (`count < 0`) the row span
3068 /// `[start.0, end.0]`. Column components are ignored — indent is always
3069 /// linewise. `shiftwidth` overrides the editor's configured shiftwidth for
3070 /// this call; pass `0` to use the current editor setting. `count == 0` is a
3071 /// no-op.
3072 ///
3073 /// Pure range-mutation primitive — does not consume input. Called by
3074 /// hjkl-vim's visual-mode operator path which has already resolved the range
3075 /// from the visual selection.
3076 ///
3077 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3078 /// grammar migration (kryptic-sh/hjkl#70).
3079 pub fn indent_range(
3080 &mut self,
3081 start: (usize, usize),
3082 end: (usize, usize),
3083 count: i32,
3084 shiftwidth: u32,
3085 ) {
3086 vim::indent_range_bridge(self, start, end, count, shiftwidth);
3087 }
3088
3089 /// Apply a case transformation (`Operator::Uppercase` /
3090 /// `Operator::Lowercase` / `Operator::ToggleCase`) to the region
3091 /// `[start, end)`. Other `Operator` variants are silently ignored (no-op).
3092 /// Yanks registers are left untouched — vim's case operators do not write
3093 /// to registers.
3094 ///
3095 /// Pure range-mutation primitive — does not consume input. Called by
3096 /// hjkl-vim's visual-mode operator path which has already resolved the range
3097 /// from the visual selection.
3098 ///
3099 /// Promoted to the public surface in 0.6.7 for Phase 4 visual-mode op
3100 /// grammar migration (kryptic-sh/hjkl#70).
3101 pub fn case_range(
3102 &mut self,
3103 start: (usize, usize),
3104 end: (usize, usize),
3105 kind: crate::vim::MotionKind,
3106 op: crate::vim::Operator,
3107 ) {
3108 vim::case_range_bridge(self, start, end, kind, op);
3109 }
3110
3111 // ─── Phase 4e: pub block-shape range-mutation primitives (hjkl#70) ──────
3112 //
3113 // Rectangular VisualBlock operations. `top_row`/`bot_row` are inclusive
3114 // line indices; `left_col`/`right_col` are inclusive char-column bounds.
3115 // Ragged-edge handling (short lines not reaching `right_col`) matches the
3116 // engine FSM's `apply_block_operator` path — short lines lose only the
3117 // chars that exist.
3118 //
3119 // `register` is the target register; `'"'` selects the unnamed register.
3120
3121 /// Delete a rectangular VisualBlock selection. `top_row` / `bot_row` are
3122 /// inclusive line bounds; `left_col` / `right_col` are inclusive column
3123 /// bounds at the visual (display) column level. Ragged-edge handling
3124 /// matches engine FSM's VisualBlock op behavior — short lines that don't
3125 /// reach `right_col` lose only the chars that exist.
3126 ///
3127 /// `register` honors the user's pending register selection.
3128 ///
3129 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3130 pub fn delete_block(
3131 &mut self,
3132 top_row: usize,
3133 bot_row: usize,
3134 left_col: usize,
3135 right_col: usize,
3136 register: char,
3137 ) {
3138 vim::delete_block_bridge(self, top_row, bot_row, left_col, right_col, register);
3139 }
3140
3141 /// Yank a rectangular VisualBlock selection into `register` without
3142 /// mutating the buffer. `'"'` selects the unnamed register.
3143 ///
3144 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3145 pub fn yank_block(
3146 &mut self,
3147 top_row: usize,
3148 bot_row: usize,
3149 left_col: usize,
3150 right_col: usize,
3151 register: char,
3152 ) {
3153 vim::yank_block_bridge(self, top_row, bot_row, left_col, right_col, register);
3154 }
3155
3156 /// Delete a rectangular VisualBlock selection and enter Insert mode (`c`
3157 /// operator). The deleted text is stashed in `register`. Mode is Insert
3158 /// on return; the caller must not issue further normal-mode ops until the
3159 /// insert session ends.
3160 ///
3161 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3162 pub fn change_block(
3163 &mut self,
3164 top_row: usize,
3165 bot_row: usize,
3166 left_col: usize,
3167 right_col: usize,
3168 register: char,
3169 ) {
3170 vim::change_block_bridge(self, top_row, bot_row, left_col, right_col, register);
3171 }
3172
3173 /// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
3174 /// Column bounds are ignored — vim's block indent is always linewise.
3175 /// `count == 0` is a no-op.
3176 ///
3177 /// Promoted in 0.6.X for Phase 4e block-op grammar migration.
3178 pub fn indent_block(
3179 &mut self,
3180 top_row: usize,
3181 bot_row: usize,
3182 _left_col: usize,
3183 _right_col: usize,
3184 count: i32,
3185 ) {
3186 vim::indent_block_bridge(self, top_row, bot_row, count);
3187 }
3188
3189 // ─── Phase 4b: pub text-object resolution (hjkl#70) ─────────────────────
3190 //
3191 // Pure functions — no cursor mutation, no mode change, no register write.
3192 // Each method delegates to `vim::text_object_*_bridge`, which in turn calls
3193 // the existing `word_text_object` private resolver in vim.rs.
3194 //
3195 // Called by hjkl-vim's `OpTextObj` reducer (chunk 4e) to resolve the range
3196 // before invoking a range-mutation primitive (`delete_range`, etc.).
3197 //
3198 // Return value: `Some((start, end))` where both positions are `(row, col)`
3199 // byte-column pairs and `end` is *exclusive* (one past the last byte to act
3200 // on), matching the convention used by `delete_range` / `yank_range` / etc.
3201 // Returns `None` when the cursor is on an empty line or the resolver cannot
3202 // find a word boundary.
3203
3204 /// Resolve the range of `iw` (inner word) at the current cursor position.
3205 ///
3206 /// An inner word is the contiguous run of keyword characters (or punctuation
3207 /// characters if the cursor is on punctuation) under the cursor, without any
3208 /// surrounding whitespace. Whitespace-only positions return `None`.
3209 ///
3210 /// Pure function — does not move the cursor or change any editor state.
3211 /// Called by hjkl-vim's `OpTextObj` reducer to resolve the range before
3212 /// invoking a range-mutation primitive (`delete_range`, etc.).
3213 ///
3214 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3215 /// migration (kryptic-sh/hjkl#70).
3216 pub fn text_object_inner_word(&self) -> Option<((usize, usize), (usize, usize))> {
3217 vim::text_object_inner_word_bridge(self)
3218 }
3219
3220 /// Resolve the range of `aw` (around word) at the current cursor position.
3221 ///
3222 /// Like `iw` but extends the range to include trailing whitespace after the
3223 /// word. If no trailing whitespace exists, leading whitespace before the word
3224 /// is absorbed instead (vim `:help text-objects` behaviour).
3225 ///
3226 /// Pure function — does not move the cursor or change any editor state.
3227 ///
3228 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3229 /// migration (kryptic-sh/hjkl#70).
3230 pub fn text_object_around_word(&self) -> Option<((usize, usize), (usize, usize))> {
3231 vim::text_object_around_word_bridge(self)
3232 }
3233
3234 /// Resolve the range of `iW` (inner WORD) at the current cursor position.
3235 ///
3236 /// A WORD is any contiguous run of non-whitespace characters — punctuation
3237 /// is not treated as a word boundary. Returns the span of the WORD under the
3238 /// cursor, without surrounding whitespace.
3239 ///
3240 /// Pure function — does not move the cursor or change any editor state.
3241 ///
3242 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3243 /// migration (kryptic-sh/hjkl#70).
3244 pub fn text_object_inner_big_word(&self) -> Option<((usize, usize), (usize, usize))> {
3245 vim::text_object_inner_big_word_bridge(self)
3246 }
3247
3248 /// Resolve the range of `aW` (around WORD) at the current cursor position.
3249 ///
3250 /// Like `iW` but extends the range to include trailing whitespace after the
3251 /// WORD. If no trailing whitespace exists, leading whitespace before the WORD
3252 /// is absorbed instead.
3253 ///
3254 /// Pure function — does not move the cursor or change any editor state.
3255 ///
3256 /// Promoted to the public surface in 0.6.X for Phase 4b text-object grammar
3257 /// migration (kryptic-sh/hjkl#70).
3258 pub fn text_object_around_big_word(&self) -> Option<((usize, usize), (usize, usize))> {
3259 vim::text_object_around_big_word_bridge(self)
3260 }
3261
3262 // ─── Phase 4c: pub text-object resolution — quote + bracket (hjkl#70) ───
3263 //
3264 // Pure functions — no cursor mutation, no mode change, no register write.
3265 // Each method delegates to `vim::text_object_*_bridge`, which in turn calls
3266 // the existing private resolvers (`quote_text_object`, `bracket_text_object`)
3267 // in vim.rs.
3268 //
3269 // Quote methods take the quote char itself (`'"'`, `'\''`, `` '`' ``).
3270 // Bracket methods take the OPEN bracket char (`'('`, `'{'`, `'['`, `'<'`);
3271 // close-bracket variants (`)`, `}`, `]`, `>`) are NOT accepted here — the
3272 // hjkl-vim grammar layer normalises close→open before calling these methods.
3273 //
3274 // Return value: `Some((start, end))` where both positions are `(row, col)`
3275 // byte-column pairs and `end` is *exclusive* (one past the last byte to act
3276 // on), matching the convention used by `delete_range` / `yank_range` / etc.
3277 // `bracket_text_object` internally distinguishes Linewise vs Exclusive
3278 // ranges for multi-line pairs; that tag is stripped here — callers receive
3279 // the same flat shape as all other text-object resolvers.
3280
3281 /// Resolve the range of `i<quote>` (inner quote) at the cursor position.
3282 ///
3283 /// `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None` when the
3284 /// cursor's line contains fewer than two occurrences of `quote`, or when no
3285 /// matching pair can be found around or ahead of the cursor.
3286 ///
3287 /// Inner range excludes the quote characters themselves.
3288 ///
3289 /// Pure function — no cursor mutation.
3290 ///
3291 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3292 /// migration (kryptic-sh/hjkl#70).
3293 pub fn text_object_inner_quote(&self, quote: char) -> Option<((usize, usize), (usize, usize))> {
3294 vim::text_object_inner_quote_bridge(self, quote)
3295 }
3296
3297 /// Resolve the range of `a<quote>` (around quote) at the cursor position.
3298 ///
3299 /// Like `i<quote>` but includes the quote characters themselves plus
3300 /// surrounding whitespace on one side: trailing whitespace after the closing
3301 /// quote if any exists; otherwise leading whitespace before the opening
3302 /// quote. This matches vim `:help text-objects` behaviour.
3303 ///
3304 /// Pure function — no cursor mutation.
3305 ///
3306 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3307 /// migration (kryptic-sh/hjkl#70).
3308 pub fn text_object_around_quote(
3309 &self,
3310 quote: char,
3311 ) -> Option<((usize, usize), (usize, usize))> {
3312 vim::text_object_around_quote_bridge(self, quote)
3313 }
3314
3315 /// Resolve the range of `i<bracket>` (inner bracket pair) at the cursor.
3316 ///
3317 /// `open` must be one of `'('`, `'{'`, `'['`, `'<'` — the corresponding
3318 /// close bracket is derived automatically. Close-bracket chars (`)`, `}`,
3319 /// `]`, `>`) are **not** accepted; hjkl-vim normalises close→open before
3320 /// calling this method. Returns `None` when no enclosing pair is found.
3321 ///
3322 /// The cursor may be anywhere inside the pair or on a bracket character
3323 /// itself. When not inside any pair the resolver falls back to a forward
3324 /// scan (targets.vim-style: `ci(` works when the cursor is before `(`).
3325 ///
3326 /// Inner range excludes the bracket characters. Multi-line pairs are
3327 /// supported; the returned range spans the full content between the
3328 /// brackets.
3329 ///
3330 /// Pure function — no cursor mutation.
3331 ///
3332 /// `ib` / `iB` aliases live in the hjkl-vim grammar layer and are not
3333 /// handled here.
3334 ///
3335 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3336 /// migration (kryptic-sh/hjkl#70).
3337 pub fn text_object_inner_bracket(
3338 &self,
3339 open: char,
3340 ) -> Option<((usize, usize), (usize, usize))> {
3341 vim::text_object_inner_bracket_bridge(self, open)
3342 }
3343
3344 /// Resolve the range of `a<bracket>` (around bracket pair) at the cursor.
3345 ///
3346 /// Like `i<bracket>` but includes the bracket characters themselves.
3347 /// `open` must be one of `'('`, `'{'`, `'['`, `'<'`.
3348 ///
3349 /// Pure function — no cursor mutation.
3350 ///
3351 /// `aB` alias lives in the hjkl-vim grammar layer and is not handled here.
3352 ///
3353 /// Promoted to the public surface in 0.6.X for Phase 4c text-object grammar
3354 /// migration (kryptic-sh/hjkl#70).
3355 pub fn text_object_around_bracket(
3356 &self,
3357 open: char,
3358 ) -> Option<((usize, usize), (usize, usize))> {
3359 vim::text_object_around_bracket_bridge(self, open)
3360 }
3361
3362 // ── Sentence text objects (is / as) ───────────────────────────────────
3363
3364 /// Resolve `is` (inner sentence) at the cursor position.
3365 ///
3366 /// Returns the range of the current sentence, excluding trailing
3367 /// whitespace. Sentence boundaries follow vim's `is` semantics (period /
3368 /// `?` / `!` followed by whitespace or end-of-paragraph).
3369 ///
3370 /// Pure function — no cursor mutation.
3371 ///
3372 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3373 /// grammar migration (kryptic-sh/hjkl#70).
3374 pub fn text_object_inner_sentence(&self) -> Option<((usize, usize), (usize, usize))> {
3375 vim::text_object_inner_sentence_bridge(self)
3376 }
3377
3378 /// Resolve `as` (around sentence) at the cursor position.
3379 ///
3380 /// Like `is` but includes trailing whitespace after the sentence
3381 /// terminator.
3382 ///
3383 /// Pure function — no cursor mutation.
3384 ///
3385 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3386 /// grammar migration (kryptic-sh/hjkl#70).
3387 pub fn text_object_around_sentence(&self) -> Option<((usize, usize), (usize, usize))> {
3388 vim::text_object_around_sentence_bridge(self)
3389 }
3390
3391 // ── Paragraph text objects (ip / ap) ──────────────────────────────────
3392
3393 /// Resolve `ip` (inner paragraph) at the cursor position.
3394 ///
3395 /// A paragraph is a block of non-blank lines bounded by blank lines or
3396 /// buffer edges. Returns `None` when the cursor is on a blank line.
3397 ///
3398 /// Pure function — no cursor mutation.
3399 ///
3400 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3401 /// grammar migration (kryptic-sh/hjkl#70).
3402 pub fn text_object_inner_paragraph(&self) -> Option<((usize, usize), (usize, usize))> {
3403 vim::text_object_inner_paragraph_bridge(self)
3404 }
3405
3406 /// Resolve `ap` (around paragraph) at the cursor position.
3407 ///
3408 /// Like `ip` but includes one trailing blank line when present.
3409 ///
3410 /// Pure function — no cursor mutation.
3411 ///
3412 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3413 /// grammar migration (kryptic-sh/hjkl#70).
3414 pub fn text_object_around_paragraph(&self) -> Option<((usize, usize), (usize, usize))> {
3415 vim::text_object_around_paragraph_bridge(self)
3416 }
3417
3418 // ── Tag text objects (it / at) ────────────────────────────────────────
3419
3420 /// Resolve `it` (inner tag) at the cursor position.
3421 ///
3422 /// Matches XML/HTML-style `<tag>...</tag>` pairs. Returns the range of
3423 /// inner content between the open and close tags (excluding the tags
3424 /// themselves).
3425 ///
3426 /// Pure function — no cursor mutation.
3427 ///
3428 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3429 /// grammar migration (kryptic-sh/hjkl#70).
3430 pub fn text_object_inner_tag(&self) -> Option<((usize, usize), (usize, usize))> {
3431 vim::text_object_inner_tag_bridge(self)
3432 }
3433
3434 /// Resolve `at` (around tag) at the cursor position.
3435 ///
3436 /// Like `it` but includes the open and close tag delimiters themselves.
3437 ///
3438 /// Pure function — no cursor mutation.
3439 ///
3440 /// Promoted to the public surface in 0.6.X for Phase 4d text-object
3441 /// grammar migration (kryptic-sh/hjkl#70).
3442 pub fn text_object_around_tag(&self) -> Option<((usize, usize), (usize, usize))> {
3443 vim::text_object_around_tag_bridge(self)
3444 }
3445
3446 /// Execute a named cursor motion `kind` repeated `count` times.
3447 ///
3448 /// Maps the keymap-layer `hjkl_vim::MotionKind` to the engine's internal
3449 /// motion primitives, bypassing the engine FSM. Identical cursor semantics
3450 /// to the FSM path — sticky column, scroll sync, and big-jump tracking are
3451 /// all applied via `vim::execute_motion` (for Down/Up) or the same helpers
3452 /// used by the FSM arms.
3453 ///
3454 /// Introduced in 0.6.1 as the host entry point for Phase 3a of
3455 /// kryptic-sh/hjkl#69: the app keymap dispatches `AppAction::Motion` and
3456 /// calls this method rather than re-entering the engine FSM.
3457 ///
3458 /// Engine FSM arms for `h`/`j`/`k`/`l`/`<BS>`/`<Space>`/`+`/`-` remain
3459 /// intact for macro-replay coverage (macros re-feed raw keys through the
3460 /// FSM). This method is the keymap / controller path only.
3461 pub fn apply_motion(&mut self, kind: hjkl_vim::MotionKind, count: usize) {
3462 vim::apply_motion_kind(self, kind, count);
3463 }
3464
3465 /// Set `vim.pending_register` to `Some(reg)` if `reg` is a valid register
3466 /// selector (`a`–`z`, `A`–`Z`, `0`–`9`, `"`, `+`, `*`, `_`). Invalid
3467 /// chars are silently ignored (no-op), matching the engine FSM's
3468 /// `handle_select_register` behaviour.
3469 ///
3470 /// Promoted to the public surface in 0.5.17 so the hjkl-vim
3471 /// `PendingState::SelectRegister` reducer can dispatch `SetPendingRegister`
3472 /// without re-entering the engine FSM. `handle_select_register` (engine FSM
3473 /// path for macro-replay / defensive coverage) delegates here to avoid
3474 /// logic duplication.
3475 pub fn set_pending_register(&mut self, reg: char) {
3476 if reg.is_ascii_alphanumeric() || matches!(reg, '"' | '+' | '*' | '_') {
3477 self.vim.pending_register = Some(reg);
3478 }
3479 // Invalid chars silently no-op (matches engine FSM behavior).
3480 }
3481
3482 /// Record a mark named `ch` at the current cursor position.
3483 ///
3484 /// Validates `ch` (must be `a`–`z` or `A`–`Z` to match vim's mark-name
3485 /// rules). Invalid chars are silently ignored (no-op), matching the engine
3486 /// FSM's `handle_set_mark` behaviour.
3487 ///
3488 /// Promoted to the public surface in 0.6.7 so the hjkl-vim
3489 /// `PendingState::SetMark` reducer can dispatch `EngineCmd::SetMark`
3490 /// without re-entering the engine FSM. `handle_set_mark` delegates here.
3491 pub fn set_mark_at_cursor(&mut self, ch: char) {
3492 vim::set_mark_at_cursor(self, ch);
3493 }
3494
3495 /// `.` dot-repeat: replay the last buffered change at the current cursor.
3496 /// `count` scales repeats (e.g. `3.` runs the last change 3 times). When
3497 /// `count` is 0, defaults to 1. No-op when no change has been buffered yet.
3498 ///
3499 /// Storage of `LastChange` stays inside engine for now; Phase 5c of
3500 /// kryptic-sh/hjkl#71 just lifts the `.` chord binding into the app
3501 /// keymap so the engine FSM `.` arm is no longer the entry point. Engine
3502 /// FSM `.` arm stays for macro-replay defensive coverage.
3503 pub fn replay_last_change(&mut self, count: usize) {
3504 vim::replay_last_change(self, count);
3505 }
3506
3507 /// Jump to the mark named `ch`, linewise (row only; col snaps to first
3508 /// non-blank). Pushes the pre-jump position onto the jumplist if the
3509 /// cursor actually moved.
3510 ///
3511 /// Accepts the same mark chars as vim's `'<ch>` command: `a`–`z`,
3512 /// `A`–`Z`, `'`/`` ` `` (jump-back peek), `.` (last edit), and the
3513 /// special auto-marks `[`, `]`, `<`, `>`. Unset marks and invalid chars
3514 /// are silently ignored (no-op), matching the engine FSM's
3515 /// `handle_goto_mark` behaviour.
3516 ///
3517 /// Promoted to the public surface in 0.6.7 so the hjkl-vim
3518 /// `PendingState::GotoMarkLine` reducer can dispatch
3519 /// `EngineCmd::GotoMarkLine` without re-entering the engine FSM.
3520 pub fn goto_mark_line(&mut self, ch: char) {
3521 vim::goto_mark(self, ch, true);
3522 }
3523
3524 /// Jump to the mark named `ch`, charwise (exact row + col). Pushes the
3525 /// pre-jump position onto the jumplist if the cursor actually moved.
3526 ///
3527 /// Accepts the same mark chars as vim's `` `<ch> `` command: `a`–`z`,
3528 /// `A`–`Z`, `'`/`` ` `` (jump-back peek), `.` (last edit), and the
3529 /// special auto-marks `[`, `]`, `<`, `>`. Unset marks and invalid chars
3530 /// are silently ignored (no-op), matching the engine FSM's
3531 /// `handle_goto_mark` behaviour.
3532 ///
3533 /// Promoted to the public surface in 0.6.7 so the hjkl-vim
3534 /// `PendingState::GotoMarkChar` reducer can dispatch
3535 /// `EngineCmd::GotoMarkChar` without re-entering the engine FSM.
3536 pub fn goto_mark_char(&mut self, ch: char) {
3537 vim::goto_mark(self, ch, false);
3538 }
3539
3540 // ── Macro controller API (Phase 5b) ──────────────────────────────────────
3541
3542 /// Begin recording keystrokes into register `reg`. The caller (app) is
3543 /// responsible for stopping the recording via `stop_macro_record` when the
3544 /// user presses bare `q`.
3545 ///
3546 /// - Uppercase `reg` (e.g. `'A'`) appends to the existing lowercase
3547 /// recording by pre-seeding `recording_keys` with the decoded text of the
3548 /// matching lowercase register, matching vim's capital-register append
3549 /// semantics.
3550 /// - Lowercase `reg` clears `recording_keys` (fresh recording).
3551 /// - Invalid chars (non-alphabetic, non-digit) are silently ignored.
3552 ///
3553 /// Promoted to the public surface in Phase 5b so the app's
3554 /// `route_chord_key` can start a recording without re-entering the engine
3555 /// FSM. `handle_record_macro_target` (engine FSM path for macro-replay
3556 /// defensive coverage) continues to use the same logic via delegation.
3557 pub fn start_macro_record(&mut self, reg: char) {
3558 if !(reg.is_ascii_alphabetic() || reg.is_ascii_digit()) {
3559 return;
3560 }
3561 self.vim.recording_macro = Some(reg);
3562 if reg.is_ascii_uppercase() {
3563 // Seed recording_keys with the existing lowercase register's text
3564 // decoded back to inputs so capital-register append continues from
3565 // where the previous recording left off.
3566 let lower = reg.to_ascii_lowercase();
3567 let text = self
3568 .registers
3569 .read(lower)
3570 .map(|s| s.text.clone())
3571 .unwrap_or_default();
3572 self.vim.recording_keys = crate::input::decode_macro(&text);
3573 } else {
3574 self.vim.recording_keys.clear();
3575 }
3576 }
3577
3578 /// Finalize the active recording: encode `recording_keys` as text and write
3579 /// to the matching (lowercase) named register. Clears both `recording_macro`
3580 /// and `recording_keys`. No-ops if no recording is active.
3581 ///
3582 /// Promoted to the public surface in Phase 5b so the app's `QChord` action
3583 /// can stop a recording when the user presses bare `q` without re-entering
3584 /// the engine FSM.
3585 pub fn stop_macro_record(&mut self) {
3586 let Some(reg) = self.vim.recording_macro.take() else {
3587 return;
3588 };
3589 let keys = std::mem::take(&mut self.vim.recording_keys);
3590 let text = crate::input::encode_macro(&keys);
3591 self.set_named_register_text(reg.to_ascii_lowercase(), text);
3592 }
3593
3594 /// Returns `true` while a `q{reg}` recording is in progress.
3595 /// Hosts use this to show a "recording @r" status indicator and to decide
3596 /// whether bare `q` should stop the recording or open the `RecordMacroTarget`
3597 /// chord.
3598 pub fn is_recording_macro(&self) -> bool {
3599 self.vim.recording_macro.is_some()
3600 }
3601
3602 /// Returns `true` while a macro is being replayed. The app sets this flag
3603 /// (via `play_macro`) and clears it (via `end_macro_replay`) around the
3604 /// re-feed loop so the recorder hook can skip double-capture.
3605 pub fn is_replaying_macro(&self) -> bool {
3606 self.vim.replaying_macro
3607 }
3608
3609 /// Decode the named register `reg` into a `Vec<crate::input::Input>` and
3610 /// prepare for replay, returning the inputs the app should re-feed through
3611 /// `route_chord_key`.
3612 ///
3613 /// Resolves `reg`:
3614 /// - `'@'` → use `vim.last_macro`; returns empty vec if none.
3615 /// - Any other char → lowercase it, read the register, decode.
3616 ///
3617 /// Side-effects:
3618 /// - Sets `vim.last_macro` to the resolved register.
3619 /// - Sets `vim.replaying_macro = true` so the recorder hook skips during
3620 /// replay. The app calls `end_macro_replay` after the loop finishes.
3621 ///
3622 /// Returns an empty vec (and no side-effects for `'@'`) if the register is
3623 /// unset or empty.
3624 pub fn play_macro(&mut self, reg: char, count: usize) -> Vec<crate::input::Input> {
3625 let resolved = if reg == '@' {
3626 match self.vim.last_macro {
3627 Some(r) => r,
3628 None => return vec![],
3629 }
3630 } else {
3631 reg.to_ascii_lowercase()
3632 };
3633 let text = match self.registers.read(resolved) {
3634 Some(slot) if !slot.text.is_empty() => slot.text.clone(),
3635 _ => return vec![],
3636 };
3637 let keys = crate::input::decode_macro(&text);
3638 self.vim.last_macro = Some(resolved);
3639 self.vim.replaying_macro = true;
3640 // Multiply by count (minimum 1).
3641 keys.repeat(count.max(1))
3642 }
3643
3644 /// Clear the `replaying_macro` flag. Called by the app after the
3645 /// re-feed loop in the `PlayMacro` commit arm completes (or aborts).
3646 pub fn end_macro_replay(&mut self) {
3647 self.vim.replaying_macro = false;
3648 }
3649
3650 /// Append `input` to the active recording (`recording_keys`) if and only
3651 /// if a recording is in progress AND we are not currently replaying.
3652 /// Called by the app's `route_chord_key` recorder hook so that user
3653 /// keystrokes captured through the app-level chord path are recorded
3654 /// (rather than relying solely on the engine FSM's in-step hook).
3655 pub fn record_input(&mut self, input: crate::input::Input) {
3656 if self.vim.recording_macro.is_some() && !self.vim.replaying_macro {
3657 self.vim.recording_keys.push(input);
3658 }
3659 }
3660
3661 #[cfg(feature = "crossterm")]
3662 pub fn handle_key(&mut self, key: KeyEvent) -> bool {
3663 let input = crossterm_to_input(key);
3664 if input.key == Key::Null {
3665 return false;
3666 }
3667 let consumed = vim::step(self, input);
3668 self.emit_cursor_shape_if_changed();
3669 consumed
3670 }
3671}
3672
3673/// Visual column of the character at `char_col` in `line`, treating `\t`
3674/// as expansion to the next `tab_width` stop and every other char as
3675/// 1 cell wide. Wide-char support (CJK, emoji) is a separate concern —
3676/// the cursor math elsewhere also assumes single-cell chars.
3677fn visual_col_for_char(line: &str, char_col: usize, tab_width: usize) -> usize {
3678 let mut visual = 0usize;
3679 for (i, ch) in line.chars().enumerate() {
3680 if i >= char_col {
3681 break;
3682 }
3683 if ch == '\t' {
3684 visual += tab_width - (visual % tab_width);
3685 } else {
3686 visual += 1;
3687 }
3688 }
3689 visual
3690}
3691
3692#[cfg(feature = "crossterm")]
3693impl From<KeyEvent> for Input {
3694 fn from(key: KeyEvent) -> Self {
3695 let k = match key.code {
3696 KeyCode::Char(c) => Key::Char(c),
3697 KeyCode::Backspace => Key::Backspace,
3698 KeyCode::Delete => Key::Delete,
3699 KeyCode::Enter => Key::Enter,
3700 KeyCode::Left => Key::Left,
3701 KeyCode::Right => Key::Right,
3702 KeyCode::Up => Key::Up,
3703 KeyCode::Down => Key::Down,
3704 KeyCode::Home => Key::Home,
3705 KeyCode::End => Key::End,
3706 KeyCode::Tab => Key::Tab,
3707 KeyCode::Esc => Key::Esc,
3708 _ => Key::Null,
3709 };
3710 Input {
3711 key: k,
3712 ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
3713 alt: key.modifiers.contains(KeyModifiers::ALT),
3714 shift: key.modifiers.contains(KeyModifiers::SHIFT),
3715 }
3716 }
3717}
3718
3719/// Crossterm `KeyEvent` → engine `Input`. Thin wrapper that delegates
3720/// to the [`From`] impl above; kept as a free fn for the in-tree
3721/// callers in the legacy ratatui-coupled paths.
3722#[cfg(feature = "crossterm")]
3723pub(super) fn crossterm_to_input(key: KeyEvent) -> Input {
3724 Input::from(key)
3725}
3726
3727#[cfg(all(test, feature = "crossterm", feature = "ratatui"))]
3728mod tests {
3729 use super::*;
3730 use crate::types::Host;
3731 use crossterm::event::KeyEvent;
3732
3733 fn key(code: KeyCode) -> KeyEvent {
3734 KeyEvent::new(code, KeyModifiers::NONE)
3735 }
3736 fn shift_key(code: KeyCode) -> KeyEvent {
3737 KeyEvent::new(code, KeyModifiers::SHIFT)
3738 }
3739 fn ctrl_key(code: KeyCode) -> KeyEvent {
3740 KeyEvent::new(code, KeyModifiers::CONTROL)
3741 }
3742
3743 #[test]
3744 fn vim_normal_to_insert() {
3745 let mut e = Editor::new(
3746 hjkl_buffer::Buffer::new(),
3747 crate::types::DefaultHost::new(),
3748 crate::types::Options::default(),
3749 );
3750 e.handle_key(key(KeyCode::Char('i')));
3751 assert_eq!(e.vim_mode(), VimMode::Insert);
3752 }
3753
3754 #[test]
3755 fn with_options_constructs_from_spec_options() {
3756 // 0.0.33 (Patch C-γ): SPEC-shaped constructor preview.
3757 // Build with custom Options + DefaultHost; confirm the
3758 // settings translation honours the SPEC field names.
3759 let opts = crate::types::Options {
3760 shiftwidth: 4,
3761 tabstop: 4,
3762 expandtab: true,
3763 iskeyword: "@,a-z".to_string(),
3764 wrap: crate::types::WrapMode::Word,
3765 ..crate::types::Options::default()
3766 };
3767 let mut e = Editor::new(
3768 hjkl_buffer::Buffer::new(),
3769 crate::types::DefaultHost::new(),
3770 opts,
3771 );
3772 assert_eq!(e.settings().shiftwidth, 4);
3773 assert_eq!(e.settings().tabstop, 4);
3774 assert!(e.settings().expandtab);
3775 assert_eq!(e.settings().iskeyword, "@,a-z");
3776 assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
3777 // Confirm input plumbing still works.
3778 e.handle_key(key(KeyCode::Char('i')));
3779 assert_eq!(e.vim_mode(), VimMode::Insert);
3780 }
3781
3782 #[test]
3783 fn feed_input_char_routes_through_handle_key() {
3784 use crate::{Modifiers, PlannedInput};
3785 let mut e = Editor::new(
3786 hjkl_buffer::Buffer::new(),
3787 crate::types::DefaultHost::new(),
3788 crate::types::Options::default(),
3789 );
3790 e.set_content("abc");
3791 // `i` enters insert mode via SPEC input.
3792 e.feed_input(PlannedInput::Char('i', Modifiers::default()));
3793 assert_eq!(e.vim_mode(), VimMode::Insert);
3794 // Type 'X' via SPEC input.
3795 e.feed_input(PlannedInput::Char('X', Modifiers::default()));
3796 assert!(e.content().contains('X'));
3797 }
3798
3799 #[test]
3800 fn feed_input_special_key_routes() {
3801 use crate::{Modifiers, PlannedInput, SpecialKey};
3802 let mut e = Editor::new(
3803 hjkl_buffer::Buffer::new(),
3804 crate::types::DefaultHost::new(),
3805 crate::types::Options::default(),
3806 );
3807 e.set_content("abc");
3808 e.feed_input(PlannedInput::Char('i', Modifiers::default()));
3809 assert_eq!(e.vim_mode(), VimMode::Insert);
3810 e.feed_input(PlannedInput::Key(SpecialKey::Esc, Modifiers::default()));
3811 assert_eq!(e.vim_mode(), VimMode::Normal);
3812 }
3813
3814 #[test]
3815 fn feed_input_mouse_paste_focus_resize_no_op() {
3816 use crate::{MouseEvent, MouseKind, PlannedInput, Pos};
3817 let mut e = Editor::new(
3818 hjkl_buffer::Buffer::new(),
3819 crate::types::DefaultHost::new(),
3820 crate::types::Options::default(),
3821 );
3822 e.set_content("abc");
3823 let mode_before = e.vim_mode();
3824 let consumed = e.feed_input(PlannedInput::Mouse(MouseEvent {
3825 kind: MouseKind::Press,
3826 pos: Pos::new(0, 0),
3827 mods: Default::default(),
3828 }));
3829 assert!(!consumed);
3830 assert_eq!(e.vim_mode(), mode_before);
3831 assert!(!e.feed_input(PlannedInput::Paste("xx".into())));
3832 assert!(!e.feed_input(PlannedInput::FocusGained));
3833 assert!(!e.feed_input(PlannedInput::FocusLost));
3834 assert!(!e.feed_input(PlannedInput::Resize(80, 24)));
3835 }
3836
3837 #[test]
3838 fn intern_style_dedups_engine_native_styles() {
3839 use crate::types::{Attrs, Color, Style};
3840 let mut e = Editor::new(
3841 hjkl_buffer::Buffer::new(),
3842 crate::types::DefaultHost::new(),
3843 crate::types::Options::default(),
3844 );
3845 let s = Style {
3846 fg: Some(Color(255, 0, 0)),
3847 bg: None,
3848 attrs: Attrs::BOLD,
3849 };
3850 let id_a = e.intern_style(s);
3851 // Re-interning the same engine style returns the same id.
3852 let id_b = e.intern_style(s);
3853 assert_eq!(id_a, id_b);
3854 // Engine accessor returns the same style back.
3855 let back = e.engine_style_at(id_a).expect("interned");
3856 assert_eq!(back, s);
3857 }
3858
3859 #[test]
3860 fn engine_style_at_out_of_range_returns_none() {
3861 let e = Editor::new(
3862 hjkl_buffer::Buffer::new(),
3863 crate::types::DefaultHost::new(),
3864 crate::types::Options::default(),
3865 );
3866 assert!(e.engine_style_at(99).is_none());
3867 }
3868
3869 #[test]
3870 fn take_changes_emits_per_row_for_block_insert() {
3871 // Visual-block insert (`Ctrl-V` then `I` then text then Esc)
3872 // produces an InsertBlock buffer edit with one chunk per
3873 // selected row. take_changes should surface N EditOps,
3874 // not a single placeholder.
3875 let mut e = Editor::new(
3876 hjkl_buffer::Buffer::new(),
3877 crate::types::DefaultHost::new(),
3878 crate::types::Options::default(),
3879 );
3880 e.set_content("aaa\nbbb\nccc\nddd");
3881 // Place cursor at (0, 0), enter visual-block, extend down 2.
3882 e.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
3883 e.handle_key(key(KeyCode::Char('j')));
3884 e.handle_key(key(KeyCode::Char('j')));
3885 // `I` to enter insert mode at the block left edge.
3886 e.handle_key(shift_key(KeyCode::Char('I')));
3887 e.handle_key(key(KeyCode::Char('X')));
3888 e.handle_key(key(KeyCode::Esc));
3889
3890 let changes = e.take_changes();
3891 // Expect at least 3 entries — one per row in the 3-row block.
3892 // Vim's block-I inserts on Esc; the cleanup may add more
3893 // EditOps for cursor sync, hence >= rather than ==.
3894 assert!(
3895 changes.len() >= 3,
3896 "expected >=3 EditOps for 3-row block insert, got {}: {changes:?}",
3897 changes.len()
3898 );
3899 }
3900
3901 #[test]
3902 fn take_changes_drains_after_insert() {
3903 let mut e = Editor::new(
3904 hjkl_buffer::Buffer::new(),
3905 crate::types::DefaultHost::new(),
3906 crate::types::Options::default(),
3907 );
3908 e.set_content("abc");
3909 // Empty initially.
3910 assert!(e.take_changes().is_empty());
3911 // Type a char in insert mode.
3912 e.handle_key(key(KeyCode::Char('i')));
3913 e.handle_key(key(KeyCode::Char('X')));
3914 let changes = e.take_changes();
3915 assert!(
3916 !changes.is_empty(),
3917 "insert mode keystroke should produce a change"
3918 );
3919 // Drained — second call empty.
3920 assert!(e.take_changes().is_empty());
3921 }
3922
3923 #[test]
3924 fn options_bridge_roundtrip() {
3925 let mut e = Editor::new(
3926 hjkl_buffer::Buffer::new(),
3927 crate::types::DefaultHost::new(),
3928 crate::types::Options::default(),
3929 );
3930 let opts = e.current_options();
3931 // 0.2.0: defaults flipped to modern editor norms — 4-space soft tabs.
3932 assert_eq!(opts.shiftwidth, 4);
3933 assert_eq!(opts.tabstop, 4);
3934
3935 let new_opts = crate::types::Options {
3936 shiftwidth: 4,
3937 tabstop: 2,
3938 ignorecase: true,
3939 ..crate::types::Options::default()
3940 };
3941 e.apply_options(&new_opts);
3942
3943 let after = e.current_options();
3944 assert_eq!(after.shiftwidth, 4);
3945 assert_eq!(after.tabstop, 2);
3946 assert!(after.ignorecase);
3947 }
3948
3949 #[test]
3950 fn selection_highlight_none_in_normal() {
3951 let mut e = Editor::new(
3952 hjkl_buffer::Buffer::new(),
3953 crate::types::DefaultHost::new(),
3954 crate::types::Options::default(),
3955 );
3956 e.set_content("hello");
3957 assert!(e.selection_highlight().is_none());
3958 }
3959
3960 #[test]
3961 fn selection_highlight_some_in_visual() {
3962 use crate::types::HighlightKind;
3963 let mut e = Editor::new(
3964 hjkl_buffer::Buffer::new(),
3965 crate::types::DefaultHost::new(),
3966 crate::types::Options::default(),
3967 );
3968 e.set_content("hello world");
3969 e.handle_key(key(KeyCode::Char('v')));
3970 e.handle_key(key(KeyCode::Char('l')));
3971 e.handle_key(key(KeyCode::Char('l')));
3972 let h = e
3973 .selection_highlight()
3974 .expect("visual mode should produce a highlight");
3975 assert_eq!(h.kind, HighlightKind::Selection);
3976 assert_eq!(h.range.start.line, 0);
3977 assert_eq!(h.range.end.line, 0);
3978 }
3979
3980 #[test]
3981 fn highlights_emit_incsearch_during_active_prompt() {
3982 use crate::types::HighlightKind;
3983 let mut e = Editor::new(
3984 hjkl_buffer::Buffer::new(),
3985 crate::types::DefaultHost::new(),
3986 crate::types::Options::default(),
3987 );
3988 e.set_content("foo bar foo\nbaz\n");
3989 // Open the `/` prompt and type `f` `o` `o`.
3990 e.handle_key(key(KeyCode::Char('/')));
3991 e.handle_key(key(KeyCode::Char('f')));
3992 e.handle_key(key(KeyCode::Char('o')));
3993 e.handle_key(key(KeyCode::Char('o')));
3994 // Prompt should be active.
3995 assert!(e.search_prompt().is_some());
3996 let hs = e.highlights_for_line(0);
3997 assert_eq!(hs.len(), 2);
3998 for h in &hs {
3999 assert_eq!(h.kind, HighlightKind::IncSearch);
4000 }
4001 }
4002
4003 #[test]
4004 fn highlights_empty_for_blank_prompt() {
4005 let mut e = Editor::new(
4006 hjkl_buffer::Buffer::new(),
4007 crate::types::DefaultHost::new(),
4008 crate::types::Options::default(),
4009 );
4010 e.set_content("foo");
4011 e.handle_key(key(KeyCode::Char('/')));
4012 // Nothing typed yet — prompt active but text empty.
4013 assert!(e.search_prompt().is_some());
4014 assert!(e.highlights_for_line(0).is_empty());
4015 }
4016
4017 #[test]
4018 fn highlights_emit_search_matches() {
4019 use crate::types::HighlightKind;
4020 let mut e = Editor::new(
4021 hjkl_buffer::Buffer::new(),
4022 crate::types::DefaultHost::new(),
4023 crate::types::Options::default(),
4024 );
4025 e.set_content("foo bar foo\nbaz qux\n");
4026 // 0.0.35: arm via the engine search state. The buffer
4027 // accessor still works (deprecated) but new code goes
4028 // through Editor.
4029 e.set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
4030 let hs = e.highlights_for_line(0);
4031 assert_eq!(hs.len(), 2);
4032 for h in &hs {
4033 assert_eq!(h.kind, HighlightKind::SearchMatch);
4034 assert_eq!(h.range.start.line, 0);
4035 assert_eq!(h.range.end.line, 0);
4036 }
4037 }
4038
4039 #[test]
4040 fn highlights_empty_without_pattern() {
4041 let mut e = Editor::new(
4042 hjkl_buffer::Buffer::new(),
4043 crate::types::DefaultHost::new(),
4044 crate::types::Options::default(),
4045 );
4046 e.set_content("foo bar");
4047 assert!(e.highlights_for_line(0).is_empty());
4048 }
4049
4050 #[test]
4051 fn highlights_empty_for_out_of_range_line() {
4052 let mut e = Editor::new(
4053 hjkl_buffer::Buffer::new(),
4054 crate::types::DefaultHost::new(),
4055 crate::types::Options::default(),
4056 );
4057 e.set_content("foo");
4058 e.set_search_pattern(Some(regex::Regex::new("foo").unwrap()));
4059 assert!(e.highlights_for_line(99).is_empty());
4060 }
4061
4062 #[test]
4063 fn render_frame_reflects_mode_and_cursor() {
4064 use crate::types::{CursorShape, SnapshotMode};
4065 let mut e = Editor::new(
4066 hjkl_buffer::Buffer::new(),
4067 crate::types::DefaultHost::new(),
4068 crate::types::Options::default(),
4069 );
4070 e.set_content("alpha\nbeta");
4071 let f = e.render_frame();
4072 assert_eq!(f.mode, SnapshotMode::Normal);
4073 assert_eq!(f.cursor_shape, CursorShape::Block);
4074 assert_eq!(f.line_count, 2);
4075
4076 e.handle_key(key(KeyCode::Char('i')));
4077 let f = e.render_frame();
4078 assert_eq!(f.mode, SnapshotMode::Insert);
4079 assert_eq!(f.cursor_shape, CursorShape::Bar);
4080 }
4081
4082 #[test]
4083 fn snapshot_roundtrips_through_restore() {
4084 use crate::types::SnapshotMode;
4085 let mut e = Editor::new(
4086 hjkl_buffer::Buffer::new(),
4087 crate::types::DefaultHost::new(),
4088 crate::types::Options::default(),
4089 );
4090 e.set_content("alpha\nbeta\ngamma");
4091 e.jump_cursor(2, 3);
4092 let snap = e.take_snapshot();
4093 assert_eq!(snap.mode, SnapshotMode::Normal);
4094 assert_eq!(snap.cursor, (2, 3));
4095 assert_eq!(snap.lines.len(), 3);
4096
4097 let mut other = Editor::new(
4098 hjkl_buffer::Buffer::new(),
4099 crate::types::DefaultHost::new(),
4100 crate::types::Options::default(),
4101 );
4102 other.restore_snapshot(snap).expect("restore");
4103 assert_eq!(other.cursor(), (2, 3));
4104 assert_eq!(other.buffer().lines().len(), 3);
4105 }
4106
4107 #[test]
4108 fn restore_snapshot_rejects_version_mismatch() {
4109 let mut e = Editor::new(
4110 hjkl_buffer::Buffer::new(),
4111 crate::types::DefaultHost::new(),
4112 crate::types::Options::default(),
4113 );
4114 let mut snap = e.take_snapshot();
4115 snap.version = 9999;
4116 match e.restore_snapshot(snap) {
4117 Err(crate::EngineError::SnapshotVersion(got, want)) => {
4118 assert_eq!(got, 9999);
4119 assert_eq!(want, crate::types::EditorSnapshot::VERSION);
4120 }
4121 other => panic!("expected SnapshotVersion err, got {other:?}"),
4122 }
4123 }
4124
4125 #[test]
4126 fn take_content_change_returns_some_on_first_dirty() {
4127 let mut e = Editor::new(
4128 hjkl_buffer::Buffer::new(),
4129 crate::types::DefaultHost::new(),
4130 crate::types::Options::default(),
4131 );
4132 e.set_content("hello");
4133 let first = e.take_content_change();
4134 assert!(first.is_some());
4135 let second = e.take_content_change();
4136 assert!(second.is_none());
4137 }
4138
4139 #[test]
4140 fn take_content_change_none_until_mutation() {
4141 let mut e = Editor::new(
4142 hjkl_buffer::Buffer::new(),
4143 crate::types::DefaultHost::new(),
4144 crate::types::Options::default(),
4145 );
4146 e.set_content("hello");
4147 // drain
4148 e.take_content_change();
4149 assert!(e.take_content_change().is_none());
4150 // mutate via insert mode
4151 e.handle_key(key(KeyCode::Char('i')));
4152 e.handle_key(key(KeyCode::Char('x')));
4153 let after = e.take_content_change();
4154 assert!(after.is_some());
4155 assert!(after.unwrap().contains('x'));
4156 }
4157
4158 #[test]
4159 fn vim_insert_to_normal() {
4160 let mut e = Editor::new(
4161 hjkl_buffer::Buffer::new(),
4162 crate::types::DefaultHost::new(),
4163 crate::types::Options::default(),
4164 );
4165 e.handle_key(key(KeyCode::Char('i')));
4166 e.handle_key(key(KeyCode::Esc));
4167 assert_eq!(e.vim_mode(), VimMode::Normal);
4168 }
4169
4170 #[test]
4171 fn vim_normal_to_visual() {
4172 let mut e = Editor::new(
4173 hjkl_buffer::Buffer::new(),
4174 crate::types::DefaultHost::new(),
4175 crate::types::Options::default(),
4176 );
4177 e.handle_key(key(KeyCode::Char('v')));
4178 assert_eq!(e.vim_mode(), VimMode::Visual);
4179 }
4180
4181 #[test]
4182 fn vim_visual_to_normal() {
4183 let mut e = Editor::new(
4184 hjkl_buffer::Buffer::new(),
4185 crate::types::DefaultHost::new(),
4186 crate::types::Options::default(),
4187 );
4188 e.handle_key(key(KeyCode::Char('v')));
4189 e.handle_key(key(KeyCode::Esc));
4190 assert_eq!(e.vim_mode(), VimMode::Normal);
4191 }
4192
4193 #[test]
4194 fn vim_shift_i_moves_to_first_non_whitespace() {
4195 let mut e = Editor::new(
4196 hjkl_buffer::Buffer::new(),
4197 crate::types::DefaultHost::new(),
4198 crate::types::Options::default(),
4199 );
4200 e.set_content(" hello");
4201 e.jump_cursor(0, 8);
4202 e.handle_key(shift_key(KeyCode::Char('I')));
4203 assert_eq!(e.vim_mode(), VimMode::Insert);
4204 assert_eq!(e.cursor(), (0, 3));
4205 }
4206
4207 #[test]
4208 fn vim_shift_a_moves_to_end_and_insert() {
4209 let mut e = Editor::new(
4210 hjkl_buffer::Buffer::new(),
4211 crate::types::DefaultHost::new(),
4212 crate::types::Options::default(),
4213 );
4214 e.set_content("hello");
4215 e.handle_key(shift_key(KeyCode::Char('A')));
4216 assert_eq!(e.vim_mode(), VimMode::Insert);
4217 assert_eq!(e.cursor().1, 5);
4218 }
4219
4220 #[test]
4221 fn count_10j_moves_down_10() {
4222 let mut e = Editor::new(
4223 hjkl_buffer::Buffer::new(),
4224 crate::types::DefaultHost::new(),
4225 crate::types::Options::default(),
4226 );
4227 e.set_content(
4228 (0..20)
4229 .map(|i| format!("line{i}"))
4230 .collect::<Vec<_>>()
4231 .join("\n")
4232 .as_str(),
4233 );
4234 for d in "10".chars() {
4235 e.handle_key(key(KeyCode::Char(d)));
4236 }
4237 e.handle_key(key(KeyCode::Char('j')));
4238 assert_eq!(e.cursor().0, 10);
4239 }
4240
4241 #[test]
4242 fn count_o_repeats_insert_on_esc() {
4243 let mut e = Editor::new(
4244 hjkl_buffer::Buffer::new(),
4245 crate::types::DefaultHost::new(),
4246 crate::types::Options::default(),
4247 );
4248 e.set_content("hello");
4249 for d in "3".chars() {
4250 e.handle_key(key(KeyCode::Char(d)));
4251 }
4252 e.handle_key(key(KeyCode::Char('o')));
4253 assert_eq!(e.vim_mode(), VimMode::Insert);
4254 for c in "world".chars() {
4255 e.handle_key(key(KeyCode::Char(c)));
4256 }
4257 e.handle_key(key(KeyCode::Esc));
4258 assert_eq!(e.vim_mode(), VimMode::Normal);
4259 assert_eq!(e.buffer().lines().len(), 4);
4260 assert!(e.buffer().lines().iter().skip(1).all(|l| l == "world"));
4261 }
4262
4263 #[test]
4264 fn count_i_repeats_text_on_esc() {
4265 let mut e = Editor::new(
4266 hjkl_buffer::Buffer::new(),
4267 crate::types::DefaultHost::new(),
4268 crate::types::Options::default(),
4269 );
4270 e.set_content("");
4271 for d in "3".chars() {
4272 e.handle_key(key(KeyCode::Char(d)));
4273 }
4274 e.handle_key(key(KeyCode::Char('i')));
4275 for c in "ab".chars() {
4276 e.handle_key(key(KeyCode::Char(c)));
4277 }
4278 e.handle_key(key(KeyCode::Esc));
4279 assert_eq!(e.vim_mode(), VimMode::Normal);
4280 assert_eq!(e.buffer().lines()[0], "ababab");
4281 }
4282
4283 #[test]
4284 fn vim_shift_o_opens_line_above() {
4285 let mut e = Editor::new(
4286 hjkl_buffer::Buffer::new(),
4287 crate::types::DefaultHost::new(),
4288 crate::types::Options::default(),
4289 );
4290 e.set_content("hello");
4291 e.handle_key(shift_key(KeyCode::Char('O')));
4292 assert_eq!(e.vim_mode(), VimMode::Insert);
4293 assert_eq!(e.cursor(), (0, 0));
4294 assert_eq!(e.buffer().lines().len(), 2);
4295 }
4296
4297 #[test]
4298 fn vim_gg_goes_to_top() {
4299 let mut e = Editor::new(
4300 hjkl_buffer::Buffer::new(),
4301 crate::types::DefaultHost::new(),
4302 crate::types::Options::default(),
4303 );
4304 e.set_content("a\nb\nc");
4305 e.jump_cursor(2, 0);
4306 e.handle_key(key(KeyCode::Char('g')));
4307 e.handle_key(key(KeyCode::Char('g')));
4308 assert_eq!(e.cursor().0, 0);
4309 }
4310
4311 #[test]
4312 fn vim_shift_g_goes_to_bottom() {
4313 let mut e = Editor::new(
4314 hjkl_buffer::Buffer::new(),
4315 crate::types::DefaultHost::new(),
4316 crate::types::Options::default(),
4317 );
4318 e.set_content("a\nb\nc");
4319 e.handle_key(shift_key(KeyCode::Char('G')));
4320 assert_eq!(e.cursor().0, 2);
4321 }
4322
4323 #[test]
4324 fn vim_dd_deletes_line() {
4325 let mut e = Editor::new(
4326 hjkl_buffer::Buffer::new(),
4327 crate::types::DefaultHost::new(),
4328 crate::types::Options::default(),
4329 );
4330 e.set_content("first\nsecond");
4331 e.handle_key(key(KeyCode::Char('d')));
4332 e.handle_key(key(KeyCode::Char('d')));
4333 assert_eq!(e.buffer().lines().len(), 1);
4334 assert_eq!(e.buffer().lines()[0], "second");
4335 }
4336
4337 #[test]
4338 fn vim_dw_deletes_word() {
4339 let mut e = Editor::new(
4340 hjkl_buffer::Buffer::new(),
4341 crate::types::DefaultHost::new(),
4342 crate::types::Options::default(),
4343 );
4344 e.set_content("hello world");
4345 e.handle_key(key(KeyCode::Char('d')));
4346 e.handle_key(key(KeyCode::Char('w')));
4347 assert_eq!(e.vim_mode(), VimMode::Normal);
4348 assert!(!e.buffer().lines()[0].starts_with("hello"));
4349 }
4350
4351 #[test]
4352 fn vim_yy_yanks_line() {
4353 let mut e = Editor::new(
4354 hjkl_buffer::Buffer::new(),
4355 crate::types::DefaultHost::new(),
4356 crate::types::Options::default(),
4357 );
4358 e.set_content("hello\nworld");
4359 e.handle_key(key(KeyCode::Char('y')));
4360 e.handle_key(key(KeyCode::Char('y')));
4361 assert!(e.last_yank.as_deref().unwrap_or("").starts_with("hello"));
4362 }
4363
4364 #[test]
4365 fn vim_yy_does_not_move_cursor() {
4366 let mut e = Editor::new(
4367 hjkl_buffer::Buffer::new(),
4368 crate::types::DefaultHost::new(),
4369 crate::types::Options::default(),
4370 );
4371 e.set_content("first\nsecond\nthird");
4372 e.jump_cursor(1, 0);
4373 let before = e.cursor();
4374 e.handle_key(key(KeyCode::Char('y')));
4375 e.handle_key(key(KeyCode::Char('y')));
4376 assert_eq!(e.cursor(), before);
4377 assert_eq!(e.vim_mode(), VimMode::Normal);
4378 }
4379
4380 #[test]
4381 fn vim_yw_yanks_word() {
4382 let mut e = Editor::new(
4383 hjkl_buffer::Buffer::new(),
4384 crate::types::DefaultHost::new(),
4385 crate::types::Options::default(),
4386 );
4387 e.set_content("hello world");
4388 e.handle_key(key(KeyCode::Char('y')));
4389 e.handle_key(key(KeyCode::Char('w')));
4390 assert_eq!(e.vim_mode(), VimMode::Normal);
4391 assert!(e.last_yank.is_some());
4392 }
4393
4394 #[test]
4395 fn vim_cc_changes_line() {
4396 let mut e = Editor::new(
4397 hjkl_buffer::Buffer::new(),
4398 crate::types::DefaultHost::new(),
4399 crate::types::Options::default(),
4400 );
4401 e.set_content("hello\nworld");
4402 e.handle_key(key(KeyCode::Char('c')));
4403 e.handle_key(key(KeyCode::Char('c')));
4404 assert_eq!(e.vim_mode(), VimMode::Insert);
4405 }
4406
4407 #[test]
4408 fn vim_u_undoes_insert_session_as_chunk() {
4409 let mut e = Editor::new(
4410 hjkl_buffer::Buffer::new(),
4411 crate::types::DefaultHost::new(),
4412 crate::types::Options::default(),
4413 );
4414 e.set_content("hello");
4415 e.handle_key(key(KeyCode::Char('i')));
4416 e.handle_key(key(KeyCode::Enter));
4417 e.handle_key(key(KeyCode::Enter));
4418 e.handle_key(key(KeyCode::Esc));
4419 assert_eq!(e.buffer().lines().len(), 3);
4420 e.handle_key(key(KeyCode::Char('u')));
4421 assert_eq!(e.buffer().lines().len(), 1);
4422 assert_eq!(e.buffer().lines()[0], "hello");
4423 }
4424
4425 #[test]
4426 fn vim_undo_redo_roundtrip() {
4427 let mut e = Editor::new(
4428 hjkl_buffer::Buffer::new(),
4429 crate::types::DefaultHost::new(),
4430 crate::types::Options::default(),
4431 );
4432 e.set_content("hello");
4433 e.handle_key(key(KeyCode::Char('i')));
4434 for c in "world".chars() {
4435 e.handle_key(key(KeyCode::Char(c)));
4436 }
4437 e.handle_key(key(KeyCode::Esc));
4438 let after = e.buffer().lines()[0].clone();
4439 e.handle_key(key(KeyCode::Char('u')));
4440 assert_eq!(e.buffer().lines()[0], "hello");
4441 e.handle_key(ctrl_key(KeyCode::Char('r')));
4442 assert_eq!(e.buffer().lines()[0], after);
4443 }
4444
4445 #[test]
4446 fn vim_u_undoes_dd() {
4447 let mut e = Editor::new(
4448 hjkl_buffer::Buffer::new(),
4449 crate::types::DefaultHost::new(),
4450 crate::types::Options::default(),
4451 );
4452 e.set_content("first\nsecond");
4453 e.handle_key(key(KeyCode::Char('d')));
4454 e.handle_key(key(KeyCode::Char('d')));
4455 assert_eq!(e.buffer().lines().len(), 1);
4456 e.handle_key(key(KeyCode::Char('u')));
4457 assert_eq!(e.buffer().lines().len(), 2);
4458 assert_eq!(e.buffer().lines()[0], "first");
4459 }
4460
4461 #[test]
4462 fn vim_ctrl_r_redoes() {
4463 let mut e = Editor::new(
4464 hjkl_buffer::Buffer::new(),
4465 crate::types::DefaultHost::new(),
4466 crate::types::Options::default(),
4467 );
4468 e.set_content("hello");
4469 e.handle_key(ctrl_key(KeyCode::Char('r')));
4470 }
4471
4472 #[test]
4473 fn vim_r_replaces_char() {
4474 let mut e = Editor::new(
4475 hjkl_buffer::Buffer::new(),
4476 crate::types::DefaultHost::new(),
4477 crate::types::Options::default(),
4478 );
4479 e.set_content("hello");
4480 e.handle_key(key(KeyCode::Char('r')));
4481 e.handle_key(key(KeyCode::Char('x')));
4482 assert_eq!(e.buffer().lines()[0].chars().next(), Some('x'));
4483 }
4484
4485 #[test]
4486 fn vim_tilde_toggles_case() {
4487 let mut e = Editor::new(
4488 hjkl_buffer::Buffer::new(),
4489 crate::types::DefaultHost::new(),
4490 crate::types::Options::default(),
4491 );
4492 e.set_content("hello");
4493 e.handle_key(key(KeyCode::Char('~')));
4494 assert_eq!(e.buffer().lines()[0].chars().next(), Some('H'));
4495 }
4496
4497 #[test]
4498 fn vim_visual_d_cuts() {
4499 let mut e = Editor::new(
4500 hjkl_buffer::Buffer::new(),
4501 crate::types::DefaultHost::new(),
4502 crate::types::Options::default(),
4503 );
4504 e.set_content("hello");
4505 e.handle_key(key(KeyCode::Char('v')));
4506 e.handle_key(key(KeyCode::Char('l')));
4507 e.handle_key(key(KeyCode::Char('l')));
4508 e.handle_key(key(KeyCode::Char('d')));
4509 assert_eq!(e.vim_mode(), VimMode::Normal);
4510 assert!(e.last_yank.is_some());
4511 }
4512
4513 #[test]
4514 fn vim_visual_c_enters_insert() {
4515 let mut e = Editor::new(
4516 hjkl_buffer::Buffer::new(),
4517 crate::types::DefaultHost::new(),
4518 crate::types::Options::default(),
4519 );
4520 e.set_content("hello");
4521 e.handle_key(key(KeyCode::Char('v')));
4522 e.handle_key(key(KeyCode::Char('l')));
4523 e.handle_key(key(KeyCode::Char('c')));
4524 assert_eq!(e.vim_mode(), VimMode::Insert);
4525 }
4526
4527 #[test]
4528 fn vim_normal_unknown_key_consumed() {
4529 let mut e = Editor::new(
4530 hjkl_buffer::Buffer::new(),
4531 crate::types::DefaultHost::new(),
4532 crate::types::Options::default(),
4533 );
4534 // Unknown keys are consumed (swallowed) rather than returning false.
4535 let consumed = e.handle_key(key(KeyCode::Char('z')));
4536 assert!(consumed);
4537 }
4538
4539 #[test]
4540 fn force_normal_clears_operator() {
4541 let mut e = Editor::new(
4542 hjkl_buffer::Buffer::new(),
4543 crate::types::DefaultHost::new(),
4544 crate::types::Options::default(),
4545 );
4546 e.handle_key(key(KeyCode::Char('d')));
4547 e.force_normal();
4548 assert_eq!(e.vim_mode(), VimMode::Normal);
4549 }
4550
4551 fn many_lines(n: usize) -> String {
4552 (0..n)
4553 .map(|i| format!("line{i}"))
4554 .collect::<Vec<_>>()
4555 .join("\n")
4556 }
4557
4558 fn prime_viewport<H: Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, height: u16) {
4559 e.set_viewport_height(height);
4560 }
4561
4562 #[test]
4563 fn zz_centers_cursor_in_viewport() {
4564 let mut e = Editor::new(
4565 hjkl_buffer::Buffer::new(),
4566 crate::types::DefaultHost::new(),
4567 crate::types::Options::default(),
4568 );
4569 e.set_content(&many_lines(100));
4570 prime_viewport(&mut e, 20);
4571 e.jump_cursor(50, 0);
4572 e.handle_key(key(KeyCode::Char('z')));
4573 e.handle_key(key(KeyCode::Char('z')));
4574 assert_eq!(e.host().viewport().top_row, 40);
4575 assert_eq!(e.cursor().0, 50);
4576 }
4577
4578 #[test]
4579 fn zt_puts_cursor_at_viewport_top_with_scrolloff() {
4580 let mut e = Editor::new(
4581 hjkl_buffer::Buffer::new(),
4582 crate::types::DefaultHost::new(),
4583 crate::types::Options::default(),
4584 );
4585 e.set_content(&many_lines(100));
4586 prime_viewport(&mut e, 20);
4587 e.jump_cursor(50, 0);
4588 e.handle_key(key(KeyCode::Char('z')));
4589 e.handle_key(key(KeyCode::Char('t')));
4590 // Cursor lands at top of viable area = top + SCROLLOFF (5).
4591 // Viewport top therefore sits at cursor - 5.
4592 assert_eq!(e.host().viewport().top_row, 45);
4593 assert_eq!(e.cursor().0, 50);
4594 }
4595
4596 #[test]
4597 fn ctrl_a_increments_number_at_cursor() {
4598 let mut e = Editor::new(
4599 hjkl_buffer::Buffer::new(),
4600 crate::types::DefaultHost::new(),
4601 crate::types::Options::default(),
4602 );
4603 e.set_content("x = 41");
4604 e.handle_key(ctrl_key(KeyCode::Char('a')));
4605 assert_eq!(e.buffer().lines()[0], "x = 42");
4606 assert_eq!(e.cursor(), (0, 5));
4607 }
4608
4609 #[test]
4610 fn ctrl_a_finds_number_to_right_of_cursor() {
4611 let mut e = Editor::new(
4612 hjkl_buffer::Buffer::new(),
4613 crate::types::DefaultHost::new(),
4614 crate::types::Options::default(),
4615 );
4616 e.set_content("foo 99 bar");
4617 e.handle_key(ctrl_key(KeyCode::Char('a')));
4618 assert_eq!(e.buffer().lines()[0], "foo 100 bar");
4619 assert_eq!(e.cursor(), (0, 6));
4620 }
4621
4622 #[test]
4623 fn ctrl_a_with_count_adds_count() {
4624 let mut e = Editor::new(
4625 hjkl_buffer::Buffer::new(),
4626 crate::types::DefaultHost::new(),
4627 crate::types::Options::default(),
4628 );
4629 e.set_content("x = 10");
4630 for d in "5".chars() {
4631 e.handle_key(key(KeyCode::Char(d)));
4632 }
4633 e.handle_key(ctrl_key(KeyCode::Char('a')));
4634 assert_eq!(e.buffer().lines()[0], "x = 15");
4635 }
4636
4637 #[test]
4638 fn ctrl_x_decrements_number() {
4639 let mut e = Editor::new(
4640 hjkl_buffer::Buffer::new(),
4641 crate::types::DefaultHost::new(),
4642 crate::types::Options::default(),
4643 );
4644 e.set_content("n=5");
4645 e.handle_key(ctrl_key(KeyCode::Char('x')));
4646 assert_eq!(e.buffer().lines()[0], "n=4");
4647 }
4648
4649 #[test]
4650 fn ctrl_x_crosses_zero_into_negative() {
4651 let mut e = Editor::new(
4652 hjkl_buffer::Buffer::new(),
4653 crate::types::DefaultHost::new(),
4654 crate::types::Options::default(),
4655 );
4656 e.set_content("v=0");
4657 e.handle_key(ctrl_key(KeyCode::Char('x')));
4658 assert_eq!(e.buffer().lines()[0], "v=-1");
4659 }
4660
4661 #[test]
4662 fn ctrl_a_on_negative_number_increments_toward_zero() {
4663 let mut e = Editor::new(
4664 hjkl_buffer::Buffer::new(),
4665 crate::types::DefaultHost::new(),
4666 crate::types::Options::default(),
4667 );
4668 e.set_content("a = -5");
4669 e.handle_key(ctrl_key(KeyCode::Char('a')));
4670 assert_eq!(e.buffer().lines()[0], "a = -4");
4671 }
4672
4673 #[test]
4674 fn ctrl_a_noop_when_no_digit_on_line() {
4675 let mut e = Editor::new(
4676 hjkl_buffer::Buffer::new(),
4677 crate::types::DefaultHost::new(),
4678 crate::types::Options::default(),
4679 );
4680 e.set_content("no digits here");
4681 e.handle_key(ctrl_key(KeyCode::Char('a')));
4682 assert_eq!(e.buffer().lines()[0], "no digits here");
4683 }
4684
4685 #[test]
4686 fn zb_puts_cursor_at_viewport_bottom_with_scrolloff() {
4687 let mut e = Editor::new(
4688 hjkl_buffer::Buffer::new(),
4689 crate::types::DefaultHost::new(),
4690 crate::types::Options::default(),
4691 );
4692 e.set_content(&many_lines(100));
4693 prime_viewport(&mut e, 20);
4694 e.jump_cursor(50, 0);
4695 e.handle_key(key(KeyCode::Char('z')));
4696 e.handle_key(key(KeyCode::Char('b')));
4697 // Cursor lands at bottom of viable area = top + height - 1 -
4698 // SCROLLOFF. For height 20, scrolloff 5: cursor at top + 14,
4699 // so top = cursor - 14 = 36.
4700 assert_eq!(e.host().viewport().top_row, 36);
4701 assert_eq!(e.cursor().0, 50);
4702 }
4703
4704 /// Contract that the TUI drain relies on: `set_content` flags the
4705 /// editor dirty (so the next `take_dirty` call reports the change),
4706 /// and a second `take_dirty` returns `false` after consumption. The
4707 /// TUI drains this flag after every programmatic content load so
4708 /// opening a tab doesn't get mistaken for a user edit and mark the
4709 /// tab dirty (which would then trigger the quit-prompt on `:q`).
4710 #[test]
4711 fn set_content_dirties_then_take_dirty_clears() {
4712 let mut e = Editor::new(
4713 hjkl_buffer::Buffer::new(),
4714 crate::types::DefaultHost::new(),
4715 crate::types::Options::default(),
4716 );
4717 e.set_content("hello");
4718 assert!(
4719 e.take_dirty(),
4720 "set_content should leave content_dirty=true"
4721 );
4722 assert!(!e.take_dirty(), "take_dirty should clear the flag");
4723 }
4724
4725 #[test]
4726 fn content_arc_returns_same_arc_until_mutation() {
4727 let mut e = Editor::new(
4728 hjkl_buffer::Buffer::new(),
4729 crate::types::DefaultHost::new(),
4730 crate::types::Options::default(),
4731 );
4732 e.set_content("hello");
4733 let a = e.content_arc();
4734 let b = e.content_arc();
4735 assert!(
4736 std::sync::Arc::ptr_eq(&a, &b),
4737 "repeated content_arc() should hit the cache"
4738 );
4739
4740 // Any mutation must invalidate the cache.
4741 e.handle_key(key(KeyCode::Char('i')));
4742 e.handle_key(key(KeyCode::Char('!')));
4743 let c = e.content_arc();
4744 assert!(
4745 !std::sync::Arc::ptr_eq(&a, &c),
4746 "mutation should invalidate content_arc() cache"
4747 );
4748 assert!(c.contains('!'));
4749 }
4750
4751 #[test]
4752 fn content_arc_cache_invalidated_by_set_content() {
4753 let mut e = Editor::new(
4754 hjkl_buffer::Buffer::new(),
4755 crate::types::DefaultHost::new(),
4756 crate::types::Options::default(),
4757 );
4758 e.set_content("one");
4759 let a = e.content_arc();
4760 e.set_content("two");
4761 let b = e.content_arc();
4762 assert!(!std::sync::Arc::ptr_eq(&a, &b));
4763 assert!(b.starts_with("two"));
4764 }
4765
4766 /// Click past the last char of a line should land the cursor on
4767 /// the line's last char (Normal mode), not one past it. The
4768 /// previous bug clamped to the line's BYTE length and used `>=`
4769 /// past-end, so clicking deep into the trailing space parked the
4770 /// cursor at `chars().count()` — past where Normal mode lives.
4771 #[test]
4772 fn mouse_click_past_eol_lands_on_last_char() {
4773 let mut e = Editor::new(
4774 hjkl_buffer::Buffer::new(),
4775 crate::types::DefaultHost::new(),
4776 crate::types::Options::default(),
4777 );
4778 e.set_content("hello");
4779 // Outer editor area: x=0, y=0, width=80. mouse_to_doc_pos
4780 // reserves row 0 for the tab bar and adds gutter padding,
4781 // so click row 1, way past the line end.
4782 let area = ratatui::layout::Rect::new(0, 0, 80, 10);
4783 e.mouse_click_in_rect(area, 78, 1);
4784 assert_eq!(e.cursor(), (0, 4));
4785 }
4786
4787 #[test]
4788 fn mouse_click_past_eol_handles_multibyte_line() {
4789 let mut e = Editor::new(
4790 hjkl_buffer::Buffer::new(),
4791 crate::types::DefaultHost::new(),
4792 crate::types::Options::default(),
4793 );
4794 // 5 chars, 6 bytes — old code's `String::len()` clamp was
4795 // wrong here.
4796 e.set_content("héllo");
4797 let area = ratatui::layout::Rect::new(0, 0, 80, 10);
4798 e.mouse_click_in_rect(area, 78, 1);
4799 assert_eq!(e.cursor(), (0, 4));
4800 }
4801
4802 #[test]
4803 fn mouse_click_inside_line_lands_on_clicked_char() {
4804 let mut e = Editor::new(
4805 hjkl_buffer::Buffer::new(),
4806 crate::types::DefaultHost::new(),
4807 crate::types::Options::default(),
4808 );
4809 e.set_content("hello world");
4810 // Gutter width = max(numberwidth=4, digits+1=2) = 4 cells, plus
4811 // 1 cell of pane padding (area_x.saturating_add(1)) = 5 total offset.
4812 // Click col 5 → char 0; click col 7 → char 2.
4813 let area = ratatui::layout::Rect::new(0, 0, 80, 10);
4814 e.mouse_click_in_rect(area, 5, 1);
4815 assert_eq!(e.cursor(), (0, 0));
4816 e.mouse_click_in_rect(area, 7, 1);
4817 assert_eq!(e.cursor(), (0, 2));
4818 }
4819
4820 /// Vim parity: a mouse-position click during insert mode counts
4821 /// as a motion and breaks the active undo group (when
4822 /// `undo_break_on_motion` is on, the default). After clicking and
4823 /// typing more chars, `u` should reverse only the post-click run.
4824 #[test]
4825 fn mouse_click_breaks_insert_undo_group_when_undobreak_on() {
4826 let mut e = Editor::new(
4827 hjkl_buffer::Buffer::new(),
4828 crate::types::DefaultHost::new(),
4829 crate::types::Options::default(),
4830 );
4831 e.set_content("hello world");
4832 let area = ratatui::layout::Rect::new(0, 0, 80, 10);
4833 // Default settings.undo_break_on_motion = true.
4834 assert!(e.settings().undo_break_on_motion);
4835 // Enter insert mode and type "AAA" before the line content.
4836 e.handle_key(key(KeyCode::Char('i')));
4837 e.handle_key(key(KeyCode::Char('A')));
4838 e.handle_key(key(KeyCode::Char('A')));
4839 e.handle_key(key(KeyCode::Char('A')));
4840 // Mouse click somewhere else on the line (still insert mode).
4841 e.mouse_click_in_rect(area, 10, 1);
4842 // Type more chars at the new cursor position.
4843 e.handle_key(key(KeyCode::Char('B')));
4844 e.handle_key(key(KeyCode::Char('B')));
4845 e.handle_key(key(KeyCode::Char('B')));
4846 // Leave insert and undo once.
4847 e.handle_key(key(KeyCode::Esc));
4848 e.handle_key(key(KeyCode::Char('u')));
4849 let line = e.buffer().line(0).unwrap_or("").to_string();
4850 assert!(
4851 line.contains("AAA"),
4852 "AAA must survive undo (separate group): {line:?}"
4853 );
4854 assert!(
4855 !line.contains("BBB"),
4856 "BBB must be undone (post-click group): {line:?}"
4857 );
4858 }
4859
4860 /// With `:set noundobreak`, the entire insert run — including
4861 /// chars typed before AND after a mouse click — should collapse
4862 /// into one undo group, so `u` clears everything.
4863 #[test]
4864 fn mouse_click_keeps_one_undo_group_when_undobreak_off() {
4865 let mut e = Editor::new(
4866 hjkl_buffer::Buffer::new(),
4867 crate::types::DefaultHost::new(),
4868 crate::types::Options::default(),
4869 );
4870 e.set_content("hello world");
4871 e.settings_mut().undo_break_on_motion = false;
4872 let area = ratatui::layout::Rect::new(0, 0, 80, 10);
4873 e.handle_key(key(KeyCode::Char('i')));
4874 e.handle_key(key(KeyCode::Char('A')));
4875 e.handle_key(key(KeyCode::Char('A')));
4876 e.mouse_click_in_rect(area, 10, 1);
4877 e.handle_key(key(KeyCode::Char('B')));
4878 e.handle_key(key(KeyCode::Char('B')));
4879 e.handle_key(key(KeyCode::Esc));
4880 e.handle_key(key(KeyCode::Char('u')));
4881 let line = e.buffer().line(0).unwrap_or("").to_string();
4882 assert!(
4883 !line.contains("AA") && !line.contains("BB"),
4884 "with undobreak off, single `u` must reverse whole insert: {line:?}"
4885 );
4886 assert_eq!(line, "hello world");
4887 }
4888
4889 // ── Patch B (0.0.29): Host trait wired into Editor ──
4890
4891 #[test]
4892 fn host_clipboard_round_trip_via_default_host() {
4893 // DefaultHost stores write_clipboard in-memory; read_clipboard
4894 // returns the most recent payload.
4895 let mut e = Editor::new(
4896 hjkl_buffer::Buffer::new(),
4897 crate::types::DefaultHost::new(),
4898 crate::types::Options::default(),
4899 );
4900 e.host_mut().write_clipboard("payload".to_string());
4901 assert_eq!(e.host_mut().read_clipboard().as_deref(), Some("payload"));
4902 }
4903
4904 #[test]
4905 fn host_records_clipboard_on_yank() {
4906 // `yy` on a single-line buffer must drive `Host::write_clipboard`
4907 // (the new Patch B side-channel) in addition to the legacy
4908 // `last_yank` mirror.
4909 let mut e = Editor::new(
4910 hjkl_buffer::Buffer::new(),
4911 crate::types::DefaultHost::new(),
4912 crate::types::Options::default(),
4913 );
4914 e.set_content("hello\n");
4915 e.handle_key(key(KeyCode::Char('y')));
4916 e.handle_key(key(KeyCode::Char('y')));
4917 // Clipboard cache holds the linewise yank.
4918 let clip = e.host_mut().read_clipboard();
4919 assert!(
4920 clip.as_deref().unwrap_or("").starts_with("hello"),
4921 "host clipboard should carry the yank: {clip:?}"
4922 );
4923 // Legacy mirror still populated for 0.0.28-era hosts.
4924 assert!(e.last_yank.as_deref().unwrap_or("").starts_with("hello"));
4925 }
4926
4927 #[test]
4928 fn host_cursor_shape_via_shared_recorder() {
4929 // Recording host backed by a leaked `Mutex` so the test can
4930 // inspect the emit sequence after the editor has consumed the
4931 // host. (Host: Send rules out Rc/RefCell.)
4932 let shapes_ptr: &'static std::sync::Mutex<Vec<crate::types::CursorShape>> =
4933 Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
4934 struct LeakHost {
4935 shapes: &'static std::sync::Mutex<Vec<crate::types::CursorShape>>,
4936 viewport: crate::types::Viewport,
4937 }
4938 impl crate::types::Host for LeakHost {
4939 type Intent = ();
4940 fn write_clipboard(&mut self, _: String) {}
4941 fn read_clipboard(&mut self) -> Option<String> {
4942 None
4943 }
4944 fn now(&self) -> core::time::Duration {
4945 core::time::Duration::ZERO
4946 }
4947 fn prompt_search(&mut self) -> Option<String> {
4948 None
4949 }
4950 fn emit_cursor_shape(&mut self, s: crate::types::CursorShape) {
4951 self.shapes.lock().unwrap().push(s);
4952 }
4953 fn viewport(&self) -> &crate::types::Viewport {
4954 &self.viewport
4955 }
4956 fn viewport_mut(&mut self) -> &mut crate::types::Viewport {
4957 &mut self.viewport
4958 }
4959 fn emit_intent(&mut self, _: Self::Intent) {}
4960 }
4961 let mut e = Editor::new(
4962 hjkl_buffer::Buffer::new(),
4963 LeakHost {
4964 shapes: shapes_ptr,
4965 viewport: crate::types::Viewport::default(),
4966 },
4967 crate::types::Options::default(),
4968 );
4969 e.set_content("abc");
4970 // Normal → Insert: Bar emit.
4971 e.handle_key(key(KeyCode::Char('i')));
4972 // Insert → Normal: Block emit.
4973 e.handle_key(key(KeyCode::Esc));
4974 let shapes = shapes_ptr.lock().unwrap().clone();
4975 assert_eq!(
4976 shapes,
4977 vec![
4978 crate::types::CursorShape::Bar,
4979 crate::types::CursorShape::Block,
4980 ],
4981 "host should observe Insert(Bar) → Normal(Block) transitions"
4982 );
4983 }
4984
4985 #[test]
4986 fn host_now_drives_chord_timeout_deterministically() {
4987 // Custom host whose `now()` is host-controlled; we drive it
4988 // forward by `timeout_len + 1ms` between the first `g` and
4989 // the second so the chord-timeout fires regardless of
4990 // wall-clock progress.
4991 let now_ptr: &'static std::sync::Mutex<core::time::Duration> =
4992 Box::leak(Box::new(std::sync::Mutex::new(core::time::Duration::ZERO)));
4993 struct ClockHost {
4994 now: &'static std::sync::Mutex<core::time::Duration>,
4995 viewport: crate::types::Viewport,
4996 }
4997 impl crate::types::Host for ClockHost {
4998 type Intent = ();
4999 fn write_clipboard(&mut self, _: String) {}
5000 fn read_clipboard(&mut self) -> Option<String> {
5001 None
5002 }
5003 fn now(&self) -> core::time::Duration {
5004 *self.now.lock().unwrap()
5005 }
5006 fn prompt_search(&mut self) -> Option<String> {
5007 None
5008 }
5009 fn emit_cursor_shape(&mut self, _: crate::types::CursorShape) {}
5010 fn viewport(&self) -> &crate::types::Viewport {
5011 &self.viewport
5012 }
5013 fn viewport_mut(&mut self) -> &mut crate::types::Viewport {
5014 &mut self.viewport
5015 }
5016 fn emit_intent(&mut self, _: Self::Intent) {}
5017 }
5018 let mut e = Editor::new(
5019 hjkl_buffer::Buffer::new(),
5020 ClockHost {
5021 now: now_ptr,
5022 viewport: crate::types::Viewport::default(),
5023 },
5024 crate::types::Options::default(),
5025 );
5026 e.set_content("a\nb\nc\n");
5027 e.jump_cursor(2, 0);
5028 // First `g` — host time = 0ms, lands in g-pending.
5029 e.handle_key(key(KeyCode::Char('g')));
5030 // Advance host time well past timeout_len (default 1000ms).
5031 *now_ptr.lock().unwrap() = core::time::Duration::from_secs(60);
5032 // Second `g` — chord-timeout fires; bare `g` re-dispatches and
5033 // does nothing on its own. Cursor must NOT have jumped to row 0.
5034 e.handle_key(key(KeyCode::Char('g')));
5035 assert_eq!(
5036 e.cursor().0,
5037 2,
5038 "Host::now() must drive `:set timeoutlen` deterministically"
5039 );
5040 }
5041
5042 // ── ContentEdit emission ─────────────────────────────────────────
5043
5044 fn fresh_editor(initial: &str) -> Editor {
5045 let buffer = hjkl_buffer::Buffer::from_str(initial);
5046 Editor::new(
5047 buffer,
5048 crate::types::DefaultHost::new(),
5049 crate::types::Options::default(),
5050 )
5051 }
5052
5053 #[test]
5054 fn content_edit_insert_char_at_origin() {
5055 let mut e = fresh_editor("");
5056 let _ = e.mutate_edit(hjkl_buffer::Edit::InsertChar {
5057 at: hjkl_buffer::Position::new(0, 0),
5058 ch: 'a',
5059 });
5060 let edits = e.take_content_edits();
5061 assert_eq!(edits.len(), 1);
5062 let ce = &edits[0];
5063 assert_eq!(ce.start_byte, 0);
5064 assert_eq!(ce.old_end_byte, 0);
5065 assert_eq!(ce.new_end_byte, 1);
5066 assert_eq!(ce.start_position, (0, 0));
5067 assert_eq!(ce.old_end_position, (0, 0));
5068 assert_eq!(ce.new_end_position, (0, 1));
5069 }
5070
5071 #[test]
5072 fn content_edit_insert_str_multiline() {
5073 // Buffer "x\ny" — insert "ab\ncd" at end of row 0.
5074 let mut e = fresh_editor("x\ny");
5075 let _ = e.mutate_edit(hjkl_buffer::Edit::InsertStr {
5076 at: hjkl_buffer::Position::new(0, 1),
5077 text: "ab\ncd".into(),
5078 });
5079 let edits = e.take_content_edits();
5080 assert_eq!(edits.len(), 1);
5081 let ce = &edits[0];
5082 assert_eq!(ce.start_byte, 1);
5083 assert_eq!(ce.old_end_byte, 1);
5084 assert_eq!(ce.new_end_byte, 1 + 5);
5085 assert_eq!(ce.start_position, (0, 1));
5086 // Insertion contains one '\n', so row+1, col = bytes after last '\n' = 2.
5087 assert_eq!(ce.new_end_position, (1, 2));
5088 }
5089
5090 #[test]
5091 fn content_edit_delete_range_charwise() {
5092 // "abcdef" — delete chars 1..4 ("bcd").
5093 let mut e = fresh_editor("abcdef");
5094 let _ = e.mutate_edit(hjkl_buffer::Edit::DeleteRange {
5095 start: hjkl_buffer::Position::new(0, 1),
5096 end: hjkl_buffer::Position::new(0, 4),
5097 kind: hjkl_buffer::MotionKind::Char,
5098 });
5099 let edits = e.take_content_edits();
5100 assert_eq!(edits.len(), 1);
5101 let ce = &edits[0];
5102 assert_eq!(ce.start_byte, 1);
5103 assert_eq!(ce.old_end_byte, 4);
5104 assert_eq!(ce.new_end_byte, 1);
5105 assert!(ce.old_end_byte > ce.new_end_byte);
5106 }
5107
5108 #[test]
5109 fn content_edit_set_content_resets() {
5110 let mut e = fresh_editor("foo");
5111 let _ = e.mutate_edit(hjkl_buffer::Edit::InsertChar {
5112 at: hjkl_buffer::Position::new(0, 0),
5113 ch: 'X',
5114 });
5115 // set_content should clear queued edits and raise the reset
5116 // flag on the next take_content_reset.
5117 e.set_content("brand new");
5118 assert!(e.take_content_reset());
5119 // Subsequent call clears the flag.
5120 assert!(!e.take_content_reset());
5121 // Edits cleared on reset.
5122 assert!(e.take_content_edits().is_empty());
5123 }
5124
5125 #[test]
5126 fn content_edit_multiple_replaces_in_order() {
5127 // Three Replace edits applied left-to-right (mimics the
5128 // substitute path's per-match Replace fan-out). Verify each
5129 // mutation queues exactly one ContentEdit and they're drained
5130 // in source-order with structurally valid byte spans.
5131 let mut e = fresh_editor("xax xbx xcx");
5132 let _ = e.take_content_edits();
5133 let _ = e.take_content_reset();
5134 // Replace each "x" with "yy", left to right. After each replace,
5135 // the next match's char-col shifts by +1 (since "yy" is 1 char
5136 // longer than "x" but they're both ASCII so byte = char here).
5137 let positions = [(0usize, 0usize), (0, 4), (0, 8)];
5138 for (row, col) in positions {
5139 let _ = e.mutate_edit(hjkl_buffer::Edit::Replace {
5140 start: hjkl_buffer::Position::new(row, col),
5141 end: hjkl_buffer::Position::new(row, col + 1),
5142 with: "yy".into(),
5143 });
5144 }
5145 let edits = e.take_content_edits();
5146 assert_eq!(edits.len(), 3);
5147 for ce in &edits {
5148 assert!(ce.start_byte <= ce.old_end_byte);
5149 assert!(ce.start_byte <= ce.new_end_byte);
5150 }
5151 // Document order.
5152 for w in edits.windows(2) {
5153 assert!(w[0].start_byte <= w[1].start_byte);
5154 }
5155 }
5156
5157 #[test]
5158 fn replace_char_at_replaces_single_char_under_cursor() {
5159 // Matches vim's `rx` semantics: replace char under cursor.
5160 let mut e = fresh_editor("abc");
5161 e.jump_cursor(0, 1); // cursor on 'b'
5162 e.replace_char_at('X', 1);
5163 let got = e.content();
5164 let got = got.trim_end_matches('\n');
5165 assert_eq!(
5166 got, "aXc",
5167 "replace_char_at(X, 1) must replace 'b' with 'X'"
5168 );
5169 // Cursor stays on the replaced char.
5170 assert_eq!(e.cursor(), (0, 1));
5171 }
5172
5173 #[test]
5174 fn replace_char_at_count_replaces_multiple_chars() {
5175 // `3rx` in vim replaces 3 chars starting at cursor.
5176 let mut e = fresh_editor("abcde");
5177 e.jump_cursor(0, 0);
5178 e.replace_char_at('Z', 3);
5179 let got = e.content();
5180 let got = got.trim_end_matches('\n');
5181 assert_eq!(
5182 got, "ZZZde",
5183 "replace_char_at(Z, 3) must replace first 3 chars"
5184 );
5185 }
5186
5187 #[test]
5188 fn find_char_method_moves_to_target() {
5189 // buffer "abcabc", cursor (0,0), f<c> → cursor (0,2).
5190 let mut e = fresh_editor("abcabc");
5191 e.jump_cursor(0, 0);
5192 e.find_char('c', true, false, 1);
5193 assert_eq!(
5194 e.cursor(),
5195 (0, 2),
5196 "find_char('c', forward=true, till=false, count=1) must land on 'c' at col 2"
5197 );
5198 }
5199
5200 // ── after_g unit tests (Phase 2b-ii) ────────────────────────────────────
5201
5202 #[test]
5203 fn after_g_gg_jumps_to_top() {
5204 let content: String = (0..20).map(|i| format!("line {i}\n")).collect();
5205 let mut e = fresh_editor(&content);
5206 e.jump_cursor(15, 0);
5207 e.after_g('g', 1);
5208 assert_eq!(e.cursor().0, 0, "gg must move cursor to row 0");
5209 }
5210
5211 #[test]
5212 fn after_g_gg_with_count_jumps_line() {
5213 // 5gg → row 4 (0-indexed).
5214 let content: String = (0..20).map(|i| format!("line {i}\n")).collect();
5215 let mut e = fresh_editor(&content);
5216 e.jump_cursor(0, 0);
5217 e.after_g('g', 5);
5218 assert_eq!(e.cursor().0, 4, "5gg must land on row 4");
5219 }
5220
5221 #[test]
5222 fn after_g_gv_restores_last_visual() {
5223 // Enter visual, move right, exit, then gv re-enters.
5224 let mut e = fresh_editor("hello world\n");
5225 // Enter char-visual at col 0, move to col 3, then exit.
5226 e.handle_key(key(KeyCode::Char('v')));
5227 e.handle_key(key(KeyCode::Char('l')));
5228 e.handle_key(key(KeyCode::Char('l')));
5229 e.handle_key(key(KeyCode::Char('l')));
5230 e.handle_key(key(KeyCode::Esc));
5231 assert_eq!(e.vim_mode(), VimMode::Normal, "should be Normal after Esc");
5232 // gv via after_g.
5233 e.after_g('v', 1);
5234 assert_eq!(
5235 e.vim_mode(),
5236 VimMode::Visual,
5237 "gv must re-enter Visual mode"
5238 );
5239 }
5240
5241 #[test]
5242 fn after_g_gj_moves_down() {
5243 let mut e = fresh_editor("line0\nline1\nline2\n");
5244 e.jump_cursor(0, 0);
5245 e.after_g('j', 1);
5246 assert_eq!(e.cursor().0, 1, "gj must move down one display row");
5247 }
5248
5249 #[test]
5250 fn after_g_gu_sets_operator_pending() {
5251 // gU enters operator-pending with Uppercase op; next key applies it.
5252 let mut e = fresh_editor("hello\n");
5253 e.after_g('U', 1);
5254 // The engine should now be chord-pending (Pending::Op set).
5255 assert!(
5256 e.is_chord_pending(),
5257 "gU must set engine chord-pending (Pending::Op)"
5258 );
5259 }
5260
5261 #[test]
5262 fn after_g_g_star_searches_forward_non_whole_word() {
5263 // g* on word "foo" in "foobar" should find the match.
5264 let mut e = fresh_editor("foo foobar\n");
5265 e.jump_cursor(0, 0); // cursor on 'f' of "foo"
5266 e.after_g('*', 1);
5267 // After g* the cursor should have moved (ScreenDown motion is
5268 // not applicable here; WordAtCursor forward moves to next match).
5269 // At minimum: no panic and mode stays Normal.
5270 assert_eq!(e.vim_mode(), VimMode::Normal, "g* must stay in Normal mode");
5271 }
5272
5273 // ── apply_motion controller tests (Phase 3a) ────────────────────────────
5274
5275 #[test]
5276 fn apply_motion_char_left_moves_cursor() {
5277 let mut e = fresh_editor("hello\n");
5278 e.jump_cursor(0, 3);
5279 e.apply_motion(hjkl_vim::MotionKind::CharLeft, 1);
5280 assert_eq!(e.cursor(), (0, 2), "CharLeft moves one col left");
5281 }
5282
5283 #[test]
5284 fn apply_motion_char_left_clamps_at_col_zero() {
5285 let mut e = fresh_editor("hello\n");
5286 e.jump_cursor(0, 0);
5287 e.apply_motion(hjkl_vim::MotionKind::CharLeft, 1);
5288 assert_eq!(e.cursor(), (0, 0), "CharLeft at col 0 must not wrap");
5289 }
5290
5291 #[test]
5292 fn apply_motion_char_left_with_count() {
5293 let mut e = fresh_editor("hello\n");
5294 e.jump_cursor(0, 4);
5295 e.apply_motion(hjkl_vim::MotionKind::CharLeft, 3);
5296 assert_eq!(e.cursor(), (0, 1), "CharLeft count=3 moves three cols left");
5297 }
5298
5299 #[test]
5300 fn apply_motion_char_right_moves_cursor() {
5301 let mut e = fresh_editor("hello\n");
5302 e.jump_cursor(0, 0);
5303 e.apply_motion(hjkl_vim::MotionKind::CharRight, 1);
5304 assert_eq!(e.cursor(), (0, 1), "CharRight moves one col right");
5305 }
5306
5307 #[test]
5308 fn apply_motion_char_right_clamps_at_last_char() {
5309 let mut e = fresh_editor("hello\n");
5310 // "hello" has chars at 0..=4; normal mode clamps at 4.
5311 e.jump_cursor(0, 4);
5312 e.apply_motion(hjkl_vim::MotionKind::CharRight, 1);
5313 assert_eq!(
5314 e.cursor(),
5315 (0, 4),
5316 "CharRight at end must not go past last char"
5317 );
5318 }
5319
5320 #[test]
5321 fn apply_motion_line_down_moves_cursor() {
5322 let mut e = fresh_editor("line0\nline1\nline2\n");
5323 e.jump_cursor(0, 0);
5324 e.apply_motion(hjkl_vim::MotionKind::LineDown, 1);
5325 assert_eq!(e.cursor().0, 1, "LineDown moves one row down");
5326 }
5327
5328 #[test]
5329 fn apply_motion_line_down_with_count() {
5330 let mut e = fresh_editor("line0\nline1\nline2\n");
5331 e.jump_cursor(0, 0);
5332 e.apply_motion(hjkl_vim::MotionKind::LineDown, 2);
5333 assert_eq!(e.cursor().0, 2, "LineDown count=2 moves two rows down");
5334 }
5335
5336 #[test]
5337 fn apply_motion_line_up_moves_cursor() {
5338 let mut e = fresh_editor("line0\nline1\nline2\n");
5339 e.jump_cursor(2, 0);
5340 e.apply_motion(hjkl_vim::MotionKind::LineUp, 1);
5341 assert_eq!(e.cursor().0, 1, "LineUp moves one row up");
5342 }
5343
5344 #[test]
5345 fn apply_motion_line_up_clamps_at_top() {
5346 let mut e = fresh_editor("line0\nline1\n");
5347 e.jump_cursor(0, 0);
5348 e.apply_motion(hjkl_vim::MotionKind::LineUp, 1);
5349 assert_eq!(e.cursor().0, 0, "LineUp at top must not go negative");
5350 }
5351
5352 #[test]
5353 fn apply_motion_first_non_blank_down_moves_and_lands_on_non_blank() {
5354 // Line 0: " hello" (indent 2), line 1: " world" (indent 2).
5355 let mut e = fresh_editor(" hello\n world\n");
5356 e.jump_cursor(0, 0);
5357 e.apply_motion(hjkl_vim::MotionKind::FirstNonBlankDown, 1);
5358 assert_eq!(e.cursor().0, 1, "FirstNonBlankDown must move to next row");
5359 assert_eq!(
5360 e.cursor().1,
5361 2,
5362 "FirstNonBlankDown must land on first non-blank col"
5363 );
5364 }
5365
5366 #[test]
5367 fn apply_motion_first_non_blank_up_moves_and_lands_on_non_blank() {
5368 let mut e = fresh_editor(" hello\n world\n");
5369 e.jump_cursor(1, 4);
5370 e.apply_motion(hjkl_vim::MotionKind::FirstNonBlankUp, 1);
5371 assert_eq!(e.cursor().0, 0, "FirstNonBlankUp must move to prev row");
5372 assert_eq!(
5373 e.cursor().1,
5374 2,
5375 "FirstNonBlankUp must land on first non-blank col"
5376 );
5377 }
5378
5379 #[test]
5380 fn apply_motion_count_zero_treated_as_one() {
5381 // count=0 must be normalised to 1 (count.max(1) in apply_motion_kind).
5382 let mut e = fresh_editor("hello\n");
5383 e.jump_cursor(0, 3);
5384 e.apply_motion(hjkl_vim::MotionKind::CharLeft, 0);
5385 assert_eq!(e.cursor(), (0, 2), "count=0 treated as 1 for CharLeft");
5386 }
5387
5388 // ── apply_motion controller tests (Phase 3b) — word motions ─────────────
5389
5390 #[test]
5391 fn apply_motion_word_forward_moves_to_next_word() {
5392 // "hello world\n": 'w' from col 0 lands on 'w' of "world" at col 6.
5393 let mut e = fresh_editor("hello world\n");
5394 e.jump_cursor(0, 0);
5395 e.apply_motion(hjkl_vim::MotionKind::WordForward, 1);
5396 assert_eq!(
5397 e.cursor(),
5398 (0, 6),
5399 "WordForward moves to start of next word"
5400 );
5401 }
5402
5403 #[test]
5404 fn apply_motion_word_forward_with_count() {
5405 // "one two three\n": 2w from col 0 → start of "three" at col 8.
5406 let mut e = fresh_editor("one two three\n");
5407 e.jump_cursor(0, 0);
5408 e.apply_motion(hjkl_vim::MotionKind::WordForward, 2);
5409 assert_eq!(e.cursor(), (0, 8), "WordForward count=2 skips two words");
5410 }
5411
5412 #[test]
5413 fn apply_motion_big_word_forward_moves_to_next_big_word() {
5414 // "foo.bar baz\n": W from col 0 skips entire "foo.bar" (one WORD) to 'b' at col 8.
5415 let mut e = fresh_editor("foo.bar baz\n");
5416 e.jump_cursor(0, 0);
5417 e.apply_motion(hjkl_vim::MotionKind::BigWordForward, 1);
5418 assert_eq!(e.cursor(), (0, 8), "BigWordForward skips the whole WORD");
5419 }
5420
5421 #[test]
5422 fn apply_motion_big_word_forward_with_count() {
5423 // "aa bb cc\n": 2W from col 0 → start of "cc" at col 6.
5424 let mut e = fresh_editor("aa bb cc\n");
5425 e.jump_cursor(0, 0);
5426 e.apply_motion(hjkl_vim::MotionKind::BigWordForward, 2);
5427 assert_eq!(e.cursor(), (0, 6), "BigWordForward count=2 skips two WORDs");
5428 }
5429
5430 #[test]
5431 fn apply_motion_word_backward_moves_to_prev_word() {
5432 // "hello world\n": 'b' from col 6 ('w') lands back at col 0 ('h').
5433 let mut e = fresh_editor("hello world\n");
5434 e.jump_cursor(0, 6);
5435 e.apply_motion(hjkl_vim::MotionKind::WordBackward, 1);
5436 assert_eq!(
5437 e.cursor(),
5438 (0, 0),
5439 "WordBackward moves to start of prev word"
5440 );
5441 }
5442
5443 #[test]
5444 fn apply_motion_word_backward_with_count() {
5445 // "one two three\n": 2b from col 8 ('t' of "three") → col 0 ('o' of "one").
5446 let mut e = fresh_editor("one two three\n");
5447 e.jump_cursor(0, 8);
5448 e.apply_motion(hjkl_vim::MotionKind::WordBackward, 2);
5449 assert_eq!(
5450 e.cursor(),
5451 (0, 0),
5452 "WordBackward count=2 skips two words back"
5453 );
5454 }
5455
5456 #[test]
5457 fn apply_motion_big_word_backward_moves_to_prev_big_word() {
5458 // "foo.bar baz\n": B from col 8 ('b' of "baz") → col 0 (start of "foo.bar" WORD).
5459 let mut e = fresh_editor("foo.bar baz\n");
5460 e.jump_cursor(0, 8);
5461 e.apply_motion(hjkl_vim::MotionKind::BigWordBackward, 1);
5462 assert_eq!(
5463 e.cursor(),
5464 (0, 0),
5465 "BigWordBackward jumps to start of prev WORD"
5466 );
5467 }
5468
5469 #[test]
5470 fn apply_motion_big_word_backward_with_count() {
5471 // "aa bb cc\n": 2B from col 6 ('c') → col 0 ('a').
5472 let mut e = fresh_editor("aa bb cc\n");
5473 e.jump_cursor(0, 6);
5474 e.apply_motion(hjkl_vim::MotionKind::BigWordBackward, 2);
5475 assert_eq!(
5476 e.cursor(),
5477 (0, 0),
5478 "BigWordBackward count=2 skips two WORDs back"
5479 );
5480 }
5481
5482 #[test]
5483 fn apply_motion_word_end_moves_to_end_of_word() {
5484 // "hello world\n": 'e' from col 0 lands on 'o' of "hello" at col 4.
5485 let mut e = fresh_editor("hello world\n");
5486 e.jump_cursor(0, 0);
5487 e.apply_motion(hjkl_vim::MotionKind::WordEnd, 1);
5488 assert_eq!(e.cursor(), (0, 4), "WordEnd moves to end of current word");
5489 }
5490
5491 #[test]
5492 fn apply_motion_word_end_with_count() {
5493 // "one two three\n": 2e from col 0 → end of "two" at col 6.
5494 let mut e = fresh_editor("one two three\n");
5495 e.jump_cursor(0, 0);
5496 e.apply_motion(hjkl_vim::MotionKind::WordEnd, 2);
5497 assert_eq!(
5498 e.cursor(),
5499 (0, 6),
5500 "WordEnd count=2 lands on end of second word"
5501 );
5502 }
5503
5504 #[test]
5505 fn apply_motion_big_word_end_moves_to_end_of_big_word() {
5506 // "foo.bar baz\n": E from col 0 → end of "foo.bar" WORD at col 6.
5507 let mut e = fresh_editor("foo.bar baz\n");
5508 e.jump_cursor(0, 0);
5509 e.apply_motion(hjkl_vim::MotionKind::BigWordEnd, 1);
5510 assert_eq!(e.cursor(), (0, 6), "BigWordEnd lands on end of WORD");
5511 }
5512
5513 #[test]
5514 fn apply_motion_big_word_end_with_count() {
5515 // "aa bb cc\n": 2E from col 0 → end of "bb" at col 4.
5516 let mut e = fresh_editor("aa bb cc\n");
5517 e.jump_cursor(0, 0);
5518 e.apply_motion(hjkl_vim::MotionKind::BigWordEnd, 2);
5519 assert_eq!(
5520 e.cursor(),
5521 (0, 4),
5522 "BigWordEnd count=2 lands on end of second WORD"
5523 );
5524 }
5525
5526 // ── apply_motion controller tests (Phase 3c) — line-anchor motions ────────
5527
5528 #[test]
5529 fn apply_motion_line_start_lands_at_col_zero() {
5530 // " foo bar \n": `0` from col 5 → col 0 unconditionally.
5531 let mut e = fresh_editor(" foo bar \n");
5532 e.jump_cursor(0, 5);
5533 e.apply_motion(hjkl_vim::MotionKind::LineStart, 1);
5534 assert_eq!(e.cursor(), (0, 0), "LineStart lands at col 0");
5535 }
5536
5537 #[test]
5538 fn apply_motion_line_start_from_beginning_stays_at_col_zero() {
5539 // Already at col 0 — motion is a no-op but must not panic.
5540 let mut e = fresh_editor(" foo bar \n");
5541 e.jump_cursor(0, 0);
5542 e.apply_motion(hjkl_vim::MotionKind::LineStart, 1);
5543 assert_eq!(e.cursor(), (0, 0), "LineStart from col 0 stays at col 0");
5544 }
5545
5546 #[test]
5547 fn apply_motion_first_non_blank_lands_on_first_non_blank() {
5548 // " foo bar \n": `^` from col 0 → col 2 ('f').
5549 let mut e = fresh_editor(" foo bar \n");
5550 e.jump_cursor(0, 0);
5551 e.apply_motion(hjkl_vim::MotionKind::FirstNonBlank, 1);
5552 assert_eq!(
5553 e.cursor(),
5554 (0, 2),
5555 "FirstNonBlank lands on first non-blank char"
5556 );
5557 }
5558
5559 #[test]
5560 fn apply_motion_first_non_blank_on_blank_line_lands_at_zero() {
5561 // " \n": all whitespace — `^` must land at col 0.
5562 let mut e = fresh_editor(" \n");
5563 e.jump_cursor(0, 2);
5564 e.apply_motion(hjkl_vim::MotionKind::FirstNonBlank, 1);
5565 assert_eq!(
5566 e.cursor(),
5567 (0, 0),
5568 "FirstNonBlank on blank line stays at col 0"
5569 );
5570 }
5571
5572 #[test]
5573 fn apply_motion_line_end_lands_on_last_char() {
5574 // " foo bar \n": last char is the second space at col 10.
5575 let mut e = fresh_editor(" foo bar \n");
5576 e.jump_cursor(0, 0);
5577 e.apply_motion(hjkl_vim::MotionKind::LineEnd, 1);
5578 assert_eq!(e.cursor(), (0, 10), "LineEnd lands on last char of line");
5579 }
5580
5581 #[test]
5582 fn apply_motion_line_end_on_empty_line_stays_at_zero() {
5583 // "\n": empty line — `$` must stay at col 0.
5584 let mut e = fresh_editor("\n");
5585 e.jump_cursor(0, 0);
5586 e.apply_motion(hjkl_vim::MotionKind::LineEnd, 1);
5587 assert_eq!(e.cursor(), (0, 0), "LineEnd on empty line stays at col 0");
5588 }
5589
5590 // ── apply_motion controller tests (Phase 3d) — doc-level motion ───────────
5591
5592 #[test]
5593 fn goto_line_count_1_lands_on_last_line() {
5594 // "foo\nbar\nbaz\n": bare `G` (count=1) → last content line (row 2).
5595 // Count convention: apply_motion_kind normalises 1 → execute_motion
5596 // with count=1 → FileBottom arm sees count <= 1 → move_bottom(0) =
5597 // last content row.
5598 let mut e = fresh_editor("foo\nbar\nbaz\n");
5599 e.jump_cursor(0, 0);
5600 e.apply_motion(hjkl_vim::MotionKind::GotoLine, 1);
5601 assert_eq!(e.cursor(), (2, 0), "bare G lands on last content row");
5602 }
5603
5604 #[test]
5605 fn goto_line_count_5_lands_on_line_5() {
5606 // 6-line buffer (rows 0-5); `5G` → row 4 (1-based line 5).
5607 let mut e = fresh_editor("a\nb\nc\nd\ne\nf\n");
5608 e.jump_cursor(0, 0);
5609 e.apply_motion(hjkl_vim::MotionKind::GotoLine, 5);
5610 assert_eq!(e.cursor(), (4, 0), "5G lands on row 4 (1-based line 5)");
5611 }
5612
5613 #[test]
5614 fn goto_line_count_past_buffer_clamps_to_last_line() {
5615 // "foo\nbar\nbaz\n": `100G` → last content line (row 2), clamped.
5616 let mut e = fresh_editor("foo\nbar\nbaz\n");
5617 e.jump_cursor(0, 0);
5618 e.apply_motion(hjkl_vim::MotionKind::GotoLine, 100);
5619 assert_eq!(e.cursor(), (2, 0), "100G clamps to last content row");
5620 }
5621
5622 // ── FindRepeat / FindRepeatReverse controller tests (Phase 3e) ────────────
5623
5624 #[test]
5625 fn find_repeat_after_f_finds_next_occurrence() {
5626 // "abcabc", cursor at (0,0). `fc` lands on (0,2). `;` repeats → (0,5).
5627 let mut e = fresh_editor("abcabc");
5628 e.jump_cursor(0, 0);
5629 e.find_char('c', true, false, 1);
5630 assert_eq!(e.cursor(), (0, 2), "fc must land on first 'c'");
5631 e.apply_motion(hjkl_vim::MotionKind::FindRepeat, 1);
5632 assert_eq!(
5633 e.cursor(),
5634 (0, 5),
5635 "find_repeat (;) must advance to second 'c'"
5636 );
5637 }
5638
5639 #[test]
5640 fn find_repeat_reverse_after_f_finds_prev_occurrence() {
5641 // "abcabc", cursor at (0,0). `fc` lands on (0,2). `;` → (0,5). `,` back → (0,2).
5642 let mut e = fresh_editor("abcabc");
5643 e.jump_cursor(0, 0);
5644 e.find_char('c', true, false, 1);
5645 assert_eq!(e.cursor(), (0, 2), "fc must land on first 'c'");
5646 e.apply_motion(hjkl_vim::MotionKind::FindRepeat, 1);
5647 assert_eq!(e.cursor(), (0, 5), "; must advance to second 'c'");
5648 e.apply_motion(hjkl_vim::MotionKind::FindRepeatReverse, 1);
5649 assert_eq!(
5650 e.cursor(),
5651 (0, 2),
5652 "find_repeat_reverse (,) must go back to first 'c'"
5653 );
5654 }
5655
5656 #[test]
5657 fn find_repeat_with_no_prior_find_is_noop() {
5658 // Fresh editor, no prior find — `;` must not move cursor.
5659 let mut e = fresh_editor("abcabc");
5660 e.jump_cursor(0, 3);
5661 e.apply_motion(hjkl_vim::MotionKind::FindRepeat, 1);
5662 assert_eq!(
5663 e.cursor(),
5664 (0, 3),
5665 "find_repeat with no prior find must be a no-op"
5666 );
5667 }
5668
5669 #[test]
5670 fn find_repeat_with_count_advances_count_times() {
5671 // "aXaXaX", cursor (0,0). `fX` → (0,1). `3;` → repeats 3× → (0,5).
5672 let mut e = fresh_editor("aXaXaX");
5673 e.jump_cursor(0, 0);
5674 e.find_char('X', true, false, 1);
5675 assert_eq!(e.cursor(), (0, 1), "fX must land on first 'X' at col 1");
5676 e.apply_motion(hjkl_vim::MotionKind::FindRepeat, 3);
5677 assert_eq!(
5678 e.cursor(),
5679 (0, 5),
5680 "3; must advance 3 times from col 1 to col 5"
5681 );
5682 }
5683
5684 // ── BracketMatch controller tests (Phase 3f) ───────────────────────────────
5685
5686 #[test]
5687 fn bracket_match_jumps_to_matching_close_paren() {
5688 // "(abc)", cursor at (0,0) on `(` — `%` must jump to `)` at (0,4).
5689 let mut e = fresh_editor("(abc)");
5690 e.jump_cursor(0, 0);
5691 e.apply_motion(hjkl_vim::MotionKind::BracketMatch, 1);
5692 assert_eq!(
5693 e.cursor(),
5694 (0, 4),
5695 "% on '(' must land on matching ')' at col 4"
5696 );
5697 }
5698
5699 #[test]
5700 fn bracket_match_jumps_to_matching_open_paren() {
5701 // "(abc)", cursor at (0,4) on `)` — `%` must jump back to `(` at (0,0).
5702 let mut e = fresh_editor("(abc)");
5703 e.jump_cursor(0, 4);
5704 e.apply_motion(hjkl_vim::MotionKind::BracketMatch, 1);
5705 assert_eq!(
5706 e.cursor(),
5707 (0, 0),
5708 "% on ')' must land on matching '(' at col 0"
5709 );
5710 }
5711
5712 #[test]
5713 fn bracket_match_with_no_match_on_line_is_noop_or_engine_behaviour() {
5714 // "abcd", cursor at (0,2) — no bracket under cursor; engine returns
5715 // false from matching_bracket, cursor must not move.
5716 let mut e = fresh_editor("abcd");
5717 e.jump_cursor(0, 2);
5718 e.apply_motion(hjkl_vim::MotionKind::BracketMatch, 1);
5719 assert_eq!(
5720 e.cursor(),
5721 (0, 2),
5722 "% with no bracket under cursor must be a no-op"
5723 );
5724 }
5725
5726 // ── Scroll / viewport motion controller tests (Phase 3g) ──────────────────
5727
5728 /// Helper: build a 20-line buffer, set viewport to rows [5..14] (height=10).
5729 fn fresh_viewport_editor() -> Editor {
5730 let content = many_lines(20);
5731 let mut e = Editor::new(
5732 hjkl_buffer::Buffer::from_str(&content),
5733 crate::types::DefaultHost::new(),
5734 crate::types::Options::default(),
5735 );
5736 // height=10, top_row=5 → visible rows 5..14.
5737 // set_viewport_height stores to the atomic; sync_buffer_from_textarea
5738 // propagates it to host.viewport_mut().height so motion helpers see it.
5739 e.set_viewport_height(10);
5740 e.sync_buffer_from_textarea();
5741 e.host_mut().viewport_mut().top_row = 5;
5742 e
5743 }
5744
5745 #[test]
5746 fn viewport_top_lands_on_first_visible_row() {
5747 // Viewport top=5, height=10. H (count=1) should land on row 5
5748 // (the first visible row, offset = count-1 = 0).
5749 let mut e = fresh_viewport_editor();
5750 e.jump_cursor(10, 0);
5751 e.apply_motion(hjkl_vim::MotionKind::ViewportTop, 1);
5752 assert_eq!(
5753 e.cursor().0,
5754 5,
5755 "H (count=1) must land on viewport top row (5)"
5756 );
5757 }
5758
5759 #[test]
5760 fn viewport_top_with_count_offsets_down() {
5761 // H with count=3 → viewport top + (3-1) = 5 + 2 = row 7.
5762 let mut e = fresh_viewport_editor();
5763 e.jump_cursor(12, 0);
5764 e.apply_motion(hjkl_vim::MotionKind::ViewportTop, 3);
5765 assert_eq!(e.cursor().0, 7, "3H must land at viewport top + 2 = row 7");
5766 }
5767
5768 #[test]
5769 fn viewport_middle_lands_on_middle_visible_row() {
5770 // Viewport top=5, height=10 → last visible = 14, mid = 5 + (14-5)/2 = 9.
5771 let mut e = fresh_viewport_editor();
5772 e.jump_cursor(0, 0);
5773 e.apply_motion(hjkl_vim::MotionKind::ViewportMiddle, 1);
5774 assert_eq!(e.cursor().0, 9, "M must land on middle visible row (9)");
5775 }
5776
5777 #[test]
5778 fn viewport_bottom_lands_on_last_visible_row() {
5779 // L (count=1) → viewport bottom, offset = count-1 = 0 → row 14.
5780 let mut e = fresh_viewport_editor();
5781 e.jump_cursor(5, 0);
5782 e.apply_motion(hjkl_vim::MotionKind::ViewportBottom, 1);
5783 assert_eq!(
5784 e.cursor().0,
5785 14,
5786 "L (count=1) must land on viewport bottom row (14)"
5787 );
5788 }
5789
5790 #[test]
5791 fn half_page_down_moves_cursor_by_half_window() {
5792 // viewport height=10, so half=5. Cursor at row 0 → row 5 after C-d.
5793 let mut e = Editor::new(
5794 hjkl_buffer::Buffer::from_str(&many_lines(30)),
5795 crate::types::DefaultHost::new(),
5796 crate::types::Options::default(),
5797 );
5798 e.set_viewport_height(10);
5799 e.jump_cursor(0, 0);
5800 e.apply_motion(hjkl_vim::MotionKind::HalfPageDown, 1);
5801 assert_eq!(
5802 e.cursor().0,
5803 5,
5804 "<C-d> from row 0 with viewport height=10 must land on row 5"
5805 );
5806 }
5807
5808 #[test]
5809 fn half_page_up_moves_cursor_by_half_window_reverse() {
5810 // viewport height=10, half=5. Cursor at row 10 → row 5 after C-u.
5811 let mut e = Editor::new(
5812 hjkl_buffer::Buffer::from_str(&many_lines(30)),
5813 crate::types::DefaultHost::new(),
5814 crate::types::Options::default(),
5815 );
5816 e.set_viewport_height(10);
5817 e.jump_cursor(10, 0);
5818 e.apply_motion(hjkl_vim::MotionKind::HalfPageUp, 1);
5819 assert_eq!(
5820 e.cursor().0,
5821 5,
5822 "<C-u> from row 10 with viewport height=10 must land on row 5"
5823 );
5824 }
5825
5826 #[test]
5827 fn full_page_down_moves_cursor_by_full_window() {
5828 // viewport height=10, full = 10 - 2 = 8. Cursor at row 0 → row 8.
5829 let mut e = Editor::new(
5830 hjkl_buffer::Buffer::from_str(&many_lines(30)),
5831 crate::types::DefaultHost::new(),
5832 crate::types::Options::default(),
5833 );
5834 e.set_viewport_height(10);
5835 e.jump_cursor(0, 0);
5836 e.apply_motion(hjkl_vim::MotionKind::FullPageDown, 1);
5837 assert_eq!(
5838 e.cursor().0,
5839 8,
5840 "<C-f> from row 0 with viewport height=10 must land on row 8"
5841 );
5842 }
5843
5844 #[test]
5845 fn full_page_up_moves_cursor_by_full_window_reverse() {
5846 // viewport height=10, full=8. Cursor at row 10 → row 2.
5847 let mut e = Editor::new(
5848 hjkl_buffer::Buffer::from_str(&many_lines(30)),
5849 crate::types::DefaultHost::new(),
5850 crate::types::Options::default(),
5851 );
5852 e.set_viewport_height(10);
5853 e.jump_cursor(10, 0);
5854 e.apply_motion(hjkl_vim::MotionKind::FullPageUp, 1);
5855 assert_eq!(
5856 e.cursor().0,
5857 2,
5858 "<C-b> from row 10 with viewport height=10 must land on row 2"
5859 );
5860 }
5861
5862 // ── set_mark_at_cursor unit tests ─────────────────────────────────────────
5863
5864 #[test]
5865 fn set_mark_at_cursor_alphabetic_records() {
5866 // `ma` at (0, 2) — mark 'a' must store (0, 2).
5867 let mut e = fresh_editor("hello");
5868 e.jump_cursor(0, 2);
5869 e.set_mark_at_cursor('a');
5870 assert_eq!(
5871 e.mark('a'),
5872 Some((0, 2)),
5873 "mark 'a' must record current pos"
5874 );
5875 }
5876
5877 #[test]
5878 fn set_mark_at_cursor_invalid_char_no_op() {
5879 // Invalid chars (digits, special) must not store a mark.
5880 let mut e = fresh_editor("hello");
5881 e.jump_cursor(0, 1);
5882 e.set_mark_at_cursor('1'); // digit — not alphanumeric in vim mark sense
5883 assert_eq!(e.mark('1'), None, "digit mark must be a no-op");
5884 e.set_mark_at_cursor('['); // special — only goto uses '[', not set_mark
5885 assert_eq!(
5886 e.mark('['),
5887 None,
5888 "bracket char must be a no-op for set_mark"
5889 );
5890 }
5891
5892 #[test]
5893 fn set_mark_at_cursor_special_left_bracket() {
5894 // Confirm '[' is NOT stored by set_mark_at_cursor (vim's `m[` is invalid).
5895 // The `[` mark is only set automatically by operator paths, not `m[`.
5896 let mut e = fresh_editor("hello");
5897 e.jump_cursor(0, 3);
5898 e.set_mark_at_cursor('[');
5899 assert_eq!(
5900 e.mark('['),
5901 None,
5902 "set_mark_at_cursor must reject '[' (vim: m[ is invalid)"
5903 );
5904 }
5905
5906 // ── goto_mark_line unit tests ─────────────────────────────────────────────
5907
5908 #[test]
5909 fn goto_mark_line_jumps_to_first_non_blank() {
5910 // Set mark 'a' at (1, 3), then jump back to (0, 0).
5911 // `'a` (linewise) must land on row 1, first non-blank column.
5912 let mut e = fresh_editor("hello\n world\n");
5913 e.jump_cursor(1, 3);
5914 e.set_mark_at_cursor('a');
5915 e.jump_cursor(0, 0);
5916 e.goto_mark_line('a');
5917 assert_eq!(e.cursor().0, 1, "goto_mark_line must jump to mark row");
5918 // " world" — first non-blank is col 2.
5919 assert_eq!(
5920 e.cursor().1,
5921 2,
5922 "goto_mark_line must land on first non-blank column"
5923 );
5924 }
5925
5926 #[test]
5927 fn goto_mark_line_unset_mark_no_op() {
5928 // Jumping to an unset mark must not move the cursor.
5929 let mut e = fresh_editor("hello\nworld\n");
5930 e.jump_cursor(1, 2);
5931 e.goto_mark_line('z'); // 'z' not set
5932 assert_eq!(e.cursor(), (1, 2), "unset mark jump must be a no-op");
5933 }
5934
5935 #[test]
5936 fn goto_mark_line_invalid_char_no_op() {
5937 // '!' is not a valid mark char — must not move cursor.
5938 let mut e = fresh_editor("hello\nworld\n");
5939 e.jump_cursor(0, 0);
5940 e.goto_mark_line('!');
5941 assert_eq!(e.cursor(), (0, 0), "invalid mark char must be a no-op");
5942 }
5943
5944 // ── goto_mark_char unit tests ─────────────────────────────────────────────
5945
5946 #[test]
5947 fn goto_mark_char_jumps_to_exact_pos() {
5948 // Set mark 'b' at (1, 4), then jump back to (0, 0).
5949 // `` `b `` (charwise) must land on (1, 4) exactly.
5950 let mut e = fresh_editor("hello\nworld\n");
5951 e.jump_cursor(1, 4);
5952 e.set_mark_at_cursor('b');
5953 e.jump_cursor(0, 0);
5954 e.goto_mark_char('b');
5955 assert_eq!(
5956 e.cursor(),
5957 (1, 4),
5958 "goto_mark_char must jump to exact mark position"
5959 );
5960 }
5961
5962 #[test]
5963 fn goto_mark_char_unset_mark_no_op() {
5964 // Jumping to an unset mark must not move the cursor.
5965 let mut e = fresh_editor("hello\nworld\n");
5966 e.jump_cursor(1, 1);
5967 e.goto_mark_char('x'); // 'x' not set
5968 assert_eq!(
5969 e.cursor(),
5970 (1, 1),
5971 "unset charwise mark jump must be a no-op"
5972 );
5973 }
5974
5975 #[test]
5976 fn goto_mark_char_invalid_char_no_op() {
5977 // '#' is not a valid mark char — must not move cursor.
5978 let mut e = fresh_editor("hello\nworld\n");
5979 e.jump_cursor(0, 2);
5980 e.goto_mark_char('#');
5981 assert_eq!(
5982 e.cursor(),
5983 (0, 2),
5984 "invalid charwise mark char must be a no-op"
5985 );
5986 }
5987
5988 // ── Macro controller API tests (Phase 5b) ─────────────────────────────────
5989
5990 #[test]
5991 fn start_macro_record_records_register() {
5992 let mut e = fresh_editor("hello");
5993 assert!(!e.is_recording_macro());
5994 e.start_macro_record('a');
5995 assert!(e.is_recording_macro());
5996 assert_eq!(e.recording_register(), Some('a'));
5997 }
5998
5999 #[test]
6000 fn start_macro_record_capital_seeds_existing() {
6001 // `qa` records "h", stop. Then `qA` should seed from existing 'a' reg.
6002 let mut e = fresh_editor("hello");
6003 e.start_macro_record('a');
6004 e.record_input(crate::input::Input {
6005 key: crate::input::Key::Char('h'),
6006 ..Default::default()
6007 });
6008 e.stop_macro_record();
6009 // Start capital 'A' — should seed from existing 'a' register.
6010 e.start_macro_record('A');
6011 // recording_keys should now contain 1 input (the seeded 'h').
6012 assert_eq!(
6013 e.vim.recording_keys.len(),
6014 1,
6015 "capital record must seed from existing lowercase reg"
6016 );
6017 }
6018
6019 #[test]
6020 fn stop_macro_record_writes_register() {
6021 let mut e = fresh_editor("hello");
6022 e.start_macro_record('a');
6023 e.record_input(crate::input::Input {
6024 key: crate::input::Key::Char('h'),
6025 ..Default::default()
6026 });
6027 e.record_input(crate::input::Input {
6028 key: crate::input::Key::Char('l'),
6029 ..Default::default()
6030 });
6031 e.stop_macro_record();
6032 assert!(!e.is_recording_macro());
6033 // Register 'a' should contain "hl".
6034 let text = e
6035 .registers()
6036 .read('a')
6037 .map(|s| s.text.clone())
6038 .unwrap_or_default();
6039 assert_eq!(
6040 text, "hl",
6041 "stop_macro_record must write encoded keys to register"
6042 );
6043 }
6044
6045 #[test]
6046 fn is_recording_macro_reflects_state() {
6047 let mut e = fresh_editor("hello");
6048 assert!(!e.is_recording_macro());
6049 e.start_macro_record('b');
6050 assert!(e.is_recording_macro());
6051 e.stop_macro_record();
6052 assert!(!e.is_recording_macro());
6053 }
6054
6055 #[test]
6056 fn play_macro_returns_decoded_inputs() {
6057 let mut e = fresh_editor("hello");
6058 // Write "jj" into register 'a'.
6059 e.set_named_register_text('a', "jj".to_string());
6060 let inputs = e.play_macro('a', 1);
6061 assert_eq!(inputs.len(), 2);
6062 assert_eq!(inputs[0].key, crate::input::Key::Char('j'));
6063 assert_eq!(inputs[1].key, crate::input::Key::Char('j'));
6064 assert!(e.is_replaying_macro(), "play_macro must set replaying flag");
6065 e.end_macro_replay();
6066 assert!(!e.is_replaying_macro());
6067 }
6068
6069 #[test]
6070 fn play_macro_at_uses_last_macro() {
6071 let mut e = fresh_editor("hello");
6072 e.set_named_register_text('a', "k".to_string());
6073 // Play 'a' first to set last_macro.
6074 let _ = e.play_macro('a', 1);
6075 e.end_macro_replay();
6076 // Now `@@` should replay 'a' again.
6077 let inputs = e.play_macro('@', 1);
6078 assert_eq!(inputs.len(), 1);
6079 assert_eq!(inputs[0].key, crate::input::Key::Char('k'));
6080 e.end_macro_replay();
6081 }
6082
6083 #[test]
6084 fn play_macro_with_count_repeats() {
6085 let mut e = fresh_editor("hello");
6086 e.set_named_register_text('a', "j".to_string());
6087 let inputs = e.play_macro('a', 3);
6088 assert_eq!(inputs.len(), 3, "3@a must produce 3 inputs");
6089 e.end_macro_replay();
6090 }
6091
6092 #[test]
6093 fn record_input_appends_when_recording() {
6094 let mut e = fresh_editor("hello");
6095 // Not recording: record_input is a no-op.
6096 e.record_input(crate::input::Input {
6097 key: crate::input::Key::Char('j'),
6098 ..Default::default()
6099 });
6100 assert_eq!(e.vim.recording_keys.len(), 0);
6101 // Start recording: record_input appends.
6102 e.start_macro_record('a');
6103 e.record_input(crate::input::Input {
6104 key: crate::input::Key::Char('j'),
6105 ..Default::default()
6106 });
6107 e.record_input(crate::input::Input {
6108 key: crate::input::Key::Char('k'),
6109 ..Default::default()
6110 });
6111 assert_eq!(e.vim.recording_keys.len(), 2);
6112 // During replay: record_input must NOT append.
6113 e.vim.replaying_macro = true;
6114 e.record_input(crate::input::Input {
6115 key: crate::input::Key::Char('l'),
6116 ..Default::default()
6117 });
6118 assert_eq!(
6119 e.vim.recording_keys.len(),
6120 2,
6121 "record_input must skip during replay"
6122 );
6123 e.vim.replaying_macro = false;
6124 e.stop_macro_record();
6125 }
6126}