Skip to main content

hjkl_buffer/
render.rs

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