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