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    /// Resolve the final style for a byte by layering every span that
798    /// contains it, broadest first and narrowest last. `Style::patch` keeps
799    /// the broader span's fields when the narrower span doesn't override
800    /// them, so a wide `@markup.raw.block` carrying just `bg = codeblock`
801    /// shines through under a narrow `@keyword` carrying just `fg = mauve`,
802    /// matching vim/Helix's layered hi-group model.
803    ///
804    /// Pre-0.6.1 behaviour was narrowest-wins-completely: only one span's
805    /// style applied per byte, so broader-span backgrounds were dropped
806    /// whenever a narrower foreground span overlapped them. That made it
807    /// impossible to give markdown code blocks a tinted bg without also
808    /// burdening every injected language's captures with the same bg.
809    ///
810    /// Hosts that want the old behaviour can ensure their narrower spans
811    /// set every field explicitly — `Style::patch` only carries broader
812    /// fields through `None` slots.
813    fn resolve_span_style(&self, row_spans: &[crate::Span], byte_offset: usize) -> Option<Style> {
814        // Collect every span containing this byte, sorted broadest first.
815        let mut overlapping: Vec<&crate::Span> = row_spans
816            .iter()
817            .filter(|s| byte_offset >= s.start_byte && byte_offset < s.end_byte)
818            .collect();
819        if overlapping.is_empty() {
820            return None;
821        }
822        overlapping.sort_by_key(|s| std::cmp::Reverse(s.end_byte.saturating_sub(s.start_byte)));
823        let mut style = self.resolver.resolve(overlapping[0].style);
824        for s in &overlapping[1..] {
825            style = style.patch(self.resolver.resolve(s.style));
826        }
827        Some(style)
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use ratatui::style::{Color, Modifier};
835    use ratatui::widgets::Widget;
836
837    fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
838        let area = Rect::new(0, 0, w, h);
839        let mut buf = TermBuffer::empty(area);
840        view.render(area, &mut buf);
841        buf
842    }
843
844    fn no_styles(_id: u32) -> Style {
845        Style::default()
846    }
847
848    /// Build a default viewport for plain (no-wrap) tests.
849    fn vp(width: u16, height: u16) -> Viewport {
850        Viewport {
851            top_row: 0,
852            top_col: 0,
853            width,
854            height,
855            wrap: Wrap::None,
856            text_width: width,
857            tab_width: 0,
858        }
859    }
860
861    #[test]
862    fn renders_plain_chars_into_terminal_buffer() {
863        let b = Buffer::from_str("hello\nworld");
864        let v = vp(20, 5);
865        let view = BufferView {
866            buffer: &b,
867            viewport: &v,
868            selection: None,
869            resolver: &(no_styles as fn(u32) -> Style),
870            cursor_line_bg: Style::default(),
871            cursor_column_bg: Style::default(),
872            selection_bg: Style::default().bg(Color::Blue),
873            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
874            gutter: None,
875            search_bg: Style::default(),
876            signs: &[],
877            conceals: &[],
878            spans: &[],
879            search_pattern: None,
880            non_text_style: Style::default(),
881            diag_overlays: &[],
882            colorcolumn_cols: &[],
883            colorcolumn_style: Style::default(),
884        };
885        let term = run_render(view, 20, 5);
886        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
887        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
888        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
889        assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
890    }
891
892    #[test]
893    fn cursor_cell_gets_reversed_style() {
894        let mut b = Buffer::from_str("abc");
895        let v = vp(10, 1);
896        b.set_cursor(crate::Position::new(0, 1));
897        let view = BufferView {
898            buffer: &b,
899            viewport: &v,
900            selection: None,
901            resolver: &(no_styles as fn(u32) -> Style),
902            cursor_line_bg: Style::default(),
903            cursor_column_bg: Style::default(),
904            selection_bg: Style::default().bg(Color::Blue),
905            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
906            gutter: None,
907            search_bg: Style::default(),
908            signs: &[],
909            conceals: &[],
910            spans: &[],
911            search_pattern: None,
912            non_text_style: Style::default(),
913            diag_overlays: &[],
914            colorcolumn_cols: &[],
915            colorcolumn_style: Style::default(),
916        };
917        let term = run_render(view, 10, 1);
918        let cursor_cell = term.cell((1, 0)).unwrap();
919        assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
920    }
921
922    #[test]
923    fn selection_bg_applies_only_to_selected_cells() {
924        use crate::{Position, Selection};
925        let b = Buffer::from_str("abcdef");
926        let v = vp(10, 1);
927        let view = BufferView {
928            buffer: &b,
929            viewport: &v,
930            selection: Some(Selection::Char {
931                anchor: Position::new(0, 1),
932                head: Position::new(0, 3),
933            }),
934            resolver: &(no_styles as fn(u32) -> Style),
935            cursor_line_bg: Style::default(),
936            cursor_column_bg: Style::default(),
937            selection_bg: Style::default().bg(Color::Blue),
938            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
939            gutter: None,
940            search_bg: Style::default(),
941            signs: &[],
942            conceals: &[],
943            spans: &[],
944            search_pattern: None,
945            non_text_style: Style::default(),
946            diag_overlays: &[],
947            colorcolumn_cols: &[],
948            colorcolumn_style: Style::default(),
949        };
950        let term = run_render(view, 10, 1);
951        assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
952        for x in 1..=3 {
953            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
954        }
955        assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
956    }
957
958    #[test]
959    fn layered_spans_blend_broad_bg_with_narrow_fg() {
960        // Regression: a wide `@markup.raw.block`-style span carrying only
961        // `bg = ...` must shine through a narrow `@keyword`-style span
962        // carrying only `fg = ...`. Pre-0.6.1 the narrow span won outright
963        // and dropped the broad bg, which made markdown code-block tinting
964        // impossible without bloating every injected language's captures.
965        use crate::Span;
966        let b = Buffer::from_str("fn main() {}");
967        let v = vp(20, 1);
968        // id=1 = broad code-block bg, id=2 = narrow keyword fg.
969        let spans = vec![vec![
970            Span::new(0, 12, 1), // bg-only, whole line
971            Span::new(0, 2, 2),  // fg-only, just "fn"
972        ]];
973        let resolver = |id: u32| -> Style {
974            match id {
975                1 => Style::default().bg(Color::DarkGray),
976                2 => Style::default().fg(Color::Magenta),
977                _ => Style::default(),
978            }
979        };
980        let view = BufferView {
981            buffer: &b,
982            viewport: &v,
983            selection: None,
984            resolver: &resolver,
985            cursor_line_bg: Style::default(),
986            cursor_column_bg: Style::default(),
987            selection_bg: Style::default().bg(Color::Blue),
988            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
989            gutter: None,
990            search_bg: Style::default(),
991            signs: &[],
992            conceals: &[],
993            spans: &spans,
994            search_pattern: None,
995            non_text_style: Style::default(),
996            diag_overlays: &[],
997            colorcolumn_cols: &[],
998            colorcolumn_style: Style::default(),
999        };
1000        let term = run_render(view, 20, 1);
1001        // Cols 0-1 ("fn"): narrow fg + broad bg.
1002        for x in 0u16..2 {
1003            let cell = term.cell((x, 0)).unwrap();
1004            assert_eq!(cell.fg, Color::Magenta, "col {x}: fg from narrow span");
1005            assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1006        }
1007        // Cols 2-11 (" main() {}"): broad bg only, no fg set.
1008        for x in 2u16..12 {
1009            let cell = term.cell((x, 0)).unwrap();
1010            assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1011            assert_eq!(
1012                cell.fg,
1013                Color::Reset,
1014                "col {x}: no fg set (broad span is bg-only)"
1015            );
1016        }
1017    }
1018
1019    #[test]
1020    fn narrow_span_with_explicit_bg_still_overrides_broad_bg() {
1021        // Regression: a narrow span that DOES set bg must override the
1022        // broader span's bg. Earlier "narrowest-wins-completely" behaviour
1023        // had this trivially; the new layered logic relies on
1024        // `Style::patch` overriding only set fields, so we pin it.
1025        use crate::Span;
1026        let b = Buffer::from_str("hello world");
1027        let v = vp(20, 1);
1028        let spans = vec![vec![
1029            Span::new(0, 11, 1), // broad bg = DarkGray
1030            Span::new(6, 11, 2), // narrow bg = Red (overrides)
1031        ]];
1032        let resolver = |id: u32| -> Style {
1033            match id {
1034                1 => Style::default().bg(Color::DarkGray),
1035                2 => Style::default().bg(Color::Red),
1036                _ => Style::default(),
1037            }
1038        };
1039        let view = BufferView {
1040            buffer: &b,
1041            viewport: &v,
1042            selection: None,
1043            resolver: &resolver,
1044            cursor_line_bg: Style::default(),
1045            cursor_column_bg: Style::default(),
1046            selection_bg: Style::default().bg(Color::Blue),
1047            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1048            gutter: None,
1049            search_bg: Style::default(),
1050            signs: &[],
1051            conceals: &[],
1052            spans: &spans,
1053            search_pattern: None,
1054            non_text_style: Style::default(),
1055            diag_overlays: &[],
1056            colorcolumn_cols: &[],
1057            colorcolumn_style: Style::default(),
1058        };
1059        let term = run_render(view, 20, 1);
1060        // Cols 0-5 ("hello "): broad bg only.
1061        for x in 0u16..6 {
1062            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::DarkGray);
1063        }
1064        // Cols 6-10 ("world"): narrow bg wins.
1065        for x in 6u16..11 {
1066            assert_eq!(
1067                term.cell((x, 0)).unwrap().bg,
1068                Color::Red,
1069                "col {x}: narrow span's bg overrides broad bg"
1070            );
1071        }
1072    }
1073
1074    #[test]
1075    fn syntax_span_fg_resolves_via_table() {
1076        use crate::Span;
1077        let b = Buffer::from_str("SELECT foo");
1078        let v = vp(20, 1);
1079        let spans = vec![vec![Span::new(0, 6, 7)]];
1080        let resolver = |id: u32| -> Style {
1081            if id == 7 {
1082                Style::default().fg(Color::Red)
1083            } else {
1084                Style::default()
1085            }
1086        };
1087        let view = BufferView {
1088            buffer: &b,
1089            viewport: &v,
1090            selection: None,
1091            resolver: &resolver,
1092            cursor_line_bg: Style::default(),
1093            cursor_column_bg: Style::default(),
1094            selection_bg: Style::default().bg(Color::Blue),
1095            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1096            gutter: None,
1097            search_bg: Style::default(),
1098            signs: &[],
1099            conceals: &[],
1100            spans: &spans,
1101            search_pattern: None,
1102            non_text_style: Style::default(),
1103            diag_overlays: &[],
1104            colorcolumn_cols: &[],
1105            colorcolumn_style: Style::default(),
1106        };
1107        let term = run_render(view, 20, 1);
1108        for x in 0..6 {
1109            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1110        }
1111    }
1112
1113    #[test]
1114    fn gutter_renders_right_aligned_line_numbers() {
1115        let b = Buffer::from_str("a\nb\nc");
1116        let v = vp(10, 3);
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: Some(Gutter {
1127                width: 4,
1128                style: Style::default().fg(Color::Yellow),
1129                line_offset: 0,
1130                ..Default::default()
1131            }),
1132            search_bg: Style::default(),
1133            signs: &[],
1134            conceals: &[],
1135            spans: &[],
1136            search_pattern: None,
1137            non_text_style: Style::default(),
1138            diag_overlays: &[],
1139            colorcolumn_cols: &[],
1140            colorcolumn_style: Style::default(),
1141        };
1142        let term = run_render(view, 10, 3);
1143        // Width 4 = 3 number cells + 1 spacer; right-aligned "  1".
1144        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1145        assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
1146        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1147        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
1148        // Text shifted right past the gutter.
1149        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1150    }
1151
1152    #[test]
1153    fn gutter_renders_relative_with_cursor_at_zero() {
1154        // 5 rows, cursor on row 2 (0-based). Relative: row 2 → 0, row 0 → 2, row 4 → 2.
1155        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1156        b.set_cursor(crate::Position::new(2, 0));
1157        let v = vp(10, 5);
1158        let view = BufferView {
1159            buffer: &b,
1160            viewport: &v,
1161            selection: None,
1162            resolver: &(no_styles as fn(u32) -> Style),
1163            cursor_line_bg: Style::default(),
1164            cursor_column_bg: Style::default(),
1165            selection_bg: Style::default().bg(Color::Blue),
1166            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1167            gutter: Some(Gutter {
1168                width: 4,
1169                style: Style::default().fg(Color::Yellow),
1170                line_offset: 0,
1171                numbers: GutterNumbers::Relative { cursor_row: 2 },
1172            }),
1173            search_bg: Style::default(),
1174            signs: &[],
1175            conceals: &[],
1176            spans: &[],
1177            search_pattern: None,
1178            non_text_style: Style::default(),
1179            diag_overlays: &[],
1180            colorcolumn_cols: &[],
1181            colorcolumn_style: Style::default(),
1182        };
1183        let term = run_render(view, 10, 5);
1184        // Width 4 = 3 number cells + 1 spacer.
1185        // Row 0 (doc 0): distance from cursor (2) = 2 → "  2"
1186        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "2");
1187        // Row 1 (doc 1): distance = 1 → "  1"
1188        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "1");
1189        // Row 2 (doc 2): cursor row → "  0"
1190        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "0");
1191        // Row 3 (doc 3): distance = 1 → "  1"
1192        assert_eq!(term.cell((2, 3)).unwrap().symbol(), "1");
1193        // Row 4 (doc 4): distance = 2 → "  2"
1194        assert_eq!(term.cell((2, 4)).unwrap().symbol(), "2");
1195    }
1196
1197    #[test]
1198    fn gutter_renders_hybrid_cursor_row_absolute() {
1199        // 3 rows, cursor on row 1 (0-based). Hybrid: row 1 → absolute (2),
1200        // row 0 → offset 1, row 2 → offset 1.
1201        let mut b = Buffer::from_str("a\nb\nc");
1202        b.set_cursor(crate::Position::new(1, 0));
1203        let v = vp(10, 3);
1204        let view = BufferView {
1205            buffer: &b,
1206            viewport: &v,
1207            selection: None,
1208            resolver: &(no_styles as fn(u32) -> Style),
1209            cursor_line_bg: Style::default(),
1210            cursor_column_bg: Style::default(),
1211            selection_bg: Style::default().bg(Color::Blue),
1212            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1213            gutter: Some(Gutter {
1214                width: 4,
1215                style: Style::default().fg(Color::Yellow),
1216                line_offset: 0,
1217                numbers: GutterNumbers::Hybrid { cursor_row: 1 },
1218            }),
1219            search_bg: Style::default(),
1220            signs: &[],
1221            conceals: &[],
1222            spans: &[],
1223            search_pattern: None,
1224            non_text_style: Style::default(),
1225            diag_overlays: &[],
1226            colorcolumn_cols: &[],
1227            colorcolumn_style: Style::default(),
1228        };
1229        let term = run_render(view, 10, 3);
1230        // Row 0 (doc 0): offset from cursor row 1 → 1
1231        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1232        // Row 1 (doc 1): cursor row → absolute 2
1233        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1234        // Row 2 (doc 2): offset from cursor row 1 → 1
1235        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "1");
1236    }
1237
1238    #[test]
1239    fn gutter_none_paints_blank_cells() {
1240        let b = Buffer::from_str("a\nb\nc");
1241        let v = vp(10, 3);
1242        let view = BufferView {
1243            buffer: &b,
1244            viewport: &v,
1245            selection: None,
1246            resolver: &(no_styles as fn(u32) -> Style),
1247            cursor_line_bg: Style::default(),
1248            cursor_column_bg: Style::default(),
1249            selection_bg: Style::default().bg(Color::Blue),
1250            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1251            gutter: Some(Gutter {
1252                width: 4,
1253                style: Style::default().fg(Color::Yellow),
1254                line_offset: 0,
1255                numbers: GutterNumbers::None,
1256            }),
1257            search_bg: Style::default(),
1258            signs: &[],
1259            conceals: &[],
1260            spans: &[],
1261            search_pattern: None,
1262            non_text_style: Style::default(),
1263            diag_overlays: &[],
1264            colorcolumn_cols: &[],
1265            colorcolumn_style: Style::default(),
1266        };
1267        let term = run_render(view, 10, 3);
1268        // All gutter cells (0..4) on every row should be blank spaces.
1269        for row in 0..3u16 {
1270            for x in 0..4u16 {
1271                assert_eq!(
1272                    term.cell((x, row)).unwrap().symbol(),
1273                    " ",
1274                    "expected blank at ({x}, {row})"
1275                );
1276            }
1277        }
1278        // Text still appears shifted right past the gutter.
1279        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1280    }
1281
1282    #[test]
1283    fn search_bg_paints_match_cells() {
1284        use regex::Regex;
1285        let b = Buffer::from_str("foo bar foo");
1286        let v = vp(20, 1);
1287        let pat = Regex::new("foo").unwrap();
1288        let view = BufferView {
1289            buffer: &b,
1290            viewport: &v,
1291            selection: None,
1292            resolver: &(no_styles as fn(u32) -> Style),
1293            cursor_line_bg: Style::default(),
1294            cursor_column_bg: Style::default(),
1295            selection_bg: Style::default().bg(Color::Blue),
1296            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1297            gutter: None,
1298            search_bg: Style::default().bg(Color::Magenta),
1299            signs: &[],
1300            conceals: &[],
1301            spans: &[],
1302            search_pattern: Some(&pat),
1303            non_text_style: Style::default(),
1304            diag_overlays: &[],
1305            colorcolumn_cols: &[],
1306            colorcolumn_style: Style::default(),
1307        };
1308        let term = run_render(view, 20, 1);
1309        for x in 0..3 {
1310            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1311        }
1312        // " bar " between matches stays default bg.
1313        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1314        for x in 8..11 {
1315            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1316        }
1317    }
1318
1319    #[test]
1320    fn search_bg_survives_cursorcolumn_overlay() {
1321        use regex::Regex;
1322        // Cursor sits on a `/foo` match. The cursorcolumn pass would
1323        // otherwise overwrite the search bg with column bg — verify
1324        // the match cells keep their search colour.
1325        let mut b = Buffer::from_str("foo bar foo");
1326        let v = vp(20, 1);
1327        let pat = Regex::new("foo").unwrap();
1328        // Cursor on column 1 (inside first `foo` match).
1329        b.set_cursor(crate::Position::new(0, 1));
1330        let view = BufferView {
1331            buffer: &b,
1332            viewport: &v,
1333            selection: None,
1334            resolver: &(no_styles as fn(u32) -> Style),
1335            cursor_line_bg: Style::default(),
1336            cursor_column_bg: Style::default().bg(Color::DarkGray),
1337            selection_bg: Style::default().bg(Color::Blue),
1338            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1339            gutter: None,
1340            search_bg: Style::default().bg(Color::Magenta),
1341            signs: &[],
1342            conceals: &[],
1343            spans: &[],
1344            search_pattern: Some(&pat),
1345            non_text_style: Style::default(),
1346            diag_overlays: &[],
1347            colorcolumn_cols: &[],
1348            colorcolumn_style: Style::default(),
1349        };
1350        let term = run_render(view, 20, 1);
1351        // Cursor cell at (1, 0) is in the search match. Search wins.
1352        assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
1353    }
1354
1355    #[test]
1356    fn highest_priority_sign_wins_per_row_and_overwrites_gutter() {
1357        let b = Buffer::from_str("a\nb\nc");
1358        let v = vp(10, 3);
1359        let signs = [
1360            Sign {
1361                row: 0,
1362                ch: 'W',
1363                style: Style::default().fg(Color::Yellow),
1364                priority: 1,
1365            },
1366            Sign {
1367                row: 0,
1368                ch: 'E',
1369                style: Style::default().fg(Color::Red),
1370                priority: 2,
1371            },
1372        ];
1373        let view = BufferView {
1374            buffer: &b,
1375            viewport: &v,
1376            selection: None,
1377            resolver: &(no_styles as fn(u32) -> Style),
1378            cursor_line_bg: Style::default(),
1379            cursor_column_bg: Style::default(),
1380            selection_bg: Style::default().bg(Color::Blue),
1381            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1382            gutter: Some(Gutter {
1383                width: 3,
1384                style: Style::default().fg(Color::DarkGray),
1385                line_offset: 0,
1386                ..Default::default()
1387            }),
1388            search_bg: Style::default(),
1389            signs: &signs,
1390            conceals: &[],
1391            spans: &[],
1392            search_pattern: None,
1393            non_text_style: Style::default(),
1394            diag_overlays: &[],
1395            colorcolumn_cols: &[],
1396            colorcolumn_style: Style::default(),
1397        };
1398        let term = run_render(view, 10, 3);
1399        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
1400        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1401        // Row 1 has no sign — leftmost cell stays as gutter content.
1402        assert_ne!(term.cell((0, 1)).unwrap().symbol(), "E");
1403    }
1404
1405    #[test]
1406    fn conceal_replaces_byte_range() {
1407        let b = Buffer::from_str("see https://example.com end");
1408        let v = vp(30, 1);
1409        let conceals = vec![Conceal {
1410            row: 0,
1411            start_byte: 4,                             // start of "https"
1412            end_byte: 4 + "https://example.com".len(), // end of URL
1413            replacement: "🔗".to_string(),
1414        }];
1415        let view = BufferView {
1416            buffer: &b,
1417            viewport: &v,
1418            selection: None,
1419            resolver: &(no_styles as fn(u32) -> Style),
1420            cursor_line_bg: Style::default(),
1421            cursor_column_bg: Style::default(),
1422            selection_bg: Style::default(),
1423            cursor_style: Style::default(),
1424            gutter: None,
1425            search_bg: Style::default(),
1426            signs: &[],
1427            conceals: &conceals,
1428            spans: &[],
1429            search_pattern: None,
1430            non_text_style: Style::default(),
1431            diag_overlays: &[],
1432            colorcolumn_cols: &[],
1433            colorcolumn_style: Style::default(),
1434        };
1435        let term = run_render(view, 30, 1);
1436        // Cells 0..=3: "see "
1437        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
1438        assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
1439        // Cell 4: the link emoji (a wide char takes 2 cells; we just
1440        // assert the first cell holds the replacement char).
1441        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
1442    }
1443
1444    #[test]
1445    fn closed_fold_collapses_rows_and_paints_marker() {
1446        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1447        let v = vp(30, 5);
1448        // Fold rows 1-3 closed. Visible should be: 'a', marker, 'e'.
1449        b.add_fold(1, 3, true);
1450        let view = BufferView {
1451            buffer: &b,
1452            viewport: &v,
1453            selection: None,
1454            resolver: &(no_styles as fn(u32) -> Style),
1455            cursor_line_bg: Style::default(),
1456            cursor_column_bg: Style::default(),
1457            selection_bg: Style::default().bg(Color::Blue),
1458            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1459            gutter: None,
1460            search_bg: Style::default(),
1461            signs: &[],
1462            conceals: &[],
1463            spans: &[],
1464            search_pattern: None,
1465            non_text_style: Style::default(),
1466            diag_overlays: &[],
1467            colorcolumn_cols: &[],
1468            colorcolumn_style: Style::default(),
1469        };
1470        let term = run_render(view, 30, 5);
1471        // Row 0: "a"
1472        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1473        // Row 1: fold marker — leading `▸ ` then the start row's
1474        // trimmed content + line count.
1475        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "▸");
1476        // Row 2: "e" (the 5th doc row, after the collapsed range).
1477        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
1478    }
1479
1480    #[test]
1481    fn open_fold_renders_normally() {
1482        let mut b = Buffer::from_str("a\nb\nc");
1483        let v = vp(5, 3);
1484        b.add_fold(0, 2, false); // open
1485        let view = BufferView {
1486            buffer: &b,
1487            viewport: &v,
1488            selection: None,
1489            resolver: &(no_styles as fn(u32) -> Style),
1490            cursor_line_bg: Style::default(),
1491            cursor_column_bg: Style::default(),
1492            selection_bg: Style::default().bg(Color::Blue),
1493            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1494            gutter: None,
1495            search_bg: Style::default(),
1496            signs: &[],
1497            conceals: &[],
1498            spans: &[],
1499            search_pattern: None,
1500            non_text_style: Style::default(),
1501            diag_overlays: &[],
1502            colorcolumn_cols: &[],
1503            colorcolumn_style: Style::default(),
1504        };
1505        let term = run_render(view, 5, 3);
1506        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1507        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1508        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
1509    }
1510
1511    #[test]
1512    fn horizontal_scroll_clips_left_chars() {
1513        let b = Buffer::from_str("abcdefgh");
1514        let mut v = vp(4, 1);
1515        v.top_col = 3;
1516        let view = BufferView {
1517            buffer: &b,
1518            viewport: &v,
1519            selection: None,
1520            resolver: &(no_styles as fn(u32) -> Style),
1521            cursor_line_bg: Style::default(),
1522            cursor_column_bg: Style::default(),
1523            selection_bg: Style::default().bg(Color::Blue),
1524            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1525            gutter: None,
1526            search_bg: Style::default(),
1527            signs: &[],
1528            conceals: &[],
1529            spans: &[],
1530            search_pattern: None,
1531            non_text_style: Style::default(),
1532            diag_overlays: &[],
1533            colorcolumn_cols: &[],
1534            colorcolumn_style: Style::default(),
1535        };
1536        let term = run_render(view, 4, 1);
1537        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
1538        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
1539    }
1540
1541    fn make_wrap_view<'a>(
1542        b: &'a Buffer,
1543        viewport: &'a Viewport,
1544        resolver: &'a (impl StyleResolver + 'a),
1545        gutter: Option<Gutter>,
1546    ) -> BufferView<'a, impl StyleResolver + 'a> {
1547        BufferView {
1548            buffer: b,
1549            viewport,
1550            selection: None,
1551            resolver,
1552            cursor_line_bg: Style::default(),
1553            cursor_column_bg: Style::default(),
1554            selection_bg: Style::default().bg(Color::Blue),
1555            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1556            gutter,
1557            search_bg: Style::default(),
1558            signs: &[],
1559            conceals: &[],
1560            spans: &[],
1561            search_pattern: None,
1562            non_text_style: Style::default(),
1563            diag_overlays: &[],
1564            colorcolumn_cols: &[],
1565            colorcolumn_style: Style::default(),
1566        }
1567    }
1568
1569    #[test]
1570    fn wrap_segments_char_breaks_at_width() {
1571        let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
1572        assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
1573    }
1574
1575    #[test]
1576    fn wrap_segments_word_backs_up_to_whitespace() {
1577        let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
1578        // First segment "alpha " ends after the space at idx 5.
1579        assert_eq!(segs[0], (0, 6));
1580        // Second segment "beta " ends after the space at idx 10.
1581        assert_eq!(segs[1], (6, 11));
1582        assert_eq!(segs[2], (11, 16));
1583    }
1584
1585    #[test]
1586    fn wrap_segments_word_falls_back_to_char_for_long_runs() {
1587        let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
1588        // No whitespace anywhere — degrades to a hard char break.
1589        assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
1590    }
1591
1592    #[test]
1593    fn wrap_char_paints_continuation_rows() {
1594        let b = Buffer::from_str("abcdefghij");
1595        let v = Viewport {
1596            top_row: 0,
1597            top_col: 0,
1598            width: 4,
1599            height: 3,
1600            wrap: Wrap::Char,
1601            text_width: 4,
1602            tab_width: 0,
1603        };
1604        let r = no_styles as fn(u32) -> Style;
1605        let view = make_wrap_view(&b, &v, &r, None);
1606        let term = run_render(view, 4, 3);
1607        // Row 0: "abcd"
1608        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1609        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
1610        // Row 1: "efgh"
1611        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
1612        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
1613        // Row 2: "ij"
1614        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
1615        assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
1616    }
1617
1618    #[test]
1619    fn wrap_char_gutter_blank_on_continuation() {
1620        let b = Buffer::from_str("abcdefgh");
1621        let v = Viewport {
1622            top_row: 0,
1623            top_col: 0,
1624            width: 6,
1625            height: 3,
1626            wrap: Wrap::Char,
1627            // Text area = 6 - 3 (gutter width) = 3.
1628            text_width: 3,
1629            tab_width: 0,
1630        };
1631        let r = no_styles as fn(u32) -> Style;
1632        let gutter = Gutter {
1633            width: 3,
1634            style: Style::default().fg(Color::Yellow),
1635            line_offset: 0,
1636            ..Default::default()
1637        };
1638        let view = make_wrap_view(&b, &v, &r, Some(gutter));
1639        let term = run_render(view, 6, 3);
1640        // Row 0: "  1" + "abc"
1641        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
1642        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
1643        // Row 1: blank gutter + "def"
1644        for x in 0..2 {
1645            assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
1646        }
1647        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
1648        assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
1649    }
1650
1651    #[test]
1652    fn wrap_char_cursor_lands_on_correct_segment() {
1653        let mut b = Buffer::from_str("abcdefghij");
1654        let v = Viewport {
1655            top_row: 0,
1656            top_col: 0,
1657            width: 4,
1658            height: 3,
1659            wrap: Wrap::Char,
1660            text_width: 4,
1661            tab_width: 0,
1662        };
1663        // Cursor on 'g' (col 6) should land on row 1, col 2.
1664        b.set_cursor(crate::Position::new(0, 6));
1665        let r = no_styles as fn(u32) -> Style;
1666        let mut view = make_wrap_view(&b, &v, &r, None);
1667        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1668        let term = run_render(view, 4, 3);
1669        assert!(
1670            term.cell((2, 1))
1671                .unwrap()
1672                .modifier
1673                .contains(Modifier::REVERSED)
1674        );
1675    }
1676
1677    #[test]
1678    fn wrap_char_eol_cursor_placeholder_on_last_segment() {
1679        let mut b = Buffer::from_str("abcdef");
1680        let v = Viewport {
1681            top_row: 0,
1682            top_col: 0,
1683            width: 4,
1684            height: 3,
1685            wrap: Wrap::Char,
1686            text_width: 4,
1687            tab_width: 0,
1688        };
1689        // Past-end cursor at col 6.
1690        b.set_cursor(crate::Position::new(0, 6));
1691        let r = no_styles as fn(u32) -> Style;
1692        let mut view = make_wrap_view(&b, &v, &r, None);
1693        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1694        let term = run_render(view, 4, 3);
1695        // Last segment is row 1 ("ef"), placeholder at x = 6 - 4 = 2.
1696        assert!(
1697            term.cell((2, 1))
1698                .unwrap()
1699                .modifier
1700                .contains(Modifier::REVERSED)
1701        );
1702    }
1703
1704    #[test]
1705    fn wrap_word_breaks_at_whitespace() {
1706        let b = Buffer::from_str("alpha beta gamma");
1707        let v = Viewport {
1708            top_row: 0,
1709            top_col: 0,
1710            width: 8,
1711            height: 3,
1712            wrap: Wrap::Word,
1713            text_width: 8,
1714            tab_width: 0,
1715        };
1716        let r = no_styles as fn(u32) -> Style;
1717        let view = make_wrap_view(&b, &v, &r, None);
1718        let term = run_render(view, 8, 3);
1719        // Row 0: "alpha "
1720        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1721        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1722        // Row 1: "beta "
1723        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1724        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
1725        // Row 2: "gamma"
1726        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
1727        assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
1728    }
1729
1730    // 0.0.37 — `BufferView` lost `Buffer::spans` / `Buffer::search_pattern`
1731    // and now takes them as parameters. The tests below cover the new
1732    // shape: empty/missing parameters, multi-row spans, regex hlsearch,
1733    // and the interaction with cursor / selection / wrap.
1734
1735    fn view_with<'a>(
1736        b: &'a Buffer,
1737        viewport: &'a Viewport,
1738        resolver: &'a (impl StyleResolver + 'a),
1739        spans: &'a [Vec<Span>],
1740        search_pattern: Option<&'a regex::Regex>,
1741    ) -> BufferView<'a, impl StyleResolver + 'a> {
1742        BufferView {
1743            buffer: b,
1744            viewport,
1745            selection: None,
1746            resolver,
1747            cursor_line_bg: Style::default(),
1748            cursor_column_bg: Style::default(),
1749            selection_bg: Style::default().bg(Color::Blue),
1750            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1751            gutter: None,
1752            search_bg: Style::default().bg(Color::Magenta),
1753            signs: &[],
1754            conceals: &[],
1755            spans,
1756            search_pattern,
1757            non_text_style: Style::default(),
1758            diag_overlays: &[],
1759            colorcolumn_cols: &[],
1760            colorcolumn_style: Style::default(),
1761        }
1762    }
1763
1764    #[test]
1765    fn empty_spans_param_renders_default_style() {
1766        let b = Buffer::from_str("hello");
1767        let v = vp(10, 1);
1768        let r = no_styles as fn(u32) -> Style;
1769        let view = view_with(&b, &v, &r, &[], None);
1770        let term = run_render(view, 10, 1);
1771        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
1772        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
1773    }
1774
1775    #[test]
1776    fn spans_param_paints_styled_byte_range() {
1777        let b = Buffer::from_str("abcdef");
1778        let v = vp(10, 1);
1779        let resolver = |id: u32| -> Style {
1780            if id == 3 {
1781                Style::default().fg(Color::Green)
1782            } else {
1783                Style::default()
1784            }
1785        };
1786        let spans = vec![vec![Span::new(0, 3, 3)]];
1787        let view = view_with(&b, &v, &resolver, &spans, None);
1788        let term = run_render(view, 10, 1);
1789        for x in 0..3 {
1790            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
1791        }
1792        assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
1793    }
1794
1795    #[test]
1796    fn spans_param_handles_per_row_overlay() {
1797        let b = Buffer::from_str("abc\ndef");
1798        let v = vp(10, 2);
1799        let resolver = |id: u32| -> Style {
1800            if id == 1 {
1801                Style::default().fg(Color::Red)
1802            } else {
1803                Style::default().fg(Color::Green)
1804            }
1805        };
1806        let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
1807        let view = view_with(&b, &v, &resolver, &spans, None);
1808        let term = run_render(view, 10, 2);
1809        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1810        assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
1811    }
1812
1813    #[test]
1814    fn spans_param_rows_beyond_get_no_styling() {
1815        let b = Buffer::from_str("abc\ndef\nghi");
1816        let v = vp(10, 3);
1817        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1818        // Only row 0 carries spans; rows 1 and 2 inherit default.
1819        let spans = vec![vec![Span::new(0, 3, 0)]];
1820        let view = view_with(&b, &v, &resolver, &spans, None);
1821        let term = run_render(view, 10, 3);
1822        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1823        assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
1824        assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
1825    }
1826
1827    #[test]
1828    fn search_pattern_none_disables_hlsearch() {
1829        let b = Buffer::from_str("foo bar foo");
1830        let v = vp(20, 1);
1831        let r = no_styles as fn(u32) -> Style;
1832        // No regex → no Magenta bg anywhere even though `search_bg` is set.
1833        let view = view_with(&b, &v, &r, &[], None);
1834        let term = run_render(view, 20, 1);
1835        for x in 0..11 {
1836            assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1837        }
1838    }
1839
1840    #[test]
1841    fn search_pattern_regex_paints_match_bg() {
1842        use regex::Regex;
1843        let b = Buffer::from_str("xyz foo xyz");
1844        let v = vp(20, 1);
1845        let r = no_styles as fn(u32) -> Style;
1846        let pat = Regex::new("foo").unwrap();
1847        let view = view_with(&b, &v, &r, &[], Some(&pat));
1848        let term = run_render(view, 20, 1);
1849        // "foo" is at chars 4..7; bg is Magenta there only.
1850        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1851        for x in 4..7 {
1852            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1853        }
1854        assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
1855    }
1856
1857    #[test]
1858    fn search_pattern_unicode_columns_are_charwise() {
1859        use regex::Regex;
1860        // "tablé foo" — match "foo" must land on char column 6, not byte.
1861        let b = Buffer::from_str("tablé foo");
1862        let v = vp(20, 1);
1863        let r = no_styles as fn(u32) -> Style;
1864        let pat = Regex::new("foo").unwrap();
1865        let view = view_with(&b, &v, &r, &[], Some(&pat));
1866        let term = run_render(view, 20, 1);
1867        // "tablé" is 5 chars + space = 6, then "foo" at 6..9.
1868        assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
1869        assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
1870        assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
1871    }
1872
1873    #[test]
1874    fn spans_param_clamps_short_row_overlay() {
1875        // Row 0 has 3 chars; span past end shouldn't crash or smear.
1876        let b = Buffer::from_str("abc");
1877        let v = vp(10, 1);
1878        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1879        let spans = vec![vec![Span::new(0, 100, 0)]];
1880        let view = view_with(&b, &v, &resolver, &spans, None);
1881        let term = run_render(view, 10, 1);
1882        for x in 0..3 {
1883            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1884        }
1885    }
1886
1887    #[test]
1888    fn spans_and_search_pattern_compose() {
1889        // hlsearch bg layers on top of the syntax span fg.
1890        use regex::Regex;
1891        let b = Buffer::from_str("foo");
1892        let v = vp(10, 1);
1893        let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
1894        let spans = vec![vec![Span::new(0, 3, 0)]];
1895        let pat = Regex::new("foo").unwrap();
1896        let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
1897        let term = run_render(view, 10, 1);
1898        let cell = term.cell((1, 0)).unwrap();
1899        assert_eq!(cell.fg, Color::Green);
1900        assert_eq!(cell.bg, Color::Magenta);
1901    }
1902
1903    /// Rows past the last buffer line paint `~` at the first text column
1904    /// (vim's NonText marker). The `non_text_style` fg is applied to those
1905    /// cells; all other cells on those rows stay default.
1906    #[test]
1907    fn tilde_marker_painted_past_eof() {
1908        // 5-line buffer rendered in a 10-row viewport.
1909        let b = Buffer::from_str("a\nb\nc\nd\ne");
1910        let v = vp(10, 10);
1911        let r = no_styles as fn(u32) -> Style;
1912        let non_text_fg = Color::DarkGray;
1913        let view = BufferView {
1914            buffer: &b,
1915            viewport: &v,
1916            selection: None,
1917            resolver: &r,
1918            cursor_line_bg: Style::default(),
1919            cursor_column_bg: Style::default(),
1920            selection_bg: Style::default().bg(Color::Blue),
1921            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1922            gutter: None,
1923            search_bg: Style::default(),
1924            signs: &[],
1925            conceals: &[],
1926            spans: &[],
1927            search_pattern: None,
1928            non_text_style: Style::default().fg(non_text_fg),
1929            diag_overlays: &[],
1930            colorcolumn_cols: &[],
1931            colorcolumn_style: Style::default(),
1932        };
1933        let term = run_render(view, 10, 10);
1934        // Rows 0-4 have content — first cell should NOT be `~`.
1935        for row in 0..5u16 {
1936            assert_ne!(
1937                term.cell((0, row)).unwrap().symbol(),
1938                "~",
1939                "row {row} is a content row, expected no tilde"
1940            );
1941        }
1942        // Rows 5-9 are past EOF — should have `~` at column 0 with non_text fg.
1943        for row in 5..10u16 {
1944            let cell = term.cell((0, row)).unwrap();
1945            assert_eq!(cell.symbol(), "~", "row {row} is past EOF, expected tilde");
1946            assert_eq!(
1947                cell.fg, non_text_fg,
1948                "row {row} tilde should use non_text_style fg"
1949            );
1950            // Rest of the row should be blank.
1951            for x in 1..10u16 {
1952                assert_eq!(
1953                    term.cell((x, row)).unwrap().symbol(),
1954                    " ",
1955                    "row {row} col {x} after tilde should be blank"
1956                );
1957            }
1958        }
1959    }
1960
1961    /// When a gutter is present, rows past EOF paint a blank gutter and
1962    /// `~` at the first text column (after the gutter).
1963    #[test]
1964    fn tilde_marker_with_gutter_past_eof() {
1965        let b = Buffer::from_str("a\nb");
1966        let v = vp(10, 5);
1967        let r = no_styles as fn(u32) -> Style;
1968        let non_text_fg = Color::DarkGray;
1969        let view = BufferView {
1970            buffer: &b,
1971            viewport: &v,
1972            selection: None,
1973            resolver: &r,
1974            cursor_line_bg: Style::default(),
1975            cursor_column_bg: Style::default(),
1976            selection_bg: Style::default().bg(Color::Blue),
1977            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1978            gutter: Some(Gutter {
1979                width: 4,
1980                style: Style::default().fg(Color::Yellow),
1981                line_offset: 0,
1982                numbers: GutterNumbers::Absolute,
1983            }),
1984            search_bg: Style::default(),
1985            signs: &[],
1986            conceals: &[],
1987            spans: &[],
1988            search_pattern: None,
1989            non_text_style: Style::default().fg(non_text_fg),
1990            diag_overlays: &[],
1991            colorcolumn_cols: &[],
1992            colorcolumn_style: Style::default(),
1993        };
1994        let term = run_render(view, 10, 5);
1995        // Rows 2-4 are past EOF.
1996        for row in 2..5u16 {
1997            // Gutter (cols 0-3) should be blank.
1998            for x in 0..4u16 {
1999                assert_eq!(
2000                    term.cell((x, row)).unwrap().symbol(),
2001                    " ",
2002                    "gutter col {x} on past-EOF row {row} should be blank"
2003                );
2004            }
2005            // Text area starts at col 4: should have `~`.
2006            let cell = term.cell((4, row)).unwrap();
2007            assert_eq!(
2008                cell.symbol(),
2009                "~",
2010                "past-EOF row {row}: expected tilde at text column"
2011            );
2012            assert_eq!(cell.fg, non_text_fg);
2013        }
2014    }
2015
2016    #[test]
2017    fn diag_overlay_paints_underline_on_range() {
2018        // Render "hello world" and apply a DiagOverlay from col 6 to 11.
2019        // The cells in that range must carry the UNDERLINED modifier; cells
2020        // outside must not.
2021        let b = Buffer::from_str("hello world");
2022        let v = vp(20, 2);
2023        let overlay = DiagOverlay {
2024            row: 0,
2025            col_start: 6,
2026            col_end: 11,
2027            style: Style::default().add_modifier(Modifier::UNDERLINED),
2028        };
2029        let view = BufferView {
2030            buffer: &b,
2031            viewport: &v,
2032            selection: None,
2033            resolver: &(no_styles as fn(u32) -> Style),
2034            cursor_line_bg: Style::default(),
2035            cursor_column_bg: Style::default(),
2036            selection_bg: Style::default().bg(Color::Blue),
2037            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2038            gutter: None,
2039            search_bg: Style::default(),
2040            signs: &[],
2041            conceals: &[],
2042            spans: &[],
2043            search_pattern: None,
2044            non_text_style: Style::default(),
2045            diag_overlays: &[overlay],
2046            colorcolumn_cols: &[],
2047            colorcolumn_style: Style::default(),
2048        };
2049        let term = run_render(view, 20, 2);
2050
2051        // Cols 0-5 ("hello ") must NOT be underlined.
2052        for x in 0u16..6 {
2053            let cell = term.cell((x, 0)).unwrap();
2054            assert!(
2055                !cell.modifier.contains(Modifier::UNDERLINED),
2056                "col {x} must not be underlined (outside overlay)"
2057            );
2058        }
2059        // Cols 6-10 ("world") must be underlined.
2060        for x in 6u16..11 {
2061            let cell = term.cell((x, 0)).unwrap();
2062            assert!(
2063                cell.modifier.contains(Modifier::UNDERLINED),
2064                "col {x} must be underlined (inside overlay)"
2065            );
2066        }
2067        // Col 11 (past end, space) must NOT be underlined.
2068        let cell = term.cell((11, 0)).unwrap();
2069        assert!(
2070            !cell.modifier.contains(Modifier::UNDERLINED),
2071            "col 11 must not be underlined (past overlay end)"
2072        );
2073    }
2074
2075    #[test]
2076    fn diag_overlay_out_of_viewport_is_ignored() {
2077        // Overlay on row 5, viewport height = 3 → must not panic or paint.
2078        let b = Buffer::from_str("a\nb\nc");
2079        let v = vp(10, 3);
2080        let overlay = DiagOverlay {
2081            row: 5,
2082            col_start: 0,
2083            col_end: 1,
2084            style: Style::default().add_modifier(Modifier::UNDERLINED),
2085        };
2086        let view = BufferView {
2087            buffer: &b,
2088            viewport: &v,
2089            selection: None,
2090            resolver: &(no_styles as fn(u32) -> Style),
2091            cursor_line_bg: Style::default(),
2092            cursor_column_bg: Style::default(),
2093            selection_bg: Style::default().bg(Color::Blue),
2094            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2095            gutter: None,
2096            search_bg: Style::default(),
2097            signs: &[],
2098            conceals: &[],
2099            spans: &[],
2100            search_pattern: None,
2101            non_text_style: Style::default(),
2102            diag_overlays: &[overlay],
2103            colorcolumn_cols: &[],
2104            colorcolumn_style: Style::default(),
2105        };
2106        // Must not panic.
2107        let _term = run_render(view, 10, 3);
2108    }
2109}