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