Skip to main content

hjkl_buffer/
render.rs

1//! Direct cell-write `ratatui::widgets::Widget` for [`crate::Buffer`].
2//!
3//! Replaces the tui-textarea + Paragraph render path. Writes one
4//! cell at a time so we can layer syntax span fg, cursor-line bg,
5//! cursor cell REVERSED, and selection bg in a single pass without
6//! the grapheme / wrap machinery `Paragraph` does. Per-row cache
7//! keyed on `dirty_gen + selection + cursor row + viewport top_col`
8//! makes the steady-state render essentially free.
9//!
10//! Caller wraps a `&Buffer` in [`BufferView`], hands it the style
11//! table that resolves opaque [`crate::Span`] style ids to real
12//! ratatui styles, and renders into a `ratatui::Frame`.
13
14use ratatui::buffer::Buffer as TermBuffer;
15use ratatui::layout::Rect;
16use ratatui::style::Style;
17use ratatui::widgets::Widget;
18use unicode_width::UnicodeWidthChar;
19
20use crate::wrap::wrap_segments;
21use crate::{Buffer, Selection, Span, Viewport, Wrap};
22
23/// Resolves an opaque [`crate::Span::style`] id to a real ratatui
24/// style. The buffer doesn't know about colours; the host (sqeel-vim
25/// or any future user) keeps a lookup table.
26pub trait StyleResolver {
27    fn resolve(&self, style_id: u32) -> Style;
28}
29
30/// Convenience impl so simple closures can drive the renderer.
31impl<F: Fn(u32) -> Style> StyleResolver for F {
32    fn resolve(&self, style_id: u32) -> Style {
33        self(style_id)
34    }
35}
36
37/// Render-time wrapper around `&Buffer` that carries the optional
38/// [`Selection`] + a [`StyleResolver`]. Created per draw, dropped
39/// when the frame is done — cheap, holds only refs.
40///
41/// 0.0.34 (Patch C-δ.1): added the [`viewport`] field. The viewport
42/// previously lived on the buffer itself; with the relocation to the
43/// engine `Host`, the renderer takes a borrow per draw.
44///
45/// 0.0.37: added the [`spans`] and [`search_pattern`] fields. Per-row
46/// syntax spans + the active `/` regex used to live on the buffer
47/// (`Buffer::spans` / `Buffer::search_pattern`); both moved out per
48/// step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The host now feeds
49/// each into the view per draw — populated from
50/// `Editor::buffer_spans()` and `Editor::search_state().pattern`.
51pub struct BufferView<'a, R: StyleResolver> {
52    pub buffer: &'a Buffer,
53    /// Viewport snapshot the host published this frame. Owned by the
54    /// engine `Host`; the renderer borrows for the duration of the
55    /// draw.
56    pub viewport: &'a Viewport,
57    pub selection: Option<Selection>,
58    pub resolver: &'a R,
59    /// Bg painted across the cursor row (vim's `cursorline`). Pass
60    /// `Style::default()` to disable.
61    pub cursor_line_bg: Style,
62    /// Bg painted down the cursor column (vim's `cursorcolumn`). Pass
63    /// `Style::default()` to disable.
64    pub cursor_column_bg: Style,
65    /// Bg painted under selected cells. Composed over syntax fg.
66    pub selection_bg: Style,
67    /// Style for the cursor cell. `REVERSED` is the conventional
68    /// choice; works against any theme.
69    pub cursor_style: Style,
70    /// Optional left-side line-number gutter. `width` includes the
71    /// trailing space separating the number from text. Pass `None`
72    /// to disable. Numbers are 1-based, right-aligned.
73    pub gutter: Option<Gutter>,
74    /// Bg painted under cells covered by an active `/` search match.
75    /// `Style::default()` to disable.
76    pub search_bg: Style,
77    /// Per-row gutter signs (LSP diagnostic dots, git diff markers,
78    /// …). Painted into the leftmost gutter column after the line
79    /// number, so they overwrite the leading space tui-style gutters
80    /// reserve. Highest-priority sign per row wins.
81    pub signs: &'a [Sign],
82    /// Per-row substitutions applied at render time. Each conceal
83    /// hides the byte range `[start_byte, end_byte)` and paints
84    /// `replacement` in its place. Empty slice = no conceals.
85    pub conceals: &'a [Conceal],
86    /// Per-row syntax spans the host has computed for this frame.
87    /// `spans[row]` carries the styled byte ranges for that row;
88    /// rows beyond `spans.len()` get no syntax styling. Pass `&[]`
89    /// for hosts without syntax integration.
90    ///
91    /// 0.0.37: lifted out of `Buffer` per step 3 of
92    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine populates
93    /// this via `Editor::buffer_spans()`.
94    pub spans: &'a [Vec<Span>],
95    /// Active `/` search regex, if any. The renderer paints
96    /// [`Self::search_bg`] under cells that match. Pass `None` to
97    /// disable hlsearch.
98    ///
99    /// 0.0.37: lifted out of `Buffer` (was `Buffer::search_pattern`)
100    /// per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine
101    /// publishes the pattern via `Editor::search_state().pattern`.
102    pub search_pattern: Option<&'a regex::Regex>,
103}
104
105/// Configuration for the line-number gutter rendered to the left of
106/// the text area. `width` is the total cell count reserved
107/// (including any trailing spacer); the renderer right-aligns the
108/// 1-based row number into the leftmost `width - 1` cells.
109///
110/// `line_offset` is added to the displayed line number, so a host
111/// rendering a windowed view of a larger document (e.g. picker preview
112/// of a 7000-line buffer) can show the original line numbers instead
113/// of starting at 1.
114#[derive(Debug, Clone, Copy, Default)]
115pub struct Gutter {
116    pub width: u16,
117    pub style: Style,
118    pub line_offset: usize,
119}
120
121/// Single-cell marker painted into the leftmost gutter column for a
122/// document row. Used by hosts to surface LSP diagnostics, git diff
123/// signs, etc. Higher `priority` wins when multiple signs land on
124/// the same row.
125#[derive(Debug, Clone, Copy)]
126pub struct Sign {
127    pub row: usize,
128    pub ch: char,
129    pub style: Style,
130    pub priority: u8,
131}
132
133/// Render-time substitution that hides a byte range and paints
134/// `replacement` in its place. The buffer's content stays unchanged;
135/// only the rendered cells differ. Used by hosts to pretty-print
136/// URLs, conceal markdown markers, etc.
137#[derive(Debug, Clone)]
138pub struct Conceal {
139    pub row: usize,
140    pub start_byte: usize,
141    pub end_byte: usize,
142    pub replacement: String,
143}
144
145impl<R: StyleResolver> Widget for BufferView<'_, R> {
146    fn render(self, area: Rect, term_buf: &mut TermBuffer) {
147        let viewport = *self.viewport;
148        let cursor = self.buffer.cursor();
149        let lines = self.buffer.lines();
150        let spans = self.spans;
151        let folds = self.buffer.folds();
152        let top_row = viewport.top_row;
153        let top_col = viewport.top_col;
154
155        let gutter_width = self.gutter.map(|g| g.width).unwrap_or(0);
156        let text_area = Rect {
157            x: area.x.saturating_add(gutter_width),
158            y: area.y,
159            width: area.width.saturating_sub(gutter_width),
160            height: area.height,
161        };
162
163        let total_rows = lines.len();
164        let mut doc_row = top_row;
165        let mut screen_row: u16 = 0;
166        let wrap_mode = viewport.wrap;
167        let seg_width = if viewport.text_width > 0 {
168            viewport.text_width
169        } else {
170            text_area.width
171        };
172        // Per-screen-row flag: true when the cell at the cursor's
173        // column on that screen row is part of an active `/` search
174        // match. The cursorcolumn pass uses this to skip cells that
175        // search bg already painted, so search highlight wins over
176        // the column bg.
177        let mut search_hit_at_cursor_col: Vec<bool> = Vec::new();
178        // Walk the document forward, skipping rows hidden by closed
179        // folds. Emit the start row of a closed fold as a marker
180        // line instead of its actual content.
181        while doc_row < total_rows && screen_row < area.height {
182            // Skip rows hidden by a closed fold (any row past start
183            // of a closed fold).
184            if folds.iter().any(|f| f.hides(doc_row)) {
185                doc_row += 1;
186                continue;
187            }
188            let folded_at_start = folds
189                .iter()
190                .find(|f| f.closed && f.start_row == doc_row)
191                .copied();
192            let line = &lines[doc_row];
193            let row_spans = spans.get(doc_row).map(Vec::as_slice).unwrap_or(&[]);
194            let sel_range = self.selection.and_then(|s| s.row_span(doc_row));
195            let is_cursor_row = doc_row == cursor.row;
196            if let Some(fold) = folded_at_start {
197                if let Some(gutter) = self.gutter {
198                    self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
199                    self.paint_signs(term_buf, area, screen_row, doc_row);
200                }
201                self.paint_fold_marker(term_buf, text_area, screen_row, fold, line, is_cursor_row);
202                search_hit_at_cursor_col.push(false);
203                screen_row += 1;
204                doc_row = fold.end_row + 1;
205                continue;
206            }
207            let search_ranges = self.row_search_ranges(line);
208            let row_has_hit_at_cursor_col = search_ranges
209                .iter()
210                .any(|&(s, e)| cursor.col >= s && cursor.col < e);
211            // Collect conceals for this row, sorted by start_byte.
212            let row_conceals: Vec<&Conceal> = {
213                let mut v: Vec<&Conceal> =
214                    self.conceals.iter().filter(|c| c.row == doc_row).collect();
215                v.sort_by_key(|c| c.start_byte);
216                v
217            };
218            // Compute screen segments for this doc row. `Wrap::None`
219            // produces a single segment that spans the whole line; the
220            // existing `top_col` horizontal scroll is preserved by
221            // passing `top_col` as the segment start. Wrap modes split
222            // the line into multiple visual rows that fit
223            // `viewport.text_width` (falls back to `text_area.width`
224            // when the host hasn't published a text width yet).
225            let segments = match wrap_mode {
226                Wrap::None => vec![(top_col, usize::MAX)],
227                _ => wrap_segments(line, seg_width, wrap_mode),
228            };
229            let last_seg_idx = segments.len().saturating_sub(1);
230            for (seg_idx, &(seg_start, seg_end)) in segments.iter().enumerate() {
231                if screen_row >= area.height {
232                    break;
233                }
234                if let Some(gutter) = self.gutter {
235                    if seg_idx == 0 {
236                        self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
237                        self.paint_signs(term_buf, area, screen_row, doc_row);
238                    } else {
239                        self.paint_blank_gutter(term_buf, area, screen_row, gutter);
240                    }
241                }
242                self.paint_row(
243                    term_buf,
244                    text_area,
245                    screen_row,
246                    line,
247                    row_spans,
248                    sel_range,
249                    &search_ranges,
250                    is_cursor_row,
251                    cursor.col,
252                    seg_start,
253                    seg_end,
254                    seg_idx == last_seg_idx,
255                    &row_conceals,
256                );
257                search_hit_at_cursor_col.push(row_has_hit_at_cursor_col);
258                screen_row += 1;
259            }
260            doc_row += 1;
261        }
262        // Cursorcolumn pass: layer the bg over the cursor's visible
263        // column once every row is painted so it composes on top of
264        // syntax / cursorline backgrounds without disturbing fg.
265        // Skipped when wrapping — the cursor's screen x depends on the
266        // segment it lands in, and vim's cursorcolumn semantics with
267        // wrap are fuzzy. Revisit if it bites.
268        if matches!(wrap_mode, Wrap::None)
269            && self.cursor_column_bg != Style::default()
270            && cursor.col >= top_col
271            && (cursor.col - top_col) < text_area.width as usize
272        {
273            let x = text_area.x + (cursor.col - top_col) as u16;
274            for sy in 0..screen_row {
275                // Skip rows where search bg already painted this cell —
276                // search highlight wins over cursorcolumn so `/foo`
277                // matches stay readable when the cursor sits on them.
278                if search_hit_at_cursor_col
279                    .get(sy as usize)
280                    .copied()
281                    .unwrap_or(false)
282                {
283                    continue;
284                }
285                let y = text_area.y + sy;
286                if let Some(cell) = term_buf.cell_mut((x, y)) {
287                    cell.set_style(cell.style().patch(self.cursor_column_bg));
288                }
289            }
290        }
291    }
292}
293
294impl<R: StyleResolver> BufferView<'_, R> {
295    /// Run the active search regex against `line` and return the
296    /// charwise `(start_col, end_col_exclusive)` ranges that need
297    /// the search bg painted. Empty when no pattern is set.
298    fn row_search_ranges(&self, line: &str) -> Vec<(usize, usize)> {
299        let Some(re) = self.search_pattern else {
300            return Vec::new();
301        };
302        re.find_iter(line)
303            .map(|m| {
304                let start = line[..m.start()].chars().count();
305                let end = line[..m.end()].chars().count();
306                (start, end)
307            })
308            .collect()
309    }
310
311    fn paint_fold_marker(
312        &self,
313        term_buf: &mut TermBuffer,
314        area: Rect,
315        screen_row: u16,
316        fold: crate::Fold,
317        first_line: &str,
318        is_cursor_row: bool,
319    ) {
320        let y = area.y + screen_row;
321        let style = if is_cursor_row && self.cursor_line_bg != Style::default() {
322            self.cursor_line_bg
323        } else {
324            Style::default()
325        };
326        // Bg the whole row first so the marker reads like one cell.
327        for x in area.x..(area.x + area.width) {
328            if let Some(cell) = term_buf.cell_mut((x, y)) {
329                cell.set_style(style);
330            }
331        }
332        // Build a label that hints at the fold's contents instead of
333        // a generic "+-- N lines folded --". Use the start row's
334        // trimmed text (truncated) plus the line count.
335        let prefix = first_line.trim();
336        let count = fold.line_count();
337        let label = if prefix.is_empty() {
338            format!("▸ {count} lines folded")
339        } else {
340            const MAX_PREFIX: usize = 60;
341            let trimmed = if prefix.chars().count() > MAX_PREFIX {
342                let head: String = prefix.chars().take(MAX_PREFIX - 1).collect();
343                format!("{head}…")
344            } else {
345                prefix.to_string()
346            };
347            format!("▸ {trimmed}  ({count} lines)")
348        };
349        let mut x = area.x;
350        let row_end_x = area.x + area.width;
351        for ch in label.chars() {
352            if x >= row_end_x {
353                break;
354            }
355            let width = ch.width().unwrap_or(1) as u16;
356            if x + width > row_end_x {
357                break;
358            }
359            if let Some(cell) = term_buf.cell_mut((x, y)) {
360                cell.set_char(ch);
361                cell.set_style(style);
362            }
363            x = x.saturating_add(width);
364        }
365    }
366
367    fn paint_signs(&self, term_buf: &mut TermBuffer, area: Rect, screen_row: u16, doc_row: usize) {
368        let Some(sign) = self
369            .signs
370            .iter()
371            .filter(|s| s.row == doc_row)
372            .max_by_key(|s| s.priority)
373        else {
374            return;
375        };
376        let y = area.y + screen_row;
377        let x = area.x;
378        if let Some(cell) = term_buf.cell_mut((x, y)) {
379            cell.set_char(sign.ch);
380            cell.set_style(sign.style);
381        }
382    }
383
384    /// Paint a wrap-continuation gutter row: blank cells in the
385    /// gutter style so the bg stays continuous, no line number.
386    fn paint_blank_gutter(
387        &self,
388        term_buf: &mut TermBuffer,
389        area: Rect,
390        screen_row: u16,
391        gutter: Gutter,
392    ) {
393        let y = area.y + screen_row;
394        for x in area.x..(area.x + gutter.width) {
395            if let Some(cell) = term_buf.cell_mut((x, y)) {
396                cell.set_char(' ');
397                cell.set_style(gutter.style);
398            }
399        }
400    }
401
402    fn paint_gutter(
403        &self,
404        term_buf: &mut TermBuffer,
405        area: Rect,
406        screen_row: u16,
407        doc_row: usize,
408        gutter: Gutter,
409    ) {
410        let y = area.y + screen_row;
411        // Total gutter cells, leaving one trailing spacer column.
412        let number_width = gutter.width.saturating_sub(1) as usize;
413        let label = format!(
414            "{:>width$}",
415            doc_row + 1 + gutter.line_offset,
416            width = number_width
417        );
418        let mut x = area.x;
419        for ch in label.chars() {
420            if x >= area.x + gutter.width.saturating_sub(1) {
421                break;
422            }
423            if let Some(cell) = term_buf.cell_mut((x, y)) {
424                cell.set_char(ch);
425                cell.set_style(gutter.style);
426            }
427            x = x.saturating_add(1);
428        }
429        // Spacer cell — same gutter style so the background is
430        // continuous when a bg colour is set.
431        let spacer_x = area.x + gutter.width.saturating_sub(1);
432        if let Some(cell) = term_buf.cell_mut((spacer_x, y)) {
433            cell.set_char(' ');
434            cell.set_style(gutter.style);
435        }
436    }
437
438    #[allow(clippy::too_many_arguments)]
439    fn paint_row(
440        &self,
441        term_buf: &mut TermBuffer,
442        area: Rect,
443        screen_row: u16,
444        line: &str,
445        row_spans: &[crate::Span],
446        sel_range: crate::RowSpan,
447        search_ranges: &[(usize, usize)],
448        is_cursor_row: bool,
449        cursor_col: usize,
450        seg_start: usize,
451        seg_end: usize,
452        is_last_segment: bool,
453        conceals: &[&Conceal],
454    ) {
455        let y = area.y + screen_row;
456        let mut screen_x = area.x;
457        let row_end_x = area.x + area.width;
458
459        // Paint cursor-line bg across the whole row first so empty
460        // trailing cells inherit the highlight (matches vim's
461        // cursorline). Selection / cursor cells overwrite below.
462        if is_cursor_row && self.cursor_line_bg != Style::default() {
463            for x in area.x..row_end_x {
464                if let Some(cell) = term_buf.cell_mut((x, y)) {
465                    cell.set_style(self.cursor_line_bg);
466                }
467            }
468        }
469
470        // Tab width for `\t` expansion — host publishes via
471        // `Viewport::tab_width` (driven by engine's `:set tabstop`).
472        // `effective_tab_width` falls back to 4 when unset.
473        let tab_width = self.viewport.effective_tab_width();
474        let mut byte_offset: usize = 0;
475        let mut line_col: usize = 0;
476        let mut chars_iter = line.chars().enumerate().peekable();
477        while let Some((col_idx, ch)) = chars_iter.next() {
478            let ch_byte_len = ch.len_utf8();
479            if col_idx >= seg_end {
480                break;
481            }
482            // If a conceal starts at this byte, paint the replacement
483            // text (using this cell's style) and skip the rest of the
484            // concealed range. Cursor / selection / search highlights
485            // still attribute to the original char positions.
486            if let Some(conc) = conceals.iter().find(|c| c.start_byte == byte_offset) {
487                if col_idx >= seg_start {
488                    let mut style = if is_cursor_row {
489                        self.cursor_line_bg
490                    } else {
491                        Style::default()
492                    };
493                    if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
494                        style = style.patch(span_style);
495                    }
496                    for rch in conc.replacement.chars() {
497                        let rwidth = rch.width().unwrap_or(1) as u16;
498                        if screen_x + rwidth > row_end_x {
499                            break;
500                        }
501                        if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
502                            cell.set_char(rch);
503                            cell.set_style(style);
504                        }
505                        screen_x += rwidth;
506                    }
507                }
508                // Advance byte_offset / chars iter past the concealed
509                // range without painting the original cells.
510                let mut consumed = ch_byte_len;
511                byte_offset += ch_byte_len;
512                while byte_offset < conc.end_byte {
513                    let Some((_, next_ch)) = chars_iter.next() else {
514                        break;
515                    };
516                    consumed += next_ch.len_utf8();
517                    byte_offset = byte_offset.saturating_add(next_ch.len_utf8());
518                }
519                let _ = consumed;
520                continue;
521            }
522            // Visible cell count: tabs expand to the next tab_width stop
523            // based on `line_col` (visible column in the *line*, not the
524            // segment), so a tab at line column 0 paints tab_width cells
525            // and a tab at line column 3 paints 1 cell.
526            let visible_width = if ch == '\t' {
527                tab_width - (line_col % tab_width)
528            } else {
529                ch.width().unwrap_or(1)
530            };
531            // Skip chars to the left of the segment start (horizontal
532            // scroll for `Wrap::None`, segment offset for wrap modes).
533            if col_idx < seg_start {
534                line_col += visible_width;
535                byte_offset += ch_byte_len;
536                continue;
537            }
538            // Stop when we run out of horizontal room.
539            let width = visible_width as u16;
540            if screen_x + width > row_end_x {
541                break;
542            }
543
544            // Resolve final style for this cell.
545            let mut style = if is_cursor_row {
546                self.cursor_line_bg
547            } else {
548                Style::default()
549            };
550            if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
551                style = style.patch(span_style);
552            }
553            // Search bg first, then selection bg — so when a visual
554            // selection covers a search match, the selection wins
555            // (last patch overwrites the bg field).
556            if self.search_bg != Style::default()
557                && search_ranges
558                    .iter()
559                    .any(|&(s, e)| col_idx >= s && col_idx < e)
560            {
561                style = style.patch(self.search_bg);
562            }
563            if let Some((lo, hi)) = sel_range
564                && col_idx >= lo
565                && col_idx <= hi
566            {
567                style = style.patch(self.selection_bg);
568            }
569            if is_cursor_row && col_idx == cursor_col {
570                style = style.patch(self.cursor_style);
571            }
572
573            if ch == '\t' {
574                // Paint tab as `visible_width` space cells carrying the
575                // resolved style — tab/text bg/cursor-line bg all paint
576                // through the expansion.
577                for k in 0..width {
578                    if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
579                        cell.set_char(' ');
580                        cell.set_style(style);
581                    }
582                }
583            } else if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
584                cell.set_char(ch);
585                cell.set_style(style);
586            }
587            screen_x += width;
588            line_col += visible_width;
589            byte_offset += ch_byte_len;
590        }
591
592        // If the cursor sits at end-of-line (insert / past-end mode),
593        // paint a single REVERSED placeholder cell so it stays visible.
594        // Only on the last segment of a wrapped row — earlier segments
595        // can't host the past-end cursor.
596        if is_cursor_row
597            && is_last_segment
598            && cursor_col >= line.chars().count()
599            && cursor_col >= seg_start
600        {
601            let pad_x = area.x + (cursor_col.saturating_sub(seg_start)) as u16;
602            if pad_x < row_end_x
603                && let Some(cell) = term_buf.cell_mut((pad_x, y))
604            {
605                cell.set_char(' ');
606                cell.set_style(self.cursor_line_bg.patch(self.cursor_style));
607            }
608        }
609    }
610
611    /// First span containing `byte_offset` wins. Buffer guarantees
612    /// non-overlapping sorted spans — vim.rs is responsible for that.
613    fn resolve_span_style(&self, row_spans: &[crate::Span], byte_offset: usize) -> Option<Style> {
614        // Return the *narrowest* span containing this byte. Hosts that
615        // overlay narrower spans on top of broader ones (e.g. TODO marker
616        // inside a comment span) rely on the more specific span winning;
617        // first-match-wins would let the broader span block the overlay.
618        let mut best: Option<&crate::Span> = None;
619        for span in row_spans {
620            if byte_offset >= span.start_byte && byte_offset < span.end_byte {
621                let len = span.end_byte - span.start_byte;
622                match best {
623                    Some(b) if (b.end_byte - b.start_byte) <= len => {}
624                    _ => best = Some(span),
625                }
626            }
627        }
628        best.map(|s| self.resolver.resolve(s.style))
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use ratatui::style::{Color, Modifier};
636    use ratatui::widgets::Widget;
637
638    fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
639        let area = Rect::new(0, 0, w, h);
640        let mut buf = TermBuffer::empty(area);
641        view.render(area, &mut buf);
642        buf
643    }
644
645    fn no_styles(_id: u32) -> Style {
646        Style::default()
647    }
648
649    /// Build a default viewport for plain (no-wrap) tests.
650    fn vp(width: u16, height: u16) -> Viewport {
651        Viewport {
652            top_row: 0,
653            top_col: 0,
654            width,
655            height,
656            wrap: Wrap::None,
657            text_width: width,
658            tab_width: 0,
659        }
660    }
661
662    #[test]
663    fn renders_plain_chars_into_terminal_buffer() {
664        let b = Buffer::from_str("hello\nworld");
665        let v = vp(20, 5);
666        let view = BufferView {
667            buffer: &b,
668            viewport: &v,
669            selection: None,
670            resolver: &(no_styles as fn(u32) -> Style),
671            cursor_line_bg: Style::default(),
672            cursor_column_bg: Style::default(),
673            selection_bg: Style::default().bg(Color::Blue),
674            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
675            gutter: None,
676            search_bg: Style::default(),
677            signs: &[],
678            conceals: &[],
679            spans: &[],
680            search_pattern: None,
681        };
682        let term = run_render(view, 20, 5);
683        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
684        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
685        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
686        assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
687    }
688
689    #[test]
690    fn cursor_cell_gets_reversed_style() {
691        let mut b = Buffer::from_str("abc");
692        let v = vp(10, 1);
693        b.set_cursor(crate::Position::new(0, 1));
694        let view = BufferView {
695            buffer: &b,
696            viewport: &v,
697            selection: None,
698            resolver: &(no_styles as fn(u32) -> Style),
699            cursor_line_bg: Style::default(),
700            cursor_column_bg: Style::default(),
701            selection_bg: Style::default().bg(Color::Blue),
702            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
703            gutter: None,
704            search_bg: Style::default(),
705            signs: &[],
706            conceals: &[],
707            spans: &[],
708            search_pattern: None,
709        };
710        let term = run_render(view, 10, 1);
711        let cursor_cell = term.cell((1, 0)).unwrap();
712        assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
713    }
714
715    #[test]
716    fn selection_bg_applies_only_to_selected_cells() {
717        use crate::{Position, Selection};
718        let b = Buffer::from_str("abcdef");
719        let v = vp(10, 1);
720        let view = BufferView {
721            buffer: &b,
722            viewport: &v,
723            selection: Some(Selection::Char {
724                anchor: Position::new(0, 1),
725                head: Position::new(0, 3),
726            }),
727            resolver: &(no_styles as fn(u32) -> Style),
728            cursor_line_bg: Style::default(),
729            cursor_column_bg: Style::default(),
730            selection_bg: Style::default().bg(Color::Blue),
731            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
732            gutter: None,
733            search_bg: Style::default(),
734            signs: &[],
735            conceals: &[],
736            spans: &[],
737            search_pattern: None,
738        };
739        let term = run_render(view, 10, 1);
740        assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
741        for x in 1..=3 {
742            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
743        }
744        assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
745    }
746
747    #[test]
748    fn syntax_span_fg_resolves_via_table() {
749        use crate::Span;
750        let b = Buffer::from_str("SELECT foo");
751        let v = vp(20, 1);
752        let spans = vec![vec![Span::new(0, 6, 7)]];
753        let resolver = |id: u32| -> Style {
754            if id == 7 {
755                Style::default().fg(Color::Red)
756            } else {
757                Style::default()
758            }
759        };
760        let view = BufferView {
761            buffer: &b,
762            viewport: &v,
763            selection: None,
764            resolver: &resolver,
765            cursor_line_bg: Style::default(),
766            cursor_column_bg: Style::default(),
767            selection_bg: Style::default().bg(Color::Blue),
768            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
769            gutter: None,
770            search_bg: Style::default(),
771            signs: &[],
772            conceals: &[],
773            spans: &spans,
774            search_pattern: None,
775        };
776        let term = run_render(view, 20, 1);
777        for x in 0..6 {
778            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
779        }
780    }
781
782    #[test]
783    fn gutter_renders_right_aligned_line_numbers() {
784        let b = Buffer::from_str("a\nb\nc");
785        let v = vp(10, 3);
786        let view = BufferView {
787            buffer: &b,
788            viewport: &v,
789            selection: None,
790            resolver: &(no_styles as fn(u32) -> Style),
791            cursor_line_bg: Style::default(),
792            cursor_column_bg: Style::default(),
793            selection_bg: Style::default().bg(Color::Blue),
794            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
795            gutter: Some(Gutter {
796                width: 4,
797                style: Style::default().fg(Color::Yellow),
798                line_offset: 0,
799            }),
800            search_bg: Style::default(),
801            signs: &[],
802            conceals: &[],
803            spans: &[],
804            search_pattern: None,
805        };
806        let term = run_render(view, 10, 3);
807        // Width 4 = 3 number cells + 1 spacer; right-aligned "  1".
808        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
809        assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
810        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
811        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
812        // Text shifted right past the gutter.
813        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
814    }
815
816    #[test]
817    fn search_bg_paints_match_cells() {
818        use regex::Regex;
819        let b = Buffer::from_str("foo bar foo");
820        let v = vp(20, 1);
821        let pat = Regex::new("foo").unwrap();
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().bg(Color::Magenta),
833            signs: &[],
834            conceals: &[],
835            spans: &[],
836            search_pattern: Some(&pat),
837        };
838        let term = run_render(view, 20, 1);
839        for x in 0..3 {
840            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
841        }
842        // " bar " between matches stays default bg.
843        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
844        for x in 8..11 {
845            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
846        }
847    }
848
849    #[test]
850    fn search_bg_survives_cursorcolumn_overlay() {
851        use regex::Regex;
852        // Cursor sits on a `/foo` match. The cursorcolumn pass would
853        // otherwise overwrite the search bg with column bg — verify
854        // the match cells keep their search colour.
855        let mut b = Buffer::from_str("foo bar foo");
856        let v = vp(20, 1);
857        let pat = Regex::new("foo").unwrap();
858        // Cursor on column 1 (inside first `foo` match).
859        b.set_cursor(crate::Position::new(0, 1));
860        let view = BufferView {
861            buffer: &b,
862            viewport: &v,
863            selection: None,
864            resolver: &(no_styles as fn(u32) -> Style),
865            cursor_line_bg: Style::default(),
866            cursor_column_bg: Style::default().bg(Color::DarkGray),
867            selection_bg: Style::default().bg(Color::Blue),
868            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
869            gutter: None,
870            search_bg: Style::default().bg(Color::Magenta),
871            signs: &[],
872            conceals: &[],
873            spans: &[],
874            search_pattern: Some(&pat),
875        };
876        let term = run_render(view, 20, 1);
877        // Cursor cell at (1, 0) is in the search match. Search wins.
878        assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
879    }
880
881    #[test]
882    fn highest_priority_sign_wins_per_row_and_overwrites_gutter() {
883        let b = Buffer::from_str("a\nb\nc");
884        let v = vp(10, 3);
885        let signs = [
886            Sign {
887                row: 0,
888                ch: 'W',
889                style: Style::default().fg(Color::Yellow),
890                priority: 1,
891            },
892            Sign {
893                row: 0,
894                ch: 'E',
895                style: Style::default().fg(Color::Red),
896                priority: 2,
897            },
898        ];
899        let view = BufferView {
900            buffer: &b,
901            viewport: &v,
902            selection: None,
903            resolver: &(no_styles as fn(u32) -> Style),
904            cursor_line_bg: Style::default(),
905            cursor_column_bg: Style::default(),
906            selection_bg: Style::default().bg(Color::Blue),
907            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
908            gutter: Some(Gutter {
909                width: 3,
910                style: Style::default().fg(Color::DarkGray),
911                line_offset: 0,
912            }),
913            search_bg: Style::default(),
914            signs: &signs,
915            conceals: &[],
916            spans: &[],
917            search_pattern: None,
918        };
919        let term = run_render(view, 10, 3);
920        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
921        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
922        // Row 1 has no sign — leftmost cell stays as gutter content.
923        assert_ne!(term.cell((0, 1)).unwrap().symbol(), "E");
924    }
925
926    #[test]
927    fn conceal_replaces_byte_range() {
928        let b = Buffer::from_str("see https://example.com end");
929        let v = vp(30, 1);
930        let conceals = vec![Conceal {
931            row: 0,
932            start_byte: 4,                             // start of "https"
933            end_byte: 4 + "https://example.com".len(), // end of URL
934            replacement: "🔗".to_string(),
935        }];
936        let view = BufferView {
937            buffer: &b,
938            viewport: &v,
939            selection: None,
940            resolver: &(no_styles as fn(u32) -> Style),
941            cursor_line_bg: Style::default(),
942            cursor_column_bg: Style::default(),
943            selection_bg: Style::default(),
944            cursor_style: Style::default(),
945            gutter: None,
946            search_bg: Style::default(),
947            signs: &[],
948            conceals: &conceals,
949            spans: &[],
950            search_pattern: None,
951        };
952        let term = run_render(view, 30, 1);
953        // Cells 0..=3: "see "
954        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
955        assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
956        // Cell 4: the link emoji (a wide char takes 2 cells; we just
957        // assert the first cell holds the replacement char).
958        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
959    }
960
961    #[test]
962    fn closed_fold_collapses_rows_and_paints_marker() {
963        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
964        let v = vp(30, 5);
965        // Fold rows 1-3 closed. Visible should be: 'a', marker, 'e'.
966        b.add_fold(1, 3, true);
967        let view = BufferView {
968            buffer: &b,
969            viewport: &v,
970            selection: None,
971            resolver: &(no_styles as fn(u32) -> Style),
972            cursor_line_bg: Style::default(),
973            cursor_column_bg: Style::default(),
974            selection_bg: Style::default().bg(Color::Blue),
975            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
976            gutter: None,
977            search_bg: Style::default(),
978            signs: &[],
979            conceals: &[],
980            spans: &[],
981            search_pattern: None,
982        };
983        let term = run_render(view, 30, 5);
984        // Row 0: "a"
985        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
986        // Row 1: fold marker — leading `▸ ` then the start row's
987        // trimmed content + line count.
988        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "▸");
989        // Row 2: "e" (the 5th doc row, after the collapsed range).
990        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
991    }
992
993    #[test]
994    fn open_fold_renders_normally() {
995        let mut b = Buffer::from_str("a\nb\nc");
996        let v = vp(5, 3);
997        b.add_fold(0, 2, false); // open
998        let view = BufferView {
999            buffer: &b,
1000            viewport: &v,
1001            selection: None,
1002            resolver: &(no_styles as fn(u32) -> Style),
1003            cursor_line_bg: Style::default(),
1004            cursor_column_bg: Style::default(),
1005            selection_bg: Style::default().bg(Color::Blue),
1006            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1007            gutter: None,
1008            search_bg: Style::default(),
1009            signs: &[],
1010            conceals: &[],
1011            spans: &[],
1012            search_pattern: None,
1013        };
1014        let term = run_render(view, 5, 3);
1015        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1016        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1017        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
1018    }
1019
1020    #[test]
1021    fn horizontal_scroll_clips_left_chars() {
1022        let b = Buffer::from_str("abcdefgh");
1023        let mut v = vp(4, 1);
1024        v.top_col = 3;
1025        let view = BufferView {
1026            buffer: &b,
1027            viewport: &v,
1028            selection: None,
1029            resolver: &(no_styles as fn(u32) -> Style),
1030            cursor_line_bg: Style::default(),
1031            cursor_column_bg: Style::default(),
1032            selection_bg: Style::default().bg(Color::Blue),
1033            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1034            gutter: None,
1035            search_bg: Style::default(),
1036            signs: &[],
1037            conceals: &[],
1038            spans: &[],
1039            search_pattern: None,
1040        };
1041        let term = run_render(view, 4, 1);
1042        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
1043        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
1044    }
1045
1046    fn make_wrap_view<'a>(
1047        b: &'a Buffer,
1048        viewport: &'a Viewport,
1049        resolver: &'a (impl StyleResolver + 'a),
1050        gutter: Option<Gutter>,
1051    ) -> BufferView<'a, impl StyleResolver + 'a> {
1052        BufferView {
1053            buffer: b,
1054            viewport,
1055            selection: None,
1056            resolver,
1057            cursor_line_bg: Style::default(),
1058            cursor_column_bg: Style::default(),
1059            selection_bg: Style::default().bg(Color::Blue),
1060            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1061            gutter,
1062            search_bg: Style::default(),
1063            signs: &[],
1064            conceals: &[],
1065            spans: &[],
1066            search_pattern: None,
1067        }
1068    }
1069
1070    #[test]
1071    fn wrap_segments_char_breaks_at_width() {
1072        let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
1073        assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
1074    }
1075
1076    #[test]
1077    fn wrap_segments_word_backs_up_to_whitespace() {
1078        let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
1079        // First segment "alpha " ends after the space at idx 5.
1080        assert_eq!(segs[0], (0, 6));
1081        // Second segment "beta " ends after the space at idx 10.
1082        assert_eq!(segs[1], (6, 11));
1083        assert_eq!(segs[2], (11, 16));
1084    }
1085
1086    #[test]
1087    fn wrap_segments_word_falls_back_to_char_for_long_runs() {
1088        let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
1089        // No whitespace anywhere — degrades to a hard char break.
1090        assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
1091    }
1092
1093    #[test]
1094    fn wrap_char_paints_continuation_rows() {
1095        let b = Buffer::from_str("abcdefghij");
1096        let v = Viewport {
1097            top_row: 0,
1098            top_col: 0,
1099            width: 4,
1100            height: 3,
1101            wrap: Wrap::Char,
1102            text_width: 4,
1103            tab_width: 0,
1104        };
1105        let r = no_styles as fn(u32) -> Style;
1106        let view = make_wrap_view(&b, &v, &r, None);
1107        let term = run_render(view, 4, 3);
1108        // Row 0: "abcd"
1109        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1110        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
1111        // Row 1: "efgh"
1112        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
1113        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
1114        // Row 2: "ij"
1115        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
1116        assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
1117    }
1118
1119    #[test]
1120    fn wrap_char_gutter_blank_on_continuation() {
1121        let b = Buffer::from_str("abcdefgh");
1122        let v = Viewport {
1123            top_row: 0,
1124            top_col: 0,
1125            width: 6,
1126            height: 3,
1127            wrap: Wrap::Char,
1128            // Text area = 6 - 3 (gutter width) = 3.
1129            text_width: 3,
1130            tab_width: 0,
1131        };
1132        let r = no_styles as fn(u32) -> Style;
1133        let gutter = Gutter {
1134            width: 3,
1135            style: Style::default().fg(Color::Yellow),
1136            line_offset: 0,
1137        };
1138        let view = make_wrap_view(&b, &v, &r, Some(gutter));
1139        let term = run_render(view, 6, 3);
1140        // Row 0: "  1" + "abc"
1141        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
1142        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
1143        // Row 1: blank gutter + "def"
1144        for x in 0..2 {
1145            assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
1146        }
1147        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
1148        assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
1149    }
1150
1151    #[test]
1152    fn wrap_char_cursor_lands_on_correct_segment() {
1153        let mut b = Buffer::from_str("abcdefghij");
1154        let v = Viewport {
1155            top_row: 0,
1156            top_col: 0,
1157            width: 4,
1158            height: 3,
1159            wrap: Wrap::Char,
1160            text_width: 4,
1161            tab_width: 0,
1162        };
1163        // Cursor on 'g' (col 6) should land on row 1, col 2.
1164        b.set_cursor(crate::Position::new(0, 6));
1165        let r = no_styles as fn(u32) -> Style;
1166        let mut view = make_wrap_view(&b, &v, &r, None);
1167        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1168        let term = run_render(view, 4, 3);
1169        assert!(
1170            term.cell((2, 1))
1171                .unwrap()
1172                .modifier
1173                .contains(Modifier::REVERSED)
1174        );
1175    }
1176
1177    #[test]
1178    fn wrap_char_eol_cursor_placeholder_on_last_segment() {
1179        let mut b = Buffer::from_str("abcdef");
1180        let v = Viewport {
1181            top_row: 0,
1182            top_col: 0,
1183            width: 4,
1184            height: 3,
1185            wrap: Wrap::Char,
1186            text_width: 4,
1187            tab_width: 0,
1188        };
1189        // Past-end cursor at col 6.
1190        b.set_cursor(crate::Position::new(0, 6));
1191        let r = no_styles as fn(u32) -> Style;
1192        let mut view = make_wrap_view(&b, &v, &r, None);
1193        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1194        let term = run_render(view, 4, 3);
1195        // Last segment is row 1 ("ef"), placeholder at x = 6 - 4 = 2.
1196        assert!(
1197            term.cell((2, 1))
1198                .unwrap()
1199                .modifier
1200                .contains(Modifier::REVERSED)
1201        );
1202    }
1203
1204    #[test]
1205    fn wrap_word_breaks_at_whitespace() {
1206        let b = Buffer::from_str("alpha beta gamma");
1207        let v = Viewport {
1208            top_row: 0,
1209            top_col: 0,
1210            width: 8,
1211            height: 3,
1212            wrap: Wrap::Word,
1213            text_width: 8,
1214            tab_width: 0,
1215        };
1216        let r = no_styles as fn(u32) -> Style;
1217        let view = make_wrap_view(&b, &v, &r, None);
1218        let term = run_render(view, 8, 3);
1219        // Row 0: "alpha "
1220        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1221        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1222        // Row 1: "beta "
1223        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1224        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
1225        // Row 2: "gamma"
1226        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
1227        assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
1228    }
1229
1230    // 0.0.37 — `BufferView` lost `Buffer::spans` / `Buffer::search_pattern`
1231    // and now takes them as parameters. The tests below cover the new
1232    // shape: empty/missing parameters, multi-row spans, regex hlsearch,
1233    // and the interaction with cursor / selection / wrap.
1234
1235    fn view_with<'a>(
1236        b: &'a Buffer,
1237        viewport: &'a Viewport,
1238        resolver: &'a (impl StyleResolver + 'a),
1239        spans: &'a [Vec<Span>],
1240        search_pattern: Option<&'a regex::Regex>,
1241    ) -> BufferView<'a, impl StyleResolver + 'a> {
1242        BufferView {
1243            buffer: b,
1244            viewport,
1245            selection: None,
1246            resolver,
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: None,
1252            search_bg: Style::default().bg(Color::Magenta),
1253            signs: &[],
1254            conceals: &[],
1255            spans,
1256            search_pattern,
1257        }
1258    }
1259
1260    #[test]
1261    fn empty_spans_param_renders_default_style() {
1262        let b = Buffer::from_str("hello");
1263        let v = vp(10, 1);
1264        let r = no_styles as fn(u32) -> Style;
1265        let view = view_with(&b, &v, &r, &[], None);
1266        let term = run_render(view, 10, 1);
1267        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
1268        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
1269    }
1270
1271    #[test]
1272    fn spans_param_paints_styled_byte_range() {
1273        let b = Buffer::from_str("abcdef");
1274        let v = vp(10, 1);
1275        let resolver = |id: u32| -> Style {
1276            if id == 3 {
1277                Style::default().fg(Color::Green)
1278            } else {
1279                Style::default()
1280            }
1281        };
1282        let spans = vec![vec![Span::new(0, 3, 3)]];
1283        let view = view_with(&b, &v, &resolver, &spans, None);
1284        let term = run_render(view, 10, 1);
1285        for x in 0..3 {
1286            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
1287        }
1288        assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
1289    }
1290
1291    #[test]
1292    fn spans_param_handles_per_row_overlay() {
1293        let b = Buffer::from_str("abc\ndef");
1294        let v = vp(10, 2);
1295        let resolver = |id: u32| -> Style {
1296            if id == 1 {
1297                Style::default().fg(Color::Red)
1298            } else {
1299                Style::default().fg(Color::Green)
1300            }
1301        };
1302        let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
1303        let view = view_with(&b, &v, &resolver, &spans, None);
1304        let term = run_render(view, 10, 2);
1305        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1306        assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
1307    }
1308
1309    #[test]
1310    fn spans_param_rows_beyond_get_no_styling() {
1311        let b = Buffer::from_str("abc\ndef\nghi");
1312        let v = vp(10, 3);
1313        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1314        // Only row 0 carries spans; rows 1 and 2 inherit default.
1315        let spans = vec![vec![Span::new(0, 3, 0)]];
1316        let view = view_with(&b, &v, &resolver, &spans, None);
1317        let term = run_render(view, 10, 3);
1318        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1319        assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
1320        assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
1321    }
1322
1323    #[test]
1324    fn search_pattern_none_disables_hlsearch() {
1325        let b = Buffer::from_str("foo bar foo");
1326        let v = vp(20, 1);
1327        let r = no_styles as fn(u32) -> Style;
1328        // No regex → no Magenta bg anywhere even though `search_bg` is set.
1329        let view = view_with(&b, &v, &r, &[], None);
1330        let term = run_render(view, 20, 1);
1331        for x in 0..11 {
1332            assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1333        }
1334    }
1335
1336    #[test]
1337    fn search_pattern_regex_paints_match_bg() {
1338        use regex::Regex;
1339        let b = Buffer::from_str("xyz foo xyz");
1340        let v = vp(20, 1);
1341        let r = no_styles as fn(u32) -> Style;
1342        let pat = Regex::new("foo").unwrap();
1343        let view = view_with(&b, &v, &r, &[], Some(&pat));
1344        let term = run_render(view, 20, 1);
1345        // "foo" is at chars 4..7; bg is Magenta there only.
1346        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1347        for x in 4..7 {
1348            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1349        }
1350        assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
1351    }
1352
1353    #[test]
1354    fn search_pattern_unicode_columns_are_charwise() {
1355        use regex::Regex;
1356        // "tablé foo" — match "foo" must land on char column 6, not byte.
1357        let b = Buffer::from_str("tablé foo");
1358        let v = vp(20, 1);
1359        let r = no_styles as fn(u32) -> Style;
1360        let pat = Regex::new("foo").unwrap();
1361        let view = view_with(&b, &v, &r, &[], Some(&pat));
1362        let term = run_render(view, 20, 1);
1363        // "tablé" is 5 chars + space = 6, then "foo" at 6..9.
1364        assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
1365        assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
1366        assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
1367    }
1368
1369    #[test]
1370    fn spans_param_clamps_short_row_overlay() {
1371        // Row 0 has 3 chars; span past end shouldn't crash or smear.
1372        let b = Buffer::from_str("abc");
1373        let v = vp(10, 1);
1374        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
1375        let spans = vec![vec![Span::new(0, 100, 0)]];
1376        let view = view_with(&b, &v, &resolver, &spans, None);
1377        let term = run_render(view, 10, 1);
1378        for x in 0..3 {
1379            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1380        }
1381    }
1382
1383    #[test]
1384    fn spans_and_search_pattern_compose() {
1385        // hlsearch bg layers on top of the syntax span fg.
1386        use regex::Regex;
1387        let b = Buffer::from_str("foo");
1388        let v = vp(10, 1);
1389        let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
1390        let spans = vec![vec![Span::new(0, 3, 0)]];
1391        let pat = Regex::new("foo").unwrap();
1392        let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
1393        let term = run_render(view, 10, 1);
1394        let cell = term.cell((1, 0)).unwrap();
1395        assert_eq!(cell.fg, Color::Green);
1396        assert_eq!(cell.bg, Color::Magenta);
1397    }
1398}