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