Skip to main content

hjkl_buffer/
render.rs

1//! Direct cell-write `ratatui::widgets::Widget` for [`crate::Buffer`].
2//!
3//! Enabled by the `ratatui` feature (off by default).
4//!
5//! ## Render path
6//!
7//! When the `ratatui` feature is enabled, [`BufferView`] implements
8//! `ratatui::widgets::Widget`. The widget is **single-pass** — text,
9//! selection, gutter signs, and styled spans all paint together. There is
10//! no separate `Paragraph` or layout step. Writes one cell at a time so
11//! syntax span fg, cursor-line bg, cursor cell REVERSED, and selection bg
12//! layer in a single pass without the grapheme / wrap machinery `Paragraph`
13//! does.
14//!
15//! Caller wraps a `&Buffer` in [`BufferView`], hands it the style table
16//! that resolves opaque [`crate::Span`] style ids to real ratatui styles
17//! via a [`StyleResolver`], and renders into a `ratatui::Frame`.
18//!
19//! ## StyleResolver hooks
20//!
21//! The [`StyleResolver`] trait is the host's bridge from opaque `u32` style
22//! ids (stored in [`crate::Span::style`]) to real `ratatui::style::Style`
23//! values. Implement it against your own theme. A convenience blanket impl
24//! exists for closures `Fn(u32) -> Style`.
25
26use ratatui::buffer::Buffer as TermBuffer;
27use ratatui::layout::Rect;
28use ratatui::style::Style;
29use ratatui::widgets::Widget;
30use unicode_width::UnicodeWidthChar;
31
32use crate::wrap::wrap_segments;
33use crate::{Buffer, Selection, Span, Viewport, Wrap};
34
35/// Resolves an opaque [`crate::Span::style`] id to a real ratatui
36/// style. The buffer doesn't know about colours; the host (sqeel-vim
37/// or any future user) keeps a lookup table.
38pub trait StyleResolver {
39    fn resolve(&self, style_id: u32) -> Style;
40}
41
42/// Convenience impl so simple closures can drive the renderer.
43impl<F: Fn(u32) -> Style> StyleResolver for F {
44    fn resolve(&self, style_id: u32) -> Style {
45        self(style_id)
46    }
47}
48
49/// Render-time wrapper around `&Buffer` that carries the optional
50/// [`Selection`] + a [`StyleResolver`]. Created per draw, dropped
51/// when the frame is done — cheap, holds only refs.
52///
53/// 0.0.34 (Patch C-δ.1): added the `viewport` field. The viewport
54/// previously lived on the buffer itself; with the relocation to the
55/// engine `Host`, the renderer takes a borrow per draw.
56///
57/// 0.0.37: added the `spans` and `search_pattern` fields. Per-row
58/// syntax spans + the active `/` regex used to live on the buffer
59/// (`Buffer::spans` / `Buffer::search_pattern`); both moved out per
60/// step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The host now feeds
61/// each into the view per draw — populated from
62/// `Editor::buffer_spans()` and `Editor::search_state().pattern`.
63pub struct BufferView<'a, R: StyleResolver> {
64    pub buffer: &'a Buffer,
65    /// Viewport snapshot the host published this frame. Owned by the
66    /// engine `Host`; the renderer borrows for the duration of the
67    /// draw.
68    pub viewport: &'a Viewport,
69    pub selection: Option<Selection>,
70    pub resolver: &'a R,
71    /// Bg painted across the cursor row (vim's `cursorline`). Pass
72    /// `Style::default()` to disable.
73    pub cursor_line_bg: Style,
74    /// Bg painted down the cursor column (vim's `cursorcolumn`). Pass
75    /// `Style::default()` to disable.
76    pub cursor_column_bg: Style,
77    /// Bg painted under selected cells. Composed over syntax fg.
78    pub selection_bg: Style,
79    /// Style for the cursor cell. `REVERSED` is the conventional
80    /// choice; works against any theme.
81    pub cursor_style: Style,
82    /// Optional left-side line-number gutter. `width` includes the
83    /// trailing space separating the number from text. Pass `None`
84    /// to disable. Numbers are 1-based, right-aligned.
85    pub gutter: Option<Gutter>,
86    /// Bg painted under cells covered by an active `/` search match.
87    /// `Style::default()` to disable.
88    pub search_bg: Style,
89    /// Per-row gutter signs (LSP diagnostic dots, git diff markers,
90    /// …). Painted into the leftmost gutter column after the line
91    /// number, so they overwrite the leading space tui-style gutters
92    /// reserve. Highest-priority sign per row wins.
93    pub signs: &'a [Sign],
94    /// Per-row substitutions applied at render time. Each conceal
95    /// hides the byte range `[start_byte, end_byte)` and paints
96    /// `replacement` in its place. Empty slice = no conceals.
97    pub conceals: &'a [Conceal],
98    /// Per-row syntax spans the host has computed for this frame.
99    /// `spans[row]` carries the styled byte ranges for that row;
100    /// rows beyond `spans.len()` get no syntax styling. Pass `&[]`
101    /// for hosts without syntax integration.
102    ///
103    /// 0.0.37: lifted out of `Buffer` per step 3 of
104    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine populates
105    /// this via `Editor::buffer_spans()`.
106    pub spans: &'a [Vec<Span>],
107    /// Active `/` search regex, if any. The renderer paints
108    /// [`Self::search_bg`] under cells that match. Pass `None` to
109    /// disable hlsearch.
110    ///
111    /// 0.0.37: lifted out of `Buffer` (was `Buffer::search_pattern`)
112    /// per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine
113    /// publishes the pattern via `Editor::search_state().pattern`.
114    pub search_pattern: Option<&'a regex::Regex>,
115    /// Style for the `~` tilde marker painted on screen rows that are
116    /// past the last buffer line (vim's `NonText` highlight group).
117    /// Pass `Style::default()` to use terminal defaults.
118    ///
119    /// The gutter on those rows is painted blank; the `~` appears at the
120    /// leftmost text column. Rows within the buffer are unaffected.
121    pub non_text_style: Style,
122    /// Diagnostic overlays (LSP inline highlights). Applied in a
123    /// post-paint pass after every row is drawn so they layer on top of
124    /// syntax and selection colours without a second layout traversal.
125    /// Pass `&[]` to disable. Added in 0.5.0.
126    pub diag_overlays: &'a [DiagOverlay],
127    /// 1-based column indices for vertical rulers (vim's `colorcolumn`).
128    /// The renderer paints `colorcolumn_style` on those text-area cells
129    /// beneath syntax highlights. Pass `&[]` to disable.
130    pub colorcolumn_cols: &'a [u16],
131    /// Background style applied to cells at a `colorcolumn` position.
132    /// Ignored when `colorcolumn_cols` is empty.
133    pub colorcolumn_style: Style,
134}
135
136/// Controls what numbers are rendered in the gutter.
137///
138/// Matches vim's `:set number` / `:set relativenumber` combinations.
139#[derive(Debug, Clone, Copy, Default)]
140pub enum GutterNumbers {
141    /// No line numbers — gutter cells painted blank (still occupies width).
142    None,
143    /// 1-based absolute row numbers (current default).
144    #[default]
145    Absolute,
146    /// Offset from `cursor_row` for non-cursor rows; cursor row shows `0`.
147    Relative { cursor_row: usize },
148    /// Vim's `nu+rnu`: cursor row shows its absolute number, others show
149    /// offset from `cursor_row`.
150    Hybrid { cursor_row: usize },
151}
152
153/// Configuration for the line-number gutter rendered to the left of
154/// the text area. `width` is the total cell count reserved
155/// (including any trailing spacer); the renderer right-aligns the
156/// 1-based row number into the leftmost `width - 1` cells.
157///
158/// `line_offset` is added to the displayed line number, so a host
159/// rendering a windowed view of a larger document (e.g. picker preview
160/// of a 7000-line buffer) can show the original line numbers instead
161/// of starting at 1. Only applied in `Absolute` mode.
162#[derive(Debug, Clone, Copy, Default)]
163pub struct Gutter {
164    pub width: u16,
165    pub style: Style,
166    pub line_offset: usize,
167    /// What kind of numbers to render. Defaults to `Absolute`.
168    pub numbers: GutterNumbers,
169}
170
171/// Single-cell marker painted into the leftmost gutter column for a
172/// document row. Used by hosts to surface LSP diagnostics, git diff
173/// signs, etc. Higher `priority` wins when multiple signs land on
174/// the same row.
175#[derive(Debug, Clone, Copy)]
176pub struct Sign {
177    pub row: usize,
178    pub ch: char,
179    pub style: Style,
180    pub priority: u8,
181}
182
183/// Render-time substitution that hides a byte range and paints
184/// `replacement` in its place. The buffer's content stays unchanged;
185/// only the rendered cells differ. Used by hosts to pretty-print
186/// URLs, conceal markdown markers, etc.
187#[derive(Debug, Clone)]
188pub struct Conceal {
189    pub row: usize,
190    pub start_byte: usize,
191    pub end_byte: usize,
192    pub replacement: String,
193}
194
195/// A char-column range on a document row that should be styled with an
196/// overlay (e.g. an underline for LSP diagnostics). Applied in a
197/// post-paint pass so it composes on top of syntax and selection colours.
198///
199/// Added in 0.5.0 for LSP diagnostic inline rendering.
200#[derive(Debug, Clone, Copy)]
201pub struct DiagOverlay {
202    /// 0-based document row.
203    pub row: usize,
204    /// 0-based start char-column (inclusive).
205    pub col_start: usize,
206    /// 0-based end char-column (exclusive).
207    pub col_end: usize,
208    /// Style applied to cells in `[col_start, col_end)`.
209    pub style: Style,
210}
211
212impl<R: StyleResolver> Widget for BufferView<'_, R> {
213    fn render(self, area: Rect, term_buf: &mut TermBuffer) {
214        let viewport = *self.viewport;
215        let cursor = self.buffer.cursor();
216        let lines = self.buffer.lines();
217        let spans = self.spans;
218        let folds = self.buffer.folds();
219        let top_row = viewport.top_row;
220        let top_col = viewport.top_col;
221
222        let gutter_width = self.gutter.map(|g| g.width).unwrap_or(0);
223        let text_area = Rect {
224            x: area.x.saturating_add(gutter_width),
225            y: area.y,
226            width: area.width.saturating_sub(gutter_width),
227            height: area.height,
228        };
229
230        let total_rows = lines.len();
231        let mut doc_row = top_row;
232        let mut screen_row: u16 = 0;
233        let wrap_mode = viewport.wrap;
234        let seg_width = if viewport.text_width > 0 {
235            viewport.text_width
236        } else {
237            text_area.width
238        };
239        // Per-screen-row flag: true when the cell at the cursor's
240        // column on that screen row is part of an active `/` search
241        // match. The cursorcolumn pass uses this to skip cells that
242        // search bg already painted, so search highlight wins over
243        // the column bg.
244        let mut search_hit_at_cursor_col: Vec<bool> = Vec::new();
245        // Walk the document forward, skipping rows hidden by closed
246        // folds. Emit the start row of a closed fold as a marker
247        // line instead of its actual content.
248        while doc_row < total_rows && screen_row < area.height {
249            // Skip rows hidden by a closed fold (any row past start
250            // of a closed fold).
251            if folds.iter().any(|f| f.hides(doc_row)) {
252                doc_row += 1;
253                continue;
254            }
255            let folded_at_start = folds
256                .iter()
257                .find(|f| f.closed && f.start_row == doc_row)
258                .copied();
259            let line = &lines[doc_row];
260            let row_spans = spans.get(doc_row).map(Vec::as_slice).unwrap_or(&[]);
261            let sel_range = self.selection.and_then(|s| s.row_span(doc_row));
262            let is_cursor_row = doc_row == cursor.row;
263            if let Some(fold) = folded_at_start {
264                if let Some(gutter) = self.gutter {
265                    self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
266                    self.paint_signs(term_buf, area, screen_row, doc_row);
267                }
268                self.paint_fold_marker(term_buf, text_area, screen_row, fold, line, is_cursor_row);
269                search_hit_at_cursor_col.push(false);
270                screen_row += 1;
271                doc_row = fold.end_row + 1;
272                continue;
273            }
274            let search_ranges = self.row_search_ranges(line);
275            let row_has_hit_at_cursor_col = search_ranges
276                .iter()
277                .any(|&(s, e)| cursor.col >= s && cursor.col < e);
278            // Collect conceals for this row, sorted by start_byte.
279            let row_conceals: Vec<&Conceal> = {
280                let mut v: Vec<&Conceal> =
281                    self.conceals.iter().filter(|c| c.row == doc_row).collect();
282                v.sort_by_key(|c| c.start_byte);
283                v
284            };
285            // Compute screen segments for this doc row. `Wrap::None`
286            // produces a single segment that spans the whole line; the
287            // existing `top_col` horizontal scroll is preserved by
288            // passing `top_col` as the segment start. Wrap modes split
289            // the line into multiple visual rows that fit
290            // `viewport.text_width` (falls back to `text_area.width`
291            // when the host hasn't published a text width yet).
292            let segments = match wrap_mode {
293                Wrap::None => vec![(top_col, usize::MAX)],
294                _ => wrap_segments(line, seg_width, wrap_mode),
295            };
296            let last_seg_idx = segments.len().saturating_sub(1);
297            for (seg_idx, &(seg_start, seg_end)) in segments.iter().enumerate() {
298                if screen_row >= area.height {
299                    break;
300                }
301                if let Some(gutter) = self.gutter {
302                    if seg_idx == 0 {
303                        self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
304                        self.paint_signs(term_buf, area, screen_row, doc_row);
305                    } else {
306                        self.paint_blank_gutter(term_buf, area, screen_row, gutter);
307                    }
308                }
309                self.paint_row(
310                    term_buf,
311                    text_area,
312                    screen_row,
313                    line,
314                    row_spans,
315                    sel_range,
316                    &search_ranges,
317                    is_cursor_row,
318                    cursor.col,
319                    seg_start,
320                    seg_end,
321                    seg_idx == last_seg_idx,
322                    &row_conceals,
323                );
324                search_hit_at_cursor_col.push(row_has_hit_at_cursor_col);
325                screen_row += 1;
326            }
327            doc_row += 1;
328        }
329        // Tilde pass: paint `~` on every screen row past the last buffer
330        // line (vim's NonText marker). Gutter on those rows stays blank.
331        while screen_row < area.height {
332            // Blank gutter if present.
333            if let Some(gutter) = self.gutter {
334                self.paint_blank_gutter(term_buf, area, screen_row, gutter);
335            }
336            // Paint `~` at the first text column.
337            let y = text_area.y + screen_row;
338            if let Some(cell) = term_buf.cell_mut((text_area.x, y)) {
339                cell.set_char('~');
340                cell.set_style(self.non_text_style);
341            }
342            screen_row += 1;
343        }
344        // Cursorcolumn pass: layer the bg over the cursor's visible
345        // column once every row is painted so it composes on top of
346        // syntax / cursorline backgrounds without disturbing fg.
347        // Skipped when wrapping — the cursor's screen x depends on the
348        // segment it lands in, and vim's cursorcolumn semantics with
349        // wrap are fuzzy. Revisit if it bites.
350        if matches!(wrap_mode, Wrap::None)
351            && self.cursor_column_bg != Style::default()
352            && cursor.col >= top_col
353            && (cursor.col - top_col) < text_area.width as usize
354        {
355            let x = text_area.x + (cursor.col - top_col) as u16;
356            for sy in 0..screen_row {
357                // Skip rows where search bg already painted this cell —
358                // search highlight wins over cursorcolumn so `/foo`
359                // matches stay readable when the cursor sits on them.
360                if search_hit_at_cursor_col
361                    .get(sy as usize)
362                    .copied()
363                    .unwrap_or(false)
364                {
365                    continue;
366                }
367                let y = text_area.y + sy;
368                if let Some(cell) = term_buf.cell_mut((x, y)) {
369                    cell.set_style(cell.style().patch(self.cursor_column_bg));
370                }
371            }
372        }
373
374        // Colorcolumn pass: paint vertical ruler(s) under syntax.
375        // Applied only in Wrap::None mode; skips indices that are
376        // scrolled out of the visible horizontal window.
377        if matches!(wrap_mode, Wrap::None) && !self.colorcolumn_cols.is_empty() {
378            for &col_1based in self.colorcolumn_cols {
379                let col = col_1based as usize; // convert to 0-based
380                if col == 0 || col < top_col + 1 {
381                    continue; // out of visible range (scrolled past left edge)
382                }
383                let screen_col = col - 1 - top_col; // 0-based screen offset
384                if screen_col >= text_area.width as usize {
385                    continue; // out of visible range (past right edge)
386                }
387                let x = text_area.x + screen_col as u16;
388                for sy in 0..screen_row {
389                    let y = text_area.y + sy;
390                    if let Some(cell) = term_buf.cell_mut((x, y)) {
391                        cell.set_style(cell.style().patch(self.colorcolumn_style));
392                    }
393                }
394            }
395        }
396
397        // Diag overlay pass: apply underline / style over visible char
398        // columns. Only supported in Wrap::None mode; wrap is a future
399        // concern. Overlays beyond the visible horizontal scroll are
400        // skipped silently.
401        if matches!(wrap_mode, Wrap::None) && !self.diag_overlays.is_empty() {
402            // Build a doc_row → screen_row map from the first pass.
403            // We re-walk the viewport range instead of storing a map to
404            // keep memory allocation proportional to the viewport.
405            let vp_top = top_row;
406            let vp_bot = vp_top + area.height as usize;
407            for overlay in self.diag_overlays {
408                if overlay.row < vp_top || overlay.row >= vp_bot {
409                    continue;
410                }
411                // Compute screen row: count non-hidden rows from vp_top
412                // to overlay.row.
413                let mut sr: u16 = 0;
414                let mut dr = vp_top;
415                while dr < overlay.row && sr < area.height {
416                    if !folds.iter().any(|f| f.hides(dr)) {
417                        sr += 1;
418                    }
419                    dr += 1;
420                }
421                if sr >= area.height {
422                    continue;
423                }
424                let y = text_area.y + sr;
425                // Paint the char columns in the overlay range, clamped
426                // to the horizontal scroll window and text area width.
427                let col_start = overlay.col_start;
428                let col_end = overlay.col_end.max(col_start + 1);
429                for col in col_start..col_end {
430                    if col < top_col {
431                        continue;
432                    }
433                    let screen_col = col - top_col;
434                    if screen_col >= text_area.width as usize {
435                        break;
436                    }
437                    let x = text_area.x + screen_col as u16;
438                    if let Some(cell) = term_buf.cell_mut((x, y)) {
439                        cell.set_style(cell.style().patch(overlay.style));
440                    }
441                }
442            }
443        }
444    }
445}
446
447impl<R: StyleResolver> BufferView<'_, R> {
448    /// Run the active search regex against `line` and return the
449    /// charwise `(start_col, end_col_exclusive)` ranges that need
450    /// the search bg painted. Empty when no pattern is set.
451    fn row_search_ranges(&self, line: &str) -> Vec<(usize, usize)> {
452        let Some(re) = self.search_pattern else {
453            return Vec::new();
454        };
455        re.find_iter(line)
456            .map(|m| {
457                let start = line[..m.start()].chars().count();
458                let end = line[..m.end()].chars().count();
459                (start, end)
460            })
461            .collect()
462    }
463
464    fn paint_fold_marker(
465        &self,
466        term_buf: &mut TermBuffer,
467        area: Rect,
468        screen_row: u16,
469        fold: crate::Fold,
470        first_line: &str,
471        is_cursor_row: bool,
472    ) {
473        let y = area.y + screen_row;
474        let style = if is_cursor_row && self.cursor_line_bg != Style::default() {
475            self.cursor_line_bg
476        } else {
477            Style::default()
478        };
479        // Bg the whole row first so the marker reads like one cell.
480        for x in area.x..(area.x + area.width) {
481            if let Some(cell) = term_buf.cell_mut((x, y)) {
482                cell.set_style(style);
483            }
484        }
485        // Build a label that hints at the fold's contents instead of
486        // a generic "+-- N lines folded --". Use the start row's
487        // trimmed text (truncated) plus the line count.
488        let prefix = first_line.trim();
489        let count = fold.line_count();
490        let label = if prefix.is_empty() {
491            format!("▸ {count} lines folded")
492        } else {
493            const MAX_PREFIX: usize = 60;
494            let trimmed = if prefix.chars().count() > MAX_PREFIX {
495                let head: String = prefix.chars().take(MAX_PREFIX - 1).collect();
496                format!("{head}…")
497            } else {
498                prefix.to_string()
499            };
500            format!("▸ {trimmed}  ({count} lines)")
501        };
502        let mut x = area.x;
503        let row_end_x = area.x + area.width;
504        for ch in label.chars() {
505            if x >= row_end_x {
506                break;
507            }
508            let width = ch.width().unwrap_or(1) as u16;
509            if x + width > row_end_x {
510                break;
511            }
512            if let Some(cell) = term_buf.cell_mut((x, y)) {
513                cell.set_char(ch);
514                cell.set_style(style);
515            }
516            x = x.saturating_add(width);
517        }
518    }
519
520    fn paint_signs(&self, term_buf: &mut TermBuffer, area: Rect, screen_row: u16, doc_row: usize) {
521        let Some(sign) = self
522            .signs
523            .iter()
524            .filter(|s| s.row == doc_row)
525            .max_by_key(|s| s.priority)
526        else {
527            return;
528        };
529        let y = area.y + screen_row;
530        let x = area.x;
531        if let Some(cell) = term_buf.cell_mut((x, y)) {
532            cell.set_char(sign.ch);
533            cell.set_style(sign.style);
534        }
535    }
536
537    /// Paint a wrap-continuation gutter row: blank cells in the
538    /// gutter style so the bg stays continuous, no line number.
539    fn paint_blank_gutter(
540        &self,
541        term_buf: &mut TermBuffer,
542        area: Rect,
543        screen_row: u16,
544        gutter: Gutter,
545    ) {
546        let y = area.y + screen_row;
547        for x in area.x..(area.x + gutter.width) {
548            if let Some(cell) = term_buf.cell_mut((x, y)) {
549                cell.set_char(' ');
550                cell.set_style(gutter.style);
551            }
552        }
553    }
554
555    fn paint_gutter(
556        &self,
557        term_buf: &mut TermBuffer,
558        area: Rect,
559        screen_row: u16,
560        doc_row: usize,
561        gutter: Gutter,
562    ) {
563        let y = area.y + screen_row;
564        // Total gutter cells, leaving one trailing spacer column.
565        let number_width = gutter.width.saturating_sub(1) as usize;
566
567        // Compute the label to display based on the numbers mode.
568        let label = match gutter.numbers {
569            GutterNumbers::None => {
570                // Blank — paint all cells (including spacer) as spaces.
571                for x in area.x..(area.x + gutter.width) {
572                    if let Some(cell) = term_buf.cell_mut((x, y)) {
573                        cell.set_char(' ');
574                        cell.set_style(gutter.style);
575                    }
576                }
577                return;
578            }
579            GutterNumbers::Absolute => {
580                format!(
581                    "{:>width$}",
582                    doc_row + 1 + gutter.line_offset,
583                    width = number_width
584                )
585            }
586            GutterNumbers::Relative { cursor_row } => {
587                let n = if doc_row == cursor_row {
588                    0
589                } else {
590                    doc_row.abs_diff(cursor_row)
591                };
592                format!("{:>width$}", n, width = number_width)
593            }
594            GutterNumbers::Hybrid { cursor_row } => {
595                let n = if doc_row == cursor_row {
596                    doc_row + 1 + gutter.line_offset
597                } else {
598                    doc_row.abs_diff(cursor_row)
599                };
600                format!("{:>width$}", n, width = number_width)
601            }
602        };
603
604        let mut x = area.x;
605        for ch in label.chars() {
606            if x >= area.x + gutter.width.saturating_sub(1) {
607                break;
608            }
609            if let Some(cell) = term_buf.cell_mut((x, y)) {
610                cell.set_char(ch);
611                cell.set_style(gutter.style);
612            }
613            x = x.saturating_add(1);
614        }
615        // Spacer cell — same gutter style so the background is
616        // continuous when a bg colour is set.
617        let spacer_x = area.x + gutter.width.saturating_sub(1);
618        if let Some(cell) = term_buf.cell_mut((spacer_x, y)) {
619            cell.set_char(' ');
620            cell.set_style(gutter.style);
621        }
622    }
623
624    #[allow(clippy::too_many_arguments)]
625    fn paint_row(
626        &self,
627        term_buf: &mut TermBuffer,
628        area: Rect,
629        screen_row: u16,
630        line: &str,
631        row_spans: &[crate::Span],
632        sel_range: crate::RowSpan,
633        search_ranges: &[(usize, usize)],
634        is_cursor_row: bool,
635        cursor_col: usize,
636        seg_start: usize,
637        seg_end: usize,
638        is_last_segment: bool,
639        conceals: &[&Conceal],
640    ) {
641        let y = area.y + screen_row;
642        let mut screen_x = area.x;
643        let row_end_x = area.x + area.width;
644
645        // Paint cursor-line bg across the whole row first so empty
646        // trailing cells inherit the highlight (matches vim's
647        // cursorline). Selection / cursor cells overwrite below.
648        if is_cursor_row && self.cursor_line_bg != Style::default() {
649            for x in area.x..row_end_x {
650                if let Some(cell) = term_buf.cell_mut((x, y)) {
651                    cell.set_style(self.cursor_line_bg);
652                }
653            }
654        }
655
656        // Tab width for `\t` expansion — host publishes via
657        // `Viewport::tab_width` (driven by engine's `:set tabstop`).
658        // `effective_tab_width` falls back to 4 when unset.
659        let tab_width = self.viewport.effective_tab_width();
660        let mut byte_offset: usize = 0;
661        let mut line_col: usize = 0;
662        let mut chars_iter = line.chars().enumerate().peekable();
663        while let Some((col_idx, ch)) = chars_iter.next() {
664            let ch_byte_len = ch.len_utf8();
665            if col_idx >= seg_end {
666                break;
667            }
668            // If a conceal starts at this byte, paint the replacement
669            // text (using this cell's style) and skip the rest of the
670            // concealed range. Cursor / selection / search highlights
671            // still attribute to the original char positions.
672            if let Some(conc) = conceals.iter().find(|c| c.start_byte == byte_offset) {
673                if col_idx >= seg_start {
674                    let mut style = if is_cursor_row {
675                        self.cursor_line_bg
676                    } else {
677                        Style::default()
678                    };
679                    if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
680                        style = style.patch(span_style);
681                    }
682                    for rch in conc.replacement.chars() {
683                        let rwidth = rch.width().unwrap_or(1) as u16;
684                        if screen_x + rwidth > row_end_x {
685                            break;
686                        }
687                        if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
688                            cell.set_char(rch);
689                            cell.set_style(style);
690                        }
691                        screen_x += rwidth;
692                    }
693                }
694                // Advance byte_offset / chars iter past the concealed
695                // range without painting the original cells.
696                let mut consumed = ch_byte_len;
697                byte_offset += ch_byte_len;
698                while byte_offset < conc.end_byte {
699                    let Some((_, next_ch)) = chars_iter.next() else {
700                        break;
701                    };
702                    consumed += next_ch.len_utf8();
703                    byte_offset = byte_offset.saturating_add(next_ch.len_utf8());
704                }
705                let _ = consumed;
706                continue;
707            }
708            // Visible cell count: tabs expand to the next tab_width stop
709            // based on `line_col` (visible column in the *line*, not the
710            // segment), so a tab at line column 0 paints tab_width cells
711            // and a tab at line column 3 paints 1 cell.
712            let visible_width = if ch == '\t' {
713                tab_width - (line_col % tab_width)
714            } else {
715                ch.width().unwrap_or(1)
716            };
717            // Skip chars to the left of the segment start (horizontal
718            // scroll for `Wrap::None`, segment offset for wrap modes).
719            if col_idx < seg_start {
720                line_col += visible_width;
721                byte_offset += ch_byte_len;
722                continue;
723            }
724            // Stop when we run out of horizontal room.
725            let width = visible_width as u16;
726            if screen_x + width > row_end_x {
727                break;
728            }
729
730            // Resolve final style for this cell.
731            let mut style = if is_cursor_row {
732                self.cursor_line_bg
733            } else {
734                Style::default()
735            };
736            if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
737                style = style.patch(span_style);
738            }
739            // Search bg first, then selection bg — so when a visual
740            // selection covers a search match, the selection wins
741            // (last patch overwrites the bg field).
742            if self.search_bg != Style::default()
743                && search_ranges
744                    .iter()
745                    .any(|&(s, e)| col_idx >= s && col_idx < e)
746            {
747                style = style.patch(self.search_bg);
748            }
749            if let Some((lo, hi)) = sel_range
750                && col_idx >= lo
751                && col_idx <= hi
752            {
753                style = style.patch(self.selection_bg);
754            }
755            if is_cursor_row && col_idx == cursor_col {
756                style = style.patch(self.cursor_style);
757            }
758
759            if ch == '\t' {
760                // Paint tab as `visible_width` space cells carrying the
761                // resolved style — tab/text bg/cursor-line bg all paint
762                // through the expansion.
763                for k in 0..width {
764                    if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
765                        cell.set_char(' ');
766                        cell.set_style(style);
767                    }
768                }
769            } else if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
770                cell.set_char(ch);
771                cell.set_style(style);
772            }
773            screen_x += width;
774            line_col += visible_width;
775            byte_offset += ch_byte_len;
776        }
777
778        // If the cursor sits at end-of-line (insert / past-end mode),
779        // paint a single REVERSED placeholder cell so it stays visible.
780        // Only on the last segment of a wrapped row — earlier segments
781        // can't host the past-end cursor.
782        if is_cursor_row
783            && is_last_segment
784            && cursor_col >= line.chars().count()
785            && cursor_col >= seg_start
786        {
787            let pad_x = area.x + (cursor_col.saturating_sub(seg_start)) as u16;
788            if pad_x < row_end_x
789                && let Some(cell) = term_buf.cell_mut((pad_x, y))
790            {
791                cell.set_char(' ');
792                cell.set_style(self.cursor_line_bg.patch(self.cursor_style));
793            }
794        }
795    }
796
797    /// First span containing `byte_offset` wins. Buffer guarantees
798    /// non-overlapping sorted spans — vim.rs is responsible for that.
799    fn resolve_span_style(&self, row_spans: &[crate::Span], byte_offset: usize) -> Option<Style> {
800        // Return the *narrowest* span containing this byte. Hosts that
801        // overlay narrower spans on top of broader ones (e.g. TODO marker
802        // inside a comment span) rely on the more specific span winning;
803        // first-match-wins would let the broader span block the overlay.
804        let mut best: Option<&crate::Span> = None;
805        for span in row_spans {
806            if byte_offset >= span.start_byte && byte_offset < span.end_byte {
807                let len = span.end_byte - span.start_byte;
808                match best {
809                    Some(b) if (b.end_byte - b.start_byte) <= len => {}
810                    _ => best = Some(span),
811                }
812            }
813        }
814        best.map(|s| self.resolver.resolve(s.style))
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use ratatui::style::{Color, Modifier};
822    use ratatui::widgets::Widget;
823
824    fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
825        let area = Rect::new(0, 0, w, h);
826        let mut buf = TermBuffer::empty(area);
827        view.render(area, &mut buf);
828        buf
829    }
830
831    fn no_styles(_id: u32) -> Style {
832        Style::default()
833    }
834
835    /// Build a default viewport for plain (no-wrap) tests.
836    fn vp(width: u16, height: u16) -> Viewport {
837        Viewport {
838            top_row: 0,
839            top_col: 0,
840            width,
841            height,
842            wrap: Wrap::None,
843            text_width: width,
844            tab_width: 0,
845        }
846    }
847
848    #[test]
849    fn renders_plain_chars_into_terminal_buffer() {
850        let b = Buffer::from_str("hello\nworld");
851        let v = vp(20, 5);
852        let view = BufferView {
853            buffer: &b,
854            viewport: &v,
855            selection: None,
856            resolver: &(no_styles as fn(u32) -> Style),
857            cursor_line_bg: Style::default(),
858            cursor_column_bg: Style::default(),
859            selection_bg: Style::default().bg(Color::Blue),
860            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
861            gutter: None,
862            search_bg: Style::default(),
863            signs: &[],
864            conceals: &[],
865            spans: &[],
866            search_pattern: None,
867            non_text_style: Style::default(),
868            diag_overlays: &[],
869            colorcolumn_cols: &[],
870            colorcolumn_style: Style::default(),
871        };
872        let term = run_render(view, 20, 5);
873        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
874        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
875        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
876        assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
877    }
878
879    #[test]
880    fn cursor_cell_gets_reversed_style() {
881        let mut b = Buffer::from_str("abc");
882        let v = vp(10, 1);
883        b.set_cursor(crate::Position::new(0, 1));
884        let view = BufferView {
885            buffer: &b,
886            viewport: &v,
887            selection: None,
888            resolver: &(no_styles as fn(u32) -> Style),
889            cursor_line_bg: Style::default(),
890            cursor_column_bg: Style::default(),
891            selection_bg: Style::default().bg(Color::Blue),
892            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
893            gutter: None,
894            search_bg: Style::default(),
895            signs: &[],
896            conceals: &[],
897            spans: &[],
898            search_pattern: None,
899            non_text_style: Style::default(),
900            diag_overlays: &[],
901            colorcolumn_cols: &[],
902            colorcolumn_style: Style::default(),
903        };
904        let term = run_render(view, 10, 1);
905        let cursor_cell = term.cell((1, 0)).unwrap();
906        assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
907    }
908
909    #[test]
910    fn selection_bg_applies_only_to_selected_cells() {
911        use crate::{Position, Selection};
912        let b = Buffer::from_str("abcdef");
913        let v = vp(10, 1);
914        let view = BufferView {
915            buffer: &b,
916            viewport: &v,
917            selection: Some(Selection::Char {
918                anchor: Position::new(0, 1),
919                head: Position::new(0, 3),
920            }),
921            resolver: &(no_styles as fn(u32) -> Style),
922            cursor_line_bg: Style::default(),
923            cursor_column_bg: Style::default(),
924            selection_bg: Style::default().bg(Color::Blue),
925            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
926            gutter: None,
927            search_bg: Style::default(),
928            signs: &[],
929            conceals: &[],
930            spans: &[],
931            search_pattern: None,
932            non_text_style: Style::default(),
933            diag_overlays: &[],
934            colorcolumn_cols: &[],
935            colorcolumn_style: Style::default(),
936        };
937        let term = run_render(view, 10, 1);
938        assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
939        for x in 1..=3 {
940            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
941        }
942        assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
943    }
944
945    #[test]
946    fn syntax_span_fg_resolves_via_table() {
947        use crate::Span;
948        let b = Buffer::from_str("SELECT foo");
949        let v = vp(20, 1);
950        let spans = vec![vec![Span::new(0, 6, 7)]];
951        let resolver = |id: u32| -> Style {
952            if id == 7 {
953                Style::default().fg(Color::Red)
954            } else {
955                Style::default()
956            }
957        };
958        let view = BufferView {
959            buffer: &b,
960            viewport: &v,
961            selection: None,
962            resolver: &resolver,
963            cursor_line_bg: Style::default(),
964            cursor_column_bg: Style::default(),
965            selection_bg: Style::default().bg(Color::Blue),
966            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
967            gutter: None,
968            search_bg: Style::default(),
969            signs: &[],
970            conceals: &[],
971            spans: &spans,
972            search_pattern: None,
973            non_text_style: Style::default(),
974            diag_overlays: &[],
975            colorcolumn_cols: &[],
976            colorcolumn_style: Style::default(),
977        };
978        let term = run_render(view, 20, 1);
979        for x in 0..6 {
980            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
981        }
982    }
983
984    #[test]
985    fn gutter_renders_right_aligned_line_numbers() {
986        let b = Buffer::from_str("a\nb\nc");
987        let v = vp(10, 3);
988        let view = BufferView {
989            buffer: &b,
990            viewport: &v,
991            selection: None,
992            resolver: &(no_styles as fn(u32) -> Style),
993            cursor_line_bg: Style::default(),
994            cursor_column_bg: Style::default(),
995            selection_bg: Style::default().bg(Color::Blue),
996            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
997            gutter: Some(Gutter {
998                width: 4,
999                style: Style::default().fg(Color::Yellow),
1000                line_offset: 0,
1001                ..Default::default()
1002            }),
1003            search_bg: Style::default(),
1004            signs: &[],
1005            conceals: &[],
1006            spans: &[],
1007            search_pattern: None,
1008            non_text_style: Style::default(),
1009            diag_overlays: &[],
1010            colorcolumn_cols: &[],
1011            colorcolumn_style: Style::default(),
1012        };
1013        let term = run_render(view, 10, 3);
1014        // Width 4 = 3 number cells + 1 spacer; right-aligned "  1".
1015        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1016        assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
1017        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1018        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
1019        // Text shifted right past the gutter.
1020        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1021    }
1022
1023    #[test]
1024    fn gutter_renders_relative_with_cursor_at_zero() {
1025        // 5 rows, cursor on row 2 (0-based). Relative: row 2 → 0, row 0 → 2, row 4 → 2.
1026        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1027        b.set_cursor(crate::Position::new(2, 0));
1028        let v = vp(10, 5);
1029        let view = BufferView {
1030            buffer: &b,
1031            viewport: &v,
1032            selection: None,
1033            resolver: &(no_styles as fn(u32) -> Style),
1034            cursor_line_bg: Style::default(),
1035            cursor_column_bg: Style::default(),
1036            selection_bg: Style::default().bg(Color::Blue),
1037            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1038            gutter: Some(Gutter {
1039                width: 4,
1040                style: Style::default().fg(Color::Yellow),
1041                line_offset: 0,
1042                numbers: GutterNumbers::Relative { cursor_row: 2 },
1043            }),
1044            search_bg: Style::default(),
1045            signs: &[],
1046            conceals: &[],
1047            spans: &[],
1048            search_pattern: None,
1049            non_text_style: Style::default(),
1050            diag_overlays: &[],
1051            colorcolumn_cols: &[],
1052            colorcolumn_style: Style::default(),
1053        };
1054        let term = run_render(view, 10, 5);
1055        // Width 4 = 3 number cells + 1 spacer.
1056        // Row 0 (doc 0): distance from cursor (2) = 2 → "  2"
1057        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "2");
1058        // Row 1 (doc 1): distance = 1 → "  1"
1059        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "1");
1060        // Row 2 (doc 2): cursor row → "  0"
1061        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "0");
1062        // Row 3 (doc 3): distance = 1 → "  1"
1063        assert_eq!(term.cell((2, 3)).unwrap().symbol(), "1");
1064        // Row 4 (doc 4): distance = 2 → "  2"
1065        assert_eq!(term.cell((2, 4)).unwrap().symbol(), "2");
1066    }
1067
1068    #[test]
1069    fn gutter_renders_hybrid_cursor_row_absolute() {
1070        // 3 rows, cursor on row 1 (0-based). Hybrid: row 1 → absolute (2),
1071        // row 0 → offset 1, row 2 → offset 1.
1072        let mut b = Buffer::from_str("a\nb\nc");
1073        b.set_cursor(crate::Position::new(1, 0));
1074        let v = vp(10, 3);
1075        let view = BufferView {
1076            buffer: &b,
1077            viewport: &v,
1078            selection: None,
1079            resolver: &(no_styles as fn(u32) -> Style),
1080            cursor_line_bg: Style::default(),
1081            cursor_column_bg: Style::default(),
1082            selection_bg: Style::default().bg(Color::Blue),
1083            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1084            gutter: Some(Gutter {
1085                width: 4,
1086                style: Style::default().fg(Color::Yellow),
1087                line_offset: 0,
1088                numbers: GutterNumbers::Hybrid { cursor_row: 1 },
1089            }),
1090            search_bg: Style::default(),
1091            signs: &[],
1092            conceals: &[],
1093            spans: &[],
1094            search_pattern: None,
1095            non_text_style: Style::default(),
1096            diag_overlays: &[],
1097            colorcolumn_cols: &[],
1098            colorcolumn_style: Style::default(),
1099        };
1100        let term = run_render(view, 10, 3);
1101        // Row 0 (doc 0): offset from cursor row 1 → 1
1102        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1103        // Row 1 (doc 1): cursor row → absolute 2
1104        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1105        // Row 2 (doc 2): offset from cursor row 1 → 1
1106        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "1");
1107    }
1108
1109    #[test]
1110    fn gutter_none_paints_blank_cells() {
1111        let b = Buffer::from_str("a\nb\nc");
1112        let v = vp(10, 3);
1113        let view = BufferView {
1114            buffer: &b,
1115            viewport: &v,
1116            selection: None,
1117            resolver: &(no_styles as fn(u32) -> Style),
1118            cursor_line_bg: Style::default(),
1119            cursor_column_bg: Style::default(),
1120            selection_bg: Style::default().bg(Color::Blue),
1121            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1122            gutter: Some(Gutter {
1123                width: 4,
1124                style: Style::default().fg(Color::Yellow),
1125                line_offset: 0,
1126                numbers: GutterNumbers::None,
1127            }),
1128            search_bg: Style::default(),
1129            signs: &[],
1130            conceals: &[],
1131            spans: &[],
1132            search_pattern: None,
1133            non_text_style: Style::default(),
1134            diag_overlays: &[],
1135            colorcolumn_cols: &[],
1136            colorcolumn_style: Style::default(),
1137        };
1138        let term = run_render(view, 10, 3);
1139        // All gutter cells (0..4) on every row should be blank spaces.
1140        for row in 0..3u16 {
1141            for x in 0..4u16 {
1142                assert_eq!(
1143                    term.cell((x, row)).unwrap().symbol(),
1144                    " ",
1145                    "expected blank at ({x}, {row})"
1146                );
1147            }
1148        }
1149        // Text still appears shifted right past the gutter.
1150        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1151    }
1152
1153    #[test]
1154    fn search_bg_paints_match_cells() {
1155        use regex::Regex;
1156        let b = Buffer::from_str("foo bar foo");
1157        let v = vp(20, 1);
1158        let pat = Regex::new("foo").unwrap();
1159        let view = BufferView {
1160            buffer: &b,
1161            viewport: &v,
1162            selection: None,
1163            resolver: &(no_styles as fn(u32) -> Style),
1164            cursor_line_bg: Style::default(),
1165            cursor_column_bg: Style::default(),
1166            selection_bg: Style::default().bg(Color::Blue),
1167            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1168            gutter: None,
1169            search_bg: Style::default().bg(Color::Magenta),
1170            signs: &[],
1171            conceals: &[],
1172            spans: &[],
1173            search_pattern: Some(&pat),
1174            non_text_style: Style::default(),
1175            diag_overlays: &[],
1176            colorcolumn_cols: &[],
1177            colorcolumn_style: Style::default(),
1178        };
1179        let term = run_render(view, 20, 1);
1180        for x in 0..3 {
1181            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1182        }
1183        // " bar " between matches stays default bg.
1184        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1185        for x in 8..11 {
1186            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1187        }
1188    }
1189
1190    #[test]
1191    fn search_bg_survives_cursorcolumn_overlay() {
1192        use regex::Regex;
1193        // Cursor sits on a `/foo` match. The cursorcolumn pass would
1194        // otherwise overwrite the search bg with column bg — verify
1195        // the match cells keep their search colour.
1196        let mut b = Buffer::from_str("foo bar foo");
1197        let v = vp(20, 1);
1198        let pat = Regex::new("foo").unwrap();
1199        // Cursor on column 1 (inside first `foo` match).
1200        b.set_cursor(crate::Position::new(0, 1));
1201        let view = BufferView {
1202            buffer: &b,
1203            viewport: &v,
1204            selection: None,
1205            resolver: &(no_styles as fn(u32) -> Style),
1206            cursor_line_bg: Style::default(),
1207            cursor_column_bg: Style::default().bg(Color::DarkGray),
1208            selection_bg: Style::default().bg(Color::Blue),
1209            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1210            gutter: None,
1211            search_bg: Style::default().bg(Color::Magenta),
1212            signs: &[],
1213            conceals: &[],
1214            spans: &[],
1215            search_pattern: Some(&pat),
1216            non_text_style: Style::default(),
1217            diag_overlays: &[],
1218            colorcolumn_cols: &[],
1219            colorcolumn_style: Style::default(),
1220        };
1221        let term = run_render(view, 20, 1);
1222        // Cursor cell at (1, 0) is in the search match. Search wins.
1223        assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
1224    }
1225
1226    #[test]
1227    fn highest_priority_sign_wins_per_row_and_overwrites_gutter() {
1228        let b = Buffer::from_str("a\nb\nc");
1229        let v = vp(10, 3);
1230        let signs = [
1231            Sign {
1232                row: 0,
1233                ch: 'W',
1234                style: Style::default().fg(Color::Yellow),
1235                priority: 1,
1236            },
1237            Sign {
1238                row: 0,
1239                ch: 'E',
1240                style: Style::default().fg(Color::Red),
1241                priority: 2,
1242            },
1243        ];
1244        let view = BufferView {
1245            buffer: &b,
1246            viewport: &v,
1247            selection: None,
1248            resolver: &(no_styles as fn(u32) -> Style),
1249            cursor_line_bg: Style::default(),
1250            cursor_column_bg: Style::default(),
1251            selection_bg: Style::default().bg(Color::Blue),
1252            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1253            gutter: Some(Gutter {
1254                width: 3,
1255                style: Style::default().fg(Color::DarkGray),
1256                line_offset: 0,
1257                ..Default::default()
1258            }),
1259            search_bg: Style::default(),
1260            signs: &signs,
1261            conceals: &[],
1262            spans: &[],
1263            search_pattern: None,
1264            non_text_style: Style::default(),
1265            diag_overlays: &[],
1266            colorcolumn_cols: &[],
1267            colorcolumn_style: Style::default(),
1268        };
1269        let term = run_render(view, 10, 3);
1270        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
1271        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1272        // Row 1 has no sign — leftmost cell stays as gutter content.
1273        assert_ne!(term.cell((0, 1)).unwrap().symbol(), "E");
1274    }
1275
1276    #[test]
1277    fn conceal_replaces_byte_range() {
1278        let b = Buffer::from_str("see https://example.com end");
1279        let v = vp(30, 1);
1280        let conceals = vec![Conceal {
1281            row: 0,
1282            start_byte: 4,                             // start of "https"
1283            end_byte: 4 + "https://example.com".len(), // end of URL
1284            replacement: "🔗".to_string(),
1285        }];
1286        let view = BufferView {
1287            buffer: &b,
1288            viewport: &v,
1289            selection: None,
1290            resolver: &(no_styles as fn(u32) -> Style),
1291            cursor_line_bg: Style::default(),
1292            cursor_column_bg: Style::default(),
1293            selection_bg: Style::default(),
1294            cursor_style: Style::default(),
1295            gutter: None,
1296            search_bg: Style::default(),
1297            signs: &[],
1298            conceals: &conceals,
1299            spans: &[],
1300            search_pattern: None,
1301            non_text_style: Style::default(),
1302            diag_overlays: &[],
1303            colorcolumn_cols: &[],
1304            colorcolumn_style: Style::default(),
1305        };
1306        let term = run_render(view, 30, 1);
1307        // Cells 0..=3: "see "
1308        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
1309        assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
1310        // Cell 4: the link emoji (a wide char takes 2 cells; we just
1311        // assert the first cell holds the replacement char).
1312        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
1313    }
1314
1315    #[test]
1316    fn closed_fold_collapses_rows_and_paints_marker() {
1317        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1318        let v = vp(30, 5);
1319        // Fold rows 1-3 closed. Visible should be: 'a', marker, 'e'.
1320        b.add_fold(1, 3, true);
1321        let view = BufferView {
1322            buffer: &b,
1323            viewport: &v,
1324            selection: None,
1325            resolver: &(no_styles as fn(u32) -> Style),
1326            cursor_line_bg: Style::default(),
1327            cursor_column_bg: Style::default(),
1328            selection_bg: Style::default().bg(Color::Blue),
1329            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1330            gutter: None,
1331            search_bg: Style::default(),
1332            signs: &[],
1333            conceals: &[],
1334            spans: &[],
1335            search_pattern: None,
1336            non_text_style: Style::default(),
1337            diag_overlays: &[],
1338            colorcolumn_cols: &[],
1339            colorcolumn_style: Style::default(),
1340        };
1341        let term = run_render(view, 30, 5);
1342        // Row 0: "a"
1343        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1344        // Row 1: fold marker — leading `▸ ` then the start row's
1345        // trimmed content + line count.
1346        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "▸");
1347        // Row 2: "e" (the 5th doc row, after the collapsed range).
1348        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
1349    }
1350
1351    #[test]
1352    fn open_fold_renders_normally() {
1353        let mut b = Buffer::from_str("a\nb\nc");
1354        let v = vp(5, 3);
1355        b.add_fold(0, 2, false); // open
1356        let view = BufferView {
1357            buffer: &b,
1358            viewport: &v,
1359            selection: None,
1360            resolver: &(no_styles as fn(u32) -> Style),
1361            cursor_line_bg: Style::default(),
1362            cursor_column_bg: Style::default(),
1363            selection_bg: Style::default().bg(Color::Blue),
1364            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1365            gutter: None,
1366            search_bg: Style::default(),
1367            signs: &[],
1368            conceals: &[],
1369            spans: &[],
1370            search_pattern: None,
1371            non_text_style: Style::default(),
1372            diag_overlays: &[],
1373            colorcolumn_cols: &[],
1374            colorcolumn_style: Style::default(),
1375        };
1376        let term = run_render(view, 5, 3);
1377        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1378        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1379        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
1380    }
1381
1382    #[test]
1383    fn horizontal_scroll_clips_left_chars() {
1384        let b = Buffer::from_str("abcdefgh");
1385        let mut v = vp(4, 1);
1386        v.top_col = 3;
1387        let view = BufferView {
1388            buffer: &b,
1389            viewport: &v,
1390            selection: None,
1391            resolver: &(no_styles as fn(u32) -> Style),
1392            cursor_line_bg: Style::default(),
1393            cursor_column_bg: Style::default(),
1394            selection_bg: Style::default().bg(Color::Blue),
1395            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1396            gutter: None,
1397            search_bg: Style::default(),
1398            signs: &[],
1399            conceals: &[],
1400            spans: &[],
1401            search_pattern: None,
1402            non_text_style: Style::default(),
1403            diag_overlays: &[],
1404            colorcolumn_cols: &[],
1405            colorcolumn_style: Style::default(),
1406        };
1407        let term = run_render(view, 4, 1);
1408        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
1409        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
1410    }
1411
1412    fn make_wrap_view<'a>(
1413        b: &'a Buffer,
1414        viewport: &'a Viewport,
1415        resolver: &'a (impl StyleResolver + 'a),
1416        gutter: Option<Gutter>,
1417    ) -> BufferView<'a, impl StyleResolver + 'a> {
1418        BufferView {
1419            buffer: b,
1420            viewport,
1421            selection: None,
1422            resolver,
1423            cursor_line_bg: Style::default(),
1424            cursor_column_bg: Style::default(),
1425            selection_bg: Style::default().bg(Color::Blue),
1426            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1427            gutter,
1428            search_bg: Style::default(),
1429            signs: &[],
1430            conceals: &[],
1431            spans: &[],
1432            search_pattern: None,
1433            non_text_style: Style::default(),
1434            diag_overlays: &[],
1435            colorcolumn_cols: &[],
1436            colorcolumn_style: Style::default(),
1437        }
1438    }
1439
1440    #[test]
1441    fn wrap_segments_char_breaks_at_width() {
1442        let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
1443        assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
1444    }
1445
1446    #[test]
1447    fn wrap_segments_word_backs_up_to_whitespace() {
1448        let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
1449        // First segment "alpha " ends after the space at idx 5.
1450        assert_eq!(segs[0], (0, 6));
1451        // Second segment "beta " ends after the space at idx 10.
1452        assert_eq!(segs[1], (6, 11));
1453        assert_eq!(segs[2], (11, 16));
1454    }
1455
1456    #[test]
1457    fn wrap_segments_word_falls_back_to_char_for_long_runs() {
1458        let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
1459        // No whitespace anywhere — degrades to a hard char break.
1460        assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
1461    }
1462
1463    #[test]
1464    fn wrap_char_paints_continuation_rows() {
1465        let b = Buffer::from_str("abcdefghij");
1466        let v = Viewport {
1467            top_row: 0,
1468            top_col: 0,
1469            width: 4,
1470            height: 3,
1471            wrap: Wrap::Char,
1472            text_width: 4,
1473            tab_width: 0,
1474        };
1475        let r = no_styles as fn(u32) -> Style;
1476        let view = make_wrap_view(&b, &v, &r, None);
1477        let term = run_render(view, 4, 3);
1478        // Row 0: "abcd"
1479        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1480        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
1481        // Row 1: "efgh"
1482        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
1483        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
1484        // Row 2: "ij"
1485        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
1486        assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
1487    }
1488
1489    #[test]
1490    fn wrap_char_gutter_blank_on_continuation() {
1491        let b = Buffer::from_str("abcdefgh");
1492        let v = Viewport {
1493            top_row: 0,
1494            top_col: 0,
1495            width: 6,
1496            height: 3,
1497            wrap: Wrap::Char,
1498            // Text area = 6 - 3 (gutter width) = 3.
1499            text_width: 3,
1500            tab_width: 0,
1501        };
1502        let r = no_styles as fn(u32) -> Style;
1503        let gutter = Gutter {
1504            width: 3,
1505            style: Style::default().fg(Color::Yellow),
1506            line_offset: 0,
1507            ..Default::default()
1508        };
1509        let view = make_wrap_view(&b, &v, &r, Some(gutter));
1510        let term = run_render(view, 6, 3);
1511        // Row 0: "  1" + "abc"
1512        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
1513        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
1514        // Row 1: blank gutter + "def"
1515        for x in 0..2 {
1516            assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
1517        }
1518        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
1519        assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
1520    }
1521
1522    #[test]
1523    fn wrap_char_cursor_lands_on_correct_segment() {
1524        let mut b = Buffer::from_str("abcdefghij");
1525        let v = Viewport {
1526            top_row: 0,
1527            top_col: 0,
1528            width: 4,
1529            height: 3,
1530            wrap: Wrap::Char,
1531            text_width: 4,
1532            tab_width: 0,
1533        };
1534        // Cursor on 'g' (col 6) should land on row 1, col 2.
1535        b.set_cursor(crate::Position::new(0, 6));
1536        let r = no_styles as fn(u32) -> Style;
1537        let mut view = make_wrap_view(&b, &v, &r, None);
1538        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1539        let term = run_render(view, 4, 3);
1540        assert!(
1541            term.cell((2, 1))
1542                .unwrap()
1543                .modifier
1544                .contains(Modifier::REVERSED)
1545        );
1546    }
1547
1548    #[test]
1549    fn wrap_char_eol_cursor_placeholder_on_last_segment() {
1550        let mut b = Buffer::from_str("abcdef");
1551        let v = Viewport {
1552            top_row: 0,
1553            top_col: 0,
1554            width: 4,
1555            height: 3,
1556            wrap: Wrap::Char,
1557            text_width: 4,
1558            tab_width: 0,
1559        };
1560        // Past-end cursor at col 6.
1561        b.set_cursor(crate::Position::new(0, 6));
1562        let r = no_styles as fn(u32) -> Style;
1563        let mut view = make_wrap_view(&b, &v, &r, None);
1564        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1565        let term = run_render(view, 4, 3);
1566        // Last segment is row 1 ("ef"), placeholder at x = 6 - 4 = 2.
1567        assert!(
1568            term.cell((2, 1))
1569                .unwrap()
1570                .modifier
1571                .contains(Modifier::REVERSED)
1572        );
1573    }
1574
1575    #[test]
1576    fn wrap_word_breaks_at_whitespace() {
1577        let b = Buffer::from_str("alpha beta gamma");
1578        let v = Viewport {
1579            top_row: 0,
1580            top_col: 0,
1581            width: 8,
1582            height: 3,
1583            wrap: Wrap::Word,
1584            text_width: 8,
1585            tab_width: 0,
1586        };
1587        let r = no_styles as fn(u32) -> Style;
1588        let view = make_wrap_view(&b, &v, &r, None);
1589        let term = run_render(view, 8, 3);
1590        // Row 0: "alpha "
1591        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1592        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1593        // Row 1: "beta "
1594        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1595        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
1596        // Row 2: "gamma"
1597        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
1598        assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
1599    }
1600
1601    // 0.0.37 — `BufferView` lost `Buffer::spans` / `Buffer::search_pattern`
1602    // and now takes them as parameters. The tests below cover the new
1603    // shape: empty/missing parameters, multi-row spans, regex hlsearch,
1604    // and the interaction with cursor / selection / wrap.
1605
1606    fn view_with<'a>(
1607        b: &'a Buffer,
1608        viewport: &'a Viewport,
1609        resolver: &'a (impl StyleResolver + 'a),
1610        spans: &'a [Vec<Span>],
1611        search_pattern: Option<&'a regex::Regex>,
1612    ) -> BufferView<'a, impl StyleResolver + 'a> {
1613        BufferView {
1614            buffer: b,
1615            viewport,
1616            selection: None,
1617            resolver,
1618            cursor_line_bg: Style::default(),
1619            cursor_column_bg: Style::default(),
1620            selection_bg: Style::default().bg(Color::Blue),
1621            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1622            gutter: None,
1623            search_bg: Style::default().bg(Color::Magenta),
1624            signs: &[],
1625            conceals: &[],
1626            spans,
1627            search_pattern,
1628            non_text_style: Style::default(),
1629            diag_overlays: &[],
1630            colorcolumn_cols: &[],
1631            colorcolumn_style: Style::default(),
1632        }
1633    }
1634
1635    #[test]
1636    fn empty_spans_param_renders_default_style() {
1637        let b = Buffer::from_str("hello");
1638        let v = vp(10, 1);
1639        let r = no_styles as fn(u32) -> Style;
1640        let view = view_with(&b, &v, &r, &[], None);
1641        let term = run_render(view, 10, 1);
1642        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
1643        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
1644    }
1645
1646    #[test]
1647    fn spans_param_paints_styled_byte_range() {
1648        let b = Buffer::from_str("abcdef");
1649        let v = vp(10, 1);
1650        let resolver = |id: u32| -> Style {
1651            if id == 3 {
1652                Style::default().fg(Color::Green)
1653            } else {
1654                Style::default()
1655            }
1656        };
1657        let spans = vec![vec![Span::new(0, 3, 3)]];
1658        let view = view_with(&b, &v, &resolver, &spans, None);
1659        let term = run_render(view, 10, 1);
1660        for x in 0..3 {
1661            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
1662        }
1663        assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
1664    }
1665
1666    #[test]
1667    fn spans_param_handles_per_row_overlay() {
1668        let b = Buffer::from_str("abc\ndef");
1669        let v = vp(10, 2);
1670        let resolver = |id: u32| -> Style {
1671            if id == 1 {
1672                Style::default().fg(Color::Red)
1673            } else {
1674                Style::default().fg(Color::Green)
1675            }
1676        };
1677        let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
1678        let view = view_with(&b, &v, &resolver, &spans, None);
1679        let term = run_render(view, 10, 2);
1680        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1681        assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
1682    }
1683
1684    #[test]
1685    fn spans_param_rows_beyond_get_no_styling() {
1686        let b = Buffer::from_str("abc\ndef\nghi");
1687        let v = vp(10, 3);
1688        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1689        // Only row 0 carries spans; rows 1 and 2 inherit default.
1690        let spans = vec![vec![Span::new(0, 3, 0)]];
1691        let view = view_with(&b, &v, &resolver, &spans, None);
1692        let term = run_render(view, 10, 3);
1693        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1694        assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
1695        assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
1696    }
1697
1698    #[test]
1699    fn search_pattern_none_disables_hlsearch() {
1700        let b = Buffer::from_str("foo bar foo");
1701        let v = vp(20, 1);
1702        let r = no_styles as fn(u32) -> Style;
1703        // No regex → no Magenta bg anywhere even though `search_bg` is set.
1704        let view = view_with(&b, &v, &r, &[], None);
1705        let term = run_render(view, 20, 1);
1706        for x in 0..11 {
1707            assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1708        }
1709    }
1710
1711    #[test]
1712    fn search_pattern_regex_paints_match_bg() {
1713        use regex::Regex;
1714        let b = Buffer::from_str("xyz foo xyz");
1715        let v = vp(20, 1);
1716        let r = no_styles as fn(u32) -> Style;
1717        let pat = Regex::new("foo").unwrap();
1718        let view = view_with(&b, &v, &r, &[], Some(&pat));
1719        let term = run_render(view, 20, 1);
1720        // "foo" is at chars 4..7; bg is Magenta there only.
1721        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1722        for x in 4..7 {
1723            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1724        }
1725        assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
1726    }
1727
1728    #[test]
1729    fn search_pattern_unicode_columns_are_charwise() {
1730        use regex::Regex;
1731        // "tablé foo" — match "foo" must land on char column 6, not byte.
1732        let b = Buffer::from_str("tablé foo");
1733        let v = vp(20, 1);
1734        let r = no_styles as fn(u32) -> Style;
1735        let pat = Regex::new("foo").unwrap();
1736        let view = view_with(&b, &v, &r, &[], Some(&pat));
1737        let term = run_render(view, 20, 1);
1738        // "tablé" is 5 chars + space = 6, then "foo" at 6..9.
1739        assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
1740        assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
1741        assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
1742    }
1743
1744    #[test]
1745    fn spans_param_clamps_short_row_overlay() {
1746        // Row 0 has 3 chars; span past end shouldn't crash or smear.
1747        let b = Buffer::from_str("abc");
1748        let v = vp(10, 1);
1749        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1750        let spans = vec![vec![Span::new(0, 100, 0)]];
1751        let view = view_with(&b, &v, &resolver, &spans, None);
1752        let term = run_render(view, 10, 1);
1753        for x in 0..3 {
1754            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1755        }
1756    }
1757
1758    #[test]
1759    fn spans_and_search_pattern_compose() {
1760        // hlsearch bg layers on top of the syntax span fg.
1761        use regex::Regex;
1762        let b = Buffer::from_str("foo");
1763        let v = vp(10, 1);
1764        let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
1765        let spans = vec![vec![Span::new(0, 3, 0)]];
1766        let pat = Regex::new("foo").unwrap();
1767        let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
1768        let term = run_render(view, 10, 1);
1769        let cell = term.cell((1, 0)).unwrap();
1770        assert_eq!(cell.fg, Color::Green);
1771        assert_eq!(cell.bg, Color::Magenta);
1772    }
1773
1774    /// Rows past the last buffer line paint `~` at the first text column
1775    /// (vim's NonText marker). The `non_text_style` fg is applied to those
1776    /// cells; all other cells on those rows stay default.
1777    #[test]
1778    fn tilde_marker_painted_past_eof() {
1779        // 5-line buffer rendered in a 10-row viewport.
1780        let b = Buffer::from_str("a\nb\nc\nd\ne");
1781        let v = vp(10, 10);
1782        let r = no_styles as fn(u32) -> Style;
1783        let non_text_fg = Color::DarkGray;
1784        let view = BufferView {
1785            buffer: &b,
1786            viewport: &v,
1787            selection: None,
1788            resolver: &r,
1789            cursor_line_bg: Style::default(),
1790            cursor_column_bg: Style::default(),
1791            selection_bg: Style::default().bg(Color::Blue),
1792            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1793            gutter: None,
1794            search_bg: Style::default(),
1795            signs: &[],
1796            conceals: &[],
1797            spans: &[],
1798            search_pattern: None,
1799            non_text_style: Style::default().fg(non_text_fg),
1800            diag_overlays: &[],
1801            colorcolumn_cols: &[],
1802            colorcolumn_style: Style::default(),
1803        };
1804        let term = run_render(view, 10, 10);
1805        // Rows 0-4 have content — first cell should NOT be `~`.
1806        for row in 0..5u16 {
1807            assert_ne!(
1808                term.cell((0, row)).unwrap().symbol(),
1809                "~",
1810                "row {row} is a content row, expected no tilde"
1811            );
1812        }
1813        // Rows 5-9 are past EOF — should have `~` at column 0 with non_text fg.
1814        for row in 5..10u16 {
1815            let cell = term.cell((0, row)).unwrap();
1816            assert_eq!(cell.symbol(), "~", "row {row} is past EOF, expected tilde");
1817            assert_eq!(
1818                cell.fg, non_text_fg,
1819                "row {row} tilde should use non_text_style fg"
1820            );
1821            // Rest of the row should be blank.
1822            for x in 1..10u16 {
1823                assert_eq!(
1824                    term.cell((x, row)).unwrap().symbol(),
1825                    " ",
1826                    "row {row} col {x} after tilde should be blank"
1827                );
1828            }
1829        }
1830    }
1831
1832    /// When a gutter is present, rows past EOF paint a blank gutter and
1833    /// `~` at the first text column (after the gutter).
1834    #[test]
1835    fn tilde_marker_with_gutter_past_eof() {
1836        let b = Buffer::from_str("a\nb");
1837        let v = vp(10, 5);
1838        let r = no_styles as fn(u32) -> Style;
1839        let non_text_fg = Color::DarkGray;
1840        let view = BufferView {
1841            buffer: &b,
1842            viewport: &v,
1843            selection: None,
1844            resolver: &r,
1845            cursor_line_bg: Style::default(),
1846            cursor_column_bg: Style::default(),
1847            selection_bg: Style::default().bg(Color::Blue),
1848            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1849            gutter: Some(Gutter {
1850                width: 4,
1851                style: Style::default().fg(Color::Yellow),
1852                line_offset: 0,
1853                numbers: GutterNumbers::Absolute,
1854            }),
1855            search_bg: Style::default(),
1856            signs: &[],
1857            conceals: &[],
1858            spans: &[],
1859            search_pattern: None,
1860            non_text_style: Style::default().fg(non_text_fg),
1861            diag_overlays: &[],
1862            colorcolumn_cols: &[],
1863            colorcolumn_style: Style::default(),
1864        };
1865        let term = run_render(view, 10, 5);
1866        // Rows 2-4 are past EOF.
1867        for row in 2..5u16 {
1868            // Gutter (cols 0-3) should be blank.
1869            for x in 0..4u16 {
1870                assert_eq!(
1871                    term.cell((x, row)).unwrap().symbol(),
1872                    " ",
1873                    "gutter col {x} on past-EOF row {row} should be blank"
1874                );
1875            }
1876            // Text area starts at col 4: should have `~`.
1877            let cell = term.cell((4, row)).unwrap();
1878            assert_eq!(
1879                cell.symbol(),
1880                "~",
1881                "past-EOF row {row}: expected tilde at text column"
1882            );
1883            assert_eq!(cell.fg, non_text_fg);
1884        }
1885    }
1886
1887    #[test]
1888    fn diag_overlay_paints_underline_on_range() {
1889        // Render "hello world" and apply a DiagOverlay from col 6 to 11.
1890        // The cells in that range must carry the UNDERLINED modifier; cells
1891        // outside must not.
1892        let b = Buffer::from_str("hello world");
1893        let v = vp(20, 2);
1894        let overlay = DiagOverlay {
1895            row: 0,
1896            col_start: 6,
1897            col_end: 11,
1898            style: Style::default().add_modifier(Modifier::UNDERLINED),
1899        };
1900        let view = BufferView {
1901            buffer: &b,
1902            viewport: &v,
1903            selection: None,
1904            resolver: &(no_styles as fn(u32) -> Style),
1905            cursor_line_bg: Style::default(),
1906            cursor_column_bg: Style::default(),
1907            selection_bg: Style::default().bg(Color::Blue),
1908            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1909            gutter: None,
1910            search_bg: Style::default(),
1911            signs: &[],
1912            conceals: &[],
1913            spans: &[],
1914            search_pattern: None,
1915            non_text_style: Style::default(),
1916            diag_overlays: &[overlay],
1917            colorcolumn_cols: &[],
1918            colorcolumn_style: Style::default(),
1919        };
1920        let term = run_render(view, 20, 2);
1921
1922        // Cols 0-5 ("hello ") must NOT be underlined.
1923        for x in 0u16..6 {
1924            let cell = term.cell((x, 0)).unwrap();
1925            assert!(
1926                !cell.modifier.contains(Modifier::UNDERLINED),
1927                "col {x} must not be underlined (outside overlay)"
1928            );
1929        }
1930        // Cols 6-10 ("world") must be underlined.
1931        for x in 6u16..11 {
1932            let cell = term.cell((x, 0)).unwrap();
1933            assert!(
1934                cell.modifier.contains(Modifier::UNDERLINED),
1935                "col {x} must be underlined (inside overlay)"
1936            );
1937        }
1938        // Col 11 (past end, space) must NOT be underlined.
1939        let cell = term.cell((11, 0)).unwrap();
1940        assert!(
1941            !cell.modifier.contains(Modifier::UNDERLINED),
1942            "col 11 must not be underlined (past overlay end)"
1943        );
1944    }
1945
1946    #[test]
1947    fn diag_overlay_out_of_viewport_is_ignored() {
1948        // Overlay on row 5, viewport height = 3 → must not panic or paint.
1949        let b = Buffer::from_str("a\nb\nc");
1950        let v = vp(10, 3);
1951        let overlay = DiagOverlay {
1952            row: 5,
1953            col_start: 0,
1954            col_end: 1,
1955            style: Style::default().add_modifier(Modifier::UNDERLINED),
1956        };
1957        let view = BufferView {
1958            buffer: &b,
1959            viewport: &v,
1960            selection: None,
1961            resolver: &(no_styles as fn(u32) -> Style),
1962            cursor_line_bg: Style::default(),
1963            cursor_column_bg: Style::default(),
1964            selection_bg: Style::default().bg(Color::Blue),
1965            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1966            gutter: None,
1967            search_bg: Style::default(),
1968            signs: &[],
1969            conceals: &[],
1970            spans: &[],
1971            search_pattern: None,
1972            non_text_style: Style::default(),
1973            diag_overlays: &[overlay],
1974            colorcolumn_cols: &[],
1975            colorcolumn_style: Style::default(),
1976        };
1977        // Must not panic.
1978        let _term = run_render(view, 10, 3);
1979    }
1980}