Skip to main content

hjkl_buffer_tui/
render.rs

1//! Direct cell-write `ratatui::widgets::Widget` for [`hjkl_buffer::Buffer`].
2//!
3//! ## Render path
4//!
5//! [`BufferView`] implements
6//! `ratatui::widgets::Widget`. The widget is **single-pass** — text,
7//! selection, gutter signs, and styled spans all paint together. There is
8//! no separate `Paragraph` or layout step. Writes one cell at a time so
9//! syntax span fg, cursor-line bg, cursor cell REVERSED, and selection bg
10//! layer in a single pass without the grapheme / wrap machinery `Paragraph`
11//! does.
12//!
13//! Caller wraps a `&Buffer` in [`BufferView`], hands it the style table
14//! that resolves opaque [`hjkl_buffer::Span`] style ids to real ratatui styles
15//! via a [`StyleResolver`], and renders into a `ratatui::Frame`.
16//!
17//! ## StyleResolver hooks
18//!
19//! The [`StyleResolver`] trait is the host's bridge from opaque `u32` style
20//! ids (stored in [`hjkl_buffer::Span::style`]) to real `ratatui::style::Style`
21//! values. Implement it against your own theme. A convenience blanket impl
22//! exists for closures `Fn(u32) -> Style`.
23
24use ratatui::buffer::Buffer as TermBuffer;
25use ratatui::layout::Rect;
26use ratatui::style::Style;
27use ratatui::widgets::Widget;
28use unicode_width::UnicodeWidthChar;
29
30use hjkl_buffer::wrap::wrap_segments;
31use hjkl_buffer::{Buffer, Selection, Span, Viewport, Wrap};
32
33/// Resolves an opaque [`hjkl_buffer::Span::style`] id to a real ratatui
34/// style. The buffer doesn't know about colours; the host (sqeel-vim
35/// or any future user) keeps a lookup table.
36pub trait StyleResolver {
37    fn resolve(&self, style_id: u32) -> Style;
38}
39
40/// Convenience impl so simple closures can drive the renderer.
41impl<F: Fn(u32) -> Style> StyleResolver for F {
42    fn resolve(&self, style_id: u32) -> Style {
43        self(style_id)
44    }
45}
46
47/// Render-time wrapper around `&Buffer` that carries the optional
48/// [`Selection`] + a [`StyleResolver`]. Created per draw, dropped
49/// when the frame is done — cheap, holds only refs.
50///
51/// 0.0.34 (Patch C-δ.1): added the `viewport` field. The viewport
52/// previously lived on the buffer itself; with the relocation to the
53/// engine `Host`, the renderer takes a borrow per draw.
54///
55/// 0.0.37: added the `spans` and `search_pattern` fields. Per-row
56/// syntax spans + the active `/` regex used to live on the buffer
57/// (`Buffer::spans` / `Buffer::search_pattern`); both moved out per
58/// step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The host now feeds
59/// each into the view per draw — populated from
60/// `Editor::buffer_spans()` and `Editor::search_state().pattern`.
61pub struct BufferView<'a, R: StyleResolver> {
62    pub buffer: &'a Buffer,
63    /// Viewport snapshot the host published this frame. Owned by the
64    /// engine `Host`; the renderer borrows for the duration of the
65    /// draw.
66    pub viewport: &'a Viewport,
67    pub selection: Option<Selection>,
68    pub resolver: &'a R,
69    /// Bg painted across the cursor row (vim's `cursorline`). Pass
70    /// `Style::default()` to disable.
71    pub cursor_line_bg: Style,
72    /// Bg painted across a closed fold's header row — brighter than
73    /// `cursor_line_bg` so a collapsed fold is visually distinct.
74    /// `Style::default()` to disable.
75    pub fold_line_bg: Style,
76    /// Bg painted down the cursor column (vim's `cursorcolumn`). Pass
77    /// `Style::default()` to disable.
78    pub cursor_column_bg: Style,
79    /// Bg painted under selected cells. Composed over syntax fg.
80    pub selection_bg: Style,
81    /// Style for the cursor cell. `REVERSED` is the conventional
82    /// choice; works against any theme.
83    pub cursor_style: Style,
84    /// Optional left-side line-number gutter. `width` includes the
85    /// trailing space separating the number from text. Pass `None`
86    /// to disable. Numbers are 1-based, right-aligned.
87    pub gutter: Option<Gutter>,
88    /// Bg painted under cells covered by an active `/` search match.
89    /// `Style::default()` to disable.
90    pub search_bg: Style,
91    /// Per-row gutter signs (LSP diagnostic dots, git diff markers,
92    /// …). Painted into the leftmost gutter column after the line
93    /// number, so they overwrite the leading space tui-style gutters
94    /// reserve. Highest-priority sign per row wins.
95    pub signs: &'a [Sign],
96    /// Per-row substitutions applied at render time. Each conceal
97    /// hides the byte range `[start_byte, end_byte)` and paints
98    /// `replacement` in its place. Empty slice = no conceals.
99    pub conceals: &'a [Conceal],
100    /// Per-row syntax spans the host has computed for this frame.
101    /// `spans[row]` carries the styled byte ranges for that row;
102    /// rows beyond `spans.len()` get no syntax styling. Pass `&[]`
103    /// for hosts without syntax integration.
104    ///
105    /// 0.0.37: lifted out of `Buffer` per step 3 of
106    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine populates
107    /// this via `Editor::buffer_spans()`.
108    pub spans: &'a [Vec<Span>],
109    /// Active `/` search regex, if any. The renderer paints
110    /// [`Self::search_bg`] under cells that match. Pass `None` to
111    /// disable hlsearch.
112    ///
113    /// 0.0.37: lifted out of `Buffer` (was `Buffer::search_pattern`)
114    /// per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine
115    /// publishes the pattern via `Editor::search_state().pattern`.
116    pub search_pattern: Option<&'a regex::Regex>,
117    /// Style for the `~` tilde marker painted on screen rows that are
118    /// past the last buffer line (vim's `NonText` highlight group).
119    /// Pass `Style::default()` to use terminal defaults.
120    ///
121    /// The gutter on those rows is painted blank; the `~` appears at the
122    /// leftmost text column. Rows within the buffer are unaffected.
123    pub non_text_style: Style,
124    /// Diagnostic overlays (LSP inline highlights). Applied in a
125    /// post-paint pass after every row is drawn so they layer on top of
126    /// syntax and selection colours without a second layout traversal.
127    /// Pass `&[]` to disable. Added in 0.5.0.
128    pub diag_overlays: &'a [DiagOverlay],
129    /// 1-based column indices for vertical rulers (vim's `colorcolumn`).
130    /// The renderer paints `colorcolumn_style` on those text-area cells
131    /// beneath syntax highlights. Pass `&[]` to disable.
132    pub colorcolumn_cols: &'a [u16],
133    /// Background style applied to cells at a `colorcolumn` position.
134    /// Ignored when `colorcolumn_cols` is empty.
135    pub colorcolumn_style: Style,
136    /// When `Some`, invisibles rendering is active. The [`hjkl_buffer::ListChars`]
137    /// value controls which glyphs substitute whitespace characters.
138    /// Matches vim's `:set list` + `:set listchars`. Pass `None` to disable.
139    pub listchars: Option<&'a hjkl_buffer::ListChars>,
140    /// When `true`, paint thin vertical indent guide characters at every
141    /// `shiftwidth`-aligned leading-whitespace column on each row. Only active
142    /// in `Wrap::None` mode. Pass `false` to disable.
143    pub indent_guides_enabled: bool,
144    /// Character to paint as the indent guide. Typically `'│'`.
145    pub indent_guide_char: char,
146    /// Number of leading visual columns that qualify as "shiftwidth" per level.
147    /// Derived from `Settings::shiftwidth` / `Settings::tabstop`. The renderer
148    /// uses this to locate guide columns on each row.
149    pub indent_guide_shiftwidth: usize,
150    /// Fg color for inactive indent guide cells.
151    pub indent_guide_fg: ratatui::style::Color,
152    /// Fg color for the active (cursor's indent level) guide cell.
153    pub indent_guide_active_fg: ratatui::style::Color,
154    /// Visual column of the active indent guide, if any. Computed from the
155    /// cursor row's leading whitespace and `indent_guide_shiftwidth`.
156    /// `None` when the cursor sits on a non-indented or empty row.
157    pub indent_guide_active_col: Option<usize>,
158    /// End-of-line virtual-text hints (e.g. inline git blame). Painted after
159    /// each row's real text in a post-pass. Empty slice = none. Added for #202.
160    pub eol_hints: &'a [EolHint],
161    /// Boxed-blame layout. When `Some`, the renderer draws exactly this
162    /// sequence of screen rows (top-to-bottom) instead of deriving rows from
163    /// `top_row`: `Content(doc_row)` paints that buffer row, `BorderTop`/
164    /// `BorderBottom` paint a box rule. The host (render level) builds the plan
165    /// — the engine's cursor/scroll stays the source of truth. `Wrap::None`
166    /// only. `None` = normal rendering.
167    pub blame_plan: Option<&'a [BlameRow]>,
168}
169
170/// One screen row in a boxed-blame layout ([`BufferView::blame_plan`]).
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum BlameRow {
173    /// A real buffer row painted at this screen line.
174    Content(usize),
175    /// Box top border with a commit title rendered into it.
176    BorderTop(String),
177    /// Box bottom border (plain rule).
178    BorderBottom,
179}
180
181/// Controls what numbers are rendered in the gutter.
182///
183/// Matches vim's `:set number` / `:set relativenumber` combinations.
184#[derive(Debug, Clone, Copy, Default)]
185pub enum GutterNumbers {
186    /// No line numbers — gutter cells painted blank (still occupies width).
187    None,
188    /// 1-based absolute row numbers (current default).
189    #[default]
190    Absolute,
191    /// Offset from `cursor_row` for non-cursor rows; cursor row shows `0`.
192    Relative { cursor_row: usize },
193    /// Vim's `nu+rnu`: cursor row shows its absolute number, others show
194    /// offset from `cursor_row`.
195    Hybrid { cursor_row: usize },
196}
197
198/// Configuration for the line-number gutter rendered to the left of
199/// the text area. `width` is the number-column cell count reserved
200/// (including any trailing spacer); the renderer right-aligns the
201/// 1-based row number into the leftmost `width - 1` cells.
202///
203/// `sign_column_width` reserves cells to the LEFT of the number column
204/// for sign chars (LSP diagnostics, git diff markers). The sign column
205/// is a dedicated strip separate from the number column: vim/neovim
206/// convention is `[ sign | number_padded | spacer | text ]`. When
207/// `sign_column_width == 0` the layout collapses to
208/// `[ number_padded | spacer | text ]`.
209///
210/// `line_offset` is added to the displayed line number, so a host
211/// rendering a windowed view of a larger document (e.g. picker preview
212/// of a 7000-line buffer) can show the original line numbers instead
213/// of starting at 1. Only applied in `Absolute` mode.
214#[derive(Debug, Clone, Copy, Default)]
215pub struct Gutter {
216    /// Width of the number column (digits + 1 trailing spacer). Does NOT
217    /// include `sign_column_width`.
218    pub width: u16,
219    pub style: Style,
220    pub line_offset: usize,
221    /// What kind of numbers to render. Defaults to `Absolute`.
222    pub numbers: GutterNumbers,
223    /// Width of the dedicated sign column to the left of the number column.
224    /// Typically 0 (no signs) or 1 (one sign char per row). Signs are
225    /// painted in `area.x .. area.x + sign_column_width`; numbers are
226    /// painted in `area.x + sign_column_width .. area.x + sign_column_width + width`.
227    pub sign_column_width: u16,
228    /// Width of the fold-indicator column, painted to the RIGHT of the
229    /// number column and LEFT of the text (`[sign][number][fold][text]`,
230    /// matching the cursor/overlay geometry which reserves
231    /// `sign_column_width + fold_column_width` of extra gutter). Typically
232    /// 0 (no fold cue) or 1. The renderer paints an open/closed fold glyph
233    /// per row into this column; `0` collapses it entirely.
234    pub fold_column_width: u16,
235}
236
237/// Single-cell marker painted into the leftmost gutter column for a
238/// document row. Used by hosts to surface LSP diagnostics, git diff
239/// signs, etc. Higher `priority` wins when multiple signs land on
240/// the same row.
241#[derive(Debug, Clone, Copy)]
242pub struct Sign {
243    pub row: usize,
244    pub ch: char,
245    pub style: Style,
246    pub priority: u8,
247}
248
249/// Render-time substitution that hides a byte range and paints
250/// `replacement` in its place. The buffer's content stays unchanged;
251/// only the rendered cells differ. Used by hosts to pretty-print
252/// URLs, conceal markdown markers, etc.
253#[derive(Debug, Clone)]
254pub struct Conceal {
255    pub row: usize,
256    pub start_byte: usize,
257    pub end_byte: usize,
258    pub replacement: String,
259}
260
261/// A char-column range on a document row that should be styled with an
262/// overlay (e.g. an underline for LSP diagnostics). Applied in a
263/// post-paint pass so it composes on top of syntax and selection colours.
264///
265/// Added in 0.5.0 for LSP diagnostic inline rendering.
266#[derive(Debug, Clone, Copy)]
267pub struct DiagOverlay {
268    /// 0-based document row.
269    pub row: usize,
270    /// 0-based start char-column (inclusive).
271    pub col_start: usize,
272    /// 0-based end char-column (exclusive).
273    pub col_end: usize,
274    /// Style applied to cells in `[col_start, col_end)`.
275    pub style: Style,
276}
277
278/// End-of-line virtual text (e.g. inline git blame). Painted in the trailing
279/// cells of `row` after its real text, in `style`, with a 2-column gap. Purely
280/// decorative — occupies no buffer bytes and never affects cursor motion or
281/// layout. Truncated at the right edge of the text area; skipped when the row's
282/// text already fills the width.
283#[derive(Debug, Clone)]
284pub struct EolHint {
285    /// 0-based document row the hint trails.
286    pub row: usize,
287    /// The virtual text to paint (already formatted by the host).
288    pub text: String,
289    /// Style for the hint cells (typically a dimmed fg).
290    pub style: Style,
291}
292
293/// Glyph for the fold-indicator column at `doc_row`:
294///   `▾` open fold start · `▸` closed fold start ·
295///   `│` row inside an open fold body · ` ` no fold.
296/// A row that is both an open-fold start and inside an outer open fold
297/// still reads as a start (`▾`), matching vim's foldcolumn precedence.
298fn fold_column_glyph(folds: &[hjkl_buffer::Fold], doc_row: usize) -> char {
299    let mut inside_open = false;
300    for f in folds {
301        if f.start_row == doc_row {
302            return if f.closed { '▸' } else { '▾' };
303        }
304        if !f.closed && doc_row > f.start_row && doc_row <= f.end_row {
305            inside_open = true;
306        }
307    }
308    if inside_open { '│' } else { ' ' }
309}
310
311/// Compute the visual display width of `line` in terminal cells, expanding
312/// tabs to the next multiple of `tab_width` (matching `paint_row`'s logic).
313/// Uses `UnicodeWidthChar` for non-tab characters, identical to how
314/// `paint_row` advances `screen_x`.
315fn display_width(line: &str, tab_width: usize) -> usize {
316    let mut col: usize = 0;
317    for ch in line.chars() {
318        if ch == '\t' {
319            col += tab_width - (col % tab_width);
320        } else {
321            col += ch.width().unwrap_or(1);
322        }
323    }
324    col
325}
326
327impl<R: StyleResolver> Widget for BufferView<'_, R> {
328    fn render(self, area: Rect, term_buf: &mut TermBuffer) {
329        // Boxed-blame layout: render the host-supplied plan instead of the
330        // normal doc-row walk. Keeps the engine's cursor/scroll authoritative;
331        // this is a pure render transform.
332        if let Some(plan) = self.blame_plan {
333            self.render_blame_plan(area, term_buf, plan);
334            return;
335        }
336        let viewport = *self.viewport;
337        let cursor = self.buffer.cursor();
338        let spans = self.spans;
339        let folds = self.buffer.folds();
340        let top_row = viewport.top_row;
341        let top_col = viewport.top_col;
342        // Fetch only the viewport-bounded row slice. The render loop walks
343        // at most `area.height` screen rows, so on a 100K-row buffer we
344        // clone ~50 rows instead of the entire Vec<String>. Closed folds
345        // can skip past the precomputed bound — the rare overflow branch
346        // falls back to `Buffer::line(row)`.
347        let total_rows = self.buffer.row_count();
348        let prefetch_end = top_row.saturating_add(area.height as usize).min(total_rows);
349        let rope = self.buffer.rope();
350        let lines_prefetch: Vec<String> = (top_row..prefetch_end)
351            .map(|i| hjkl_buffer::rope_line_str(&rope, i))
352            .collect();
353        let prefetch_base = top_row;
354        let prefetch_end_idx = prefetch_end;
355        let line_at = |row: usize| -> String {
356            if row >= prefetch_base && row < prefetch_end_idx {
357                lines_prefetch[row - prefetch_base].clone()
358            } else {
359                hjkl_buffer::rope_line_str(&rope, row)
360            }
361        };
362
363        let gutter_total = self
364            .gutter
365            .map(|g| g.sign_column_width + g.width + g.fold_column_width)
366            .unwrap_or(0);
367        let text_area = Rect {
368            x: area.x.saturating_add(gutter_total),
369            y: area.y,
370            width: area.width.saturating_sub(gutter_total),
371            height: area.height,
372        };
373
374        // total_rows already captured above.
375        let mut doc_row = top_row;
376        let mut screen_row: u16 = 0;
377        let wrap_mode = viewport.wrap;
378        let seg_width = if viewport.text_width > 0 {
379            viewport.text_width
380        } else {
381            text_area.width
382        };
383        // Per-screen-row flag: true when the cell at the cursor's
384        // column on that screen row is part of an active `/` search
385        // match. The cursorcolumn pass uses this to skip cells that
386        // search bg already painted, so search highlight wins over
387        // the column bg.
388        let mut search_hit_at_cursor_col: Vec<bool> = Vec::new();
389        // Map each painted screen row to the doc row whose LAST visual segment
390        // landed there, so the EOL-hint pass can paint at the right y for the
391        // correct row (handles folds + soft-wrap: only the last segment row of
392        // a doc row is recorded). `None` for tilde/blank rows.
393        let mut screen_to_doc: Vec<Option<usize>> = vec![None; area.height as usize];
394        // Walk the document forward, skipping rows hidden by closed
395        // folds. Emit the start row of a closed fold as a marker
396        // line instead of its actual content.
397        while doc_row < total_rows && screen_row < area.height {
398            // Skip rows hidden by a closed fold (any row past start
399            // of a closed fold).
400            if folds.iter().any(|f| f.hides(doc_row)) {
401                doc_row += 1;
402                continue;
403            }
404            let folded_at_start = folds
405                .iter()
406                .find(|f| f.closed && f.start_row == doc_row)
407                .copied();
408            let line_owned = line_at(doc_row);
409            let line: &str = line_owned.as_str();
410            let row_spans = spans.get(doc_row).map(Vec::as_slice).unwrap_or(&[]);
411            let sel_range = self.selection.and_then(|s| s.row_span(doc_row));
412            let is_cursor_row = doc_row == cursor.row;
413            if let Some(fold) = folded_at_start {
414                if let Some(gutter) = self.gutter {
415                    self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
416                    self.paint_signs(term_buf, area, screen_row, doc_row, gutter);
417                    self.paint_fold_column(term_buf, area, screen_row, doc_row, gutter, &folds);
418                }
419                // Render the fold's first line exactly like a normal line
420                // (real syntax highlighting, selection, search matches, conceals).
421                // Only the first screen segment (Wrap::None: top_col..MAX) is needed
422                // since a closed fold collapses to exactly one screen row.
423                let fold_search_ranges = self.row_search_ranges(line);
424                let fold_conceals: Vec<&Conceal> = {
425                    let mut v: Vec<&Conceal> =
426                        self.conceals.iter().filter(|c| c.row == doc_row).collect();
427                    v.sort_by_key(|c| c.start_byte);
428                    v
429                };
430                self.paint_row(
431                    term_buf,
432                    text_area,
433                    screen_row,
434                    line,
435                    row_spans,
436                    sel_range,
437                    &fold_search_ranges,
438                    is_cursor_row,
439                    cursor.col,
440                    top_col,
441                    usize::MAX,
442                    true, // is_last_segment
443                    &fold_conceals,
444                );
445                // Overlay fold bg across the full row (gutter + text) so the
446                // collapsed fold header is visually distinct. Patched so only
447                // bg changes — fg, glyphs, and text are preserved.
448                if self.fold_line_bg != Style::default() {
449                    let y = area.y + screen_row;
450                    for x in area.x..(area.x + area.width) {
451                        if let Some(cell) = term_buf.cell_mut((x, y)) {
452                            cell.set_style(cell.style().patch(self.fold_line_bg));
453                        }
454                    }
455                }
456                let fold_has_hit = fold_search_ranges
457                    .iter()
458                    .any(|&(s, e)| cursor.col >= s && cursor.col < e);
459                search_hit_at_cursor_col.push(fold_has_hit);
460                if (screen_row as usize) < screen_to_doc.len() {
461                    screen_to_doc[screen_row as usize] = Some(doc_row);
462                }
463                screen_row += 1;
464                doc_row = fold.end_row + 1;
465                continue;
466            }
467            let search_ranges = self.row_search_ranges(line);
468            let row_has_hit_at_cursor_col = search_ranges
469                .iter()
470                .any(|&(s, e)| cursor.col >= s && cursor.col < e);
471            // Collect conceals for this row, sorted by start_byte.
472            let row_conceals: Vec<&Conceal> = {
473                let mut v: Vec<&Conceal> =
474                    self.conceals.iter().filter(|c| c.row == doc_row).collect();
475                v.sort_by_key(|c| c.start_byte);
476                v
477            };
478            // Compute screen segments for this doc row. `Wrap::None`
479            // produces a single segment that spans the whole line; the
480            // existing `top_col` horizontal scroll is preserved by
481            // passing `top_col` as the segment start. Wrap modes split
482            // the line into multiple visual rows that fit
483            // `viewport.text_width` (falls back to `text_area.width`
484            // when the host hasn't published a text width yet).
485            let segments = match wrap_mode {
486                Wrap::None => vec![(top_col, usize::MAX)],
487                _ => wrap_segments(line, seg_width, wrap_mode),
488            };
489            let last_seg_idx = segments.len().saturating_sub(1);
490            for (seg_idx, &(seg_start, seg_end)) in segments.iter().enumerate() {
491                if screen_row >= area.height {
492                    break;
493                }
494                if let Some(gutter) = self.gutter {
495                    if seg_idx == 0 {
496                        self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
497                        self.paint_signs(term_buf, area, screen_row, doc_row, gutter);
498                        self.paint_fold_column(term_buf, area, screen_row, doc_row, gutter, &folds);
499                    } else {
500                        self.paint_blank_gutter(term_buf, area, screen_row, gutter);
501                    }
502                }
503                self.paint_row(
504                    term_buf,
505                    text_area,
506                    screen_row,
507                    line,
508                    row_spans,
509                    sel_range,
510                    &search_ranges,
511                    is_cursor_row,
512                    cursor.col,
513                    seg_start,
514                    seg_end,
515                    seg_idx == last_seg_idx,
516                    &row_conceals,
517                );
518                search_hit_at_cursor_col.push(row_has_hit_at_cursor_col);
519                if seg_idx == last_seg_idx && (screen_row as usize) < screen_to_doc.len() {
520                    screen_to_doc[screen_row as usize] = Some(doc_row);
521                }
522                screen_row += 1;
523            }
524            doc_row += 1;
525        }
526        // Tilde pass: paint `~` on every screen row past the last buffer
527        // line (vim's NonText marker). Gutter on those rows stays blank.
528        while screen_row < area.height {
529            // Blank gutter if present.
530            if let Some(gutter) = self.gutter {
531                self.paint_blank_gutter(term_buf, area, screen_row, gutter);
532            }
533            // Paint `~` at the first text column.
534            let y = text_area.y + screen_row;
535            if let Some(cell) = term_buf.cell_mut((text_area.x, y)) {
536                cell.set_char('~');
537                cell.set_style(self.non_text_style);
538            }
539            screen_row += 1;
540        }
541        // ── EOL virtual-text pass (#202): paint host hints after row text ──
542        if !self.eol_hints.is_empty() {
543            let tab_width = self.viewport.effective_tab_width();
544            for (sr, maybe_doc) in screen_to_doc.iter().enumerate() {
545                let Some(doc) = *maybe_doc else { continue };
546                let Some(hint) = self.eol_hints.iter().find(|h| h.row == doc) else {
547                    continue;
548                };
549                // Visual width of the row's text in cells (tab-expanded),
550                // minus horizontal scroll, to find where real text ends.
551                let line = line_at(doc);
552                let text_cols = display_width(&line, tab_width);
553                let start_vis = text_cols.saturating_sub(top_col);
554                // 2-col gap after the text.
555                let x = text_area
556                    .x
557                    .saturating_add(start_vis as u16)
558                    .saturating_add(2);
559                let y = text_area.y + sr as u16;
560                let right = text_area.x + text_area.width;
561                if x >= right {
562                    continue; // no room
563                }
564                for (i, ch) in hint.text.chars().enumerate() {
565                    let cx = x + i as u16;
566                    if cx >= right {
567                        break;
568                    }
569                    if let Some(cell) = term_buf.cell_mut((cx, y)) {
570                        cell.set_char(ch);
571                        cell.set_style(hint.style);
572                    }
573                }
574            }
575        }
576
577        // Cursorcolumn pass: layer the bg over the cursor's visible
578        // column once every row is painted so it composes on top of
579        // syntax / cursorline backgrounds without disturbing fg.
580        // Skipped when wrapping — the cursor's screen x depends on the
581        // segment it lands in, and vim's cursorcolumn semantics with
582        // wrap are fuzzy. Revisit if it bites.
583        if matches!(wrap_mode, Wrap::None)
584            && self.cursor_column_bg != Style::default()
585            && cursor.col >= top_col
586            && (cursor.col - top_col) < text_area.width as usize
587        {
588            let x = text_area.x + (cursor.col - top_col) as u16;
589            for sy in 0..screen_row {
590                // Skip rows where search bg already painted this cell —
591                // search highlight wins over cursorcolumn so `/foo`
592                // matches stay readable when the cursor sits on them.
593                if search_hit_at_cursor_col
594                    .get(sy as usize)
595                    .copied()
596                    .unwrap_or(false)
597                {
598                    continue;
599                }
600                let y = text_area.y + sy;
601                if let Some(cell) = term_buf.cell_mut((x, y)) {
602                    cell.set_style(cell.style().patch(self.cursor_column_bg));
603                }
604            }
605        }
606
607        // Colorcolumn pass: paint vertical ruler(s) under syntax.
608        // Applied only in Wrap::None mode; skips indices that are
609        // scrolled out of the visible horizontal window.
610        if matches!(wrap_mode, Wrap::None) && !self.colorcolumn_cols.is_empty() {
611            for &col_1based in self.colorcolumn_cols {
612                let col = col_1based as usize; // convert to 0-based
613                if col == 0 || col < top_col + 1 {
614                    continue; // out of visible range (scrolled past left edge)
615                }
616                let screen_col = col - 1 - top_col; // 0-based screen offset
617                if screen_col >= text_area.width as usize {
618                    continue; // out of visible range (past right edge)
619                }
620                let x = text_area.x + screen_col as u16;
621                for sy in 0..screen_row {
622                    let y = text_area.y + sy;
623                    if let Some(cell) = term_buf.cell_mut((x, y)) {
624                        cell.set_style(cell.style().patch(self.colorcolumn_style));
625                    }
626                }
627            }
628        }
629
630        // Indent guides pass: paint guide characters at shiftwidth-aligned
631        // columns on every visible row that has sufficient leading whitespace.
632        // Only active in Wrap::None mode. Computes leading visual width per
633        // screen row from the pre-fetched line strings.
634        if matches!(wrap_mode, Wrap::None)
635            && self.indent_guides_enabled
636            && self.indent_guide_shiftwidth > 0
637        {
638            let sw = self.indent_guide_shiftwidth;
639            let tab_width = self.viewport.effective_tab_width();
640            // Walk the same doc_row range as the first pass.
641            let mut ig_doc_row = top_row;
642            let mut ig_screen_row: u16 = 0;
643            while ig_doc_row < total_rows && ig_screen_row < area.height {
644                if folds.iter().any(|f| f.hides(ig_doc_row)) {
645                    ig_doc_row += 1;
646                    continue;
647                }
648                // Skip closed fold markers — they collapse to a single marker row.
649                if let Some(fold) = folds
650                    .iter()
651                    .find(|f| f.closed && f.start_row == ig_doc_row)
652                    .copied()
653                {
654                    ig_screen_row += 1;
655                    ig_doc_row = fold.end_row + 1;
656                    continue;
657                }
658                let line_owned = line_at(ig_doc_row);
659                let line: &str = line_owned.as_str();
660                // Compute leading visual column count: walk until non-whitespace.
661                let mut leading_vcols: usize = 0;
662                for ch in line.chars() {
663                    match ch {
664                        ' ' => leading_vcols += 1,
665                        '\t' => {
666                            leading_vcols += tab_width - (leading_vcols % tab_width);
667                        }
668                        _ => break,
669                    }
670                }
671                // Paint guides at sw, 2*sw, 3*sw, ... while < leading_vcols.
672                let y = text_area.y + ig_screen_row;
673                let mut guide_col = sw;
674                while guide_col < leading_vcols {
675                    // Convert visual column to screen x, accounting for top_col scroll.
676                    if guide_col >= top_col {
677                        let screen_col = guide_col - top_col;
678                        if screen_col < text_area.width as usize {
679                            let x = text_area.x + screen_col as u16;
680                            let is_active = Some(guide_col) == self.indent_guide_active_col;
681                            let fg = if is_active {
682                                self.indent_guide_active_fg
683                            } else {
684                                self.indent_guide_fg
685                            };
686                            if let Some(cell) = term_buf.cell_mut((x, y)) {
687                                // Only paint guide if the cell currently holds a space
688                                // (i.e., leading whitespace was painted there).
689                                if cell.symbol() == " " {
690                                    cell.set_char(self.indent_guide_char);
691                                    // Preserve bg; only set fg.
692                                    let existing = cell.style();
693                                    cell.set_style(existing.fg(fg));
694                                }
695                            }
696                        }
697                    }
698                    guide_col += sw;
699                }
700                ig_screen_row += 1;
701                ig_doc_row += 1;
702            }
703        }
704
705        // Diag overlay pass: apply underline / style over visible char
706        // columns. Only supported in Wrap::None mode; wrap is a future
707        // concern. Overlays beyond the visible horizontal scroll are
708        // skipped silently.
709        if matches!(wrap_mode, Wrap::None) && !self.diag_overlays.is_empty() {
710            // Build a doc_row → screen_row map from the first pass.
711            // We re-walk the viewport range instead of storing a map to
712            // keep memory allocation proportional to the viewport.
713            let vp_top = top_row;
714            let vp_bot = vp_top + area.height as usize;
715            for overlay in self.diag_overlays {
716                if overlay.row < vp_top || overlay.row >= vp_bot {
717                    continue;
718                }
719                // Compute screen row: count non-hidden rows from vp_top
720                // to overlay.row.
721                let mut sr: u16 = 0;
722                let mut dr = vp_top;
723                while dr < overlay.row && sr < area.height {
724                    if !folds.iter().any(|f| f.hides(dr)) {
725                        sr += 1;
726                    }
727                    dr += 1;
728                }
729                if sr >= area.height {
730                    continue;
731                }
732                let y = text_area.y + sr;
733                // Paint the char columns in the overlay range, clamped
734                // to the horizontal scroll window and text area width.
735                let col_start = overlay.col_start;
736                let col_end = overlay.col_end.max(col_start + 1);
737                for col in col_start..col_end {
738                    if col < top_col {
739                        continue;
740                    }
741                    let screen_col = col - top_col;
742                    if screen_col >= text_area.width as usize {
743                        break;
744                    }
745                    let x = text_area.x + screen_col as u16;
746                    if let Some(cell) = term_buf.cell_mut((x, y)) {
747                        cell.set_style(cell.style().patch(overlay.style));
748                    }
749                }
750            }
751        }
752    }
753}
754
755/// Left-frame width reserved by the boxed-blame layout (the `│`/`┌`/`└`
756/// column). The host adds this when computing the cursor's screen column.
757pub const BLAME_BOX_FRAME_LEFT: u16 = 1;
758
759impl<R: StyleResolver> BufferView<'_, R> {
760    /// Render the boxed-blame plan: one screen row per [`BlameRow`]. Content
761    /// rows paint the buffer line (gutter + syntax/selection/search) framed by
762    /// `│` sides; border rows draw the box top (with commit title) / bottom.
763    /// `Wrap::None` only — blame mode requires it.
764    fn render_blame_plan(&self, area: Rect, term_buf: &mut TermBuffer, plan: &[BlameRow]) {
765        let viewport = *self.viewport;
766        let top_col = viewport.top_col;
767        let cursor = self.buffer.cursor();
768        let folds = self.buffer.folds();
769        let rope = self.buffer.rope();
770
771        let frame_x = area.x;
772        let right_x = area.x + area.width.saturating_sub(1);
773        // Inner region after the left frame column.
774        let inner = Rect {
775            x: area.x + BLAME_BOX_FRAME_LEFT,
776            y: area.y,
777            width: area.width.saturating_sub(BLAME_BOX_FRAME_LEFT + 1), // also drop right frame
778            height: area.height,
779        };
780        let gutter_total = self
781            .gutter
782            .map(|g| g.sign_column_width + g.width + g.fold_column_width)
783            .unwrap_or(0);
784        let text_area = Rect {
785            x: inner.x.saturating_add(gutter_total),
786            y: inner.y,
787            width: inner.width.saturating_sub(gutter_total),
788            height: inner.height,
789        };
790
791        let border_style = self.non_text_style;
792        let title_style = self
793            .non_text_style
794            .add_modifier(ratatui::style::Modifier::BOLD);
795
796        for (sr, item) in plan.iter().enumerate().take(area.height as usize) {
797            let screen_row = sr as u16;
798            let y = area.y + screen_row;
799            match item {
800                BlameRow::Content(dr) => {
801                    let dr = *dr;
802                    let line_owned = hjkl_buffer::rope_line_str(&rope, dr);
803                    let line: &str = line_owned.as_str();
804                    let row_spans = self.spans.get(dr).map(Vec::as_slice).unwrap_or(&[]);
805                    let sel_range = self.selection.and_then(|s| s.row_span(dr));
806                    let is_cursor_row = dr == cursor.row;
807                    if let Some(gutter) = self.gutter {
808                        self.paint_gutter(term_buf, inner, screen_row, dr, gutter);
809                        self.paint_signs(term_buf, inner, screen_row, dr, gutter);
810                        self.paint_fold_column(term_buf, inner, screen_row, dr, gutter, &folds);
811                    }
812                    let search_ranges = self.row_search_ranges(line);
813                    let row_conceals: Vec<&Conceal> = {
814                        let mut v: Vec<&Conceal> =
815                            self.conceals.iter().filter(|c| c.row == dr).collect();
816                        v.sort_by_key(|c| c.start_byte);
817                        v
818                    };
819                    self.paint_row(
820                        term_buf,
821                        text_area,
822                        screen_row,
823                        line,
824                        row_spans,
825                        sel_range,
826                        &search_ranges,
827                        is_cursor_row,
828                        cursor.col,
829                        top_col,
830                        usize::MAX,
831                        true,
832                        &row_conceals,
833                    );
834                    // Side frame borders.
835                    if let Some(cell) = term_buf.cell_mut((frame_x, y)) {
836                        cell.set_char('\u{2502}'); // │
837                        cell.set_style(border_style);
838                    }
839                    if let Some(cell) = term_buf.cell_mut((right_x, y)) {
840                        cell.set_char('\u{2502}');
841                        cell.set_style(border_style);
842                    }
843                }
844                BlameRow::BorderTop(_) | BlameRow::BorderBottom => {
845                    let (lc, rc) = match item {
846                        BlameRow::BorderTop(_) => ('\u{250c}', '\u{2510}'), // ┌ ┐
847                        _ => ('\u{2514}', '\u{2518}'),                      // └ ┘
848                    };
849                    for x in frame_x..=right_x {
850                        if let Some(cell) = term_buf.cell_mut((x, y)) {
851                            let ch = if x == frame_x {
852                                lc
853                            } else if x == right_x {
854                                rc
855                            } else {
856                                '\u{2500}' // ─
857                            };
858                            cell.set_char(ch);
859                            cell.set_style(border_style);
860                        }
861                    }
862                    if let BlameRow::BorderTop(title) = item {
863                        // Lay " title " over the rule, after the corner + one ─.
864                        let label = format!(" {title} ");
865                        let start = frame_x + 2;
866                        for (i, ch) in label.chars().enumerate() {
867                            let x = start + i as u16;
868                            if x >= right_x {
869                                break;
870                            }
871                            if let Some(cell) = term_buf.cell_mut((x, y)) {
872                                cell.set_char(ch);
873                                cell.set_style(title_style);
874                            }
875                        }
876                    }
877                }
878            }
879        }
880    }
881
882    /// Run the active search regex against `line` and return the
883    /// charwise `(start_col, end_col_exclusive)` ranges that need
884    /// the search bg painted. Empty when no pattern is set.
885    fn row_search_ranges(&self, line: &str) -> Vec<(usize, usize)> {
886        let Some(re) = self.search_pattern else {
887            return Vec::new();
888        };
889        re.find_iter(line)
890            .map(|m| {
891                let start = line[..m.start()].chars().count();
892                let end = line[..m.end()].chars().count();
893                (start, end)
894            })
895            .collect()
896    }
897
898    fn paint_signs(
899        &self,
900        term_buf: &mut TermBuffer,
901        area: Rect,
902        screen_row: u16,
903        doc_row: usize,
904        gutter: Gutter,
905    ) {
906        // Only paint when a sign column is reserved.
907        if gutter.sign_column_width == 0 {
908            return;
909        }
910        let y = area.y + screen_row;
911        let sign_x = area.x;
912        // Fill sign column cells with blank first (gutter style bg).
913        for x in sign_x..sign_x + gutter.sign_column_width {
914            if let Some(cell) = term_buf.cell_mut((x, y)) {
915                cell.set_char(' ');
916                cell.set_style(gutter.style);
917            }
918        }
919        // Paint the highest-priority sign for this row in the leftmost cell.
920        let Some(sign) = self
921            .signs
922            .iter()
923            .filter(|s| s.row == doc_row)
924            .max_by_key(|s| s.priority)
925        else {
926            return;
927        };
928        if let Some(cell) = term_buf.cell_mut((sign_x, y)) {
929            cell.set_char(sign.ch);
930            cell.set_style(sign.style);
931        }
932    }
933
934    /// Paint a wrap-continuation gutter row: blank cells in the
935    /// gutter style so the bg stays continuous, no line number.
936    fn paint_blank_gutter(
937        &self,
938        term_buf: &mut TermBuffer,
939        area: Rect,
940        screen_row: u16,
941        gutter: Gutter,
942    ) {
943        let y = area.y + screen_row;
944        let total = gutter.sign_column_width + gutter.width;
945        for x in area.x..(area.x + total) {
946            if let Some(cell) = term_buf.cell_mut((x, y)) {
947                cell.set_char(' ');
948                cell.set_style(gutter.style);
949            }
950        }
951    }
952
953    fn paint_gutter(
954        &self,
955        term_buf: &mut TermBuffer,
956        area: Rect,
957        screen_row: u16,
958        doc_row: usize,
959        gutter: Gutter,
960    ) {
961        let y = area.y + screen_row;
962        // Number column starts after the sign column.
963        let num_start = area.x + gutter.sign_column_width;
964        // Total gutter cells in the number column, leaving one trailing spacer.
965        let number_width = gutter.width.saturating_sub(1) as usize;
966
967        // Compute the label to display based on the numbers mode.
968        let label = match gutter.numbers {
969            GutterNumbers::None => {
970                // Blank — paint all number-column cells (including spacer) as spaces.
971                for x in num_start..(num_start + gutter.width) {
972                    if let Some(cell) = term_buf.cell_mut((x, y)) {
973                        cell.set_char(' ');
974                        cell.set_style(gutter.style);
975                    }
976                }
977                return;
978            }
979            GutterNumbers::Absolute => {
980                format!(
981                    "{:>width$}",
982                    doc_row + 1 + gutter.line_offset,
983                    width = number_width
984                )
985            }
986            GutterNumbers::Relative { cursor_row } => {
987                let n = if doc_row == cursor_row {
988                    0
989                } else {
990                    doc_row.abs_diff(cursor_row)
991                };
992                format!("{:>width$}", n, width = number_width)
993            }
994            GutterNumbers::Hybrid { cursor_row } => {
995                let n = if doc_row == cursor_row {
996                    doc_row + 1 + gutter.line_offset
997                } else {
998                    doc_row.abs_diff(cursor_row)
999                };
1000                format!("{:>width$}", n, width = number_width)
1001            }
1002        };
1003
1004        let mut x = num_start;
1005        for ch in label.chars() {
1006            if x >= num_start + gutter.width.saturating_sub(1) {
1007                break;
1008            }
1009            if let Some(cell) = term_buf.cell_mut((x, y)) {
1010                cell.set_char(ch);
1011                cell.set_style(gutter.style);
1012            }
1013            x = x.saturating_add(1);
1014        }
1015        // Spacer cell — same gutter style so the background is
1016        // continuous when a bg colour is set.
1017        let spacer_x = num_start + gutter.width.saturating_sub(1);
1018        if let Some(cell) = term_buf.cell_mut((spacer_x, y)) {
1019            cell.set_char(' ');
1020            cell.set_style(gutter.style);
1021        }
1022    }
1023
1024    /// Paint the fold-indicator column for `doc_row`. The column sits to
1025    /// the RIGHT of the number column and LEFT of the text
1026    /// (`[sign][number][fold][text]`). One glyph per row:
1027    ///   `▸` closed fold start · `▾` open fold start ·
1028    ///   `│` open fold body · ` ` otherwise.
1029    /// No-op when `fold_column_width == 0`.
1030    fn paint_fold_column(
1031        &self,
1032        term_buf: &mut TermBuffer,
1033        area: Rect,
1034        screen_row: u16,
1035        doc_row: usize,
1036        gutter: Gutter,
1037        folds: &[hjkl_buffer::Fold],
1038    ) {
1039        if gutter.fold_column_width == 0 {
1040            return;
1041        }
1042        let y = area.y + screen_row;
1043        let fold_x = area.x + gutter.sign_column_width + gutter.width;
1044        let glyph = fold_column_glyph(folds, doc_row);
1045        // Only the leftmost fold cell carries the glyph; any remaining
1046        // fold-column cells are blanked so the gutter bg stays continuous.
1047        for (i, x) in (fold_x..fold_x + gutter.fold_column_width).enumerate() {
1048            if let Some(cell) = term_buf.cell_mut((x, y)) {
1049                cell.set_char(if i == 0 { glyph } else { ' ' });
1050                cell.set_style(gutter.style);
1051            }
1052        }
1053    }
1054
1055    #[allow(clippy::too_many_arguments)]
1056    fn paint_row(
1057        &self,
1058        term_buf: &mut TermBuffer,
1059        area: Rect,
1060        screen_row: u16,
1061        line: &str,
1062        row_spans: &[hjkl_buffer::Span],
1063        sel_range: hjkl_buffer::RowSpan,
1064        search_ranges: &[(usize, usize)],
1065        is_cursor_row: bool,
1066        cursor_col: usize,
1067        seg_start: usize,
1068        seg_end: usize,
1069        is_last_segment: bool,
1070        conceals: &[&Conceal],
1071    ) {
1072        let y = area.y + screen_row;
1073        let mut screen_x = area.x;
1074        let row_end_x = area.x + area.width;
1075
1076        // Paint cursor-line bg across the whole row first so empty
1077        // trailing cells inherit the highlight (matches vim's
1078        // cursorline). Selection / cursor cells overwrite below.
1079        if is_cursor_row && self.cursor_line_bg != Style::default() {
1080            for x in area.x..row_end_x {
1081                if let Some(cell) = term_buf.cell_mut((x, y)) {
1082                    cell.set_style(self.cursor_line_bg);
1083                }
1084            }
1085        }
1086
1087        // Tab width for `\t` expansion — host publishes via
1088        // `Viewport::tab_width` (driven by engine's `:set tabstop`).
1089        // `effective_tab_width` falls back to 4 when unset.
1090        let tab_width = self.viewport.effective_tab_width();
1091
1092        // Precompute trailing-whitespace boundary for `:set list` trail rendering.
1093        // `trail_byte_start` is the byte index of the first trailing space/tab
1094        // at the end of the line. Bytes at or past this index are "trailing".
1095        let trail_byte_start: usize = if self.listchars.is_some() {
1096            line.trim_end_matches([' ', '\t']).len()
1097        } else {
1098            line.len()
1099        };
1100
1101        let mut byte_offset: usize = 0;
1102        let mut line_col: usize = 0;
1103        let mut chars_iter = line.chars().enumerate().peekable();
1104        while let Some((col_idx, ch)) = chars_iter.next() {
1105            let ch_byte_len = ch.len_utf8();
1106            if col_idx >= seg_end {
1107                break;
1108            }
1109            // If a conceal starts at this byte, paint the replacement
1110            // text (using this cell's style) and skip the rest of the
1111            // concealed range. Cursor / selection / search highlights
1112            // still attribute to the original char positions.
1113            if let Some(conc) = conceals.iter().find(|c| c.start_byte == byte_offset) {
1114                if col_idx >= seg_start {
1115                    let mut style = if is_cursor_row {
1116                        self.cursor_line_bg
1117                    } else {
1118                        Style::default()
1119                    };
1120                    if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
1121                        style = style.patch(span_style);
1122                    }
1123                    for rch in conc.replacement.chars() {
1124                        let rwidth = rch.width().unwrap_or(1) as u16;
1125                        if screen_x + rwidth > row_end_x {
1126                            break;
1127                        }
1128                        if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1129                            cell.set_char(rch);
1130                            cell.set_style(style);
1131                        }
1132                        screen_x += rwidth;
1133                    }
1134                }
1135                // Advance byte_offset / chars iter past the concealed
1136                // range without painting the original cells.
1137                let mut consumed = ch_byte_len;
1138                byte_offset += ch_byte_len;
1139                while byte_offset < conc.end_byte {
1140                    let Some((_, next_ch)) = chars_iter.next() else {
1141                        break;
1142                    };
1143                    consumed += next_ch.len_utf8();
1144                    byte_offset = byte_offset.saturating_add(next_ch.len_utf8());
1145                }
1146                let _ = consumed;
1147                continue;
1148            }
1149            // Visible cell count: tabs expand to the next tab_width stop
1150            // based on `line_col` (visible column in the *line*, not the
1151            // segment), so a tab at line column 0 paints tab_width cells
1152            // and a tab at line column 3 paints 1 cell.
1153            let visible_width = if ch == '\t' {
1154                tab_width - (line_col % tab_width)
1155            } else {
1156                ch.width().unwrap_or(1)
1157            };
1158            // Skip chars to the left of the segment start (horizontal
1159            // scroll for `Wrap::None`, segment offset for wrap modes).
1160            if col_idx < seg_start {
1161                line_col += visible_width;
1162                byte_offset += ch_byte_len;
1163                continue;
1164            }
1165            // Stop when we run out of horizontal room.
1166            let width = visible_width as u16;
1167            if screen_x + width > row_end_x {
1168                break;
1169            }
1170
1171            // Resolve final style for this cell.
1172            let mut style = if is_cursor_row {
1173                self.cursor_line_bg
1174            } else {
1175                Style::default()
1176            };
1177            if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
1178                style = style.patch(span_style);
1179            }
1180            // Search bg first, then selection bg — so when a visual
1181            // selection covers a search match, the selection wins
1182            // (last patch overwrites the bg field).
1183            if self.search_bg != Style::default()
1184                && search_ranges
1185                    .iter()
1186                    .any(|&(s, e)| col_idx >= s && col_idx < e)
1187            {
1188                style = style.patch(self.search_bg);
1189            }
1190            if let Some((lo, hi)) = sel_range
1191                && col_idx >= lo
1192                && col_idx <= hi
1193            {
1194                style = style.patch(self.selection_bg);
1195            }
1196            if is_cursor_row && col_idx == cursor_col {
1197                style = style.patch(self.cursor_style);
1198            }
1199
1200            if ch == '\t' {
1201                // Paint tab expansion. When `:set list` is on, substitute
1202                // the leading cell with `tab_lead` and fill cells with
1203                // `tab_fill` (or spaces when tab_fill is None).
1204                if let Some(lc) = self.listchars {
1205                    // tab_lead occupies the first cell
1206                    if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1207                        cell.set_char(lc.tab_lead);
1208                        cell.set_style(style);
1209                    }
1210                    let fill_ch = lc.tab_fill.unwrap_or(' ');
1211                    for k in 1..width {
1212                        if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
1213                            cell.set_char(fill_ch);
1214                            cell.set_style(style);
1215                        }
1216                    }
1217                } else {
1218                    // Default: paint tab as `visible_width` space cells carrying
1219                    // the resolved style — tab/text bg/cursor-line bg all paint
1220                    // through the expansion.
1221                    for k in 0..width {
1222                        if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
1223                            cell.set_char(' ');
1224                            cell.set_style(style);
1225                        }
1226                    }
1227                }
1228            } else if let Some(lc) = self.listchars {
1229                // Listchars substitutions for non-tab characters.
1230                let display_ch = if ch == '\u{00a0}' {
1231                    // Non-breaking space
1232                    lc.nbsp.unwrap_or(ch)
1233                } else if ch == ' ' {
1234                    let is_trailing = byte_offset >= trail_byte_start;
1235                    if is_trailing {
1236                        lc.trail.or(lc.space).unwrap_or(ch)
1237                    } else {
1238                        lc.space.unwrap_or(ch)
1239                    }
1240                } else {
1241                    ch
1242                };
1243                if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1244                    cell.set_char(display_ch);
1245                    cell.set_style(style);
1246                }
1247            } else if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
1248                cell.set_char(ch);
1249                cell.set_style(style);
1250            }
1251            screen_x += width;
1252            line_col += visible_width;
1253            byte_offset += ch_byte_len;
1254        }
1255
1256        // When `:set list` is on and `eol` is configured, paint the EOL marker
1257        // immediately after the last character on the last segment.
1258        if is_last_segment
1259            && let Some(lc) = self.listchars
1260            && let Some(eol_ch) = lc.eol
1261            && screen_x < row_end_x
1262            && let Some(cell) = term_buf.cell_mut((screen_x, y))
1263        {
1264            cell.set_char(eol_ch);
1265            cell.set_style(Style::default());
1266            screen_x += 1;
1267        }
1268        let _ = screen_x; // consumed by eol branch above; not used further
1269
1270        // Empty-line selection placeholder. Without this, an empty row
1271        // covered by a v/V/Ctrl-V selection paints zero cells — the user
1272        // loses the visible marker that the row is part of the range.
1273        //
1274        // For Char/Line selections, `hi == usize::MAX` (the "whole row"
1275        // sentinel from `Selection::row_span`); paint a single ' ' cell
1276        // at col 0 — matches Neovim's marker on otherwise-empty rows.
1277        //
1278        // For Block selections, `hi` is bounded by the block's right
1279        // column; paint cols `lo..=hi` so the block stays visually
1280        // rectangular even where rows have no chars (also matches
1281        // Neovim).
1282        //
1283        // Layered before the cursor-EOL placeholder so the terminal
1284        // cursor still wins visually.
1285        if let Some((lo, hi)) = sel_range
1286            && is_last_segment
1287            && line.chars().count() <= seg_start
1288        {
1289            let (start_col, end_col) = if hi == usize::MAX { (0, 0) } else { (lo, hi) };
1290            for col in start_col..=end_col {
1291                let pad_x = area.x + col as u16;
1292                if pad_x >= row_end_x {
1293                    break;
1294                }
1295                if let Some(cell) = term_buf.cell_mut((pad_x, y)) {
1296                    let prev = cell.style();
1297                    cell.set_char(' ');
1298                    cell.set_style(prev.patch(self.selection_bg));
1299                }
1300            }
1301        }
1302
1303        // If the cursor sits at end-of-line (insert / past-end mode),
1304        // paint a single REVERSED placeholder cell so it stays visible.
1305        // Only on the last segment of a wrapped row — earlier segments
1306        // can't host the past-end cursor.
1307        if is_cursor_row
1308            && is_last_segment
1309            && cursor_col >= line.chars().count()
1310            && cursor_col >= seg_start
1311        {
1312            let pad_x = area.x + (cursor_col.saturating_sub(seg_start)) as u16;
1313            if pad_x < row_end_x
1314                && let Some(cell) = term_buf.cell_mut((pad_x, y))
1315            {
1316                cell.set_char(' ');
1317                cell.set_style(self.cursor_line_bg.patch(self.cursor_style));
1318            }
1319        }
1320    }
1321
1322    /// Resolve the final style for a byte by layering every span that
1323    /// contains it, broadest first and narrowest last. `Style::patch` keeps
1324    /// the broader span's fields when the narrower span doesn't override
1325    /// them, so a wide `@markup.raw.block` carrying just `bg = codeblock`
1326    /// shines through under a narrow `@keyword` carrying just `fg = mauve`,
1327    /// matching vim/Helix's layered hi-group model.
1328    ///
1329    /// Pre-0.6.1 behaviour was narrowest-wins-completely: only one span's
1330    /// style applied per byte, so broader-span backgrounds were dropped
1331    /// whenever a narrower foreground span overlapped them. That made it
1332    /// impossible to give markdown code blocks a tinted bg without also
1333    /// burdening every injected language's captures with the same bg.
1334    ///
1335    /// Hosts that want the old behaviour can ensure their narrower spans
1336    /// set every field explicitly — `Style::patch` only carries broader
1337    /// fields through `None` slots.
1338    fn resolve_span_style(
1339        &self,
1340        row_spans: &[hjkl_buffer::Span],
1341        byte_offset: usize,
1342    ) -> Option<Style> {
1343        // Collect every span containing this byte, sorted broadest first.
1344        let mut overlapping: Vec<&hjkl_buffer::Span> = row_spans
1345            .iter()
1346            .filter(|s| byte_offset >= s.start_byte && byte_offset < s.end_byte)
1347            .collect();
1348        if overlapping.is_empty() {
1349            return None;
1350        }
1351        overlapping.sort_by_key(|s| std::cmp::Reverse(s.end_byte.saturating_sub(s.start_byte)));
1352        let mut style = self.resolver.resolve(overlapping[0].style);
1353        for s in &overlapping[1..] {
1354            style = style.patch(self.resolver.resolve(s.style));
1355        }
1356        Some(style)
1357    }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use super::*;
1363    use ratatui::style::{Color, Modifier};
1364    use ratatui::widgets::Widget;
1365
1366    fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
1367        let area = Rect::new(0, 0, w, h);
1368        let mut buf = TermBuffer::empty(area);
1369        view.render(area, &mut buf);
1370        buf
1371    }
1372
1373    fn no_styles(_id: u32) -> Style {
1374        Style::default()
1375    }
1376
1377    /// Build a default viewport for plain (no-wrap) tests.
1378    fn vp(width: u16, height: u16) -> Viewport {
1379        Viewport {
1380            top_row: 0,
1381            top_col: 0,
1382            width,
1383            height,
1384            wrap: Wrap::None,
1385            text_width: width,
1386            tab_width: 0,
1387        }
1388    }
1389
1390    #[test]
1391    fn renders_plain_chars_into_terminal_buffer() {
1392        let b = Buffer::from_str("hello\nworld");
1393        let v = vp(20, 5);
1394        let view = BufferView {
1395            buffer: &b,
1396            viewport: &v,
1397            selection: None,
1398            resolver: &(no_styles as fn(u32) -> Style),
1399            cursor_line_bg: Style::default(),
1400            cursor_column_bg: Style::default(),
1401            selection_bg: Style::default().bg(Color::Blue),
1402            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1403            gutter: None,
1404            search_bg: Style::default(),
1405            signs: &[],
1406            conceals: &[],
1407            spans: &[],
1408            search_pattern: None,
1409            non_text_style: Style::default(),
1410            diag_overlays: &[],
1411            colorcolumn_cols: &[],
1412            colorcolumn_style: Style::default(),
1413            listchars: None,
1414            indent_guides_enabled: false,
1415            indent_guide_char: '│',
1416            indent_guide_shiftwidth: 4,
1417            indent_guide_fg: Color::Reset,
1418            indent_guide_active_fg: Color::Reset,
1419            indent_guide_active_col: None,
1420            fold_line_bg: Style::default(),
1421            eol_hints: &[],
1422            blame_plan: None,
1423        };
1424        let term = run_render(view, 20, 5);
1425        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
1426        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
1427        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
1428        assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
1429    }
1430
1431    #[test]
1432    fn cursor_cell_gets_reversed_style() {
1433        let mut b = Buffer::from_str("abc");
1434        let v = vp(10, 1);
1435        b.set_cursor(hjkl_buffer::Position::new(0, 1));
1436        let view = BufferView {
1437            buffer: &b,
1438            viewport: &v,
1439            selection: None,
1440            resolver: &(no_styles as fn(u32) -> Style),
1441            cursor_line_bg: Style::default(),
1442            cursor_column_bg: Style::default(),
1443            selection_bg: Style::default().bg(Color::Blue),
1444            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1445            gutter: None,
1446            search_bg: Style::default(),
1447            signs: &[],
1448            conceals: &[],
1449            spans: &[],
1450            search_pattern: None,
1451            non_text_style: Style::default(),
1452            diag_overlays: &[],
1453            colorcolumn_cols: &[],
1454            colorcolumn_style: Style::default(),
1455            listchars: None,
1456            indent_guides_enabled: false,
1457            indent_guide_char: '│',
1458            indent_guide_shiftwidth: 4,
1459            indent_guide_fg: Color::Reset,
1460            indent_guide_active_fg: Color::Reset,
1461            indent_guide_active_col: None,
1462            fold_line_bg: Style::default(),
1463            eol_hints: &[],
1464            blame_plan: None,
1465        };
1466        let term = run_render(view, 10, 1);
1467        let cursor_cell = term.cell((1, 0)).unwrap();
1468        assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
1469    }
1470
1471    #[test]
1472    fn selection_bg_applies_only_to_selected_cells() {
1473        use hjkl_buffer::{Position, Selection};
1474        let b = Buffer::from_str("abcdef");
1475        let v = vp(10, 1);
1476        let view = BufferView {
1477            buffer: &b,
1478            viewport: &v,
1479            selection: Some(Selection::Char {
1480                anchor: Position::new(0, 1),
1481                head: Position::new(0, 3),
1482            }),
1483            resolver: &(no_styles as fn(u32) -> Style),
1484            cursor_line_bg: Style::default(),
1485            cursor_column_bg: Style::default(),
1486            selection_bg: Style::default().bg(Color::Blue),
1487            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1488            gutter: None,
1489            search_bg: Style::default(),
1490            signs: &[],
1491            conceals: &[],
1492            spans: &[],
1493            search_pattern: None,
1494            non_text_style: Style::default(),
1495            diag_overlays: &[],
1496            colorcolumn_cols: &[],
1497            colorcolumn_style: Style::default(),
1498            listchars: None,
1499            indent_guides_enabled: false,
1500            indent_guide_char: '│',
1501            indent_guide_shiftwidth: 4,
1502            indent_guide_fg: Color::Reset,
1503            indent_guide_active_fg: Color::Reset,
1504            indent_guide_active_col: None,
1505            fold_line_bg: Style::default(),
1506            eol_hints: &[],
1507            blame_plan: None,
1508        };
1509        let term = run_render(view, 10, 1);
1510        assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
1511        for x in 1..=3 {
1512            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
1513        }
1514        assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
1515    }
1516
1517    #[test]
1518    fn selection_paints_placeholder_on_empty_line_charwise() {
1519        // Char selection spanning two lines, middle empty row must show
1520        // a selection cell at col 0 so the user can see the row is in range.
1521        use hjkl_buffer::{Position, Selection};
1522        let b = Buffer::from_str("abc\n\nxyz");
1523        let v = vp(10, 3);
1524        let view = BufferView {
1525            buffer: &b,
1526            viewport: &v,
1527            selection: Some(Selection::Char {
1528                anchor: Position::new(0, 0),
1529                head: Position::new(2, 2),
1530            }),
1531            resolver: &(no_styles as fn(u32) -> Style),
1532            cursor_line_bg: Style::default(),
1533            cursor_column_bg: Style::default(),
1534            selection_bg: Style::default().bg(Color::Blue),
1535            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1536            gutter: None,
1537            search_bg: Style::default(),
1538            signs: &[],
1539            conceals: &[],
1540            spans: &[],
1541            search_pattern: None,
1542            non_text_style: Style::default(),
1543            diag_overlays: &[],
1544            colorcolumn_cols: &[],
1545            colorcolumn_style: Style::default(),
1546            listchars: None,
1547            indent_guides_enabled: false,
1548            indent_guide_char: '│',
1549            indent_guide_shiftwidth: 4,
1550            indent_guide_fg: Color::Reset,
1551            indent_guide_active_fg: Color::Reset,
1552            indent_guide_active_col: None,
1553            fold_line_bg: Style::default(),
1554            eol_hints: &[],
1555            blame_plan: None,
1556        };
1557        let term = run_render(view, 10, 3);
1558        // Empty middle row (y=1) — col 0 must carry the selection bg.
1559        assert_eq!(term.cell((0, 1)).unwrap().bg, Color::Blue);
1560    }
1561
1562    #[test]
1563    fn selection_paints_placeholder_on_empty_line_linewise() {
1564        use hjkl_buffer::Selection;
1565        let b = Buffer::from_str("abc\n\nxyz");
1566        let v = vp(10, 3);
1567        let view = BufferView {
1568            buffer: &b,
1569            viewport: &v,
1570            selection: Some(Selection::Line {
1571                anchor_row: 0,
1572                head_row: 2,
1573            }),
1574            resolver: &(no_styles as fn(u32) -> Style),
1575            cursor_line_bg: Style::default(),
1576            cursor_column_bg: Style::default(),
1577            selection_bg: Style::default().bg(Color::Blue),
1578            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1579            gutter: None,
1580            search_bg: Style::default(),
1581            signs: &[],
1582            conceals: &[],
1583            spans: &[],
1584            search_pattern: None,
1585            non_text_style: Style::default(),
1586            diag_overlays: &[],
1587            colorcolumn_cols: &[],
1588            colorcolumn_style: Style::default(),
1589            listchars: None,
1590            indent_guides_enabled: false,
1591            indent_guide_char: '│',
1592            indent_guide_shiftwidth: 4,
1593            indent_guide_fg: Color::Reset,
1594            indent_guide_active_fg: Color::Reset,
1595            indent_guide_active_col: None,
1596            fold_line_bg: Style::default(),
1597            eol_hints: &[],
1598            blame_plan: None,
1599        };
1600        let term = run_render(view, 10, 3);
1601        assert_eq!(term.cell((0, 1)).unwrap().bg, Color::Blue);
1602    }
1603
1604    #[test]
1605    fn selection_paints_placeholder_on_empty_line_blockwise() {
1606        // Block selection at cols 2..=5 over rows 0..=2 with empty middle.
1607        // The empty row must paint cols 2..=5 (the block's full width),
1608        // NOT just col 0 — otherwise the block looks broken at empty
1609        // rows. Matches Neovim's rectangular block highlight.
1610        use hjkl_buffer::{Position, Selection};
1611        let b = Buffer::from_str("abcdef\n\nuvwxyz");
1612        let v = vp(10, 3);
1613        let view = BufferView {
1614            buffer: &b,
1615            viewport: &v,
1616            selection: Some(Selection::Block {
1617                anchor: Position::new(0, 2),
1618                head: Position::new(2, 5),
1619            }),
1620            resolver: &(no_styles as fn(u32) -> Style),
1621            cursor_line_bg: Style::default(),
1622            cursor_column_bg: Style::default(),
1623            selection_bg: Style::default().bg(Color::Blue),
1624            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1625            gutter: None,
1626            search_bg: Style::default(),
1627            signs: &[],
1628            conceals: &[],
1629            spans: &[],
1630            search_pattern: None,
1631            non_text_style: Style::default(),
1632            diag_overlays: &[],
1633            colorcolumn_cols: &[],
1634            colorcolumn_style: Style::default(),
1635            listchars: None,
1636            indent_guides_enabled: false,
1637            indent_guide_char: '│',
1638            indent_guide_shiftwidth: 4,
1639            indent_guide_fg: Color::Reset,
1640            indent_guide_active_fg: Color::Reset,
1641            indent_guide_active_col: None,
1642            fold_line_bg: Style::default(),
1643            eol_hints: &[],
1644            blame_plan: None,
1645        };
1646        let term = run_render(view, 10, 3);
1647        // Empty row (y=1): cols 2..=5 carry selection bg (block width).
1648        for x in 2u16..=5 {
1649            assert_eq!(
1650                term.cell((x, 1)).unwrap().bg,
1651                Color::Blue,
1652                "empty row col {x} should carry block selection bg"
1653            );
1654        }
1655        // Col 0 and 1 on empty row MUST NOT carry selection bg — block
1656        // starts at col 2.
1657        assert!(term.cell((0, 1)).unwrap().bg != Color::Blue);
1658        assert!(term.cell((1, 1)).unwrap().bg != Color::Blue);
1659        // Col 6 (just past block right edge) also clear.
1660        assert!(term.cell((6, 1)).unwrap().bg != Color::Blue);
1661        // Non-empty rows still highlight cols 2..=5.
1662        for x in 2u16..=5 {
1663            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
1664        }
1665    }
1666
1667    #[test]
1668    fn selection_block_placeholder_clips_to_row_width() {
1669        // Block right edge past row width must stop at row_end_x.
1670        use hjkl_buffer::{Position, Selection};
1671        let b = Buffer::from_str("abc\n\nxyz");
1672        let v = vp(5, 3);
1673        let view = BufferView {
1674            buffer: &b,
1675            viewport: &v,
1676            selection: Some(Selection::Block {
1677                anchor: Position::new(0, 1),
1678                head: Position::new(2, 20),
1679            }),
1680            resolver: &(no_styles as fn(u32) -> Style),
1681            cursor_line_bg: Style::default(),
1682            cursor_column_bg: Style::default(),
1683            selection_bg: Style::default().bg(Color::Blue),
1684            cursor_style: Style::default(),
1685            gutter: None,
1686            search_bg: Style::default(),
1687            signs: &[],
1688            conceals: &[],
1689            spans: &[],
1690            search_pattern: None,
1691            non_text_style: Style::default(),
1692            diag_overlays: &[],
1693            colorcolumn_cols: &[],
1694            colorcolumn_style: Style::default(),
1695            listchars: None,
1696            indent_guides_enabled: false,
1697            indent_guide_char: '│',
1698            indent_guide_shiftwidth: 4,
1699            indent_guide_fg: Color::Reset,
1700            indent_guide_active_fg: Color::Reset,
1701            indent_guide_active_col: None,
1702            fold_line_bg: Style::default(),
1703            eol_hints: &[],
1704            blame_plan: None,
1705        };
1706        // 5-wide area; block lo=1, hi=20 → paint cols 1..=4 (rest clipped).
1707        let term = run_render(view, 5, 3);
1708        for x in 1u16..=4 {
1709            assert_eq!(
1710                term.cell((x, 1)).unwrap().bg,
1711                Color::Blue,
1712                "col {x} clipped block placeholder"
1713            );
1714        }
1715        // No panic from pad_x past row_end_x is the main thing.
1716    }
1717
1718    #[test]
1719    fn layered_spans_blend_broad_bg_with_narrow_fg() {
1720        // Regression: a wide `@markup.raw.block`-style span carrying only
1721        // `bg = ...` must shine through a narrow `@keyword`-style span
1722        // carrying only `fg = ...`. Pre-0.6.1 the narrow span won outright
1723        // and dropped the broad bg, which made markdown code-block tinting
1724        // impossible without bloating every injected language's captures.
1725        use hjkl_buffer::Span;
1726        let b = Buffer::from_str("fn main() {}");
1727        let v = vp(20, 1);
1728        // id=1 = broad code-block bg, id=2 = narrow keyword fg.
1729        let spans = vec![vec![
1730            Span::new(0, 12, 1), // bg-only, whole line
1731            Span::new(0, 2, 2),  // fg-only, just "fn"
1732        ]];
1733        let resolver = |id: u32| -> Style {
1734            match id {
1735                1 => Style::default().bg(Color::DarkGray),
1736                2 => Style::default().fg(Color::Magenta),
1737                _ => Style::default(),
1738            }
1739        };
1740        let view = BufferView {
1741            buffer: &b,
1742            viewport: &v,
1743            selection: None,
1744            resolver: &resolver,
1745            cursor_line_bg: Style::default(),
1746            cursor_column_bg: Style::default(),
1747            selection_bg: Style::default().bg(Color::Blue),
1748            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1749            gutter: None,
1750            search_bg: Style::default(),
1751            signs: &[],
1752            conceals: &[],
1753            spans: &spans,
1754            search_pattern: None,
1755            non_text_style: Style::default(),
1756            diag_overlays: &[],
1757            colorcolumn_cols: &[],
1758            colorcolumn_style: Style::default(),
1759            listchars: None,
1760            indent_guides_enabled: false,
1761            indent_guide_char: '│',
1762            indent_guide_shiftwidth: 4,
1763            indent_guide_fg: Color::Reset,
1764            indent_guide_active_fg: Color::Reset,
1765            indent_guide_active_col: None,
1766            fold_line_bg: Style::default(),
1767            eol_hints: &[],
1768            blame_plan: None,
1769        };
1770        let term = run_render(view, 20, 1);
1771        // Cols 0-1 ("fn"): narrow fg + broad bg.
1772        for x in 0u16..2 {
1773            let cell = term.cell((x, 0)).unwrap();
1774            assert_eq!(cell.fg, Color::Magenta, "col {x}: fg from narrow span");
1775            assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1776        }
1777        // Cols 2-11 (" main() {}"): broad bg only, no fg set.
1778        for x in 2u16..12 {
1779            let cell = term.cell((x, 0)).unwrap();
1780            assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1781            assert_eq!(
1782                cell.fg,
1783                Color::Reset,
1784                "col {x}: no fg set (broad span is bg-only)"
1785            );
1786        }
1787    }
1788
1789    #[test]
1790    fn narrow_span_with_explicit_bg_still_overrides_broad_bg() {
1791        // Regression: a narrow span that DOES set bg must override the
1792        // broader span's bg. Earlier "narrowest-wins-completely" behaviour
1793        // had this trivially; the new layered logic relies on
1794        // `Style::patch` overriding only set fields, so we pin it.
1795        use hjkl_buffer::Span;
1796        let b = Buffer::from_str("hello world");
1797        let v = vp(20, 1);
1798        let spans = vec![vec![
1799            Span::new(0, 11, 1), // broad bg = DarkGray
1800            Span::new(6, 11, 2), // narrow bg = Red (overrides)
1801        ]];
1802        let resolver = |id: u32| -> Style {
1803            match id {
1804                1 => Style::default().bg(Color::DarkGray),
1805                2 => Style::default().bg(Color::Red),
1806                _ => Style::default(),
1807            }
1808        };
1809        let view = BufferView {
1810            buffer: &b,
1811            viewport: &v,
1812            selection: None,
1813            resolver: &resolver,
1814            cursor_line_bg: Style::default(),
1815            cursor_column_bg: Style::default(),
1816            selection_bg: Style::default().bg(Color::Blue),
1817            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1818            gutter: None,
1819            search_bg: Style::default(),
1820            signs: &[],
1821            conceals: &[],
1822            spans: &spans,
1823            search_pattern: None,
1824            non_text_style: Style::default(),
1825            diag_overlays: &[],
1826            colorcolumn_cols: &[],
1827            colorcolumn_style: Style::default(),
1828            listchars: None,
1829            indent_guides_enabled: false,
1830            indent_guide_char: '│',
1831            indent_guide_shiftwidth: 4,
1832            indent_guide_fg: Color::Reset,
1833            indent_guide_active_fg: Color::Reset,
1834            indent_guide_active_col: None,
1835            fold_line_bg: Style::default(),
1836            eol_hints: &[],
1837            blame_plan: None,
1838        };
1839        let term = run_render(view, 20, 1);
1840        // Cols 0-5 ("hello "): broad bg only.
1841        for x in 0u16..6 {
1842            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::DarkGray);
1843        }
1844        // Cols 6-10 ("world"): narrow bg wins.
1845        for x in 6u16..11 {
1846            assert_eq!(
1847                term.cell((x, 0)).unwrap().bg,
1848                Color::Red,
1849                "col {x}: narrow span's bg overrides broad bg"
1850            );
1851        }
1852    }
1853
1854    #[test]
1855    fn syntax_span_fg_resolves_via_table() {
1856        use hjkl_buffer::Span;
1857        let b = Buffer::from_str("SELECT foo");
1858        let v = vp(20, 1);
1859        let spans = vec![vec![Span::new(0, 6, 7)]];
1860        let resolver = |id: u32| -> Style {
1861            if id == 7 {
1862                Style::default().fg(Color::Red)
1863            } else {
1864                Style::default()
1865            }
1866        };
1867        let view = BufferView {
1868            buffer: &b,
1869            viewport: &v,
1870            selection: None,
1871            resolver: &resolver,
1872            cursor_line_bg: Style::default(),
1873            cursor_column_bg: Style::default(),
1874            selection_bg: Style::default().bg(Color::Blue),
1875            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1876            gutter: None,
1877            search_bg: Style::default(),
1878            signs: &[],
1879            conceals: &[],
1880            spans: &spans,
1881            search_pattern: None,
1882            non_text_style: Style::default(),
1883            diag_overlays: &[],
1884            colorcolumn_cols: &[],
1885            colorcolumn_style: Style::default(),
1886            listchars: None,
1887            indent_guides_enabled: false,
1888            indent_guide_char: '│',
1889            indent_guide_shiftwidth: 4,
1890            indent_guide_fg: Color::Reset,
1891            indent_guide_active_fg: Color::Reset,
1892            indent_guide_active_col: None,
1893            fold_line_bg: Style::default(),
1894            eol_hints: &[],
1895            blame_plan: None,
1896        };
1897        let term = run_render(view, 20, 1);
1898        for x in 0..6 {
1899            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1900        }
1901    }
1902
1903    #[test]
1904    fn gutter_renders_right_aligned_line_numbers() {
1905        let b = Buffer::from_str("a\nb\nc");
1906        let v = vp(10, 3);
1907        let view = BufferView {
1908            buffer: &b,
1909            viewport: &v,
1910            selection: None,
1911            resolver: &(no_styles as fn(u32) -> Style),
1912            cursor_line_bg: Style::default(),
1913            cursor_column_bg: Style::default(),
1914            selection_bg: Style::default().bg(Color::Blue),
1915            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1916            gutter: Some(Gutter {
1917                width: 4,
1918                style: Style::default().fg(Color::Yellow),
1919                line_offset: 0,
1920                ..Default::default()
1921            }),
1922            search_bg: Style::default(),
1923            signs: &[],
1924            conceals: &[],
1925            spans: &[],
1926            search_pattern: None,
1927            non_text_style: Style::default(),
1928            diag_overlays: &[],
1929            colorcolumn_cols: &[],
1930            colorcolumn_style: Style::default(),
1931            listchars: None,
1932            indent_guides_enabled: false,
1933            indent_guide_char: '│',
1934            indent_guide_shiftwidth: 4,
1935            indent_guide_fg: Color::Reset,
1936            indent_guide_active_fg: Color::Reset,
1937            indent_guide_active_col: None,
1938            fold_line_bg: Style::default(),
1939            eol_hints: &[],
1940            blame_plan: None,
1941        };
1942        let term = run_render(view, 10, 3);
1943        // Width 4 = 3 number cells + 1 spacer; right-aligned "  1".
1944        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1945        assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
1946        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1947        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
1948        // Text shifted right past the gutter.
1949        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1950    }
1951
1952    #[test]
1953    fn gutter_renders_relative_with_cursor_at_zero() {
1954        // 5 rows, cursor on row 2 (0-based). Relative: row 2 → 0, row 0 → 2, row 4 → 2.
1955        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1956        b.set_cursor(hjkl_buffer::Position::new(2, 0));
1957        let v = vp(10, 5);
1958        let view = BufferView {
1959            buffer: &b,
1960            viewport: &v,
1961            selection: None,
1962            resolver: &(no_styles as fn(u32) -> Style),
1963            cursor_line_bg: Style::default(),
1964            cursor_column_bg: Style::default(),
1965            selection_bg: Style::default().bg(Color::Blue),
1966            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1967            gutter: Some(Gutter {
1968                width: 4,
1969                style: Style::default().fg(Color::Yellow),
1970                line_offset: 0,
1971                numbers: GutterNumbers::Relative { cursor_row: 2 },
1972                sign_column_width: 0,
1973                fold_column_width: 0,
1974            }),
1975            search_bg: Style::default(),
1976            signs: &[],
1977            conceals: &[],
1978            spans: &[],
1979            search_pattern: None,
1980            non_text_style: Style::default(),
1981            diag_overlays: &[],
1982            colorcolumn_cols: &[],
1983            colorcolumn_style: Style::default(),
1984            listchars: None,
1985            indent_guides_enabled: false,
1986            indent_guide_char: '│',
1987            indent_guide_shiftwidth: 4,
1988            indent_guide_fg: Color::Reset,
1989            indent_guide_active_fg: Color::Reset,
1990            indent_guide_active_col: None,
1991            fold_line_bg: Style::default(),
1992            eol_hints: &[],
1993            blame_plan: None,
1994        };
1995        let term = run_render(view, 10, 5);
1996        // Width 4 = 3 number cells + 1 spacer.
1997        // Row 0 (doc 0): distance from cursor (2) = 2 → "  2"
1998        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "2");
1999        // Row 1 (doc 1): distance = 1 → "  1"
2000        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "1");
2001        // Row 2 (doc 2): cursor row → "  0"
2002        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "0");
2003        // Row 3 (doc 3): distance = 1 → "  1"
2004        assert_eq!(term.cell((2, 3)).unwrap().symbol(), "1");
2005        // Row 4 (doc 4): distance = 2 → "  2"
2006        assert_eq!(term.cell((2, 4)).unwrap().symbol(), "2");
2007    }
2008
2009    #[test]
2010    fn gutter_renders_hybrid_cursor_row_absolute() {
2011        // 3 rows, cursor on row 1 (0-based). Hybrid: row 1 → absolute (2),
2012        // row 0 → offset 1, row 2 → offset 1.
2013        let mut b = Buffer::from_str("a\nb\nc");
2014        b.set_cursor(hjkl_buffer::Position::new(1, 0));
2015        let v = vp(10, 3);
2016        let view = BufferView {
2017            buffer: &b,
2018            viewport: &v,
2019            selection: None,
2020            resolver: &(no_styles as fn(u32) -> Style),
2021            cursor_line_bg: Style::default(),
2022            cursor_column_bg: Style::default(),
2023            selection_bg: Style::default().bg(Color::Blue),
2024            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2025            gutter: Some(Gutter {
2026                width: 4,
2027                style: Style::default().fg(Color::Yellow),
2028                line_offset: 0,
2029                numbers: GutterNumbers::Hybrid { cursor_row: 1 },
2030                sign_column_width: 0,
2031                fold_column_width: 0,
2032            }),
2033            search_bg: Style::default(),
2034            signs: &[],
2035            conceals: &[],
2036            spans: &[],
2037            search_pattern: None,
2038            non_text_style: Style::default(),
2039            diag_overlays: &[],
2040            colorcolumn_cols: &[],
2041            colorcolumn_style: Style::default(),
2042            listchars: None,
2043            indent_guides_enabled: false,
2044            indent_guide_char: '│',
2045            indent_guide_shiftwidth: 4,
2046            indent_guide_fg: Color::Reset,
2047            indent_guide_active_fg: Color::Reset,
2048            indent_guide_active_col: None,
2049            fold_line_bg: Style::default(),
2050            eol_hints: &[],
2051            blame_plan: None,
2052        };
2053        let term = run_render(view, 10, 3);
2054        // Row 0 (doc 0): offset from cursor row 1 → 1
2055        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
2056        // Row 1 (doc 1): cursor row → absolute 2
2057        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
2058        // Row 2 (doc 2): offset from cursor row 1 → 1
2059        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "1");
2060    }
2061
2062    #[test]
2063    fn gutter_none_paints_blank_cells() {
2064        let b = Buffer::from_str("a\nb\nc");
2065        let v = vp(10, 3);
2066        let view = BufferView {
2067            buffer: &b,
2068            viewport: &v,
2069            selection: None,
2070            resolver: &(no_styles as fn(u32) -> Style),
2071            cursor_line_bg: Style::default(),
2072            cursor_column_bg: Style::default(),
2073            selection_bg: Style::default().bg(Color::Blue),
2074            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2075            gutter: Some(Gutter {
2076                width: 4,
2077                style: Style::default().fg(Color::Yellow),
2078                line_offset: 0,
2079                numbers: GutterNumbers::None,
2080                sign_column_width: 0,
2081                fold_column_width: 0,
2082            }),
2083            search_bg: Style::default(),
2084            signs: &[],
2085            conceals: &[],
2086            spans: &[],
2087            search_pattern: None,
2088            non_text_style: Style::default(),
2089            diag_overlays: &[],
2090            colorcolumn_cols: &[],
2091            colorcolumn_style: Style::default(),
2092            listchars: None,
2093            indent_guides_enabled: false,
2094            indent_guide_char: '│',
2095            indent_guide_shiftwidth: 4,
2096            indent_guide_fg: Color::Reset,
2097            indent_guide_active_fg: Color::Reset,
2098            indent_guide_active_col: None,
2099            fold_line_bg: Style::default(),
2100            eol_hints: &[],
2101            blame_plan: None,
2102        };
2103        let term = run_render(view, 10, 3);
2104        // All gutter cells (0..4) on every row should be blank spaces.
2105        for row in 0..3u16 {
2106            for x in 0..4u16 {
2107                assert_eq!(
2108                    term.cell((x, row)).unwrap().symbol(),
2109                    " ",
2110                    "expected blank at ({x}, {row})"
2111                );
2112            }
2113        }
2114        // Text still appears shifted right past the gutter.
2115        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
2116    }
2117
2118    #[test]
2119    fn search_bg_paints_match_cells() {
2120        use regex::Regex;
2121        let b = Buffer::from_str("foo bar foo");
2122        let v = vp(20, 1);
2123        let pat = Regex::new("foo").unwrap();
2124        let view = BufferView {
2125            buffer: &b,
2126            viewport: &v,
2127            selection: None,
2128            resolver: &(no_styles as fn(u32) -> Style),
2129            cursor_line_bg: Style::default(),
2130            cursor_column_bg: Style::default(),
2131            selection_bg: Style::default().bg(Color::Blue),
2132            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2133            gutter: None,
2134            search_bg: Style::default().bg(Color::Magenta),
2135            signs: &[],
2136            conceals: &[],
2137            spans: &[],
2138            search_pattern: Some(&pat),
2139            non_text_style: Style::default(),
2140            diag_overlays: &[],
2141            colorcolumn_cols: &[],
2142            colorcolumn_style: Style::default(),
2143            listchars: None,
2144            indent_guides_enabled: false,
2145            indent_guide_char: '│',
2146            indent_guide_shiftwidth: 4,
2147            indent_guide_fg: Color::Reset,
2148            indent_guide_active_fg: Color::Reset,
2149            indent_guide_active_col: None,
2150            fold_line_bg: Style::default(),
2151            eol_hints: &[],
2152            blame_plan: None,
2153        };
2154        let term = run_render(view, 20, 1);
2155        for x in 0..3 {
2156            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2157        }
2158        // " bar " between matches stays default bg.
2159        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
2160        for x in 8..11 {
2161            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2162        }
2163    }
2164
2165    #[test]
2166    fn search_bg_survives_cursorcolumn_overlay() {
2167        use regex::Regex;
2168        // Cursor sits on a `/foo` match. The cursorcolumn pass would
2169        // otherwise overwrite the search bg with column bg — verify
2170        // the match cells keep their search colour.
2171        let mut b = Buffer::from_str("foo bar foo");
2172        let v = vp(20, 1);
2173        let pat = Regex::new("foo").unwrap();
2174        // Cursor on column 1 (inside first `foo` match).
2175        b.set_cursor(hjkl_buffer::Position::new(0, 1));
2176        let view = BufferView {
2177            buffer: &b,
2178            viewport: &v,
2179            selection: None,
2180            resolver: &(no_styles as fn(u32) -> Style),
2181            cursor_line_bg: Style::default(),
2182            cursor_column_bg: Style::default().bg(Color::DarkGray),
2183            selection_bg: Style::default().bg(Color::Blue),
2184            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2185            gutter: None,
2186            search_bg: Style::default().bg(Color::Magenta),
2187            signs: &[],
2188            conceals: &[],
2189            spans: &[],
2190            search_pattern: Some(&pat),
2191            non_text_style: Style::default(),
2192            diag_overlays: &[],
2193            colorcolumn_cols: &[],
2194            colorcolumn_style: Style::default(),
2195            listchars: None,
2196            indent_guides_enabled: false,
2197            indent_guide_char: '│',
2198            indent_guide_shiftwidth: 4,
2199            indent_guide_fg: Color::Reset,
2200            indent_guide_active_fg: Color::Reset,
2201            indent_guide_active_col: None,
2202            fold_line_bg: Style::default(),
2203            eol_hints: &[],
2204            blame_plan: None,
2205        };
2206        let term = run_render(view, 20, 1);
2207        // Cursor cell at (1, 0) is in the search match. Search wins.
2208        assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
2209    }
2210
2211    #[test]
2212    fn highest_priority_sign_wins_per_row_in_dedicated_sign_column() {
2213        // Layout: sign_column_width=1, width=3 → total gutter = 4 cells.
2214        // Sign column at x=0; number column at x=1..4; text at x=4.
2215        let b = Buffer::from_str("a\nb\nc");
2216        let v = vp(10, 3);
2217        let signs = [
2218            Sign {
2219                row: 0,
2220                ch: 'W',
2221                style: Style::default().fg(Color::Yellow),
2222                priority: 1,
2223            },
2224            Sign {
2225                row: 0,
2226                ch: 'E',
2227                style: Style::default().fg(Color::Red),
2228                priority: 2,
2229            },
2230        ];
2231        let view = BufferView {
2232            buffer: &b,
2233            viewport: &v,
2234            selection: None,
2235            resolver: &(no_styles as fn(u32) -> Style),
2236            cursor_line_bg: Style::default(),
2237            cursor_column_bg: Style::default(),
2238            selection_bg: Style::default().bg(Color::Blue),
2239            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2240            gutter: Some(Gutter {
2241                width: 3,
2242                style: Style::default().fg(Color::DarkGray),
2243                line_offset: 0,
2244                sign_column_width: 1,
2245                fold_column_width: 0,
2246                ..Default::default()
2247            }),
2248            search_bg: Style::default(),
2249            signs: &signs,
2250            conceals: &[],
2251            spans: &[],
2252            search_pattern: None,
2253            non_text_style: Style::default(),
2254            diag_overlays: &[],
2255            colorcolumn_cols: &[],
2256            colorcolumn_style: Style::default(),
2257            listchars: None,
2258            indent_guides_enabled: false,
2259            indent_guide_char: '│',
2260            indent_guide_shiftwidth: 4,
2261            indent_guide_fg: Color::Reset,
2262            indent_guide_active_fg: Color::Reset,
2263            indent_guide_active_col: None,
2264            fold_line_bg: Style::default(),
2265            eol_hints: &[],
2266            blame_plan: None,
2267        };
2268        let term = run_render(view, 10, 3);
2269        // Sign 'E' (higher priority) lands in the sign column at x=0.
2270        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
2271        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2272        // Number column at x=1 must NOT be the sign char.
2273        assert_ne!(term.cell((1, 0)).unwrap().symbol(), "E");
2274        // Row 1 has no sign — sign column cell stays blank.
2275        assert_eq!(term.cell((0, 1)).unwrap().symbol(), " ");
2276    }
2277
2278    #[test]
2279    fn conceal_replaces_byte_range() {
2280        let b = Buffer::from_str("see https://example.com end");
2281        let v = vp(30, 1);
2282        let conceals = vec![Conceal {
2283            row: 0,
2284            start_byte: 4,                             // start of "https"
2285            end_byte: 4 + "https://example.com".len(), // end of URL
2286            replacement: "🔗".to_string(),
2287        }];
2288        let view = BufferView {
2289            buffer: &b,
2290            viewport: &v,
2291            selection: None,
2292            resolver: &(no_styles as fn(u32) -> Style),
2293            cursor_line_bg: Style::default(),
2294            cursor_column_bg: Style::default(),
2295            selection_bg: Style::default(),
2296            cursor_style: Style::default(),
2297            gutter: None,
2298            search_bg: Style::default(),
2299            signs: &[],
2300            conceals: &conceals,
2301            spans: &[],
2302            search_pattern: None,
2303            non_text_style: Style::default(),
2304            diag_overlays: &[],
2305            colorcolumn_cols: &[],
2306            colorcolumn_style: Style::default(),
2307            listchars: None,
2308            indent_guides_enabled: false,
2309            indent_guide_char: '│',
2310            indent_guide_shiftwidth: 4,
2311            indent_guide_fg: Color::Reset,
2312            indent_guide_active_fg: Color::Reset,
2313            indent_guide_active_col: None,
2314            fold_line_bg: Style::default(),
2315            eol_hints: &[],
2316            blame_plan: None,
2317        };
2318        let term = run_render(view, 30, 1);
2319        // Cells 0..=3: "see "
2320        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
2321        assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
2322        // Cell 4: the link emoji (a wide char takes 2 cells; we just
2323        // assert the first cell holds the replacement char).
2324        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
2325    }
2326
2327    /// Closed fold header renders the first line's real content (no summary
2328    /// text), and the `fold_line_bg` is overlaid across that row.
2329    #[test]
2330    fn closed_fold_renders_first_line_content_with_fold_bg() {
2331        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
2332        let v = vp(30, 5);
2333        // Fold rows 1-3 closed. Visible should be: 'a', fold-header, 'e'.
2334        b.add_fold(1, 3, true);
2335        let fold_bg = Color::Rgb(0x3a, 0x4a, 0x5a);
2336        let view = BufferView {
2337            buffer: &b,
2338            viewport: &v,
2339            selection: None,
2340            resolver: &(no_styles as fn(u32) -> Style),
2341            cursor_line_bg: Style::default(),
2342            fold_line_bg: Style::default().bg(fold_bg),
2343            cursor_column_bg: Style::default(),
2344            selection_bg: Style::default().bg(Color::Blue),
2345            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2346            gutter: None,
2347            search_bg: Style::default(),
2348            signs: &[],
2349            conceals: &[],
2350            spans: &[],
2351            search_pattern: None,
2352            non_text_style: Style::default(),
2353            diag_overlays: &[],
2354            colorcolumn_cols: &[],
2355            colorcolumn_style: Style::default(),
2356            listchars: None,
2357            indent_guides_enabled: false,
2358            indent_guide_char: '│',
2359            indent_guide_shiftwidth: 4,
2360            indent_guide_fg: Color::Reset,
2361            indent_guide_active_fg: Color::Reset,
2362            indent_guide_active_col: None,
2363            eol_hints: &[],
2364            blame_plan: None,
2365        };
2366        let term = run_render(view, 30, 5);
2367        // Row 0: "a" — normal line, no fold bg.
2368        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2369        assert_ne!(
2370            term.cell((0, 0)).unwrap().bg,
2371            fold_bg,
2372            "row 0 is not a fold header and must NOT carry fold_bg"
2373        );
2374        // Row 1: closed fold header — renders doc row 1's real content ('b'),
2375        // no summary text. The fold_line_bg must be applied.
2376        assert_eq!(
2377            term.cell((0, 1)).unwrap().symbol(),
2378            "b",
2379            "fold header row must show the first line's real content, not summary text"
2380        );
2381        assert_eq!(
2382            term.cell((0, 1)).unwrap().bg,
2383            fold_bg,
2384            "fold header row must carry fold_line_bg"
2385        );
2386        // Row 2: "e" (the 5th doc row, after the collapsed range).
2387        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
2388    }
2389
2390    #[test]
2391    fn open_fold_renders_normally() {
2392        let mut b = Buffer::from_str("a\nb\nc");
2393        let v = vp(5, 3);
2394        b.add_fold(0, 2, false); // open
2395        let view = BufferView {
2396            buffer: &b,
2397            viewport: &v,
2398            selection: None,
2399            resolver: &(no_styles as fn(u32) -> Style),
2400            cursor_line_bg: Style::default(),
2401            cursor_column_bg: Style::default(),
2402            selection_bg: Style::default().bg(Color::Blue),
2403            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2404            gutter: None,
2405            search_bg: Style::default(),
2406            signs: &[],
2407            conceals: &[],
2408            spans: &[],
2409            search_pattern: None,
2410            non_text_style: Style::default(),
2411            diag_overlays: &[],
2412            colorcolumn_cols: &[],
2413            colorcolumn_style: Style::default(),
2414            listchars: None,
2415            indent_guides_enabled: false,
2416            indent_guide_char: '│',
2417            indent_guide_shiftwidth: 4,
2418            indent_guide_fg: Color::Reset,
2419            indent_guide_active_fg: Color::Reset,
2420            indent_guide_active_col: None,
2421            fold_line_bg: Style::default(),
2422            eol_hints: &[],
2423            blame_plan: None,
2424        };
2425        let term = run_render(view, 5, 3);
2426        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2427        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
2428        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
2429    }
2430
2431    #[test]
2432    fn horizontal_scroll_clips_left_chars() {
2433        let b = Buffer::from_str("abcdefgh");
2434        let mut v = vp(4, 1);
2435        v.top_col = 3;
2436        let view = BufferView {
2437            buffer: &b,
2438            viewport: &v,
2439            selection: None,
2440            resolver: &(no_styles as fn(u32) -> Style),
2441            cursor_line_bg: Style::default(),
2442            cursor_column_bg: Style::default(),
2443            selection_bg: Style::default().bg(Color::Blue),
2444            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2445            gutter: None,
2446            search_bg: Style::default(),
2447            signs: &[],
2448            conceals: &[],
2449            spans: &[],
2450            search_pattern: None,
2451            non_text_style: Style::default(),
2452            diag_overlays: &[],
2453            colorcolumn_cols: &[],
2454            colorcolumn_style: Style::default(),
2455            listchars: None,
2456            indent_guides_enabled: false,
2457            indent_guide_char: '│',
2458            indent_guide_shiftwidth: 4,
2459            indent_guide_fg: Color::Reset,
2460            indent_guide_active_fg: Color::Reset,
2461            indent_guide_active_col: None,
2462            fold_line_bg: Style::default(),
2463            eol_hints: &[],
2464            blame_plan: None,
2465        };
2466        let term = run_render(view, 4, 1);
2467        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
2468        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
2469    }
2470
2471    fn make_wrap_view<'a>(
2472        b: &'a Buffer,
2473        viewport: &'a Viewport,
2474        resolver: &'a (impl StyleResolver + 'a),
2475        gutter: Option<Gutter>,
2476    ) -> BufferView<'a, impl StyleResolver + 'a> {
2477        BufferView {
2478            buffer: b,
2479            viewport,
2480            selection: None,
2481            resolver,
2482            cursor_line_bg: Style::default(),
2483            fold_line_bg: Style::default(),
2484            cursor_column_bg: Style::default(),
2485            selection_bg: Style::default().bg(Color::Blue),
2486            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2487            gutter,
2488            search_bg: Style::default(),
2489            signs: &[],
2490            conceals: &[],
2491            spans: &[],
2492            search_pattern: None,
2493            non_text_style: Style::default(),
2494            diag_overlays: &[],
2495            colorcolumn_cols: &[],
2496            colorcolumn_style: Style::default(),
2497            listchars: None,
2498            indent_guides_enabled: false,
2499            indent_guide_char: '│',
2500            indent_guide_shiftwidth: 4,
2501            indent_guide_fg: Color::Reset,
2502            indent_guide_active_fg: Color::Reset,
2503            indent_guide_active_col: None,
2504            eol_hints: &[],
2505            blame_plan: None,
2506        }
2507    }
2508
2509    #[test]
2510    fn wrap_segments_char_breaks_at_width() {
2511        let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
2512        assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
2513    }
2514
2515    #[test]
2516    fn wrap_segments_word_backs_up_to_whitespace() {
2517        let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
2518        // First segment "alpha " ends after the space at idx 5.
2519        assert_eq!(segs[0], (0, 6));
2520        // Second segment "beta " ends after the space at idx 10.
2521        assert_eq!(segs[1], (6, 11));
2522        assert_eq!(segs[2], (11, 16));
2523    }
2524
2525    #[test]
2526    fn wrap_segments_word_falls_back_to_char_for_long_runs() {
2527        let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
2528        // No whitespace anywhere — degrades to a hard char break.
2529        assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
2530    }
2531
2532    #[test]
2533    fn wrap_char_paints_continuation_rows() {
2534        let b = Buffer::from_str("abcdefghij");
2535        let v = Viewport {
2536            top_row: 0,
2537            top_col: 0,
2538            width: 4,
2539            height: 3,
2540            wrap: Wrap::Char,
2541            text_width: 4,
2542            tab_width: 0,
2543        };
2544        let r = no_styles as fn(u32) -> Style;
2545        let view = make_wrap_view(&b, &v, &r, None);
2546        let term = run_render(view, 4, 3);
2547        // Row 0: "abcd"
2548        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2549        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
2550        // Row 1: "efgh"
2551        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
2552        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
2553        // Row 2: "ij"
2554        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
2555        assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
2556    }
2557
2558    #[test]
2559    fn wrap_char_gutter_blank_on_continuation() {
2560        let b = Buffer::from_str("abcdefgh");
2561        let v = Viewport {
2562            top_row: 0,
2563            top_col: 0,
2564            width: 6,
2565            height: 3,
2566            wrap: Wrap::Char,
2567            // Text area = 6 - 3 (gutter width) = 3.
2568            text_width: 3,
2569            tab_width: 0,
2570        };
2571        let r = no_styles as fn(u32) -> Style;
2572        let gutter = Gutter {
2573            width: 3,
2574            style: Style::default().fg(Color::Yellow),
2575            line_offset: 0,
2576            ..Default::default()
2577        };
2578        let view = make_wrap_view(&b, &v, &r, Some(gutter));
2579        let term = run_render(view, 6, 3);
2580        // Row 0: "  1" + "abc"
2581        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
2582        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
2583        // Row 1: blank gutter + "def"
2584        for x in 0..2 {
2585            assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
2586        }
2587        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
2588        assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
2589    }
2590
2591    #[test]
2592    fn wrap_char_cursor_lands_on_correct_segment() {
2593        let mut b = Buffer::from_str("abcdefghij");
2594        let v = Viewport {
2595            top_row: 0,
2596            top_col: 0,
2597            width: 4,
2598            height: 3,
2599            wrap: Wrap::Char,
2600            text_width: 4,
2601            tab_width: 0,
2602        };
2603        // Cursor on 'g' (col 6) should land on row 1, col 2.
2604        b.set_cursor(hjkl_buffer::Position::new(0, 6));
2605        let r = no_styles as fn(u32) -> Style;
2606        let mut view = make_wrap_view(&b, &v, &r, None);
2607        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
2608        let term = run_render(view, 4, 3);
2609        assert!(
2610            term.cell((2, 1))
2611                .unwrap()
2612                .modifier
2613                .contains(Modifier::REVERSED)
2614        );
2615    }
2616
2617    #[test]
2618    fn wrap_char_eol_cursor_placeholder_on_last_segment() {
2619        let mut b = Buffer::from_str("abcdef");
2620        let v = Viewport {
2621            top_row: 0,
2622            top_col: 0,
2623            width: 4,
2624            height: 3,
2625            wrap: Wrap::Char,
2626            text_width: 4,
2627            tab_width: 0,
2628        };
2629        // Past-end cursor at col 6.
2630        b.set_cursor(hjkl_buffer::Position::new(0, 6));
2631        let r = no_styles as fn(u32) -> Style;
2632        let mut view = make_wrap_view(&b, &v, &r, None);
2633        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
2634        let term = run_render(view, 4, 3);
2635        // Last segment is row 1 ("ef"), placeholder at x = 6 - 4 = 2.
2636        assert!(
2637            term.cell((2, 1))
2638                .unwrap()
2639                .modifier
2640                .contains(Modifier::REVERSED)
2641        );
2642    }
2643
2644    #[test]
2645    fn wrap_word_breaks_at_whitespace() {
2646        let b = Buffer::from_str("alpha beta gamma");
2647        let v = Viewport {
2648            top_row: 0,
2649            top_col: 0,
2650            width: 8,
2651            height: 3,
2652            wrap: Wrap::Word,
2653            text_width: 8,
2654            tab_width: 0,
2655        };
2656        let r = no_styles as fn(u32) -> Style;
2657        let view = make_wrap_view(&b, &v, &r, None);
2658        let term = run_render(view, 8, 3);
2659        // Row 0: "alpha "
2660        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
2661        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
2662        // Row 1: "beta "
2663        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
2664        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
2665        // Row 2: "gamma"
2666        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
2667        assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
2668    }
2669
2670    // 0.0.37 — `BufferView` lost `Buffer::spans` / `Buffer::search_pattern`
2671    // and now takes them as parameters. The tests below cover the new
2672    // shape: empty/missing parameters, multi-row spans, regex hlsearch,
2673    // and the interaction with cursor / selection / wrap.
2674
2675    fn view_with<'a>(
2676        b: &'a Buffer,
2677        viewport: &'a Viewport,
2678        resolver: &'a (impl StyleResolver + 'a),
2679        spans: &'a [Vec<Span>],
2680        search_pattern: Option<&'a regex::Regex>,
2681    ) -> BufferView<'a, impl StyleResolver + 'a> {
2682        BufferView {
2683            buffer: b,
2684            viewport,
2685            selection: None,
2686            resolver,
2687            cursor_line_bg: Style::default(),
2688            fold_line_bg: Style::default(),
2689            cursor_column_bg: Style::default(),
2690            selection_bg: Style::default().bg(Color::Blue),
2691            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2692            gutter: None,
2693            search_bg: Style::default().bg(Color::Magenta),
2694            signs: &[],
2695            conceals: &[],
2696            spans,
2697            search_pattern,
2698            non_text_style: Style::default(),
2699            diag_overlays: &[],
2700            colorcolumn_cols: &[],
2701            colorcolumn_style: Style::default(),
2702            listchars: None,
2703            indent_guides_enabled: false,
2704            indent_guide_char: '│',
2705            indent_guide_shiftwidth: 4,
2706            indent_guide_fg: Color::Reset,
2707            indent_guide_active_fg: Color::Reset,
2708            indent_guide_active_col: None,
2709            eol_hints: &[],
2710            blame_plan: None,
2711        }
2712    }
2713
2714    #[test]
2715    fn empty_spans_param_renders_default_style() {
2716        let b = Buffer::from_str("hello");
2717        let v = vp(10, 1);
2718        let r = no_styles as fn(u32) -> Style;
2719        let view = view_with(&b, &v, &r, &[], None);
2720        let term = run_render(view, 10, 1);
2721        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
2722        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
2723    }
2724
2725    #[test]
2726    fn spans_param_paints_styled_byte_range() {
2727        let b = Buffer::from_str("abcdef");
2728        let v = vp(10, 1);
2729        let resolver = |id: u32| -> Style {
2730            if id == 3 {
2731                Style::default().fg(Color::Green)
2732            } else {
2733                Style::default()
2734            }
2735        };
2736        let spans = vec![vec![Span::new(0, 3, 3)]];
2737        let view = view_with(&b, &v, &resolver, &spans, None);
2738        let term = run_render(view, 10, 1);
2739        for x in 0..3 {
2740            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
2741        }
2742        assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
2743    }
2744
2745    #[test]
2746    fn spans_param_handles_per_row_overlay() {
2747        let b = Buffer::from_str("abc\ndef");
2748        let v = vp(10, 2);
2749        let resolver = |id: u32| -> Style {
2750            if id == 1 {
2751                Style::default().fg(Color::Red)
2752            } else {
2753                Style::default().fg(Color::Green)
2754            }
2755        };
2756        let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
2757        let view = view_with(&b, &v, &resolver, &spans, None);
2758        let term = run_render(view, 10, 2);
2759        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2760        assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
2761    }
2762
2763    #[test]
2764    fn spans_param_rows_beyond_get_no_styling() {
2765        let b = Buffer::from_str("abc\ndef\nghi");
2766        let v = vp(10, 3);
2767        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
2768        // Only row 0 carries spans; rows 1 and 2 inherit default.
2769        let spans = vec![vec![Span::new(0, 3, 0)]];
2770        let view = view_with(&b, &v, &resolver, &spans, None);
2771        let term = run_render(view, 10, 3);
2772        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2773        assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
2774        assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
2775    }
2776
2777    #[test]
2778    fn search_pattern_none_disables_hlsearch() {
2779        let b = Buffer::from_str("foo bar foo");
2780        let v = vp(20, 1);
2781        let r = no_styles as fn(u32) -> Style;
2782        // No regex → no Magenta bg anywhere even though `search_bg` is set.
2783        let view = view_with(&b, &v, &r, &[], None);
2784        let term = run_render(view, 20, 1);
2785        for x in 0..11 {
2786            assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2787        }
2788    }
2789
2790    #[test]
2791    fn search_pattern_regex_paints_match_bg() {
2792        use regex::Regex;
2793        let b = Buffer::from_str("xyz foo xyz");
2794        let v = vp(20, 1);
2795        let r = no_styles as fn(u32) -> Style;
2796        let pat = Regex::new("foo").unwrap();
2797        let view = view_with(&b, &v, &r, &[], Some(&pat));
2798        let term = run_render(view, 20, 1);
2799        // "foo" is at chars 4..7; bg is Magenta there only.
2800        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
2801        for x in 4..7 {
2802            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2803        }
2804        assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
2805    }
2806
2807    #[test]
2808    fn search_pattern_unicode_columns_are_charwise() {
2809        use regex::Regex;
2810        // "tablé foo" — match "foo" must land on char column 6, not byte.
2811        let b = Buffer::from_str("tablé foo");
2812        let v = vp(20, 1);
2813        let r = no_styles as fn(u32) -> Style;
2814        let pat = Regex::new("foo").unwrap();
2815        let view = view_with(&b, &v, &r, &[], Some(&pat));
2816        let term = run_render(view, 20, 1);
2817        // "tablé" is 5 chars + space = 6, then "foo" at 6..9.
2818        assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
2819        assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
2820        assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
2821    }
2822
2823    #[test]
2824    fn spans_param_clamps_short_row_overlay() {
2825        // Row 0 has 3 chars; span past end shouldn't crash or smear.
2826        let b = Buffer::from_str("abc");
2827        let v = vp(10, 1);
2828        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
2829        let spans = vec![vec![Span::new(0, 100, 0)]];
2830        let view = view_with(&b, &v, &resolver, &spans, None);
2831        let term = run_render(view, 10, 1);
2832        for x in 0..3 {
2833            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
2834        }
2835    }
2836
2837    #[test]
2838    fn spans_and_search_pattern_compose() {
2839        // hlsearch bg layers on top of the syntax span fg.
2840        use regex::Regex;
2841        let b = Buffer::from_str("foo");
2842        let v = vp(10, 1);
2843        let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
2844        let spans = vec![vec![Span::new(0, 3, 0)]];
2845        let pat = Regex::new("foo").unwrap();
2846        let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
2847        let term = run_render(view, 10, 1);
2848        let cell = term.cell((1, 0)).unwrap();
2849        assert_eq!(cell.fg, Color::Green);
2850        assert_eq!(cell.bg, Color::Magenta);
2851    }
2852
2853    /// Rows past the last buffer line paint `~` at the first text column
2854    /// (vim's NonText marker). The `non_text_style` fg is applied to those
2855    /// cells; all other cells on those rows stay default.
2856    #[test]
2857    fn tilde_marker_painted_past_eof() {
2858        // 5-line buffer rendered in a 10-row viewport.
2859        let b = Buffer::from_str("a\nb\nc\nd\ne");
2860        let v = vp(10, 10);
2861        let r = no_styles as fn(u32) -> Style;
2862        let non_text_fg = Color::DarkGray;
2863        let view = BufferView {
2864            buffer: &b,
2865            viewport: &v,
2866            selection: None,
2867            resolver: &r,
2868            cursor_line_bg: Style::default(),
2869            cursor_column_bg: Style::default(),
2870            selection_bg: Style::default().bg(Color::Blue),
2871            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2872            gutter: None,
2873            search_bg: Style::default(),
2874            signs: &[],
2875            conceals: &[],
2876            spans: &[],
2877            search_pattern: None,
2878            non_text_style: Style::default().fg(non_text_fg),
2879            diag_overlays: &[],
2880            colorcolumn_cols: &[],
2881            colorcolumn_style: Style::default(),
2882            listchars: None,
2883            indent_guides_enabled: false,
2884            indent_guide_char: '│',
2885            indent_guide_shiftwidth: 4,
2886            indent_guide_fg: Color::Reset,
2887            indent_guide_active_fg: Color::Reset,
2888            indent_guide_active_col: None,
2889            fold_line_bg: Style::default(),
2890            eol_hints: &[],
2891            blame_plan: None,
2892        };
2893        let term = run_render(view, 10, 10);
2894        // Rows 0-4 have content — first cell should NOT be `~`.
2895        for row in 0..5u16 {
2896            assert_ne!(
2897                term.cell((0, row)).unwrap().symbol(),
2898                "~",
2899                "row {row} is a content row, expected no tilde"
2900            );
2901        }
2902        // Rows 5-9 are past EOF — should have `~` at column 0 with non_text fg.
2903        for row in 5..10u16 {
2904            let cell = term.cell((0, row)).unwrap();
2905            assert_eq!(cell.symbol(), "~", "row {row} is past EOF, expected tilde");
2906            assert_eq!(
2907                cell.fg, non_text_fg,
2908                "row {row} tilde should use non_text_style fg"
2909            );
2910            // Rest of the row should be blank.
2911            for x in 1..10u16 {
2912                assert_eq!(
2913                    term.cell((x, row)).unwrap().symbol(),
2914                    " ",
2915                    "row {row} col {x} after tilde should be blank"
2916                );
2917            }
2918        }
2919    }
2920
2921    /// When a gutter is present, rows past EOF paint a blank gutter and
2922    /// `~` at the first text column (after the gutter).
2923    #[test]
2924    fn tilde_marker_with_gutter_past_eof() {
2925        let b = Buffer::from_str("a\nb");
2926        let v = vp(10, 5);
2927        let r = no_styles as fn(u32) -> Style;
2928        let non_text_fg = Color::DarkGray;
2929        let view = BufferView {
2930            buffer: &b,
2931            viewport: &v,
2932            selection: None,
2933            resolver: &r,
2934            cursor_line_bg: Style::default(),
2935            cursor_column_bg: Style::default(),
2936            selection_bg: Style::default().bg(Color::Blue),
2937            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2938            gutter: Some(Gutter {
2939                width: 4,
2940                style: Style::default().fg(Color::Yellow),
2941                line_offset: 0,
2942                numbers: GutterNumbers::Absolute,
2943                sign_column_width: 0,
2944                fold_column_width: 0,
2945            }),
2946            search_bg: Style::default(),
2947            signs: &[],
2948            conceals: &[],
2949            spans: &[],
2950            search_pattern: None,
2951            non_text_style: Style::default().fg(non_text_fg),
2952            diag_overlays: &[],
2953            colorcolumn_cols: &[],
2954            colorcolumn_style: Style::default(),
2955            listchars: None,
2956            indent_guides_enabled: false,
2957            indent_guide_char: '│',
2958            indent_guide_shiftwidth: 4,
2959            indent_guide_fg: Color::Reset,
2960            indent_guide_active_fg: Color::Reset,
2961            indent_guide_active_col: None,
2962            fold_line_bg: Style::default(),
2963            eol_hints: &[],
2964            blame_plan: None,
2965        };
2966        let term = run_render(view, 10, 5);
2967        // Rows 2-4 are past EOF.
2968        for row in 2..5u16 {
2969            // Gutter (cols 0-3) should be blank.
2970            for x in 0..4u16 {
2971                assert_eq!(
2972                    term.cell((x, row)).unwrap().symbol(),
2973                    " ",
2974                    "gutter col {x} on past-EOF row {row} should be blank"
2975                );
2976            }
2977            // Text area starts at col 4: should have `~`.
2978            let cell = term.cell((4, row)).unwrap();
2979            assert_eq!(
2980                cell.symbol(),
2981                "~",
2982                "past-EOF row {row}: expected tilde at text column"
2983            );
2984            assert_eq!(cell.fg, non_text_fg);
2985        }
2986    }
2987
2988    #[test]
2989    fn diag_overlay_paints_underline_on_range() {
2990        // Render "hello world" and apply a DiagOverlay from col 6 to 11.
2991        // The cells in that range must carry the UNDERLINED modifier; cells
2992        // outside must not.
2993        let b = Buffer::from_str("hello world");
2994        let v = vp(20, 2);
2995        let overlay = DiagOverlay {
2996            row: 0,
2997            col_start: 6,
2998            col_end: 11,
2999            style: Style::default().add_modifier(Modifier::UNDERLINED),
3000        };
3001        let view = BufferView {
3002            buffer: &b,
3003            viewport: &v,
3004            selection: None,
3005            resolver: &(no_styles as fn(u32) -> Style),
3006            cursor_line_bg: Style::default(),
3007            cursor_column_bg: Style::default(),
3008            selection_bg: Style::default().bg(Color::Blue),
3009            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3010            gutter: None,
3011            search_bg: Style::default(),
3012            signs: &[],
3013            conceals: &[],
3014            spans: &[],
3015            search_pattern: None,
3016            non_text_style: Style::default(),
3017            diag_overlays: &[overlay],
3018            colorcolumn_cols: &[],
3019            colorcolumn_style: Style::default(),
3020            listchars: None,
3021            indent_guides_enabled: false,
3022            indent_guide_char: '│',
3023            indent_guide_shiftwidth: 4,
3024            indent_guide_fg: Color::Reset,
3025            indent_guide_active_fg: Color::Reset,
3026            indent_guide_active_col: None,
3027            fold_line_bg: Style::default(),
3028            eol_hints: &[],
3029            blame_plan: None,
3030        };
3031        let term = run_render(view, 20, 2);
3032
3033        // Cols 0-5 ("hello ") must NOT be underlined.
3034        for x in 0u16..6 {
3035            let cell = term.cell((x, 0)).unwrap();
3036            assert!(
3037                !cell.modifier.contains(Modifier::UNDERLINED),
3038                "col {x} must not be underlined (outside overlay)"
3039            );
3040        }
3041        // Cols 6-10 ("world") must be underlined.
3042        for x in 6u16..11 {
3043            let cell = term.cell((x, 0)).unwrap();
3044            assert!(
3045                cell.modifier.contains(Modifier::UNDERLINED),
3046                "col {x} must be underlined (inside overlay)"
3047            );
3048        }
3049        // Col 11 (past end, space) must NOT be underlined.
3050        let cell = term.cell((11, 0)).unwrap();
3051        assert!(
3052            !cell.modifier.contains(Modifier::UNDERLINED),
3053            "col 11 must not be underlined (past overlay end)"
3054        );
3055    }
3056
3057    #[test]
3058    fn diag_overlay_out_of_viewport_is_ignored() {
3059        // Overlay on row 5, viewport height = 3 → must not panic or paint.
3060        let b = Buffer::from_str("a\nb\nc");
3061        let v = vp(10, 3);
3062        let overlay = DiagOverlay {
3063            row: 5,
3064            col_start: 0,
3065            col_end: 1,
3066            style: Style::default().add_modifier(Modifier::UNDERLINED),
3067        };
3068        let view = BufferView {
3069            buffer: &b,
3070            viewport: &v,
3071            selection: None,
3072            resolver: &(no_styles as fn(u32) -> Style),
3073            cursor_line_bg: Style::default(),
3074            cursor_column_bg: Style::default(),
3075            selection_bg: Style::default().bg(Color::Blue),
3076            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3077            gutter: None,
3078            search_bg: Style::default(),
3079            signs: &[],
3080            conceals: &[],
3081            spans: &[],
3082            search_pattern: None,
3083            non_text_style: Style::default(),
3084            diag_overlays: &[overlay],
3085            colorcolumn_cols: &[],
3086            colorcolumn_style: Style::default(),
3087            listchars: None,
3088            indent_guides_enabled: false,
3089            indent_guide_char: '│',
3090            indent_guide_shiftwidth: 4,
3091            indent_guide_fg: Color::Reset,
3092            indent_guide_active_fg: Color::Reset,
3093            indent_guide_active_col: None,
3094            fold_line_bg: Style::default(),
3095            eol_hints: &[],
3096            blame_plan: None,
3097        };
3098        // Must not panic.
3099        let _term = run_render(view, 10, 3);
3100    }
3101
3102    // ── T5: dedicated sign-column tests ──────────────────────────────────────
3103
3104    /// A sign on row 0 must render in the sign column (x=0) and NOT overwrite
3105    /// any digit of the line-number column. With 5-digit line count (max 13109
3106    /// lines), gutter.width=6 (5 digits + 1 spacer), sign_column_width=1:
3107    ///   x=0          → sign char (e.g. '~')
3108    ///   x=1..5       → digits "13109" right-aligned in 5 cells
3109    ///   x=6          → spacer ' '
3110    ///   x=7..        → text
3111    #[test]
3112    fn paint_signs_in_dedicated_column_does_not_overwrite_line_number() {
3113        // Build a buffer with enough lines that the max line number is 5 digits.
3114        // We don't need all 13109 lines — just enough rows to get a 5-digit
3115        // line_offset. We'll use line_offset to fake the large document.
3116        let b = Buffer::from_str("a\nb");
3117        // num_w = 6 (5 digits + 1 spacer), sign_w = 1, total = 7
3118        let v = vp(20, 2);
3119        let sign = Sign {
3120            row: 0,
3121            ch: '~',
3122            style: Style::default().fg(Color::Red),
3123            priority: 10,
3124        };
3125        let view = BufferView {
3126            buffer: &b,
3127            viewport: &v,
3128            selection: None,
3129            resolver: &(no_styles as fn(u32) -> Style),
3130            cursor_line_bg: Style::default(),
3131            cursor_column_bg: Style::default(),
3132            selection_bg: Style::default().bg(Color::Blue),
3133            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3134            gutter: Some(Gutter {
3135                width: 6, // 5 digit cells + 1 spacer
3136                style: Style::default(),
3137                line_offset: 13108, // row 0 displays as 13109
3138                sign_column_width: 1,
3139                fold_column_width: 0,
3140                ..Default::default()
3141            }),
3142            search_bg: Style::default(),
3143            signs: &[sign],
3144            conceals: &[],
3145            spans: &[],
3146            search_pattern: None,
3147            non_text_style: Style::default(),
3148            diag_overlays: &[],
3149            colorcolumn_cols: &[],
3150            colorcolumn_style: Style::default(),
3151            listchars: None,
3152            indent_guides_enabled: false,
3153            indent_guide_char: '│',
3154            indent_guide_shiftwidth: 4,
3155            indent_guide_fg: Color::Reset,
3156            indent_guide_active_fg: Color::Reset,
3157            indent_guide_active_col: None,
3158            fold_line_bg: Style::default(),
3159            eol_hints: &[],
3160            blame_plan: None,
3161        };
3162        let term = run_render(view, 20, 2);
3163        // Sign column (x=0) must contain the sign char '~'.
3164        assert_eq!(
3165            term.cell((0, 0)).unwrap().symbol(),
3166            "~",
3167            "sign column (x=0) must hold the sign char"
3168        );
3169        // Number column digits: right-aligned "13109" in 5 cells at x=1..5.
3170        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1", "x=1 must be '1'");
3171        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "3", "x=2 must be '3'");
3172        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "1", "x=3 must be '1'");
3173        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "0", "x=4 must be '0'");
3174        assert_eq!(term.cell((5, 0)).unwrap().symbol(), "9", "x=5 must be '9'");
3175        // Spacer at x=6.
3176        assert_eq!(
3177            term.cell((6, 0)).unwrap().symbol(),
3178            " ",
3179            "x=6 must be spacer"
3180        );
3181        // Text 'a' at x=7.
3182        assert_eq!(
3183            term.cell((7, 0)).unwrap().symbol(),
3184            "a",
3185            "text must start at x=sign_w+num_w=7"
3186        );
3187    }
3188
3189    /// When sign_column_width=0 (no sign column), signs Vec is ignored and the
3190    /// layout collapses to [ number_padded | spacer | text ] as before.
3191    #[test]
3192    fn paint_signs_zero_sign_column_width_layout_collapses() {
3193        let b = Buffer::from_str("abc");
3194        let v = vp(10, 1);
3195        let sign = Sign {
3196            row: 0,
3197            ch: 'E',
3198            style: Style::default().fg(Color::Red),
3199            priority: 10,
3200        };
3201        let view = BufferView {
3202            buffer: &b,
3203            viewport: &v,
3204            selection: None,
3205            resolver: &(no_styles as fn(u32) -> Style),
3206            cursor_line_bg: Style::default(),
3207            cursor_column_bg: Style::default(),
3208            selection_bg: Style::default().bg(Color::Blue),
3209            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
3210            // gutter width=3, sign_column_width=0 → text at x=3
3211            gutter: Some(Gutter {
3212                width: 3,
3213                style: Style::default(),
3214                line_offset: 0,
3215                sign_column_width: 0,
3216                fold_column_width: 0,
3217                ..Default::default()
3218            }),
3219            search_bg: Style::default(),
3220            signs: &[sign],
3221            conceals: &[],
3222            spans: &[],
3223            search_pattern: None,
3224            non_text_style: Style::default(),
3225            diag_overlays: &[],
3226            colorcolumn_cols: &[],
3227            colorcolumn_style: Style::default(),
3228            listchars: None,
3229            indent_guides_enabled: false,
3230            indent_guide_char: '│',
3231            indent_guide_shiftwidth: 4,
3232            indent_guide_fg: Color::Reset,
3233            indent_guide_active_fg: Color::Reset,
3234            indent_guide_active_col: None,
3235            fold_line_bg: Style::default(),
3236            eol_hints: &[],
3237            blame_plan: None,
3238        };
3239        let term = run_render(view, 10, 1);
3240        // No sign column: x=0 must be a number digit or space, NOT 'E'.
3241        assert_ne!(
3242            term.cell((0, 0)).unwrap().symbol(),
3243            "E",
3244            "with sign_column_width=0, sign char must not appear in the gutter"
3245        );
3246        // Text starts at x=3 (gutter.width).
3247        assert_eq!(
3248            term.cell((3, 0)).unwrap().symbol(),
3249            "a",
3250            "text must start at x=gutter.width when sign_column_width=0"
3251        );
3252    }
3253
3254    // ── Indent guide tests ──────────────────────────────────────────────────
3255
3256    /// Helper to build a BufferView with indent guides configured.
3257    fn indent_guide_view<'a>(
3258        b: &'a Buffer,
3259        viewport: &'a Viewport,
3260        shiftwidth: usize,
3261        guide_char: char,
3262        guide_fg: Color,
3263        active_fg: Color,
3264        active_col: Option<usize>,
3265    ) -> BufferView<'a, impl StyleResolver + 'a> {
3266        BufferView {
3267            buffer: b,
3268            viewport,
3269            selection: None,
3270            resolver: &(no_styles as fn(u32) -> Style),
3271            cursor_line_bg: Style::default(),
3272            fold_line_bg: Style::default(),
3273            cursor_column_bg: Style::default(),
3274            selection_bg: Style::default(),
3275            cursor_style: Style::default(),
3276            gutter: None,
3277            search_bg: Style::default(),
3278            signs: &[],
3279            conceals: &[],
3280            spans: &[],
3281            search_pattern: None,
3282            non_text_style: Style::default(),
3283            diag_overlays: &[],
3284            colorcolumn_cols: &[],
3285            colorcolumn_style: Style::default(),
3286            listchars: None,
3287            indent_guides_enabled: true,
3288            indent_guide_char: guide_char,
3289            indent_guide_shiftwidth: shiftwidth,
3290            indent_guide_fg: guide_fg,
3291            indent_guide_active_fg: active_fg,
3292            indent_guide_active_col: active_col,
3293            eol_hints: &[],
3294            blame_plan: None,
3295        }
3296    }
3297
3298    #[test]
3299    fn indent_guides_disabled_paints_nothing() {
3300        // Even with indented content, flag=false → no guide chars.
3301        let b = Buffer::from_str("    foo\n        bar");
3302        let v = vp(20, 2);
3303        let view = BufferView {
3304            buffer: &b,
3305            viewport: &v,
3306            selection: None,
3307            resolver: &(no_styles as fn(u32) -> Style),
3308            cursor_line_bg: Style::default(),
3309            cursor_column_bg: Style::default(),
3310            selection_bg: Style::default(),
3311            cursor_style: Style::default(),
3312            gutter: None,
3313            search_bg: Style::default(),
3314            signs: &[],
3315            conceals: &[],
3316            spans: &[],
3317            search_pattern: None,
3318            non_text_style: Style::default(),
3319            diag_overlays: &[],
3320            colorcolumn_cols: &[],
3321            colorcolumn_style: Style::default(),
3322            listchars: None,
3323            indent_guides_enabled: false,
3324            indent_guide_char: '│',
3325            indent_guide_shiftwidth: 4,
3326            indent_guide_fg: Color::DarkGray,
3327            indent_guide_active_fg: Color::Gray,
3328            indent_guide_active_col: None,
3329            fold_line_bg: Style::default(),
3330            eol_hints: &[],
3331            blame_plan: None,
3332        };
3333        let term = run_render(view, 20, 2);
3334        // No cell should contain '│' anywhere.
3335        for y in 0..2u16 {
3336            for x in 0..20u16 {
3337                assert_ne!(
3338                    term.cell((x, y)).unwrap().symbol(),
3339                    "│",
3340                    "no guide expected at ({x}, {y}) when disabled"
3341                );
3342            }
3343        }
3344    }
3345
3346    #[test]
3347    fn indent_guides_basic_two_levels() {
3348        // fn() {\n    if foo {\n        bar();\n    }
3349        // shiftwidth=4: guides at col 4 on rows 1+2+3, col 8 on row 2.
3350        let b = Buffer::from_str("fn() {\n    if foo {\n        bar();\n    }");
3351        let v = vp(20, 4);
3352        // Active col: None (just test inactive guides).
3353        let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, None);
3354        let term = run_render(view, 20, 4);
3355        // Row 0 "fn() {" — no indent, no guides.
3356        assert_ne!(term.cell((4, 0)).unwrap().symbol(), "│");
3357        // Row 1 "    if foo {" — leading 4 spaces → guide at col 4 NOT painted
3358        // (col 4 is first non-space). Actually guide is only at col 4 when
3359        // leading_vcols > 4. Here leading_vcols = 4, and guide_col = 4 which is
3360        // NOT < 4, so no guide on row 1... wait.
3361        // Per spec: paint at sw, 2*sw, 3*sw, ... while col < leading_vcols.
3362        // leading_vcols = 4 → guide_col = 4 → 4 < 4 is false → no guide on row 1.
3363        // Actually row 1 has 4 leading spaces. sw=4. First guide col = 4.
3364        // 4 < 4 = false → no guide at col 4 on row 1.
3365        // Row 2 "        bar();" — 8 leading spaces → guides at 4 and 8? 4 < 8 yes, 8 < 8 no.
3366        // So guide at col 4 only on row 2.
3367        // Row 3 "    }" — 4 spaces → same as row 1: no guide (4 < 4 = false).
3368        //
3369        // This test verifies the actual behavior: guide at col 4 on row 2 only.
3370        assert_ne!(
3371            term.cell((4, 1)).unwrap().symbol(),
3372            "│",
3373            "row1: no guide (leading_vcols=4, sw=4, 4<4=false)"
3374        );
3375        assert_eq!(
3376            term.cell((4, 2)).unwrap().symbol(),
3377            "│",
3378            "row2: guide at col 4 (leading_vcols=8)"
3379        );
3380        assert_ne!(
3381            term.cell((8, 2)).unwrap().symbol(),
3382            "│",
3383            "row2: no guide at col 8 (8<8=false)"
3384        );
3385        assert_ne!(
3386            term.cell((4, 3)).unwrap().symbol(),
3387            "│",
3388            "row3: no guide (leading_vcols=4, 4<4=false)"
3389        );
3390    }
3391
3392    #[test]
3393    fn indent_guides_skip_when_no_indent() {
3394        let b = Buffer::from_str("no_indent\nstill_none");
3395        let v = vp(20, 2);
3396        let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, None);
3397        let term = run_render(view, 20, 2);
3398        for y in 0..2u16 {
3399            for x in 0..20u16 {
3400                assert_ne!(
3401                    term.cell((x, y)).unwrap().symbol(),
3402                    "│",
3403                    "no guide expected on non-indented rows"
3404                );
3405            }
3406        }
3407    }
3408
3409    #[test]
3410    fn indent_guides_respects_tabs() {
3411        // shiftwidth=4 tabstop=4 line "\t\tfoo": visual cols 0..3=tab, 4..7=tab, 8='f'.
3412        // leading_vcols = 8 (two tabs each expanding to 4 cells).
3413        // Guide at sw=4 only (4 < 8 = true; 8 < 8 = false).
3414        let b = Buffer::from_str("\t\tfoo");
3415        let mut v = vp(20, 1);
3416        v.tab_width = 4;
3417        let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, None);
3418        let term = run_render(view, 20, 1);
3419        // Col 4 should have '│' (tab expansion: first tab = cells 0-3, second tab = cells 4-7).
3420        // Cell at screen x=4 is inside the second tab expansion → space → guide painted.
3421        assert_eq!(
3422            term.cell((4, 0)).unwrap().symbol(),
3423            "│",
3424            "guide at visual col 4 inside second tab"
3425        );
3426        // Col 8 is 'f' — not a space, no guide (and 8 < 8 is false anyway).
3427        assert_eq!(term.cell((8, 0)).unwrap().symbol(), "f");
3428    }
3429
3430    #[test]
3431    fn indent_guides_active_col_uses_active_fg() {
3432        // 8 leading spaces, shiftwidth=4 → guide at col 4.
3433        // active_col = 4 → that guide gets active_fg (Gray), not inactive (DarkGray).
3434        let b = Buffer::from_str("        code");
3435        let v = vp(20, 1);
3436        let active_col = Some(4usize);
3437        let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, active_col);
3438        let term = run_render(view, 20, 1);
3439        let cell = term.cell((4, 0)).unwrap();
3440        assert_eq!(cell.symbol(), "│", "guide painted at col 4");
3441        assert_eq!(cell.fg, Color::Gray, "active col uses active_fg (Gray)");
3442    }
3443
3444    #[test]
3445    fn indent_guides_inactive_col_uses_inactive_fg() {
3446        // 12 leading spaces, shiftwidth=4 → guides at col 4 and col 8.
3447        // active_col = 8 → col 4 is inactive (DarkGray), col 8 is active (Gray).
3448        let b = Buffer::from_str("            code");
3449        let v = vp(20, 1);
3450        let view = indent_guide_view(&b, &v, 4, '│', Color::DarkGray, Color::Gray, Some(8));
3451        let term = run_render(view, 20, 1);
3452        // Col 4 inactive.
3453        let cell4 = term.cell((4, 0)).unwrap();
3454        assert_eq!(cell4.symbol(), "│", "guide at col 4");
3455        assert_eq!(cell4.fg, Color::DarkGray, "col 4 uses inactive fg");
3456        // Col 8 active.
3457        let cell8 = term.cell((8, 0)).unwrap();
3458        assert_eq!(cell8.symbol(), "│", "guide at col 8");
3459        assert_eq!(cell8.fg, Color::Gray, "col 8 uses active fg");
3460    }
3461
3462    #[test]
3463    fn indent_guides_custom_char_paints_that_char() {
3464        // Use ':' as guide character.
3465        let b = Buffer::from_str("        code");
3466        let v = vp(20, 1);
3467        let view = indent_guide_view(&b, &v, 4, ':', Color::DarkGray, Color::Gray, None);
3468        let term = run_render(view, 20, 1);
3469        assert_eq!(
3470            term.cell((4, 0)).unwrap().symbol(),
3471            ":",
3472            "custom guide char ':' at col 4"
3473        );
3474    }
3475
3476    #[test]
3477    fn eol_hint_paints_after_text() {
3478        // Buffer with line "ab". EolHint{row:0, text:"BLAME"} must paint the
3479        // 5 chars "BLAME" somewhere to the right of "ab" on screen row 0.
3480        let b = Buffer::from_str("ab");
3481        let v = vp(30, 1);
3482        let hint = EolHint {
3483            row: 0,
3484            text: "BLAME".into(),
3485            style: Style::default(),
3486        };
3487        let view = BufferView {
3488            buffer: &b,
3489            viewport: &v,
3490            selection: None,
3491            resolver: &(no_styles as fn(u32) -> Style),
3492            cursor_line_bg: Style::default(),
3493            fold_line_bg: Style::default(),
3494            cursor_column_bg: Style::default(),
3495            selection_bg: Style::default(),
3496            cursor_style: Style::default(),
3497            gutter: None,
3498            search_bg: Style::default(),
3499            signs: &[],
3500            conceals: &[],
3501            spans: &[],
3502            search_pattern: None,
3503            non_text_style: Style::default(),
3504            diag_overlays: &[],
3505            colorcolumn_cols: &[],
3506            colorcolumn_style: Style::default(),
3507            listchars: None,
3508            indent_guides_enabled: false,
3509            indent_guide_char: '│',
3510            indent_guide_shiftwidth: 4,
3511            indent_guide_fg: Color::Reset,
3512            indent_guide_active_fg: Color::Reset,
3513            indent_guide_active_col: None,
3514            eol_hints: &[hint],
3515            blame_plan: None,
3516        };
3517        let term = run_render(view, 30, 1);
3518        // "ab" is at x=0 and x=1; hint starts at x = 2 + 2 (gap) = 4.
3519        // Scan row 0 for the substring "BLAME".
3520        let row_text: String = (0..30u16)
3521            .map(|x| {
3522                term.cell((x, 0))
3523                    .map(|c| c.symbol().chars().next().unwrap_or(' '))
3524                    .unwrap_or(' ')
3525            })
3526            .collect();
3527        assert!(
3528            row_text.contains("BLAME"),
3529            "expected 'BLAME' to appear in row 0, got: {row_text:?}"
3530        );
3531    }
3532
3533    #[test]
3534    fn fold_column_glyph_open_closed_body() {
3535        // Outer fold rows 1..=4 OPEN, with a CLOSED fold rows 2..=3 inside.
3536        let mut b = hjkl_buffer::Buffer::from_str("a\nb\nc\nd\ne\nf");
3537        b.add_fold(1, 4, false); // open outer
3538        b.add_fold(2, 3, true); // closed inner
3539        let folds = b.folds();
3540        assert_eq!(super::fold_column_glyph(&folds, 0), ' ', "row 0: no fold");
3541        assert_eq!(
3542            super::fold_column_glyph(&folds, 1),
3543            '\u{25be}',
3544            "row 1: open fold start = \u{25be}"
3545        );
3546        assert_eq!(
3547            super::fold_column_glyph(&folds, 2),
3548            '\u{25b8}',
3549            "row 2: closed fold start = \u{25b8}"
3550        );
3551        // row 4 is inside the open outer fold body (and not a start) → bar.
3552        assert_eq!(
3553            super::fold_column_glyph(&folds, 4),
3554            '\u{2502}',
3555            "row 4: open fold body = \u{2502}"
3556        );
3557        assert_eq!(
3558            super::fold_column_glyph(&folds, 5),
3559            ' ',
3560            "row 5: outside all folds"
3561        );
3562    }
3563}