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