Skip to main content

hjkl_vim/vim/
visual_ops.rs

1//! Vim FSM: visual ops.
2//!
3//! Split out of the monolithic `vim.rs` (#267 follow-up).
4
5use hjkl_vim_types::{InsertReason, LastChange, Mode, Motion, Operator, RangeKind, VisualExtent};
6
7use hjkl_engine::rope_util::rope_line_to_str;
8
9use super::*;
10use crate::vim_state::{vim, vim_mut};
11use hjkl_engine::Editor;
12use hjkl_engine::buf_helpers::{
13    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc,
14};
15
16pub fn apply_visual_operator<H: hjkl_engine::types::Host>(
17    ed: &mut Editor<hjkl_buffer::View, H>,
18    op: Operator,
19    count: usize,
20) {
21    include_closed_folds_in_selection(ed);
22    visual_operator_body(ed, op, count);
23    remember_operator_column(ed);
24}
25
26/// vim's `do_pending_operator` in Visual mode: a selection end inside a
27/// closed fold takes the whole fold. The earlier end moves to the fold's first
28/// line, column 0, and the later one to its last line, past the last
29/// character, so `zfjvd` deletes both folded lines with their line breaks.
30fn include_closed_folds_in_selection<H: hjkl_engine::types::Host>(
31    ed: &mut Editor<hjkl_buffer::View, H>,
32) {
33    let mode = vim(ed).mode;
34    let anchor = match mode {
35        Mode::Visual => vim(ed).visual_anchor,
36        Mode::VisualLine => (vim(ed).visual_line_anchor, 0),
37        Mode::VisualBlock => vim(ed).block_anchor,
38        _ => return,
39    };
40    let cursor = ed.cursor();
41    let fold_edge = |ed: &Editor<hjkl_buffer::View, H>, pos: (usize, usize), last: bool| {
42        let (first, end) = expand_linewise_over_closed_folds(ed.buffer(), pos.0, pos.0);
43        if first == end {
44            pos
45        } else if last {
46            (end, buf_line_chars(ed.buffer(), end))
47        } else {
48            (first, 0)
49        }
50    };
51    let anchor_first = anchor < cursor;
52    let new_anchor = fold_edge(ed, anchor, !anchor_first);
53    let new_cursor = fold_edge(ed, cursor, anchor_first);
54    if (new_anchor, new_cursor) == (anchor, cursor) {
55        return;
56    }
57    match mode {
58        Mode::Visual => vim_mut(ed).visual_anchor = new_anchor,
59        Mode::VisualLine => vim_mut(ed).visual_line_anchor = new_anchor.0,
60        _ => {
61            vim_mut(ed).block_anchor = new_anchor;
62            vim_mut(ed).block_vcol = new_cursor.1;
63        }
64    }
65    buf_set_cursor_rc(ed.buffer_mut(), new_cursor.0, new_cursor.1);
66}
67
68/// vim sets `'curswant'` from the cursor once an operator is done
69/// (`w_set_curswant`): a following `j` / `k` aims for the column the
70/// operator left the cursor in, not the one it had before. The sticky
71/// column is a display column, so a tab before the cursor counts at its
72/// width. An operator that entered Insert mode leaves that to the end of the
73/// insert.
74pub fn remember_operator_column<H: hjkl_engine::types::Host>(
75    ed: &mut Editor<hjkl_buffer::View, H>,
76) {
77    if vim(ed).mode == Mode::Normal {
78        remember_cursor_column(ed);
79    }
80}
81
82/// Make the cursor's display column the sticky column, as vim does after
83/// any command that sets `w_set_curswant`: a tab before the cursor counts
84/// at its width, and a cursor on a tab in Normal mode at its last cell.
85pub fn remember_cursor_column<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
86    let (row, col) = ed.cursor();
87    let want = ed.cursor_want(row, col);
88    ed.set_sticky_col(Some(want));
89}
90
91/// Where a Visual operator starts: the selection's first character, the
92/// Visual-line anchor's column 0 (or the cursor when it is above the
93/// anchor), or a block's top-left corner. The undo step saves it, and a
94/// refused operator leaves the cursor there.
95pub fn visual_op_start<H: hjkl_engine::types::Host>(
96    ed: &Editor<hjkl_buffer::View, H>,
97) -> (usize, usize) {
98    let cursor = ed.cursor();
99    match vim(ed).mode {
100        Mode::Visual => order(vim(ed).visual_anchor, cursor).0,
101        Mode::VisualLine if cursor.0 < vim(ed).visual_line_anchor => cursor,
102        Mode::VisualLine => (vim(ed).visual_line_anchor, 0),
103        Mode::VisualBlock => {
104            let (top, _, left, _) = block_bounds(ed);
105            (top, left)
106        }
107        _ => cursor,
108    }
109}
110
111fn visual_operator_body<H: hjkl_engine::types::Host>(
112    ed: &mut Editor<hjkl_buffer::View, H>,
113    op: Operator,
114    count: usize,
115) {
116    // `count` is the number of indent levels for `>` / `<` (vim `2>` = two
117    // shiftwidths); other visual operators ignore it.
118    let levels = count.max(1);
119    // Read the explicit register before an operator consumes it, so the
120    // dot-repeat entry can name it (`:h redo-register`).
121    let register = vim(ed).pending_register;
122    if matches!(op, Operator::Delete | Operator::Yank | Operator::Change)
123        && refuse_read_only_register(ed)
124    {
125        let start = visual_op_start(ed);
126        refused_operator(ed, op, start, RangeKind::Exclusive);
127        return;
128    }
129    match vim(ed).mode {
130        Mode::VisualLine => {
131            let (cursor_row, cursor_col) = ed.cursor();
132            let anchor_row = vim(ed).visual_line_anchor;
133            let top = cursor_row.min(anchor_row);
134            let bot = cursor_row.max(anchor_row);
135            ed.set_yank_linewise(true);
136            // vim takes the Visual-line anchor at column 0, and the earlier of
137            // it and the cursor as the operator's start — the cursor the undo
138            // step saves and where a yank leaves it: `V>u` returns to
139            // (top, 0), `Vk>u` and `Vky` to the cursor. `gq` / `gw` keep the
140            // cursor.
141            let start_col = if cursor_row < anchor_row {
142                cursor_col
143            } else {
144                0
145            };
146            if !matches!(op, Operator::Reflow | Operator::ReflowKeepCursor) {
147                buf_set_cursor_rc(ed.buffer_mut(), top, start_col);
148            }
149            match op {
150                Operator::Yank => {
151                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
152                    if !text.is_empty() {
153                        ed.record_yank_to_host(text.clone());
154                        let target = vim_mut(ed).pending_register.take();
155                        ed.record_yank(text, true, target);
156                    }
157                    vim_mut(ed).mode = Mode::Normal;
158                }
159                Operator::Delete => {
160                    ed.push_undo();
161                    // Capture `total` (incl. ropey's phantom trailing row)
162                    // before the cut so the phantom-row clamp below can decide
163                    // where the cursor lands. Same reasoning as the `dd`
164                    // (linewise.rs) and `run_operator_over_range` (op_motion.rs)
165                    // delete paths — a linewise Visual delete reaching the true
166                    // last row must not park the cursor on the phantom empty
167                    // final line.
168                    let total = buf_row_count(ed.buffer());
169                    let deleted_through_last = bot + 1 >= total;
170                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
171                    land_after_linewise_delete(ed, top, deleted_through_last);
172                    record_visual_last_change(
173                        ed,
174                        op,
175                        VisualExtent::Line {
176                            lines: bot - top + 1,
177                        },
178                        register,
179                        1,
180                    );
181                    vim_mut(ed).mode = Mode::Normal;
182                }
183                Operator::Change => {
184                    // Vim `Vc` / `Vjc`: same linewise-change semantics as
185                    // `cc` — preserve first line's indent, enter insert.
186                    // Record BEFORE entering insert: `AfterChange` patches
187                    // `inserted` into this entry when the user hits Esc.
188                    record_visual_last_change(
189                        ed,
190                        op,
191                        VisualExtent::Line {
192                            lines: bot - top + 1,
193                        },
194                        register,
195                        1,
196                    );
197                    change_linewise_rows(ed, top, bot, ed.cursor().1);
198                }
199                Operator::Uppercase
200                | Operator::Lowercase
201                | Operator::ToggleCase
202                | Operator::Rot13 => {
203                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
204                    buf_set_cursor_rc(ed.buffer_mut(), top, 0);
205                    record_visual_last_change(
206                        ed,
207                        op,
208                        VisualExtent::Line {
209                            lines: bot - top + 1,
210                        },
211                        register,
212                        1,
213                    );
214                }
215                Operator::Indent | Operator::Outdent => {
216                    ed.push_undo();
217                    if op == Operator::Indent {
218                        indent_rows(ed, top, bot, levels);
219                    } else {
220                        outdent_rows(ed, top, bot, levels);
221                    }
222                    record_visual_last_change(
223                        ed,
224                        op,
225                        VisualExtent::Line {
226                            lines: bot - top + 1,
227                        },
228                        register,
229                        levels,
230                    );
231                    vim_mut(ed).mode = Mode::Normal;
232                }
233                Operator::Reflow => {
234                    ed.push_undo();
235                    let (cursor_row, _) = ed.cursor();
236                    let bot = cursor_row.max(vim(ed).visual_line_anchor);
237                    reflow_rows(ed, top, bot);
238                    vim_mut(ed).mode = Mode::Normal;
239                }
240                Operator::ReflowKeepCursor => {
241                    let saved = ed.cursor();
242                    ed.push_undo();
243                    let (cursor_row, _) = ed.cursor();
244                    let bot = cursor_row.max(vim(ed).visual_line_anchor);
245                    let (before, after, opts) = reflow_rows_keep_cursor(ed, top, bot);
246                    let (new_row, new_col) =
247                        reflow_keep_cursor(top, saved.0, saved.1, &before, &after, opts);
248                    buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
249                    vim_mut(ed).mode = Mode::Normal;
250                }
251                Operator::AutoIndent => {
252                    ed.push_undo();
253                    auto_indent_rows(ed, top, bot);
254                    vim_mut(ed).mode = Mode::Normal;
255                }
256                // Filter is dispatched through Editor::filter_range, not here.
257                Operator::Filter => {}
258                // Comment is dispatched through the app layer (engine_actions.rs), not here.
259                Operator::Comment => {}
260                // Visual `zf` is handled inline in `handle_after_z`,
261                // never routed through this dispatcher.
262                Operator::Fold => unreachable!("Visual zf takes its own path"),
263            }
264        }
265        Mode::Visual => {
266            ed.set_yank_linewise(false);
267            let anchor = vim(ed).visual_anchor;
268            let cursor = ed.cursor();
269            let (top, bot) = order(anchor, cursor);
270            // As in Visual-line: the selection's start is the operator's
271            // start, the cursor the undo step saves (`vll~u` returns to the
272            // first selected character). `gq` / `gw` keep the cursor.
273            if !matches!(op, Operator::Reflow | Operator::ReflowKeepCursor) {
274                buf_set_cursor_rc(ed.buffer_mut(), top.0, top.1);
275            }
276            match op {
277                Operator::Yank => {
278                    let (from, to, kind) = charwise_visual_range(ed, top, bot);
279                    let text = read_vim_range(ed, from, to, kind);
280                    if !text.is_empty() {
281                        ed.record_yank_to_host(text.clone());
282                        let target = vim_mut(ed).pending_register.take();
283                        ed.record_yank(text, false, target);
284                    }
285                    buf_set_cursor_rc(ed.buffer_mut(), top.0, top.1);
286                    vim_mut(ed).mode = Mode::Normal;
287                }
288                Operator::Delete => {
289                    ed.push_undo();
290                    let (from, to, kind) = charwise_visual_range(ed, top, bot);
291                    cut_vim_range(ed, from, to, kind);
292                    // A cut through the end of the line leaves the cursor past
293                    // the new end; Normal mode puts it on the last character.
294                    clamp_cursor_to_normal_mode(ed);
295                    record_visual_last_change(ed, op, charwise_extent(top, bot), register, 1);
296                    vim_mut(ed).mode = Mode::Normal;
297                }
298                Operator::Change => {
299                    ed.push_undo();
300                    // Record BEFORE entering insert: `AfterChange` patches
301                    // `inserted` into this entry when the user hits Esc.
302                    record_visual_last_change(ed, op, charwise_extent(top, bot), register, 1);
303                    let (from, to, kind) = charwise_visual_range(ed, top, bot);
304                    cut_vim_range(ed, from, to, kind);
305                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
306                }
307                Operator::Uppercase
308                | Operator::Lowercase
309                | Operator::ToggleCase
310                | Operator::Rot13 => {
311                    let (from, to, kind) = charwise_visual_range(ed, top, bot);
312                    apply_case_op_to_selection(ed, op, from, to, kind);
313                    record_visual_last_change(ed, op, charwise_extent(top, bot), register, 1);
314                }
315                Operator::Indent | Operator::Outdent => {
316                    ed.push_undo();
317                    if op == Operator::Indent {
318                        indent_rows(ed, top.0, bot.0, levels);
319                    } else {
320                        outdent_rows(ed, top.0, bot.0, levels);
321                    }
322                    // Indent/outdent always act linewise regardless of the
323                    // originating charwise selection (`:h v_>` — columns are
324                    // ignored), so the dot-repeat extent is Line, not Char.
325                    record_visual_last_change(
326                        ed,
327                        op,
328                        VisualExtent::Line {
329                            lines: bot.0 - top.0 + 1,
330                        },
331                        register,
332                        levels,
333                    );
334                    vim_mut(ed).mode = Mode::Normal;
335                }
336                Operator::Reflow => {
337                    ed.push_undo();
338                    let anchor = vim(ed).visual_anchor;
339                    let cursor = ed.cursor();
340                    let (top, bot) = order(anchor, cursor);
341                    reflow_rows(ed, top.0, bot.0);
342                    vim_mut(ed).mode = Mode::Normal;
343                }
344                Operator::ReflowKeepCursor => {
345                    let saved = ed.cursor();
346                    ed.push_undo();
347                    let anchor = vim(ed).visual_anchor;
348                    let cursor = ed.cursor();
349                    let (top, bot) = order(anchor, cursor);
350                    let (before, after, opts) = reflow_rows_keep_cursor(ed, top.0, bot.0);
351                    let (new_row, new_col) =
352                        reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after, opts);
353                    buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
354                    vim_mut(ed).mode = Mode::Normal;
355                }
356                Operator::AutoIndent => {
357                    ed.push_undo();
358                    let anchor = vim(ed).visual_anchor;
359                    let cursor = ed.cursor();
360                    let (top, bot) = order(anchor, cursor);
361                    auto_indent_rows(ed, top.0, bot.0);
362                    vim_mut(ed).mode = Mode::Normal;
363                }
364                // Filter is dispatched through Editor::filter_range, not here.
365                Operator::Filter => {}
366                // Comment is dispatched through the app layer (engine_actions.rs), not here.
367                Operator::Comment => {}
368                Operator::Fold => unreachable!("Visual zf takes its own path"),
369            }
370        }
371        Mode::VisualBlock => apply_block_operator(ed, op, levels),
372        _ => {}
373    }
374}
375/// B1: record `LastChange::VisualOp` for dot-repeat, unless this call is
376/// itself a replay (`.` must not overwrite the entry it's replaying).
377fn record_visual_last_change<H: hjkl_engine::types::Host>(
378    ed: &mut Editor<hjkl_buffer::View, H>,
379    op: Operator,
380    extent: VisualExtent,
381    register: Option<char>,
382    levels: usize,
383) {
384    if !vim(ed).replaying {
385        vim_mut(ed).last_change = Some(LastChange::VisualOp {
386            op,
387            extent,
388            inserted: None,
389            register,
390            levels,
391        });
392    }
393}
394/// The range a charwise Visual selection `top..=bot` covers. On an empty
395/// line the selection's one cell is the line break, which vim yanks and
396/// changes with it (`vy` there yanks "\n"); as an exclusive range ending at
397/// the next line's start, reading and cutting agree on that. The buffer's
398/// last line has no break: ropey's phantom row after a trailing newline is
399/// not a line.
400pub fn charwise_visual_range<H: hjkl_engine::types::Host>(
401    ed: &Editor<hjkl_buffer::View, H>,
402    top: (usize, usize),
403    bot: (usize, usize),
404) -> ((usize, usize), (usize, usize), RangeKind) {
405    let rows = buf_row_count(ed.buffer());
406    let next_is_line =
407        bot.0 + 1 < rows && !(bot.0 + 2 == rows && buf_line_chars(ed.buffer(), bot.0 + 1) == 0);
408    if bot.1 >= buf_line_chars(ed.buffer(), bot.0) && next_is_line {
409        (top, (bot.0 + 1, 0), RangeKind::Exclusive)
410    } else {
411        (top, bot, RangeKind::Inclusive)
412    }
413}
414/// B1: derive the charwise dot-repeat extent from an ordered `(top, bot)`
415/// INCLUSIVE range (`:h v_.`). Single-line selections store the raw
416/// selected char count as `width` (replay selects that many chars starting
417/// at the cursor); multi-line selections store the last line's char count
418/// measured from column 0 (replay's last line always starts at column 0 —
419/// only the first line tracks the cursor's column).
420fn charwise_extent(top: (usize, usize), bot: (usize, usize)) -> VisualExtent {
421    if top.0 == bot.0 {
422        VisualExtent::Char {
423            lines: 1,
424            width: bot.1 - top.1 + 1,
425        }
426    } else {
427        VisualExtent::Char {
428            lines: bot.0 - top.0 + 1,
429            width: bot.1 + 1,
430        }
431    }
432}
433/// B-block: derive the blockwise dot-repeat extent (`:h v_.` for blocks)
434/// from an ordered inclusive rectangle. `rows`/`cols` are the height/width;
435/// `to_eol` carries the `$`-ragged flag so replay re-runs to each row's own
436/// EOL (`:h v_b_$`).
437/// What `.` needs to rebuild a block: its rows and its width in display
438/// columns (vim's `v_.` replays the same screen size from the cursor).
439fn block_extent(top: usize, bot: usize, cols: BlockCols) -> VisualExtent {
440    let to_eol = cols.right == usize::MAX;
441    VisualExtent::Block {
442        rows: bot - top + 1,
443        cols: if to_eol {
444            1
445        } else {
446            cols.right + 1 - cols.left
447        },
448        to_eol,
449    }
450}
451/// Compute `(top_row, bot_row, left_col, right_col)` for the current
452/// VisualBlock selection. Columns are inclusive on both ends. Uses the
453/// tracked virtual column (see [`update_block_vcol`] for when it moves)
454/// rather than reading the cursor directly.
455pub fn block_bounds<H: hjkl_engine::types::Host>(
456    ed: &Editor<hjkl_buffer::View, H>,
457) -> (usize, usize, usize, usize) {
458    let (ar, ac) = vim(ed).block_anchor;
459    let (cr, _) = ed.cursor();
460    let cc = vim(ed).block_vcol;
461    let top = ar.min(cr);
462    let bot = ar.max(cr);
463    let left = ac.min(cc);
464    let right = ac.max(cc);
465    (top, bot, left, right)
466}
467/// Update the virtual column after a motion in VisualBlock mode.
468/// Horizontal motions and `j` / `k` sync `block_vcol` to the new cursor
469/// column; search and the other motions leave it alone.
470///
471/// `$` (`Motion::LineEnd`) additionally marks the block "ragged" (`:h
472/// v_b_$`, [`crate::vim_state::VimState::block_to_eol`]) — every row then
473/// resolves its own right edge to its own EOL until a DIFFERENT
474/// horizontal motion re-establishes a fixed column, at which point the
475/// flag clears. `j` / `k` preserve it, matching vim.
476pub fn update_block_vcol<H: hjkl_engine::types::Host>(
477    ed: &mut Editor<hjkl_buffer::View, H>,
478    motion: &Motion,
479) {
480    match motion {
481        Motion::LineEnd => {
482            vim_mut(ed).block_vcol = ed.cursor().1;
483            vim_mut(ed).block_to_eol = true;
484        }
485        Motion::Left
486        | Motion::Right
487        | Motion::SpaceFwd
488        | Motion::BackspaceBack
489        | Motion::WordFwd
490        | Motion::BigWordFwd
491        | Motion::WordBack
492        | Motion::BigWordBack
493        | Motion::WordEnd
494        | Motion::BigWordEnd
495        | Motion::WordEndBack
496        | Motion::BigWordEndBack
497        | Motion::LineStart
498        | Motion::Home
499        | Motion::FirstNonBlank
500        | Motion::LastNonBlank
501        | Motion::LineMiddle
502        | Motion::ScreenLineMiddle
503        | Motion::ScreenLineStart
504        | Motion::ScreenLineFirstNonBlank
505        | Motion::ScreenLineEnd
506        | Motion::WordAtCursor { .. }
507        | Motion::Find { .. }
508        | Motion::FindRepeat { .. }
509        | Motion::MatchBracket
510        // `gg` / `G` reset vim's curswant to where they land, so the block's
511        // column follows them: `<C-v>Gd` takes columns 0 to the anchor's.
512        // `H` / `M` / `L` would too by the same measurement, but a corpus
513        // case pins the old column for them (see `docs/backlog.md`).
514        | Motion::FileTop
515        | Motion::FileBottom
516        // So do the other jumps that set curswant: `{` `}` `(` `)`, the
517        // section and unmatched-bracket motions, `+` `-` `_`, `|`, `n` `N`.
518        | Motion::ParagraphPrev
519        | Motion::ParagraphNext
520        | Motion::SentencePrev
521        | Motion::SentenceNext
522        | Motion::SectionBackward
523        | Motion::SectionForward
524        | Motion::SectionEndBackward
525        | Motion::SectionEndForward
526        | Motion::UnmatchedBracket { .. }
527        | Motion::FirstNonBlankNextLine
528        | Motion::FirstNonBlankPrevLine
529        | Motion::FirstNonBlankLine
530        | Motion::GotoColumn
531        | Motion::SearchNext { .. } => {
532            vim_mut(ed).block_vcol = ed.cursor().1;
533            vim_mut(ed).block_to_eol = false;
534        }
535        // `j` / `k` — vim's `block_prep` reads the cursor's own column, which
536        // the curswant puts back once the cursor reaches a long enough line,
537        // so on a shorter line the block narrows to where the cursor landed
538        // (the NUL, in Visual). The ragged flag survives.
539        Motion::Up | Motion::Down => {
540            vim_mut(ed).block_vcol = ed.cursor().1;
541        }
542        // `gj` / `gk` and `H` / `M` / `L` — preserve vcol AND the ragged flag.
543        _ => {}
544    }
545}
546pub fn visual_selection_for_search<H: hjkl_engine::types::Host>(
547    ed: &mut Editor<hjkl_buffer::View, H>,
548) -> ((usize, usize), String) {
549    match vim(ed).mode {
550        Mode::Visual => {
551            let (top, bot) = order(vim(ed).visual_anchor, ed.cursor());
552            (top, read_vim_range(ed, top, bot, RangeKind::Inclusive))
553        }
554        Mode::VisualLine => {
555            let top = vim(ed).visual_line_anchor.min(ed.cursor().0);
556            let bot = vim(ed).visual_line_anchor.max(ed.cursor().0);
557            let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
558            (
559                (top, 0),
560                text.strip_suffix('\n').unwrap_or(&text).to_owned(),
561            )
562        }
563        Mode::VisualBlock => {
564            let (top, bot, cols) = block_rect(ed);
565            (
566                (top, block_start_col(ed, top, cols)),
567                block_yank(ed, top, bot, cols),
568            )
569        }
570        Mode::Normal | Mode::Insert => (ed.cursor(), String::new()),
571    }
572}
573
574/// Yank / delete / change / replace a rectangular selection. Yanked text
575/// is stored as one string per row joined with `\n`, but the register is
576/// flagged BLOCKWISE (with the block's column width) so `p`/`P` re-insert
577/// it as columns at the cursor — vim's true block-paste geometry — rather
578/// than spilling each row onto its own new line (see the `blockwise` slot
579/// flag and `do_block_paste`). The joined-`\n` string is also what the RPC
580/// / charwise-fallback paths read.
581pub fn apply_block_operator<H: hjkl_engine::types::Host>(
582    ed: &mut Editor<hjkl_buffer::View, H>,
583    op: Operator,
584    count: usize,
585) {
586    // Same capture as `apply_visual_operator` — this is also reached
587    // directly from `range_ops`, so it cannot rely on that one.
588    let register = vim(ed).pending_register;
589    // The block in display columns; a `$` block (`:h v_b_$`) has
590    // `cols.right == usize::MAX`, so every row runs to its own end.
591    let (top, bot, cols) = block_rect(ed);
592    let left_col =
593        block_start_col(ed, top, cols).min(buf_line_chars(ed.buffer(), top).saturating_sub(1));
594    if matches!(op, Operator::Delete | Operator::Yank | Operator::Change)
595        && refuse_read_only_register(ed)
596    {
597        refused_operator(ed, op, (top, left_col), RangeKind::Exclusive);
598        return;
599    }
600    // Snapshot the block text for yank / clipboard.
601    let yank = block_yank(ed, top, bot, cols);
602    let block_width = block_register_width(&yank, cols);
603    // vim's `do_pending_operator` puts the cursor on the operator's start
604    // before the operator saves its undo step, so `u` returns to the block's
605    // top-left corner, not to where the selection ended.
606    ed.jump_cursor(top, left_col);
607
608    match op {
609        Operator::Yank => {
610            if !yank.is_empty() {
611                ed.record_yank_to_host(yank.clone());
612                let target = vim_mut(ed).pending_register.take();
613                ed.record_yank_block(yank, block_width, target);
614            }
615            vim_mut(ed).mode = Mode::Normal;
616            ed.jump_cursor(top, left_col);
617        }
618        Operator::Delete => {
619            ed.push_undo();
620            delete_block_contents(ed, top, bot, cols);
621            if !yank.is_empty() {
622                ed.record_yank_to_host(yank.clone());
623                let target = vim_mut(ed).pending_register.take();
624                ed.record_delete_block(yank, block_width, target);
625            }
626            vim_mut(ed).mode = Mode::Normal;
627            // Cursor lands on the block's left column, clamped to the row's
628            // new last valid column — a ragged (`D` / `$`) delete can leave
629            // the top row shorter than `left`, and nvim parks on its EOL
630            // rather than past it (`<C-v>jjD` on "abcd…" → (0,0), not (0,1)).
631            let line_len = buf_line_chars(ed.buffer(), top);
632            ed.jump_cursor(top, left_col.min(line_len.saturating_sub(1)));
633            record_visual_last_change(ed, op, block_extent(top, bot, cols), register, 1);
634        }
635        Operator::Change => {
636            ed.push_undo();
637            // Record BEFORE entering insert: the `BlockChange` finish site
638            // (comment.rs) patches `inserted` into this entry on Esc, exactly
639            // as `AfterChange` does for charwise/linewise `c`.
640            record_visual_last_change(ed, op, block_extent(top, bot, cols), register, 1);
641            // `c`'s replicated typed text always lands at the LEFT column
642            // on every row (`:h v_b_c`) — unaffected by a ragged right
643            // edge, so only the delete side needs `to_eol`.
644            // The typed text goes where the block's left edge now falls on
645            // the top row, past any spaces a cut tab left behind.
646            let top_line = buf_line(ed.buffer(), top).unwrap_or_default();
647            let top_span = block_row(&top_line, cols, ed.settings().tabstop, true);
648            let insert_col = top_span.start + top_span.startspaces;
649            delete_block_contents(ed, top, bot, cols);
650            if !yank.is_empty() {
651                ed.record_yank_to_host(yank.clone());
652                let target = vim_mut(ed).pending_register.take();
653                ed.record_delete_block(yank, block_width, target);
654            }
655            ed.jump_cursor(top, insert_col);
656            begin_insert_noundo(
657                ed,
658                1,
659                InsertReason::BlockChange {
660                    top,
661                    bot,
662                    col: insert_col,
663                },
664            );
665        }
666        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
667            ed.push_undo();
668            transform_block_case(ed, op, top, bot, cols);
669            vim_mut(ed).mode = Mode::Normal;
670            ed.jump_cursor(top, left_col);
671            record_visual_last_change(ed, op, block_extent(top, bot, cols), register, 1);
672        }
673        Operator::Indent | Operator::Outdent => {
674            // VisualBlock `>` / `<` shifts at the BLOCK's left column, not at
675            // the start of the line. This used to fall through to the
676            // linewise `indent_rows` / `outdent_rows` under a comment
677            // claiming vim did the same; it does not — `<C-v>jl>` with the
678            // block starting at column 2 turns "abcdef" into "ab    cdef",
679            // where the linewise fallback produced "    abcdef".
680            ed.push_undo();
681            if op == Operator::Indent {
682                indent_block(ed, top, bot, cols, count.max(1));
683            } else {
684                outdent_block(ed, top, bot, cols, count.max(1));
685            }
686            vim_mut(ed).mode = Mode::Normal;
687            record_visual_last_change(ed, op, block_extent(top, bot, cols), register, count.max(1));
688        }
689        Operator::Fold => unreachable!("Visual zf takes its own path"),
690        Operator::Reflow => {
691            // Reflow over the block falls back to linewise reflow over
692            // the row range — column slicing for `gq` doesn't make
693            // sense.
694            ed.push_undo();
695            reflow_rows(ed, top, bot);
696            vim_mut(ed).mode = Mode::Normal;
697        }
698        Operator::ReflowKeepCursor => {
699            // `gw` over a block: same fallback as `gq` but restore cursor.
700            let saved = ed.cursor();
701            ed.push_undo();
702            let (before, after, opts) = reflow_rows_keep_cursor(ed, top, bot);
703            let (new_row, new_col) =
704                reflow_keep_cursor(top, saved.0, saved.1, &before, &after, opts);
705            buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
706            vim_mut(ed).mode = Mode::Normal;
707        }
708        Operator::AutoIndent => {
709            // AutoIndent over the block falls back to linewise
710            // auto-indent over the row range.
711            ed.push_undo();
712            auto_indent_rows(ed, top, bot);
713            vim_mut(ed).mode = Mode::Normal;
714        }
715        // Filter is dispatched through Editor::filter_range, not here.
716        Operator::Filter => {}
717        // Comment is dispatched through the app layer (engine_actions.rs), not here.
718        Operator::Comment => {}
719    }
720}
721/// In-place case transform over the block's rows, each over the characters
722/// wholly inside the block's display columns (vim's `op_tilde` over
723/// `block_prep`). Rows that end before the block are left alone.
724pub fn transform_block_case<H: hjkl_engine::types::Host>(
725    ed: &mut Editor<hjkl_buffer::View, H>,
726    op: Operator,
727    top: usize,
728    bot: usize,
729    cols: BlockCols,
730) {
731    use hjkl_buffer::{Edit, Position};
732    let tabstop = ed.settings().tabstop;
733    let rope = hjkl_engine::types::Query::rope(ed.buffer());
734    for r in top..=bot.min(rope.len_lines().saturating_sub(1)) {
735        let line = rope_line_to_str(&rope, r);
736        let span = block_row(&line, cols, tabstop, false);
737        if span.start >= span.end {
738            continue;
739        }
740        let mid: String = line
741            .chars()
742            .skip(span.start)
743            .take(span.end - span.start)
744            .collect();
745        let transformed = match op {
746            Operator::Uppercase => mid.to_uppercase(),
747            Operator::Lowercase => mid.to_lowercase(),
748            Operator::ToggleCase => toggle_case_str(&mid),
749            Operator::Rot13 => rot13_str(&mid),
750            _ => mid,
751        };
752        ed.mutate_edit(Edit::Replace {
753            start: Position::new(r, span.start),
754            end: Position::new(r, span.end),
755            with: transformed,
756        });
757    }
758    let saved_yank = ed.yank();
759    let saved_linewise = ed.yank_linewise();
760    let start =
761        block_start_col(ed, top, cols).min(buf_line_chars(ed.buffer(), top).saturating_sub(1));
762    buf_set_cursor_rc(ed.buffer_mut(), top, start);
763    ed.set_yank(saved_yank);
764    ed.set_yank_linewise(saved_linewise);
765}
766/// The character the block starts at on `row`: the one holding its left
767/// display column, or the row's end when the row stops short of it. Where
768/// vim's `do_pending_operator` puts the cursor before a block operator.
769pub fn block_start_col<H: hjkl_engine::types::Host>(
770    ed: &Editor<hjkl_buffer::View, H>,
771    row: usize,
772    cols: BlockCols,
773) -> usize {
774    let line = buf_line(ed.buffer(), row).unwrap_or_default();
775    block_row(&line, cols, ed.settings().tabstop, true).start
776}
777/// The display columns of a block `width` cells wide whose left edge is the
778/// character at `col` on `row`, as dot-repeat rebuilds one at the cursor
779/// (vim's `v_.` keeps the block's screen size).
780pub fn block_cols_at<H: hjkl_engine::types::Host>(
781    ed: &Editor<hjkl_buffer::View, H>,
782    row: usize,
783    col: usize,
784    width: usize,
785    to_eol: bool,
786) -> BlockCols {
787    let line = buf_line(ed.buffer(), row).unwrap_or_default();
788    let left = hjkl_buffer::char_col_to_visual_col(&line, col, ed.settings().tabstop);
789    BlockCols {
790        left,
791        right: if to_eol {
792            usize::MAX
793        } else {
794            left + width.max(1) - 1
795        },
796    }
797}
798/// Yank the block: on each row the characters wholly inside its display
799/// columns, with spaces for the cells of a character straddling an edge, and
800/// a row that ends before the block padded to its width (vim's `op_yank` over
801/// `block_prep`). A `$` block takes each row to its own end.
802pub fn block_yank<H: hjkl_engine::types::Host>(
803    ed: &Editor<hjkl_buffer::View, H>,
804    top: usize,
805    bot: usize,
806    cols: BlockCols,
807) -> String {
808    let tabstop = ed.settings().tabstop;
809    let rope = hjkl_engine::types::Query::rope(ed.buffer());
810    let n = rope.len_lines();
811    let mut rows: Vec<String> = Vec::new();
812    for r in top..=bot {
813        if r >= n {
814            break;
815        }
816        let line = rope_line_to_str(&rope, r);
817        let span = block_row(&line, cols, tabstop, false);
818        let mut text = " ".repeat(span.startspaces);
819        text.extend(line.chars().skip(span.start).take(span.end - span.start));
820        text.push_str(&" ".repeat(span.endspaces));
821        rows.push(text);
822    }
823    rows.join("\n")
824}
825/// The width a block register pads each row to on paste: the block's
826/// display width, or the widest row of a `$` block.
827pub fn block_register_width(yank: &str, cols: BlockCols) -> usize {
828    if cols.right == usize::MAX {
829        yank.split('\n')
830            .map(|s| s.chars().count())
831            .max()
832            .unwrap_or(0)
833    } else {
834        cols.right + 1 - cols.left
835    }
836}
837/// Delete the block from each row: the characters it covers, a character
838/// straddling an edge taken whole with the cells of it outside the block put
839/// back as spaces (vim's `op_delete` over `block_prep`). Each row is its own
840/// edit in the caller's undo group (no `push_undo` here).
841pub fn delete_block_contents<H: hjkl_engine::types::Host>(
842    ed: &mut Editor<hjkl_buffer::View, H>,
843    top: usize,
844    bot: usize,
845    cols: BlockCols,
846) {
847    use hjkl_buffer::{Edit, Position};
848    ed.sync_buffer_content_from_textarea();
849    let tabstop = ed.settings().tabstop;
850    let last_row = bot.min(buf_row_count(ed.buffer()).saturating_sub(1));
851    for row in top..=last_row {
852        let line = buf_line(ed.buffer(), row).unwrap_or_default();
853        let span = block_row(&line, cols, tabstop, true);
854        let spaces = span.startspaces + span.endspaces;
855        if span.start == span.end && spaces == 0 {
856            continue;
857        }
858        ed.mutate_edit(Edit::Replace {
859            start: Position::new(row, span.start),
860            end: Position::new(row, span.end),
861            with: " ".repeat(spaces),
862        });
863    }
864}
865/// Replace each character cell in the block with `ch`. Ragged (`:h
866/// v_b_$`) per row when `vim(ed).block_to_eol` is set.
867pub fn block_replace<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>, ch: char) {
868    let (top, bot, cols) = block_rect(ed);
869    let to_eol = vim(ed).block_to_eol;
870    // `r` isn't an `Operator`, so record via its own dot-repeat variant.
871    if !vim(ed).replaying {
872        vim_mut(ed).last_change = Some(LastChange::VisualBlockReplace {
873            ch,
874            rows: bot - top + 1,
875            cols: if to_eol {
876                1
877            } else {
878                cols.right + 1 - cols.left
879            },
880            to_eol,
881        });
882    }
883    block_replace_bounds(ed, ch, top, bot, cols);
884}
885/// Core of block `r`, vim's `op_replace` over `block_prep`: every display
886/// cell of the block on each row becomes `ch`, so a tab inside it turns into
887/// one `ch` per cell, and a tab cut by an edge keeps its outside cells as
888/// spaces. A row that ends inside the block is replaced up to its end; one
889/// that ends before it is left alone. Split out so dot-repeat can drive it
890/// with a rectangle reconstructed at the cursor.
891pub fn block_replace_bounds<H: hjkl_engine::types::Host>(
892    ed: &mut Editor<hjkl_buffer::View, H>,
893    ch: char,
894    top: usize,
895    bot: usize,
896    cols: BlockCols,
897) {
898    ed.push_undo();
899    ed.sync_buffer_content_from_textarea();
900    use hjkl_buffer::{Edit, Position};
901    let tabstop = ed.settings().tabstop;
902    let rope = hjkl_engine::types::Query::rope(ed.buffer());
903    for r in top..=bot.min(rope.len_lines().saturating_sub(1)) {
904        let line = rope_line_to_str(&rope, r);
905        let span = block_row(&line, cols, tabstop, true);
906        let end_vcol = hjkl_buffer::char_col_to_visual_col(&line, span.end, tabstop);
907        let short = cols.right == usize::MAX || end_vcol <= cols.right;
908        let cells = if short {
909            end_vcol.saturating_sub(cols.left)
910        } else {
911            cols.right + 1 - cols.left
912        };
913        if span.start == span.end || cells == 0 {
914            continue;
915        }
916        let mut with = " ".repeat(span.startspaces);
917        with.extend(std::iter::repeat_n(ch, cells));
918        if !short {
919            with.push_str(&" ".repeat(span.endspaces));
920        }
921        ed.mutate_edit(Edit::Replace {
922            start: Position::new(r, span.start),
923            end: Position::new(r, span.end),
924            with,
925        });
926    }
927    vim_mut(ed).mode = Mode::Normal;
928    let start =
929        block_start_col(ed, top, cols).min(buf_line_chars(ed.buffer(), top).saturating_sub(1));
930    ed.jump_cursor(top, start);
931}
932/// Replicate `inserted` text across block rows `(top + 1)..=bot` at `col`.
933/// `pad` distinguishes vim's two block-edge behaviours (`:h v_b_I` vs `:h
934/// v_b_A`): when `pad` is true, rows shorter than `col` are padded with
935/// spaces first so the text still lands at `col` (`A`); when false, rows
936/// shorter than `col` are skipped entirely — no padding, no insert on that
937/// row (`I`). `to_eol` (`:h v_b_$`, `A` only) overrides `col` per row with
938/// that row's own current EOL — always exactly reachable, so it never pads
939/// or skips. Leaves the cursor untouched — callers position it afterward.
940///
941/// Shared by the live `I`/`A`/`c` finish path (`finish_insert_session`) and
942/// their dot-repeat replays.
943pub fn replicate_block_text<H: hjkl_engine::types::Host>(
944    ed: &mut Editor<hjkl_buffer::View, H>,
945    inserted: &str,
946    top: usize,
947    bot: usize,
948    col: usize,
949    pad: bool,
950    to_eol: bool,
951) {
952    use hjkl_buffer::{Edit, Position};
953    // `col` is a character column on the top row; the other rows take the
954    // same display column, which a tab before it puts at a different
955    // character.
956    let tabstop = ed.settings().tabstop;
957    let top_line = buf_line(ed.buffer(), top).unwrap_or_default();
958    let vcol = hjkl_buffer::char_col_to_visual_col(&top_line, col, tabstop);
959    for r in (top + 1)..=bot {
960        if !to_eol {
961            let line = buf_line(ed.buffer(), r).unwrap_or_default();
962            if let Some((tab, start, width)) = tab_cut_at(&line, vcol, tabstop) {
963                ed.mutate_edit(Edit::Replace {
964                    start: Position::new(r, tab),
965                    end: Position::new(r, tab + 1),
966                    with: " ".repeat(width),
967                });
968                ed.mutate_edit(Edit::InsertStr {
969                    at: Position::new(r, tab + vcol - start),
970                    text: inserted.to_string(),
971                });
972                continue;
973            }
974        }
975        let line_len = buf_line_chars(ed.buffer(), r);
976        let line = buf_line(ed.buffer(), r).unwrap_or_default();
977        let row_col = if to_eol {
978            line_len
979        } else {
980            match char_at_vcol(&line, vcol, tabstop) {
981                Ok(c) => c,
982                Err(short) => {
983                    if !pad {
984                        // vim `v_b_I`: row doesn't reach the block's left
985                        // column — skip it, no padding, no insert.
986                        continue;
987                    }
988                    ed.mutate_edit(Edit::InsertStr {
989                        at: Position::new(r, line_len),
990                        text: " ".repeat(short),
991                    });
992                    line_len + short
993                }
994            }
995        };
996        ed.mutate_edit(Edit::InsertStr {
997            at: Position::new(r, row_col),
998            text: inserted.to_string(),
999        });
1000    }
1001}
1002/// Dot-repeat replay of a block `d` / case op / `c` (`:h v_.` for blocks).
1003/// Reconstructs a `rows` × `cols` rectangle with its TOP-LEFT corner at the
1004/// current cursor (restoring the `$`-ragged `to_eol` flag) and re-runs `op`.
1005/// `inserted` carries the retyped `c` text (`None` for `d` / case ops).
1006pub fn replay_block_visual_op<H: hjkl_engine::types::Host>(
1007    ed: &mut Editor<hjkl_buffer::View, H>,
1008    op: Operator,
1009    rows: usize,
1010    cols: usize,
1011    to_eol: bool,
1012    inserted: Option<String>,
1013    levels: usize,
1014) {
1015    let (top, left) = ed.cursor();
1016    let bot = top + rows.saturating_sub(1);
1017    // `d` / `c` yank the deleted rectangle into the unnamed register just
1018    // like the live block delete (`:h v_.` re-does the whole change,
1019    // register included) — verified against nvim (`.` after a block `d`
1020    // leaves the SECOND rectangle in `"`, not the first).
1021    let cols = block_cols_at(ed, top, left, cols, to_eol);
1022    let record_delete = |ed: &mut Editor<hjkl_buffer::View, H>| {
1023        let yank = block_yank(ed, top, bot, cols);
1024        if !yank.is_empty() {
1025            let block_width = block_register_width(&yank, cols);
1026            ed.record_yank_to_host(yank.clone());
1027            // The replay's register comes from the change being repeated —
1028            // `replay_last_change` puts it back in `pending_register` before
1029            // calling here, so this consumes it exactly like the live path.
1030            let target = vim_mut(ed).pending_register.take();
1031            ed.record_delete_block(yank, block_width, target);
1032        }
1033    };
1034    match op {
1035        Operator::Delete => {
1036            ed.push_undo();
1037            record_delete(ed);
1038            delete_block_contents(ed, top, bot, cols);
1039            let line_len = buf_line_chars(ed.buffer(), top);
1040            ed.jump_cursor(top, left.min(line_len.saturating_sub(1)));
1041        }
1042        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
1043            ed.push_undo();
1044            transform_block_case(ed, op, top, bot, cols);
1045            ed.jump_cursor(top, left);
1046        }
1047        Operator::Indent => {
1048            ed.push_undo();
1049            indent_block(ed, top, bot, cols, levels);
1050        }
1051        Operator::Outdent => {
1052            ed.push_undo();
1053            outdent_block(ed, top, bot, cols, levels);
1054        }
1055        Operator::Change => {
1056            ed.push_undo();
1057            record_delete(ed);
1058            // As the live block `c`: the text goes where the block's left
1059            // edge falls on the top row, past any spaces a cut tab left.
1060            let top_line = buf_line(ed.buffer(), top).unwrap_or_default();
1061            let top_span = block_row(&top_line, cols, ed.settings().tabstop, true);
1062            let left = top_span.start + top_span.startspaces;
1063            delete_block_contents(ed, top, bot, cols);
1064            let text = inserted.unwrap_or_default();
1065            if text.is_empty() {
1066                ed.jump_cursor(top, left);
1067            } else {
1068                use hjkl_buffer::{Edit, Position};
1069                // Top row directly, remaining rows via `replicate_block_text`
1070                // (skips rows too short to reach `left`, `:h v_b_c`).
1071                ed.mutate_edit(Edit::InsertStr {
1072                    at: Position::new(top, left),
1073                    text: text.clone(),
1074                });
1075                replicate_block_text(ed, &text, top, bot, left, false, false);
1076                let ins_chars = text.chars().count();
1077                let line_len = buf_line_chars(ed.buffer(), top);
1078                ed.jump_cursor(
1079                    top,
1080                    (left + ins_chars)
1081                        .saturating_sub(1)
1082                        .min(line_len.saturating_sub(1)),
1083                );
1084            }
1085        }
1086        _ => {}
1087    }
1088    vim_mut(ed).mode = Mode::Normal;
1089}
1090/// Dot-repeat replay of block `r{ch}` — reconstruct the rectangle at the
1091/// cursor and re-replace.
1092pub fn replay_block_replace<H: hjkl_engine::types::Host>(
1093    ed: &mut Editor<hjkl_buffer::View, H>,
1094    ch: char,
1095    rows: usize,
1096    cols: usize,
1097    to_eol: bool,
1098) {
1099    let (top, left) = ed.cursor();
1100    let bot = top + rows.saturating_sub(1);
1101    let cols = block_cols_at(ed, top, left, cols, to_eol);
1102    block_replace_bounds(ed, ch, top, bot, cols);
1103}
1104/// Dot-repeat replay of block `I` / `A` — re-insert `text` at the block's
1105/// left (`append == false`) or right (`append == true`) edge over a
1106/// rectangle reconstructed at the cursor. Ragged `A` (`to_eol`) appends at
1107/// each row's own EOL. Cursor lands on the block's LEFT edge, matching the
1108/// live finish path.
1109pub fn replay_block_insert<H: hjkl_engine::types::Host>(
1110    ed: &mut Editor<hjkl_buffer::View, H>,
1111    text: &str,
1112    rows: usize,
1113    cols: usize,
1114    to_eol: bool,
1115    append: bool,
1116) {
1117    use hjkl_buffer::{Edit, Position};
1118    let (top, left) = ed.cursor();
1119    let bot = top + rows.saturating_sub(1);
1120    ed.push_undo();
1121    ed.sync_buffer_content_from_textarea();
1122    // `A` appends `cols` columns past the left edge (or each row's own EOL
1123    // when ragged); `I` inserts at the left edge.
1124    // `cols` is the block's display width: `A` appends at the display column
1125    // just past it, padding a short top row to reach it.
1126    let line_len = buf_line_chars(ed.buffer(), top);
1127    let top_col = if append {
1128        if to_eol {
1129            line_len
1130        } else {
1131            let line = buf_line(ed.buffer(), top).unwrap_or_default();
1132            let tabstop = ed.settings().tabstop;
1133            let target = hjkl_buffer::char_col_to_visual_col(&line, left, tabstop) + cols;
1134            match char_at_vcol(&line, target, tabstop) {
1135                Ok(c) => c,
1136                Err(short) => {
1137                    ed.mutate_edit(Edit::InsertStr {
1138                        at: Position::new(top, line_len),
1139                        text: " ".repeat(short),
1140                    });
1141                    line_len + short
1142                }
1143            }
1144        }
1145    } else {
1146        left
1147    };
1148    // Top row (cursor row) always reaches `top_col` — `left` for `I`, or a
1149    // just-padded column for `A`.
1150    ed.mutate_edit(Edit::InsertStr {
1151        at: Position::new(top, top_col),
1152        text: text.to_string(),
1153    });
1154    replicate_block_text(ed, text, top, bot, top_col, append, to_eol);
1155    ed.jump_cursor(top, left);
1156    vim_mut(ed).mode = Mode::Normal;
1157}
1158/// B2: `r{ch}` in charwise (`v`) or linewise (`V`) Visual mode — replace
1159/// every character in the selection with `ch`; newlines are NOT replaced
1160/// (a linewise selection keeps its original line count). Registers are
1161/// left untouched (`r` doesn't yank/delete). Cursor lands at the start of
1162/// the selection — verified against real nvim: charwise `llvjrX` on
1163/// `"aaaa\nbbbb\n..."` lands on `(0, 2)` (the selection's top), linewise
1164/// `VjrZ` lands on `(top, 0)`.
1165pub fn visual_replace_char<H: hjkl_engine::types::Host>(
1166    ed: &mut Editor<hjkl_buffer::View, H>,
1167    ch: char,
1168) {
1169    let linewise = matches!(vim(ed).mode, Mode::VisualLine);
1170    let (top, bot) = if linewise {
1171        let cursor_row = buf_cursor_pos(ed.buffer()).row;
1172        let anchor_row = vim(ed).visual_line_anchor;
1173        (
1174            (cursor_row.min(anchor_row), 0),
1175            (cursor_row.max(anchor_row), 0),
1176        )
1177    } else {
1178        let anchor = vim(ed).visual_anchor;
1179        let cursor = ed.cursor();
1180        order(anchor, cursor)
1181    };
1182    // `r` isn't an `Operator`, so record via its own dot-repeat variant
1183    // (mirrors block `r` / `VisualBlockReplace`). Charwise stores the same
1184    // `charwise_extent` the visual OPERATORS use; linewise stores the row
1185    // count — `.` then re-replaces a same-size region at the cursor.
1186    if !vim(ed).replaying {
1187        let extent = if linewise {
1188            VisualExtent::Line {
1189                lines: bot.0 - top.0 + 1,
1190            }
1191        } else {
1192            charwise_extent(top, bot)
1193        };
1194        vim_mut(ed).last_change = Some(LastChange::VisualReplace { ch, extent });
1195    }
1196    // The undo step saves the selection's start, as for the operators: in
1197    // Visual-line that is column 0 unless the cursor is above the anchor.
1198    let start = if !linewise {
1199        top
1200    } else if ed.cursor().0 < vim(ed).visual_line_anchor {
1201        ed.cursor()
1202    } else {
1203        top
1204    };
1205    buf_set_cursor_rc(ed.buffer_mut(), start.0, start.1);
1206    replace_range_with_char(ed, linewise, top, bot, ch);
1207}
1208/// Core of charwise/linewise Visual `r`: replace every character in
1209/// `(top..=bot)` with `ch`, preserving newlines. Split out so dot-repeat can
1210/// drive it with a region reconstructed at the cursor (`replay_visual_replace`)
1211/// rather than the live selection. Cursor lands at the selection start
1212/// (`top.1` charwise, column 0 linewise).
1213fn replace_range_with_char<H: hjkl_engine::types::Host>(
1214    ed: &mut Editor<hjkl_buffer::View, H>,
1215    linewise: bool,
1216    top: (usize, usize),
1217    bot: (usize, usize),
1218    ch: char,
1219) {
1220    ed.push_undo();
1221    ed.sync_buffer_content_from_textarea();
1222    use hjkl_buffer::{Edit, Position};
1223    let rope = hjkl_engine::types::Query::rope(ed.buffer());
1224    let last_row = bot.0.min(rope.len_lines().saturating_sub(1));
1225    for row in top.0..=last_row {
1226        let chars: Vec<char> = rope_line_to_str(&rope, row).chars().collect();
1227        let lo = if !linewise && row == top.0 { top.1 } else { 0 };
1228        let hi = if !linewise && row == bot.0 {
1229            (bot.1 + 1).min(chars.len())
1230        } else {
1231            chars.len()
1232        };
1233        if lo >= hi {
1234            continue;
1235        }
1236        // Replace only the selection's span — the row's head/tail stay put.
1237        let middle: String = std::iter::repeat_n(ch, hi - lo).collect();
1238        ed.mutate_edit(Edit::Replace {
1239            start: Position::new(row, lo),
1240            end: Position::new(row, hi),
1241            with: middle,
1242        });
1243    }
1244    vim_mut(ed).mode = Mode::Normal;
1245    ed.jump_cursor(top.0, if linewise { 0 } else { top.1 });
1246}
1247/// Dot-repeat replay of charwise/linewise Visual `r{ch}` — reconstruct a
1248/// same-size region anchored at the cursor from the stored `extent` (mirrors
1249/// the `VisualExtent::Char`/`Line` shape the visual operators replay, `:h
1250/// v_.`), then re-replace. `VisualExtent::Block` never reaches here (block `r`
1251/// rides `VisualBlockReplace`).
1252pub fn replay_visual_replace<H: hjkl_engine::types::Host>(
1253    ed: &mut Editor<hjkl_buffer::View, H>,
1254    ch: char,
1255    extent: VisualExtent,
1256) {
1257    let (r, c) = ed.cursor();
1258    match extent {
1259        VisualExtent::Line { lines } => {
1260            let top = (r, 0);
1261            let bot = (r + lines.saturating_sub(1), 0);
1262            replace_range_with_char(ed, true, top, bot, ch);
1263        }
1264        VisualExtent::Char { lines, width } => {
1265            let top = (r, c);
1266            // Same reconstruction the charwise operator replay uses: single
1267            // line keeps its raw `width` from the cursor; multi-line runs the
1268            // first line cursor-to-EOL, middle lines whole, last line's first
1269            // `width` chars (`bot.1` measured from column 0).
1270            let bot = if lines <= 1 {
1271                (r, c + width.saturating_sub(1))
1272            } else {
1273                (r + lines - 1, width.saturating_sub(1))
1274            };
1275            replace_range_with_char(ed, false, top, bot, ch);
1276        }
1277        VisualExtent::Block { .. } => {}
1278    }
1279}