Skip to main content

hjkl_buffer/
render.rs

1//! Direct cell-write `ratatui::widgets::Widget` for [`crate::Buffer`].
2//!
3//! Enabled by the `ratatui` feature (off by default).
4//!
5//! ## Render path
6//!
7//! When the `ratatui` feature is enabled, [`BufferView`] implements
8//! `ratatui::widgets::Widget`. The widget is **single-pass** — text,
9//! selection, gutter signs, and styled spans all paint together. There is
10//! no separate `Paragraph` or layout step. Writes one cell at a time so
11//! syntax span fg, cursor-line bg, cursor cell REVERSED, and selection bg
12//! layer in a single pass without the grapheme / wrap machinery `Paragraph`
13//! does.
14//!
15//! Caller wraps a `&Buffer` in [`BufferView`], hands it the style table
16//! that resolves opaque [`crate::Span`] style ids to real ratatui styles
17//! via a [`StyleResolver`], and renders into a `ratatui::Frame`.
18//!
19//! ## StyleResolver hooks
20//!
21//! The [`StyleResolver`] trait is the host's bridge from opaque `u32` style
22//! ids (stored in [`crate::Span::style`]) to real `ratatui::style::Style`
23//! values. Implement it against your own theme. A convenience blanket impl
24//! exists for closures `Fn(u32) -> Style`.
25
26use ratatui::buffer::Buffer as TermBuffer;
27use ratatui::layout::Rect;
28use ratatui::style::Style;
29use ratatui::widgets::Widget;
30use unicode_width::UnicodeWidthChar;
31
32use crate::wrap::wrap_segments;
33use crate::{Buffer, Selection, Span, Viewport, Wrap};
34
35/// Resolves an opaque [`crate::Span::style`] id to a real ratatui
36/// style. The buffer doesn't know about colours; the host (sqeel-vim
37/// or any future user) keeps a lookup table.
38pub trait StyleResolver {
39    fn resolve(&self, style_id: u32) -> Style;
40}
41
42/// Convenience impl so simple closures can drive the renderer.
43impl<F: Fn(u32) -> Style> StyleResolver for F {
44    fn resolve(&self, style_id: u32) -> Style {
45        self(style_id)
46    }
47}
48
49/// Render-time wrapper around `&Buffer` that carries the optional
50/// [`Selection`] + a [`StyleResolver`]. Created per draw, dropped
51/// when the frame is done — cheap, holds only refs.
52///
53/// 0.0.34 (Patch C-δ.1): added the `viewport` field. The viewport
54/// previously lived on the buffer itself; with the relocation to the
55/// engine `Host`, the renderer takes a borrow per draw.
56///
57/// 0.0.37: added the `spans` and `search_pattern` fields. Per-row
58/// syntax spans + the active `/` regex used to live on the buffer
59/// (`Buffer::spans` / `Buffer::search_pattern`); both moved out per
60/// step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The host now feeds
61/// each into the view per draw — populated from
62/// `Editor::buffer_spans()` and `Editor::search_state().pattern`.
63pub struct BufferView<'a, R: StyleResolver> {
64    pub buffer: &'a Buffer,
65    /// Viewport snapshot the host published this frame. Owned by the
66    /// engine `Host`; the renderer borrows for the duration of the
67    /// draw.
68    pub viewport: &'a Viewport,
69    pub selection: Option<Selection>,
70    pub resolver: &'a R,
71    /// Bg painted across the cursor row (vim's `cursorline`). Pass
72    /// `Style::default()` to disable.
73    pub cursor_line_bg: Style,
74    /// Bg painted down the cursor column (vim's `cursorcolumn`). Pass
75    /// `Style::default()` to disable.
76    pub cursor_column_bg: Style,
77    /// Bg painted under selected cells. Composed over syntax fg.
78    pub selection_bg: Style,
79    /// Style for the cursor cell. `REVERSED` is the conventional
80    /// choice; works against any theme.
81    pub cursor_style: Style,
82    /// Optional left-side line-number gutter. `width` includes the
83    /// trailing space separating the number from text. Pass `None`
84    /// to disable. Numbers are 1-based, right-aligned.
85    pub gutter: Option<Gutter>,
86    /// Bg painted under cells covered by an active `/` search match.
87    /// `Style::default()` to disable.
88    pub search_bg: Style,
89    /// Per-row gutter signs (LSP diagnostic dots, git diff markers,
90    /// …). Painted into the leftmost gutter column after the line
91    /// number, so they overwrite the leading space tui-style gutters
92    /// reserve. Highest-priority sign per row wins.
93    pub signs: &'a [Sign],
94    /// Per-row substitutions applied at render time. Each conceal
95    /// hides the byte range `[start_byte, end_byte)` and paints
96    /// `replacement` in its place. Empty slice = no conceals.
97    pub conceals: &'a [Conceal],
98    /// Per-row syntax spans the host has computed for this frame.
99    /// `spans[row]` carries the styled byte ranges for that row;
100    /// rows beyond `spans.len()` get no syntax styling. Pass `&[]`
101    /// for hosts without syntax integration.
102    ///
103    /// 0.0.37: lifted out of `Buffer` per step 3 of
104    /// `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine populates
105    /// this via `Editor::buffer_spans()`.
106    pub spans: &'a [Vec<Span>],
107    /// Active `/` search regex, if any. The renderer paints
108    /// [`Self::search_bg`] under cells that match. Pass `None` to
109    /// disable hlsearch.
110    ///
111    /// 0.0.37: lifted out of `Buffer` (was `Buffer::search_pattern`)
112    /// per step 3 of `DESIGN_33_METHOD_CLASSIFICATION.md`. The engine
113    /// publishes the pattern via `Editor::search_state().pattern`.
114    pub search_pattern: Option<&'a regex::Regex>,
115    /// Style for the `~` tilde marker painted on screen rows that are
116    /// past the last buffer line (vim's `NonText` highlight group).
117    /// Pass `Style::default()` to use terminal defaults.
118    ///
119    /// The gutter on those rows is painted blank; the `~` appears at the
120    /// leftmost text column. Rows within the buffer are unaffected.
121    pub non_text_style: Style,
122    /// Diagnostic overlays (LSP inline highlights). Applied in a
123    /// post-paint pass after every row is drawn so they layer on top of
124    /// syntax and selection colours without a second layout traversal.
125    /// Pass `&[]` to disable. Added in 0.5.0.
126    pub diag_overlays: &'a [DiagOverlay],
127    /// 1-based column indices for vertical rulers (vim's `colorcolumn`).
128    /// The renderer paints `colorcolumn_style` on those text-area cells
129    /// beneath syntax highlights. Pass `&[]` to disable.
130    pub colorcolumn_cols: &'a [u16],
131    /// Background style applied to cells at a `colorcolumn` position.
132    /// Ignored when `colorcolumn_cols` is empty.
133    pub colorcolumn_style: Style,
134}
135
136/// Controls what numbers are rendered in the gutter.
137///
138/// Matches vim's `:set number` / `:set relativenumber` combinations.
139#[derive(Debug, Clone, Copy, Default)]
140pub enum GutterNumbers {
141    /// No line numbers — gutter cells painted blank (still occupies width).
142    None,
143    /// 1-based absolute row numbers (current default).
144    #[default]
145    Absolute,
146    /// Offset from `cursor_row` for non-cursor rows; cursor row shows `0`.
147    Relative { cursor_row: usize },
148    /// Vim's `nu+rnu`: cursor row shows its absolute number, others show
149    /// offset from `cursor_row`.
150    Hybrid { cursor_row: usize },
151}
152
153/// Configuration for the line-number gutter rendered to the left of
154/// the text area. `width` is the number-column cell count reserved
155/// (including any trailing spacer); the renderer right-aligns the
156/// 1-based row number into the leftmost `width - 1` cells.
157///
158/// `sign_column_width` reserves cells to the LEFT of the number column
159/// for sign chars (LSP diagnostics, git diff markers). The sign column
160/// is a dedicated strip separate from the number column: vim/neovim
161/// convention is `[ sign | number_padded | spacer | text ]`. When
162/// `sign_column_width == 0` the layout collapses to
163/// `[ number_padded | spacer | text ]`.
164///
165/// `line_offset` is added to the displayed line number, so a host
166/// rendering a windowed view of a larger document (e.g. picker preview
167/// of a 7000-line buffer) can show the original line numbers instead
168/// of starting at 1. Only applied in `Absolute` mode.
169#[derive(Debug, Clone, Copy, Default)]
170pub struct Gutter {
171    /// Width of the number column (digits + 1 trailing spacer). Does NOT
172    /// include `sign_column_width`.
173    pub width: u16,
174    pub style: Style,
175    pub line_offset: usize,
176    /// What kind of numbers to render. Defaults to `Absolute`.
177    pub numbers: GutterNumbers,
178    /// Width of the dedicated sign column to the left of the number column.
179    /// Typically 0 (no signs) or 1 (one sign char per row). Signs are
180    /// painted in `area.x .. area.x + sign_column_width`; numbers are
181    /// painted in `area.x + sign_column_width .. area.x + sign_column_width + width`.
182    pub sign_column_width: u16,
183}
184
185/// Single-cell marker painted into the leftmost gutter column for a
186/// document row. Used by hosts to surface LSP diagnostics, git diff
187/// signs, etc. Higher `priority` wins when multiple signs land on
188/// the same row.
189#[derive(Debug, Clone, Copy)]
190pub struct Sign {
191    pub row: usize,
192    pub ch: char,
193    pub style: Style,
194    pub priority: u8,
195}
196
197/// Render-time substitution that hides a byte range and paints
198/// `replacement` in its place. The buffer's content stays unchanged;
199/// only the rendered cells differ. Used by hosts to pretty-print
200/// URLs, conceal markdown markers, etc.
201#[derive(Debug, Clone)]
202pub struct Conceal {
203    pub row: usize,
204    pub start_byte: usize,
205    pub end_byte: usize,
206    pub replacement: String,
207}
208
209/// A char-column range on a document row that should be styled with an
210/// overlay (e.g. an underline for LSP diagnostics). Applied in a
211/// post-paint pass so it composes on top of syntax and selection colours.
212///
213/// Added in 0.5.0 for LSP diagnostic inline rendering.
214#[derive(Debug, Clone, Copy)]
215pub struct DiagOverlay {
216    /// 0-based document row.
217    pub row: usize,
218    /// 0-based start char-column (inclusive).
219    pub col_start: usize,
220    /// 0-based end char-column (exclusive).
221    pub col_end: usize,
222    /// Style applied to cells in `[col_start, col_end)`.
223    pub style: Style,
224}
225
226impl<R: StyleResolver> Widget for BufferView<'_, R> {
227    fn render(self, area: Rect, term_buf: &mut TermBuffer) {
228        let viewport = *self.viewport;
229        let cursor = self.buffer.cursor();
230        let lines = self.buffer.lines();
231        let spans = self.spans;
232        let folds = self.buffer.folds();
233        let top_row = viewport.top_row;
234        let top_col = viewport.top_col;
235
236        let gutter_total = self
237            .gutter
238            .map(|g| g.sign_column_width + g.width)
239            .unwrap_or(0);
240        let text_area = Rect {
241            x: area.x.saturating_add(gutter_total),
242            y: area.y,
243            width: area.width.saturating_sub(gutter_total),
244            height: area.height,
245        };
246
247        let total_rows = lines.len();
248        let mut doc_row = top_row;
249        let mut screen_row: u16 = 0;
250        let wrap_mode = viewport.wrap;
251        let seg_width = if viewport.text_width > 0 {
252            viewport.text_width
253        } else {
254            text_area.width
255        };
256        // Per-screen-row flag: true when the cell at the cursor's
257        // column on that screen row is part of an active `/` search
258        // match. The cursorcolumn pass uses this to skip cells that
259        // search bg already painted, so search highlight wins over
260        // the column bg.
261        let mut search_hit_at_cursor_col: Vec<bool> = Vec::new();
262        // Walk the document forward, skipping rows hidden by closed
263        // folds. Emit the start row of a closed fold as a marker
264        // line instead of its actual content.
265        while doc_row < total_rows && screen_row < area.height {
266            // Skip rows hidden by a closed fold (any row past start
267            // of a closed fold).
268            if folds.iter().any(|f| f.hides(doc_row)) {
269                doc_row += 1;
270                continue;
271            }
272            let folded_at_start = folds
273                .iter()
274                .find(|f| f.closed && f.start_row == doc_row)
275                .copied();
276            let line = &lines[doc_row];
277            let row_spans = spans.get(doc_row).map(Vec::as_slice).unwrap_or(&[]);
278            let sel_range = self.selection.and_then(|s| s.row_span(doc_row));
279            let is_cursor_row = doc_row == cursor.row;
280            if let Some(fold) = folded_at_start {
281                if let Some(gutter) = self.gutter {
282                    self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
283                    self.paint_signs(term_buf, area, screen_row, doc_row, gutter);
284                }
285                self.paint_fold_marker(term_buf, text_area, screen_row, fold, line, is_cursor_row);
286                search_hit_at_cursor_col.push(false);
287                screen_row += 1;
288                doc_row = fold.end_row + 1;
289                continue;
290            }
291            let search_ranges = self.row_search_ranges(line);
292            let row_has_hit_at_cursor_col = search_ranges
293                .iter()
294                .any(|&(s, e)| cursor.col >= s && cursor.col < e);
295            // Collect conceals for this row, sorted by start_byte.
296            let row_conceals: Vec<&Conceal> = {
297                let mut v: Vec<&Conceal> =
298                    self.conceals.iter().filter(|c| c.row == doc_row).collect();
299                v.sort_by_key(|c| c.start_byte);
300                v
301            };
302            // Compute screen segments for this doc row. `Wrap::None`
303            // produces a single segment that spans the whole line; the
304            // existing `top_col` horizontal scroll is preserved by
305            // passing `top_col` as the segment start. Wrap modes split
306            // the line into multiple visual rows that fit
307            // `viewport.text_width` (falls back to `text_area.width`
308            // when the host hasn't published a text width yet).
309            let segments = match wrap_mode {
310                Wrap::None => vec![(top_col, usize::MAX)],
311                _ => wrap_segments(line, seg_width, wrap_mode),
312            };
313            let last_seg_idx = segments.len().saturating_sub(1);
314            for (seg_idx, &(seg_start, seg_end)) in segments.iter().enumerate() {
315                if screen_row >= area.height {
316                    break;
317                }
318                if let Some(gutter) = self.gutter {
319                    if seg_idx == 0 {
320                        self.paint_gutter(term_buf, area, screen_row, doc_row, gutter);
321                        self.paint_signs(term_buf, area, screen_row, doc_row, gutter);
322                    } else {
323                        self.paint_blank_gutter(term_buf, area, screen_row, gutter);
324                    }
325                }
326                self.paint_row(
327                    term_buf,
328                    text_area,
329                    screen_row,
330                    line,
331                    row_spans,
332                    sel_range,
333                    &search_ranges,
334                    is_cursor_row,
335                    cursor.col,
336                    seg_start,
337                    seg_end,
338                    seg_idx == last_seg_idx,
339                    &row_conceals,
340                );
341                search_hit_at_cursor_col.push(row_has_hit_at_cursor_col);
342                screen_row += 1;
343            }
344            doc_row += 1;
345        }
346        // Tilde pass: paint `~` on every screen row past the last buffer
347        // line (vim's NonText marker). Gutter on those rows stays blank.
348        while screen_row < area.height {
349            // Blank gutter if present.
350            if let Some(gutter) = self.gutter {
351                self.paint_blank_gutter(term_buf, area, screen_row, gutter);
352            }
353            // Paint `~` at the first text column.
354            let y = text_area.y + screen_row;
355            if let Some(cell) = term_buf.cell_mut((text_area.x, y)) {
356                cell.set_char('~');
357                cell.set_style(self.non_text_style);
358            }
359            screen_row += 1;
360        }
361        // Cursorcolumn pass: layer the bg over the cursor's visible
362        // column once every row is painted so it composes on top of
363        // syntax / cursorline backgrounds without disturbing fg.
364        // Skipped when wrapping — the cursor's screen x depends on the
365        // segment it lands in, and vim's cursorcolumn semantics with
366        // wrap are fuzzy. Revisit if it bites.
367        if matches!(wrap_mode, Wrap::None)
368            && self.cursor_column_bg != Style::default()
369            && cursor.col >= top_col
370            && (cursor.col - top_col) < text_area.width as usize
371        {
372            let x = text_area.x + (cursor.col - top_col) as u16;
373            for sy in 0..screen_row {
374                // Skip rows where search bg already painted this cell —
375                // search highlight wins over cursorcolumn so `/foo`
376                // matches stay readable when the cursor sits on them.
377                if search_hit_at_cursor_col
378                    .get(sy as usize)
379                    .copied()
380                    .unwrap_or(false)
381                {
382                    continue;
383                }
384                let y = text_area.y + sy;
385                if let Some(cell) = term_buf.cell_mut((x, y)) {
386                    cell.set_style(cell.style().patch(self.cursor_column_bg));
387                }
388            }
389        }
390
391        // Colorcolumn pass: paint vertical ruler(s) under syntax.
392        // Applied only in Wrap::None mode; skips indices that are
393        // scrolled out of the visible horizontal window.
394        if matches!(wrap_mode, Wrap::None) && !self.colorcolumn_cols.is_empty() {
395            for &col_1based in self.colorcolumn_cols {
396                let col = col_1based as usize; // convert to 0-based
397                if col == 0 || col < top_col + 1 {
398                    continue; // out of visible range (scrolled past left edge)
399                }
400                let screen_col = col - 1 - top_col; // 0-based screen offset
401                if screen_col >= text_area.width as usize {
402                    continue; // out of visible range (past right edge)
403                }
404                let x = text_area.x + screen_col as u16;
405                for sy in 0..screen_row {
406                    let y = text_area.y + sy;
407                    if let Some(cell) = term_buf.cell_mut((x, y)) {
408                        cell.set_style(cell.style().patch(self.colorcolumn_style));
409                    }
410                }
411            }
412        }
413
414        // Diag overlay pass: apply underline / style over visible char
415        // columns. Only supported in Wrap::None mode; wrap is a future
416        // concern. Overlays beyond the visible horizontal scroll are
417        // skipped silently.
418        if matches!(wrap_mode, Wrap::None) && !self.diag_overlays.is_empty() {
419            // Build a doc_row → screen_row map from the first pass.
420            // We re-walk the viewport range instead of storing a map to
421            // keep memory allocation proportional to the viewport.
422            let vp_top = top_row;
423            let vp_bot = vp_top + area.height as usize;
424            for overlay in self.diag_overlays {
425                if overlay.row < vp_top || overlay.row >= vp_bot {
426                    continue;
427                }
428                // Compute screen row: count non-hidden rows from vp_top
429                // to overlay.row.
430                let mut sr: u16 = 0;
431                let mut dr = vp_top;
432                while dr < overlay.row && sr < area.height {
433                    if !folds.iter().any(|f| f.hides(dr)) {
434                        sr += 1;
435                    }
436                    dr += 1;
437                }
438                if sr >= area.height {
439                    continue;
440                }
441                let y = text_area.y + sr;
442                // Paint the char columns in the overlay range, clamped
443                // to the horizontal scroll window and text area width.
444                let col_start = overlay.col_start;
445                let col_end = overlay.col_end.max(col_start + 1);
446                for col in col_start..col_end {
447                    if col < top_col {
448                        continue;
449                    }
450                    let screen_col = col - top_col;
451                    if screen_col >= text_area.width as usize {
452                        break;
453                    }
454                    let x = text_area.x + screen_col as u16;
455                    if let Some(cell) = term_buf.cell_mut((x, y)) {
456                        cell.set_style(cell.style().patch(overlay.style));
457                    }
458                }
459            }
460        }
461    }
462}
463
464impl<R: StyleResolver> BufferView<'_, R> {
465    /// Run the active search regex against `line` and return the
466    /// charwise `(start_col, end_col_exclusive)` ranges that need
467    /// the search bg painted. Empty when no pattern is set.
468    fn row_search_ranges(&self, line: &str) -> Vec<(usize, usize)> {
469        let Some(re) = self.search_pattern else {
470            return Vec::new();
471        };
472        re.find_iter(line)
473            .map(|m| {
474                let start = line[..m.start()].chars().count();
475                let end = line[..m.end()].chars().count();
476                (start, end)
477            })
478            .collect()
479    }
480
481    fn paint_fold_marker(
482        &self,
483        term_buf: &mut TermBuffer,
484        area: Rect,
485        screen_row: u16,
486        fold: crate::Fold,
487        first_line: &str,
488        is_cursor_row: bool,
489    ) {
490        let y = area.y + screen_row;
491        let style = if is_cursor_row && self.cursor_line_bg != Style::default() {
492            self.cursor_line_bg
493        } else {
494            Style::default()
495        };
496        // Bg the whole row first so the marker reads like one cell.
497        for x in area.x..(area.x + area.width) {
498            if let Some(cell) = term_buf.cell_mut((x, y)) {
499                cell.set_style(style);
500            }
501        }
502        // Build a label that hints at the fold's contents instead of
503        // a generic "+-- N lines folded --". Use the start row's
504        // trimmed text (truncated) plus the line count.
505        let prefix = first_line.trim();
506        let count = fold.line_count();
507        let label = if prefix.is_empty() {
508            format!("▸ {count} lines folded")
509        } else {
510            const MAX_PREFIX: usize = 60;
511            let trimmed = if prefix.chars().count() > MAX_PREFIX {
512                let head: String = prefix.chars().take(MAX_PREFIX - 1).collect();
513                format!("{head}…")
514            } else {
515                prefix.to_string()
516            };
517            format!("▸ {trimmed}  ({count} lines)")
518        };
519        let mut x = area.x;
520        let row_end_x = area.x + area.width;
521        for ch in label.chars() {
522            if x >= row_end_x {
523                break;
524            }
525            let width = ch.width().unwrap_or(1) as u16;
526            if x + width > row_end_x {
527                break;
528            }
529            if let Some(cell) = term_buf.cell_mut((x, y)) {
530                cell.set_char(ch);
531                cell.set_style(style);
532            }
533            x = x.saturating_add(width);
534        }
535    }
536
537    fn paint_signs(
538        &self,
539        term_buf: &mut TermBuffer,
540        area: Rect,
541        screen_row: u16,
542        doc_row: usize,
543        gutter: Gutter,
544    ) {
545        // Only paint when a sign column is reserved.
546        if gutter.sign_column_width == 0 {
547            return;
548        }
549        let y = area.y + screen_row;
550        let sign_x = area.x;
551        // Fill sign column cells with blank first (gutter style bg).
552        for x in sign_x..sign_x + gutter.sign_column_width {
553            if let Some(cell) = term_buf.cell_mut((x, y)) {
554                cell.set_char(' ');
555                cell.set_style(gutter.style);
556            }
557        }
558        // Paint the highest-priority sign for this row in the leftmost cell.
559        let Some(sign) = self
560            .signs
561            .iter()
562            .filter(|s| s.row == doc_row)
563            .max_by_key(|s| s.priority)
564        else {
565            return;
566        };
567        if let Some(cell) = term_buf.cell_mut((sign_x, y)) {
568            cell.set_char(sign.ch);
569            cell.set_style(sign.style);
570        }
571    }
572
573    /// Paint a wrap-continuation gutter row: blank cells in the
574    /// gutter style so the bg stays continuous, no line number.
575    fn paint_blank_gutter(
576        &self,
577        term_buf: &mut TermBuffer,
578        area: Rect,
579        screen_row: u16,
580        gutter: Gutter,
581    ) {
582        let y = area.y + screen_row;
583        let total = gutter.sign_column_width + gutter.width;
584        for x in area.x..(area.x + total) {
585            if let Some(cell) = term_buf.cell_mut((x, y)) {
586                cell.set_char(' ');
587                cell.set_style(gutter.style);
588            }
589        }
590    }
591
592    fn paint_gutter(
593        &self,
594        term_buf: &mut TermBuffer,
595        area: Rect,
596        screen_row: u16,
597        doc_row: usize,
598        gutter: Gutter,
599    ) {
600        let y = area.y + screen_row;
601        // Number column starts after the sign column.
602        let num_start = area.x + gutter.sign_column_width;
603        // Total gutter cells in the number column, leaving one trailing spacer.
604        let number_width = gutter.width.saturating_sub(1) as usize;
605
606        // Compute the label to display based on the numbers mode.
607        let label = match gutter.numbers {
608            GutterNumbers::None => {
609                // Blank — paint all number-column cells (including spacer) as spaces.
610                for x in num_start..(num_start + gutter.width) {
611                    if let Some(cell) = term_buf.cell_mut((x, y)) {
612                        cell.set_char(' ');
613                        cell.set_style(gutter.style);
614                    }
615                }
616                return;
617            }
618            GutterNumbers::Absolute => {
619                format!(
620                    "{:>width$}",
621                    doc_row + 1 + gutter.line_offset,
622                    width = number_width
623                )
624            }
625            GutterNumbers::Relative { cursor_row } => {
626                let n = if doc_row == cursor_row {
627                    0
628                } else {
629                    doc_row.abs_diff(cursor_row)
630                };
631                format!("{:>width$}", n, width = number_width)
632            }
633            GutterNumbers::Hybrid { cursor_row } => {
634                let n = if doc_row == cursor_row {
635                    doc_row + 1 + gutter.line_offset
636                } else {
637                    doc_row.abs_diff(cursor_row)
638                };
639                format!("{:>width$}", n, width = number_width)
640            }
641        };
642
643        let mut x = num_start;
644        for ch in label.chars() {
645            if x >= num_start + gutter.width.saturating_sub(1) {
646                break;
647            }
648            if let Some(cell) = term_buf.cell_mut((x, y)) {
649                cell.set_char(ch);
650                cell.set_style(gutter.style);
651            }
652            x = x.saturating_add(1);
653        }
654        // Spacer cell — same gutter style so the background is
655        // continuous when a bg colour is set.
656        let spacer_x = num_start + gutter.width.saturating_sub(1);
657        if let Some(cell) = term_buf.cell_mut((spacer_x, y)) {
658            cell.set_char(' ');
659            cell.set_style(gutter.style);
660        }
661    }
662
663    #[allow(clippy::too_many_arguments)]
664    fn paint_row(
665        &self,
666        term_buf: &mut TermBuffer,
667        area: Rect,
668        screen_row: u16,
669        line: &str,
670        row_spans: &[crate::Span],
671        sel_range: crate::RowSpan,
672        search_ranges: &[(usize, usize)],
673        is_cursor_row: bool,
674        cursor_col: usize,
675        seg_start: usize,
676        seg_end: usize,
677        is_last_segment: bool,
678        conceals: &[&Conceal],
679    ) {
680        let y = area.y + screen_row;
681        let mut screen_x = area.x;
682        let row_end_x = area.x + area.width;
683
684        // Paint cursor-line bg across the whole row first so empty
685        // trailing cells inherit the highlight (matches vim's
686        // cursorline). Selection / cursor cells overwrite below.
687        if is_cursor_row && self.cursor_line_bg != Style::default() {
688            for x in area.x..row_end_x {
689                if let Some(cell) = term_buf.cell_mut((x, y)) {
690                    cell.set_style(self.cursor_line_bg);
691                }
692            }
693        }
694
695        // Tab width for `\t` expansion — host publishes via
696        // `Viewport::tab_width` (driven by engine's `:set tabstop`).
697        // `effective_tab_width` falls back to 4 when unset.
698        let tab_width = self.viewport.effective_tab_width();
699        let mut byte_offset: usize = 0;
700        let mut line_col: usize = 0;
701        let mut chars_iter = line.chars().enumerate().peekable();
702        while let Some((col_idx, ch)) = chars_iter.next() {
703            let ch_byte_len = ch.len_utf8();
704            if col_idx >= seg_end {
705                break;
706            }
707            // If a conceal starts at this byte, paint the replacement
708            // text (using this cell's style) and skip the rest of the
709            // concealed range. Cursor / selection / search highlights
710            // still attribute to the original char positions.
711            if let Some(conc) = conceals.iter().find(|c| c.start_byte == byte_offset) {
712                if col_idx >= seg_start {
713                    let mut style = if is_cursor_row {
714                        self.cursor_line_bg
715                    } else {
716                        Style::default()
717                    };
718                    if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
719                        style = style.patch(span_style);
720                    }
721                    for rch in conc.replacement.chars() {
722                        let rwidth = rch.width().unwrap_or(1) as u16;
723                        if screen_x + rwidth > row_end_x {
724                            break;
725                        }
726                        if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
727                            cell.set_char(rch);
728                            cell.set_style(style);
729                        }
730                        screen_x += rwidth;
731                    }
732                }
733                // Advance byte_offset / chars iter past the concealed
734                // range without painting the original cells.
735                let mut consumed = ch_byte_len;
736                byte_offset += ch_byte_len;
737                while byte_offset < conc.end_byte {
738                    let Some((_, next_ch)) = chars_iter.next() else {
739                        break;
740                    };
741                    consumed += next_ch.len_utf8();
742                    byte_offset = byte_offset.saturating_add(next_ch.len_utf8());
743                }
744                let _ = consumed;
745                continue;
746            }
747            // Visible cell count: tabs expand to the next tab_width stop
748            // based on `line_col` (visible column in the *line*, not the
749            // segment), so a tab at line column 0 paints tab_width cells
750            // and a tab at line column 3 paints 1 cell.
751            let visible_width = if ch == '\t' {
752                tab_width - (line_col % tab_width)
753            } else {
754                ch.width().unwrap_or(1)
755            };
756            // Skip chars to the left of the segment start (horizontal
757            // scroll for `Wrap::None`, segment offset for wrap modes).
758            if col_idx < seg_start {
759                line_col += visible_width;
760                byte_offset += ch_byte_len;
761                continue;
762            }
763            // Stop when we run out of horizontal room.
764            let width = visible_width as u16;
765            if screen_x + width > row_end_x {
766                break;
767            }
768
769            // Resolve final style for this cell.
770            let mut style = if is_cursor_row {
771                self.cursor_line_bg
772            } else {
773                Style::default()
774            };
775            if let Some(span_style) = self.resolve_span_style(row_spans, byte_offset) {
776                style = style.patch(span_style);
777            }
778            // Search bg first, then selection bg — so when a visual
779            // selection covers a search match, the selection wins
780            // (last patch overwrites the bg field).
781            if self.search_bg != Style::default()
782                && search_ranges
783                    .iter()
784                    .any(|&(s, e)| col_idx >= s && col_idx < e)
785            {
786                style = style.patch(self.search_bg);
787            }
788            if let Some((lo, hi)) = sel_range
789                && col_idx >= lo
790                && col_idx <= hi
791            {
792                style = style.patch(self.selection_bg);
793            }
794            if is_cursor_row && col_idx == cursor_col {
795                style = style.patch(self.cursor_style);
796            }
797
798            if ch == '\t' {
799                // Paint tab as `visible_width` space cells carrying the
800                // resolved style — tab/text bg/cursor-line bg all paint
801                // through the expansion.
802                for k in 0..width {
803                    if let Some(cell) = term_buf.cell_mut((screen_x + k, y)) {
804                        cell.set_char(' ');
805                        cell.set_style(style);
806                    }
807                }
808            } else if let Some(cell) = term_buf.cell_mut((screen_x, y)) {
809                cell.set_char(ch);
810                cell.set_style(style);
811            }
812            screen_x += width;
813            line_col += visible_width;
814            byte_offset += ch_byte_len;
815        }
816
817        // Empty-line selection placeholder. Without this, an empty row
818        // covered by a v/V/Ctrl-V selection paints zero cells — the user
819        // loses the visible marker that the row is part of the range.
820        //
821        // For Char/Line selections, `hi == usize::MAX` (the "whole row"
822        // sentinel from `Selection::row_span`); paint a single ' ' cell
823        // at col 0 — matches Neovim's marker on otherwise-empty rows.
824        //
825        // For Block selections, `hi` is bounded by the block's right
826        // column; paint cols `lo..=hi` so the block stays visually
827        // rectangular even where rows have no chars (also matches
828        // Neovim).
829        //
830        // Layered before the cursor-EOL placeholder so the terminal
831        // cursor still wins visually.
832        if let Some((lo, hi)) = sel_range
833            && is_last_segment
834            && line.chars().count() <= seg_start
835        {
836            let (start_col, end_col) = if hi == usize::MAX { (0, 0) } else { (lo, hi) };
837            for col in start_col..=end_col {
838                let pad_x = area.x + col as u16;
839                if pad_x >= row_end_x {
840                    break;
841                }
842                if let Some(cell) = term_buf.cell_mut((pad_x, y)) {
843                    let prev = cell.style();
844                    cell.set_char(' ');
845                    cell.set_style(prev.patch(self.selection_bg));
846                }
847            }
848        }
849
850        // If the cursor sits at end-of-line (insert / past-end mode),
851        // paint a single REVERSED placeholder cell so it stays visible.
852        // Only on the last segment of a wrapped row — earlier segments
853        // can't host the past-end cursor.
854        if is_cursor_row
855            && is_last_segment
856            && cursor_col >= line.chars().count()
857            && cursor_col >= seg_start
858        {
859            let pad_x = area.x + (cursor_col.saturating_sub(seg_start)) as u16;
860            if pad_x < row_end_x
861                && let Some(cell) = term_buf.cell_mut((pad_x, y))
862            {
863                cell.set_char(' ');
864                cell.set_style(self.cursor_line_bg.patch(self.cursor_style));
865            }
866        }
867    }
868
869    /// Resolve the final style for a byte by layering every span that
870    /// contains it, broadest first and narrowest last. `Style::patch` keeps
871    /// the broader span's fields when the narrower span doesn't override
872    /// them, so a wide `@markup.raw.block` carrying just `bg = codeblock`
873    /// shines through under a narrow `@keyword` carrying just `fg = mauve`,
874    /// matching vim/Helix's layered hi-group model.
875    ///
876    /// Pre-0.6.1 behaviour was narrowest-wins-completely: only one span's
877    /// style applied per byte, so broader-span backgrounds were dropped
878    /// whenever a narrower foreground span overlapped them. That made it
879    /// impossible to give markdown code blocks a tinted bg without also
880    /// burdening every injected language's captures with the same bg.
881    ///
882    /// Hosts that want the old behaviour can ensure their narrower spans
883    /// set every field explicitly — `Style::patch` only carries broader
884    /// fields through `None` slots.
885    fn resolve_span_style(&self, row_spans: &[crate::Span], byte_offset: usize) -> Option<Style> {
886        // Collect every span containing this byte, sorted broadest first.
887        let mut overlapping: Vec<&crate::Span> = row_spans
888            .iter()
889            .filter(|s| byte_offset >= s.start_byte && byte_offset < s.end_byte)
890            .collect();
891        if overlapping.is_empty() {
892            return None;
893        }
894        overlapping.sort_by_key(|s| std::cmp::Reverse(s.end_byte.saturating_sub(s.start_byte)));
895        let mut style = self.resolver.resolve(overlapping[0].style);
896        for s in &overlapping[1..] {
897            style = style.patch(self.resolver.resolve(s.style));
898        }
899        Some(style)
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use ratatui::style::{Color, Modifier};
907    use ratatui::widgets::Widget;
908
909    fn run_render<R: StyleResolver>(view: BufferView<'_, R>, w: u16, h: u16) -> TermBuffer {
910        let area = Rect::new(0, 0, w, h);
911        let mut buf = TermBuffer::empty(area);
912        view.render(area, &mut buf);
913        buf
914    }
915
916    fn no_styles(_id: u32) -> Style {
917        Style::default()
918    }
919
920    /// Build a default viewport for plain (no-wrap) tests.
921    fn vp(width: u16, height: u16) -> Viewport {
922        Viewport {
923            top_row: 0,
924            top_col: 0,
925            width,
926            height,
927            wrap: Wrap::None,
928            text_width: width,
929            tab_width: 0,
930        }
931    }
932
933    #[test]
934    fn renders_plain_chars_into_terminal_buffer() {
935        let b = Buffer::from_str("hello\nworld");
936        let v = vp(20, 5);
937        let view = BufferView {
938            buffer: &b,
939            viewport: &v,
940            selection: None,
941            resolver: &(no_styles as fn(u32) -> Style),
942            cursor_line_bg: Style::default(),
943            cursor_column_bg: Style::default(),
944            selection_bg: Style::default().bg(Color::Blue),
945            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
946            gutter: None,
947            search_bg: Style::default(),
948            signs: &[],
949            conceals: &[],
950            spans: &[],
951            search_pattern: None,
952            non_text_style: Style::default(),
953            diag_overlays: &[],
954            colorcolumn_cols: &[],
955            colorcolumn_style: Style::default(),
956        };
957        let term = run_render(view, 20, 5);
958        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
959        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "o");
960        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "w");
961        assert_eq!(term.cell((4, 1)).unwrap().symbol(), "d");
962    }
963
964    #[test]
965    fn cursor_cell_gets_reversed_style() {
966        let mut b = Buffer::from_str("abc");
967        let v = vp(10, 1);
968        b.set_cursor(crate::Position::new(0, 1));
969        let view = BufferView {
970            buffer: &b,
971            viewport: &v,
972            selection: None,
973            resolver: &(no_styles as fn(u32) -> Style),
974            cursor_line_bg: Style::default(),
975            cursor_column_bg: Style::default(),
976            selection_bg: Style::default().bg(Color::Blue),
977            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
978            gutter: None,
979            search_bg: Style::default(),
980            signs: &[],
981            conceals: &[],
982            spans: &[],
983            search_pattern: None,
984            non_text_style: Style::default(),
985            diag_overlays: &[],
986            colorcolumn_cols: &[],
987            colorcolumn_style: Style::default(),
988        };
989        let term = run_render(view, 10, 1);
990        let cursor_cell = term.cell((1, 0)).unwrap();
991        assert!(cursor_cell.modifier.contains(Modifier::REVERSED));
992    }
993
994    #[test]
995    fn selection_bg_applies_only_to_selected_cells() {
996        use crate::{Position, Selection};
997        let b = Buffer::from_str("abcdef");
998        let v = vp(10, 1);
999        let view = BufferView {
1000            buffer: &b,
1001            viewport: &v,
1002            selection: Some(Selection::Char {
1003                anchor: Position::new(0, 1),
1004                head: Position::new(0, 3),
1005            }),
1006            resolver: &(no_styles as fn(u32) -> Style),
1007            cursor_line_bg: Style::default(),
1008            cursor_column_bg: Style::default(),
1009            selection_bg: Style::default().bg(Color::Blue),
1010            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1011            gutter: None,
1012            search_bg: Style::default(),
1013            signs: &[],
1014            conceals: &[],
1015            spans: &[],
1016            search_pattern: None,
1017            non_text_style: Style::default(),
1018            diag_overlays: &[],
1019            colorcolumn_cols: &[],
1020            colorcolumn_style: Style::default(),
1021        };
1022        let term = run_render(view, 10, 1);
1023        assert!(term.cell((0, 0)).unwrap().bg != Color::Blue);
1024        for x in 1..=3 {
1025            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
1026        }
1027        assert!(term.cell((4, 0)).unwrap().bg != Color::Blue);
1028    }
1029
1030    #[test]
1031    fn selection_paints_placeholder_on_empty_line_charwise() {
1032        // Char selection spanning two lines, middle empty row must show
1033        // a selection cell at col 0 so the user can see the row is in range.
1034        use crate::{Position, Selection};
1035        let b = Buffer::from_str("abc\n\nxyz");
1036        let v = vp(10, 3);
1037        let view = BufferView {
1038            buffer: &b,
1039            viewport: &v,
1040            selection: Some(Selection::Char {
1041                anchor: Position::new(0, 0),
1042                head: Position::new(2, 2),
1043            }),
1044            resolver: &(no_styles as fn(u32) -> Style),
1045            cursor_line_bg: Style::default(),
1046            cursor_column_bg: Style::default(),
1047            selection_bg: Style::default().bg(Color::Blue),
1048            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1049            gutter: None,
1050            search_bg: Style::default(),
1051            signs: &[],
1052            conceals: &[],
1053            spans: &[],
1054            search_pattern: None,
1055            non_text_style: Style::default(),
1056            diag_overlays: &[],
1057            colorcolumn_cols: &[],
1058            colorcolumn_style: Style::default(),
1059        };
1060        let term = run_render(view, 10, 3);
1061        // Empty middle row (y=1) — col 0 must carry the selection bg.
1062        assert_eq!(term.cell((0, 1)).unwrap().bg, Color::Blue);
1063    }
1064
1065    #[test]
1066    fn selection_paints_placeholder_on_empty_line_linewise() {
1067        use crate::Selection;
1068        let b = Buffer::from_str("abc\n\nxyz");
1069        let v = vp(10, 3);
1070        let view = BufferView {
1071            buffer: &b,
1072            viewport: &v,
1073            selection: Some(Selection::Line {
1074                anchor_row: 0,
1075                head_row: 2,
1076            }),
1077            resolver: &(no_styles as fn(u32) -> Style),
1078            cursor_line_bg: Style::default(),
1079            cursor_column_bg: Style::default(),
1080            selection_bg: Style::default().bg(Color::Blue),
1081            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1082            gutter: None,
1083            search_bg: Style::default(),
1084            signs: &[],
1085            conceals: &[],
1086            spans: &[],
1087            search_pattern: None,
1088            non_text_style: Style::default(),
1089            diag_overlays: &[],
1090            colorcolumn_cols: &[],
1091            colorcolumn_style: Style::default(),
1092        };
1093        let term = run_render(view, 10, 3);
1094        assert_eq!(term.cell((0, 1)).unwrap().bg, Color::Blue);
1095    }
1096
1097    #[test]
1098    fn selection_paints_placeholder_on_empty_line_blockwise() {
1099        // Block selection at cols 2..=5 over rows 0..=2 with empty middle.
1100        // The empty row must paint cols 2..=5 (the block's full width),
1101        // NOT just col 0 — otherwise the block looks broken at empty
1102        // rows. Matches Neovim's rectangular block highlight.
1103        use crate::{Position, Selection};
1104        let b = Buffer::from_str("abcdef\n\nuvwxyz");
1105        let v = vp(10, 3);
1106        let view = BufferView {
1107            buffer: &b,
1108            viewport: &v,
1109            selection: Some(Selection::Block {
1110                anchor: Position::new(0, 2),
1111                head: Position::new(2, 5),
1112            }),
1113            resolver: &(no_styles as fn(u32) -> Style),
1114            cursor_line_bg: Style::default(),
1115            cursor_column_bg: Style::default(),
1116            selection_bg: Style::default().bg(Color::Blue),
1117            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1118            gutter: None,
1119            search_bg: Style::default(),
1120            signs: &[],
1121            conceals: &[],
1122            spans: &[],
1123            search_pattern: None,
1124            non_text_style: Style::default(),
1125            diag_overlays: &[],
1126            colorcolumn_cols: &[],
1127            colorcolumn_style: Style::default(),
1128        };
1129        let term = run_render(view, 10, 3);
1130        // Empty row (y=1): cols 2..=5 carry selection bg (block width).
1131        for x in 2u16..=5 {
1132            assert_eq!(
1133                term.cell((x, 1)).unwrap().bg,
1134                Color::Blue,
1135                "empty row col {x} should carry block selection bg"
1136            );
1137        }
1138        // Col 0 and 1 on empty row MUST NOT carry selection bg — block
1139        // starts at col 2.
1140        assert!(term.cell((0, 1)).unwrap().bg != Color::Blue);
1141        assert!(term.cell((1, 1)).unwrap().bg != Color::Blue);
1142        // Col 6 (just past block right edge) also clear.
1143        assert!(term.cell((6, 1)).unwrap().bg != Color::Blue);
1144        // Non-empty rows still highlight cols 2..=5.
1145        for x in 2u16..=5 {
1146            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Blue);
1147        }
1148    }
1149
1150    #[test]
1151    fn selection_block_placeholder_clips_to_row_width() {
1152        // Block right edge past row width must stop at row_end_x.
1153        use crate::{Position, Selection};
1154        let b = Buffer::from_str("abc\n\nxyz");
1155        let v = vp(5, 3);
1156        let view = BufferView {
1157            buffer: &b,
1158            viewport: &v,
1159            selection: Some(Selection::Block {
1160                anchor: Position::new(0, 1),
1161                head: Position::new(2, 20),
1162            }),
1163            resolver: &(no_styles as fn(u32) -> Style),
1164            cursor_line_bg: Style::default(),
1165            cursor_column_bg: Style::default(),
1166            selection_bg: Style::default().bg(Color::Blue),
1167            cursor_style: Style::default(),
1168            gutter: None,
1169            search_bg: Style::default(),
1170            signs: &[],
1171            conceals: &[],
1172            spans: &[],
1173            search_pattern: None,
1174            non_text_style: Style::default(),
1175            diag_overlays: &[],
1176            colorcolumn_cols: &[],
1177            colorcolumn_style: Style::default(),
1178        };
1179        // 5-wide area; block lo=1, hi=20 → paint cols 1..=4 (rest clipped).
1180        let term = run_render(view, 5, 3);
1181        for x in 1u16..=4 {
1182            assert_eq!(
1183                term.cell((x, 1)).unwrap().bg,
1184                Color::Blue,
1185                "col {x} clipped block placeholder"
1186            );
1187        }
1188        // No panic from pad_x past row_end_x is the main thing.
1189    }
1190
1191    #[test]
1192    fn layered_spans_blend_broad_bg_with_narrow_fg() {
1193        // Regression: a wide `@markup.raw.block`-style span carrying only
1194        // `bg = ...` must shine through a narrow `@keyword`-style span
1195        // carrying only `fg = ...`. Pre-0.6.1 the narrow span won outright
1196        // and dropped the broad bg, which made markdown code-block tinting
1197        // impossible without bloating every injected language's captures.
1198        use crate::Span;
1199        let b = Buffer::from_str("fn main() {}");
1200        let v = vp(20, 1);
1201        // id=1 = broad code-block bg, id=2 = narrow keyword fg.
1202        let spans = vec![vec![
1203            Span::new(0, 12, 1), // bg-only, whole line
1204            Span::new(0, 2, 2),  // fg-only, just "fn"
1205        ]];
1206        let resolver = |id: u32| -> Style {
1207            match id {
1208                1 => Style::default().bg(Color::DarkGray),
1209                2 => Style::default().fg(Color::Magenta),
1210                _ => Style::default(),
1211            }
1212        };
1213        let view = BufferView {
1214            buffer: &b,
1215            viewport: &v,
1216            selection: None,
1217            resolver: &resolver,
1218            cursor_line_bg: Style::default(),
1219            cursor_column_bg: Style::default(),
1220            selection_bg: Style::default().bg(Color::Blue),
1221            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1222            gutter: None,
1223            search_bg: Style::default(),
1224            signs: &[],
1225            conceals: &[],
1226            spans: &spans,
1227            search_pattern: None,
1228            non_text_style: Style::default(),
1229            diag_overlays: &[],
1230            colorcolumn_cols: &[],
1231            colorcolumn_style: Style::default(),
1232        };
1233        let term = run_render(view, 20, 1);
1234        // Cols 0-1 ("fn"): narrow fg + broad bg.
1235        for x in 0u16..2 {
1236            let cell = term.cell((x, 0)).unwrap();
1237            assert_eq!(cell.fg, Color::Magenta, "col {x}: fg from narrow span");
1238            assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1239        }
1240        // Cols 2-11 (" main() {}"): broad bg only, no fg set.
1241        for x in 2u16..12 {
1242            let cell = term.cell((x, 0)).unwrap();
1243            assert_eq!(cell.bg, Color::DarkGray, "col {x}: bg from broad span");
1244            assert_eq!(
1245                cell.fg,
1246                Color::Reset,
1247                "col {x}: no fg set (broad span is bg-only)"
1248            );
1249        }
1250    }
1251
1252    #[test]
1253    fn narrow_span_with_explicit_bg_still_overrides_broad_bg() {
1254        // Regression: a narrow span that DOES set bg must override the
1255        // broader span's bg. Earlier "narrowest-wins-completely" behaviour
1256        // had this trivially; the new layered logic relies on
1257        // `Style::patch` overriding only set fields, so we pin it.
1258        use crate::Span;
1259        let b = Buffer::from_str("hello world");
1260        let v = vp(20, 1);
1261        let spans = vec![vec![
1262            Span::new(0, 11, 1), // broad bg = DarkGray
1263            Span::new(6, 11, 2), // narrow bg = Red (overrides)
1264        ]];
1265        let resolver = |id: u32| -> Style {
1266            match id {
1267                1 => Style::default().bg(Color::DarkGray),
1268                2 => Style::default().bg(Color::Red),
1269                _ => Style::default(),
1270            }
1271        };
1272        let view = BufferView {
1273            buffer: &b,
1274            viewport: &v,
1275            selection: None,
1276            resolver: &resolver,
1277            cursor_line_bg: Style::default(),
1278            cursor_column_bg: Style::default(),
1279            selection_bg: Style::default().bg(Color::Blue),
1280            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1281            gutter: None,
1282            search_bg: Style::default(),
1283            signs: &[],
1284            conceals: &[],
1285            spans: &spans,
1286            search_pattern: None,
1287            non_text_style: Style::default(),
1288            diag_overlays: &[],
1289            colorcolumn_cols: &[],
1290            colorcolumn_style: Style::default(),
1291        };
1292        let term = run_render(view, 20, 1);
1293        // Cols 0-5 ("hello "): broad bg only.
1294        for x in 0u16..6 {
1295            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::DarkGray);
1296        }
1297        // Cols 6-10 ("world"): narrow bg wins.
1298        for x in 6u16..11 {
1299            assert_eq!(
1300                term.cell((x, 0)).unwrap().bg,
1301                Color::Red,
1302                "col {x}: narrow span's bg overrides broad bg"
1303            );
1304        }
1305    }
1306
1307    #[test]
1308    fn syntax_span_fg_resolves_via_table() {
1309        use crate::Span;
1310        let b = Buffer::from_str("SELECT foo");
1311        let v = vp(20, 1);
1312        let spans = vec![vec![Span::new(0, 6, 7)]];
1313        let resolver = |id: u32| -> Style {
1314            if id == 7 {
1315                Style::default().fg(Color::Red)
1316            } else {
1317                Style::default()
1318            }
1319        };
1320        let view = BufferView {
1321            buffer: &b,
1322            viewport: &v,
1323            selection: None,
1324            resolver: &resolver,
1325            cursor_line_bg: Style::default(),
1326            cursor_column_bg: Style::default(),
1327            selection_bg: Style::default().bg(Color::Blue),
1328            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1329            gutter: None,
1330            search_bg: Style::default(),
1331            signs: &[],
1332            conceals: &[],
1333            spans: &spans,
1334            search_pattern: None,
1335            non_text_style: Style::default(),
1336            diag_overlays: &[],
1337            colorcolumn_cols: &[],
1338            colorcolumn_style: Style::default(),
1339        };
1340        let term = run_render(view, 20, 1);
1341        for x in 0..6 {
1342            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
1343        }
1344    }
1345
1346    #[test]
1347    fn gutter_renders_right_aligned_line_numbers() {
1348        let b = Buffer::from_str("a\nb\nc");
1349        let v = vp(10, 3);
1350        let view = BufferView {
1351            buffer: &b,
1352            viewport: &v,
1353            selection: None,
1354            resolver: &(no_styles as fn(u32) -> Style),
1355            cursor_line_bg: Style::default(),
1356            cursor_column_bg: Style::default(),
1357            selection_bg: Style::default().bg(Color::Blue),
1358            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1359            gutter: Some(Gutter {
1360                width: 4,
1361                style: Style::default().fg(Color::Yellow),
1362                line_offset: 0,
1363                ..Default::default()
1364            }),
1365            search_bg: Style::default(),
1366            signs: &[],
1367            conceals: &[],
1368            spans: &[],
1369            search_pattern: None,
1370            non_text_style: Style::default(),
1371            diag_overlays: &[],
1372            colorcolumn_cols: &[],
1373            colorcolumn_style: Style::default(),
1374        };
1375        let term = run_render(view, 10, 3);
1376        // Width 4 = 3 number cells + 1 spacer; right-aligned "  1".
1377        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1378        assert_eq!(term.cell((2, 0)).unwrap().fg, Color::Yellow);
1379        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1380        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "3");
1381        // Text shifted right past the gutter.
1382        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1383    }
1384
1385    #[test]
1386    fn gutter_renders_relative_with_cursor_at_zero() {
1387        // 5 rows, cursor on row 2 (0-based). Relative: row 2 → 0, row 0 → 2, row 4 → 2.
1388        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1389        b.set_cursor(crate::Position::new(2, 0));
1390        let v = vp(10, 5);
1391        let view = BufferView {
1392            buffer: &b,
1393            viewport: &v,
1394            selection: None,
1395            resolver: &(no_styles as fn(u32) -> Style),
1396            cursor_line_bg: Style::default(),
1397            cursor_column_bg: Style::default(),
1398            selection_bg: Style::default().bg(Color::Blue),
1399            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1400            gutter: Some(Gutter {
1401                width: 4,
1402                style: Style::default().fg(Color::Yellow),
1403                line_offset: 0,
1404                numbers: GutterNumbers::Relative { cursor_row: 2 },
1405                sign_column_width: 0,
1406            }),
1407            search_bg: Style::default(),
1408            signs: &[],
1409            conceals: &[],
1410            spans: &[],
1411            search_pattern: None,
1412            non_text_style: Style::default(),
1413            diag_overlays: &[],
1414            colorcolumn_cols: &[],
1415            colorcolumn_style: Style::default(),
1416        };
1417        let term = run_render(view, 10, 5);
1418        // Width 4 = 3 number cells + 1 spacer.
1419        // Row 0 (doc 0): distance from cursor (2) = 2 → "  2"
1420        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "2");
1421        // Row 1 (doc 1): distance = 1 → "  1"
1422        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "1");
1423        // Row 2 (doc 2): cursor row → "  0"
1424        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "0");
1425        // Row 3 (doc 3): distance = 1 → "  1"
1426        assert_eq!(term.cell((2, 3)).unwrap().symbol(), "1");
1427        // Row 4 (doc 4): distance = 2 → "  2"
1428        assert_eq!(term.cell((2, 4)).unwrap().symbol(), "2");
1429    }
1430
1431    #[test]
1432    fn gutter_renders_hybrid_cursor_row_absolute() {
1433        // 3 rows, cursor on row 1 (0-based). Hybrid: row 1 → absolute (2),
1434        // row 0 → offset 1, row 2 → offset 1.
1435        let mut b = Buffer::from_str("a\nb\nc");
1436        b.set_cursor(crate::Position::new(1, 0));
1437        let v = vp(10, 3);
1438        let view = BufferView {
1439            buffer: &b,
1440            viewport: &v,
1441            selection: None,
1442            resolver: &(no_styles as fn(u32) -> Style),
1443            cursor_line_bg: Style::default(),
1444            cursor_column_bg: Style::default(),
1445            selection_bg: Style::default().bg(Color::Blue),
1446            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1447            gutter: Some(Gutter {
1448                width: 4,
1449                style: Style::default().fg(Color::Yellow),
1450                line_offset: 0,
1451                numbers: GutterNumbers::Hybrid { cursor_row: 1 },
1452                sign_column_width: 0,
1453            }),
1454            search_bg: Style::default(),
1455            signs: &[],
1456            conceals: &[],
1457            spans: &[],
1458            search_pattern: None,
1459            non_text_style: Style::default(),
1460            diag_overlays: &[],
1461            colorcolumn_cols: &[],
1462            colorcolumn_style: Style::default(),
1463        };
1464        let term = run_render(view, 10, 3);
1465        // Row 0 (doc 0): offset from cursor row 1 → 1
1466        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "1");
1467        // Row 1 (doc 1): cursor row → absolute 2
1468        assert_eq!(term.cell((2, 1)).unwrap().symbol(), "2");
1469        // Row 2 (doc 2): offset from cursor row 1 → 1
1470        assert_eq!(term.cell((2, 2)).unwrap().symbol(), "1");
1471    }
1472
1473    #[test]
1474    fn gutter_none_paints_blank_cells() {
1475        let b = Buffer::from_str("a\nb\nc");
1476        let v = vp(10, 3);
1477        let view = BufferView {
1478            buffer: &b,
1479            viewport: &v,
1480            selection: None,
1481            resolver: &(no_styles as fn(u32) -> Style),
1482            cursor_line_bg: Style::default(),
1483            cursor_column_bg: Style::default(),
1484            selection_bg: Style::default().bg(Color::Blue),
1485            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1486            gutter: Some(Gutter {
1487                width: 4,
1488                style: Style::default().fg(Color::Yellow),
1489                line_offset: 0,
1490                numbers: GutterNumbers::None,
1491                sign_column_width: 0,
1492            }),
1493            search_bg: Style::default(),
1494            signs: &[],
1495            conceals: &[],
1496            spans: &[],
1497            search_pattern: None,
1498            non_text_style: Style::default(),
1499            diag_overlays: &[],
1500            colorcolumn_cols: &[],
1501            colorcolumn_style: Style::default(),
1502        };
1503        let term = run_render(view, 10, 3);
1504        // All gutter cells (0..4) on every row should be blank spaces.
1505        for row in 0..3u16 {
1506            for x in 0..4u16 {
1507                assert_eq!(
1508                    term.cell((x, row)).unwrap().symbol(),
1509                    " ",
1510                    "expected blank at ({x}, {row})"
1511                );
1512            }
1513        }
1514        // Text still appears shifted right past the gutter.
1515        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1516    }
1517
1518    #[test]
1519    fn search_bg_paints_match_cells() {
1520        use regex::Regex;
1521        let b = Buffer::from_str("foo bar foo");
1522        let v = vp(20, 1);
1523        let pat = Regex::new("foo").unwrap();
1524        let view = BufferView {
1525            buffer: &b,
1526            viewport: &v,
1527            selection: None,
1528            resolver: &(no_styles as fn(u32) -> Style),
1529            cursor_line_bg: Style::default(),
1530            cursor_column_bg: Style::default(),
1531            selection_bg: Style::default().bg(Color::Blue),
1532            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1533            gutter: None,
1534            search_bg: Style::default().bg(Color::Magenta),
1535            signs: &[],
1536            conceals: &[],
1537            spans: &[],
1538            search_pattern: Some(&pat),
1539            non_text_style: Style::default(),
1540            diag_overlays: &[],
1541            colorcolumn_cols: &[],
1542            colorcolumn_style: Style::default(),
1543        };
1544        let term = run_render(view, 20, 1);
1545        for x in 0..3 {
1546            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1547        }
1548        // " bar " between matches stays default bg.
1549        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
1550        for x in 8..11 {
1551            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
1552        }
1553    }
1554
1555    #[test]
1556    fn search_bg_survives_cursorcolumn_overlay() {
1557        use regex::Regex;
1558        // Cursor sits on a `/foo` match. The cursorcolumn pass would
1559        // otherwise overwrite the search bg with column bg — verify
1560        // the match cells keep their search colour.
1561        let mut b = Buffer::from_str("foo bar foo");
1562        let v = vp(20, 1);
1563        let pat = Regex::new("foo").unwrap();
1564        // Cursor on column 1 (inside first `foo` match).
1565        b.set_cursor(crate::Position::new(0, 1));
1566        let view = BufferView {
1567            buffer: &b,
1568            viewport: &v,
1569            selection: None,
1570            resolver: &(no_styles as fn(u32) -> Style),
1571            cursor_line_bg: Style::default(),
1572            cursor_column_bg: Style::default().bg(Color::DarkGray),
1573            selection_bg: Style::default().bg(Color::Blue),
1574            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1575            gutter: None,
1576            search_bg: Style::default().bg(Color::Magenta),
1577            signs: &[],
1578            conceals: &[],
1579            spans: &[],
1580            search_pattern: Some(&pat),
1581            non_text_style: Style::default(),
1582            diag_overlays: &[],
1583            colorcolumn_cols: &[],
1584            colorcolumn_style: Style::default(),
1585        };
1586        let term = run_render(view, 20, 1);
1587        // Cursor cell at (1, 0) is in the search match. Search wins.
1588        assert_eq!(term.cell((1, 0)).unwrap().bg, Color::Magenta);
1589    }
1590
1591    #[test]
1592    fn highest_priority_sign_wins_per_row_in_dedicated_sign_column() {
1593        // Layout: sign_column_width=1, width=3 → total gutter = 4 cells.
1594        // Sign column at x=0; number column at x=1..4; text at x=4.
1595        let b = Buffer::from_str("a\nb\nc");
1596        let v = vp(10, 3);
1597        let signs = [
1598            Sign {
1599                row: 0,
1600                ch: 'W',
1601                style: Style::default().fg(Color::Yellow),
1602                priority: 1,
1603            },
1604            Sign {
1605                row: 0,
1606                ch: 'E',
1607                style: Style::default().fg(Color::Red),
1608                priority: 2,
1609            },
1610        ];
1611        let view = BufferView {
1612            buffer: &b,
1613            viewport: &v,
1614            selection: None,
1615            resolver: &(no_styles as fn(u32) -> Style),
1616            cursor_line_bg: Style::default(),
1617            cursor_column_bg: Style::default(),
1618            selection_bg: Style::default().bg(Color::Blue),
1619            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1620            gutter: Some(Gutter {
1621                width: 3,
1622                style: Style::default().fg(Color::DarkGray),
1623                line_offset: 0,
1624                sign_column_width: 1,
1625                ..Default::default()
1626            }),
1627            search_bg: Style::default(),
1628            signs: &signs,
1629            conceals: &[],
1630            spans: &[],
1631            search_pattern: None,
1632            non_text_style: Style::default(),
1633            diag_overlays: &[],
1634            colorcolumn_cols: &[],
1635            colorcolumn_style: Style::default(),
1636        };
1637        let term = run_render(view, 10, 3);
1638        // Sign 'E' (higher priority) lands in the sign column at x=0.
1639        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "E");
1640        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
1641        // Number column at x=1 must NOT be the sign char.
1642        assert_ne!(term.cell((1, 0)).unwrap().symbol(), "E");
1643        // Row 1 has no sign — sign column cell stays blank.
1644        assert_eq!(term.cell((0, 1)).unwrap().symbol(), " ");
1645    }
1646
1647    #[test]
1648    fn conceal_replaces_byte_range() {
1649        let b = Buffer::from_str("see https://example.com end");
1650        let v = vp(30, 1);
1651        let conceals = vec![Conceal {
1652            row: 0,
1653            start_byte: 4,                             // start of "https"
1654            end_byte: 4 + "https://example.com".len(), // end of URL
1655            replacement: "🔗".to_string(),
1656        }];
1657        let view = BufferView {
1658            buffer: &b,
1659            viewport: &v,
1660            selection: None,
1661            resolver: &(no_styles as fn(u32) -> Style),
1662            cursor_line_bg: Style::default(),
1663            cursor_column_bg: Style::default(),
1664            selection_bg: Style::default(),
1665            cursor_style: Style::default(),
1666            gutter: None,
1667            search_bg: Style::default(),
1668            signs: &[],
1669            conceals: &conceals,
1670            spans: &[],
1671            search_pattern: None,
1672            non_text_style: Style::default(),
1673            diag_overlays: &[],
1674            colorcolumn_cols: &[],
1675            colorcolumn_style: Style::default(),
1676        };
1677        let term = run_render(view, 30, 1);
1678        // Cells 0..=3: "see "
1679        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "s");
1680        assert_eq!(term.cell((3, 0)).unwrap().symbol(), " ");
1681        // Cell 4: the link emoji (a wide char takes 2 cells; we just
1682        // assert the first cell holds the replacement char).
1683        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "🔗");
1684    }
1685
1686    #[test]
1687    fn closed_fold_collapses_rows_and_paints_marker() {
1688        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
1689        let v = vp(30, 5);
1690        // Fold rows 1-3 closed. Visible should be: 'a', marker, 'e'.
1691        b.add_fold(1, 3, true);
1692        let view = BufferView {
1693            buffer: &b,
1694            viewport: &v,
1695            selection: None,
1696            resolver: &(no_styles as fn(u32) -> Style),
1697            cursor_line_bg: Style::default(),
1698            cursor_column_bg: Style::default(),
1699            selection_bg: Style::default().bg(Color::Blue),
1700            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1701            gutter: None,
1702            search_bg: Style::default(),
1703            signs: &[],
1704            conceals: &[],
1705            spans: &[],
1706            search_pattern: None,
1707            non_text_style: Style::default(),
1708            diag_overlays: &[],
1709            colorcolumn_cols: &[],
1710            colorcolumn_style: Style::default(),
1711        };
1712        let term = run_render(view, 30, 5);
1713        // Row 0: "a"
1714        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1715        // Row 1: fold marker — leading `▸ ` then the start row's
1716        // trimmed content + line count.
1717        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "▸");
1718        // Row 2: "e" (the 5th doc row, after the collapsed range).
1719        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "e");
1720    }
1721
1722    #[test]
1723    fn open_fold_renders_normally() {
1724        let mut b = Buffer::from_str("a\nb\nc");
1725        let v = vp(5, 3);
1726        b.add_fold(0, 2, false); // open
1727        let view = BufferView {
1728            buffer: &b,
1729            viewport: &v,
1730            selection: None,
1731            resolver: &(no_styles as fn(u32) -> Style),
1732            cursor_line_bg: Style::default(),
1733            cursor_column_bg: Style::default(),
1734            selection_bg: Style::default().bg(Color::Blue),
1735            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1736            gutter: None,
1737            search_bg: Style::default(),
1738            signs: &[],
1739            conceals: &[],
1740            spans: &[],
1741            search_pattern: None,
1742            non_text_style: Style::default(),
1743            diag_overlays: &[],
1744            colorcolumn_cols: &[],
1745            colorcolumn_style: Style::default(),
1746        };
1747        let term = run_render(view, 5, 3);
1748        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1749        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1750        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "c");
1751    }
1752
1753    #[test]
1754    fn horizontal_scroll_clips_left_chars() {
1755        let b = Buffer::from_str("abcdefgh");
1756        let mut v = vp(4, 1);
1757        v.top_col = 3;
1758        let view = BufferView {
1759            buffer: &b,
1760            viewport: &v,
1761            selection: None,
1762            resolver: &(no_styles as fn(u32) -> Style),
1763            cursor_line_bg: Style::default(),
1764            cursor_column_bg: Style::default(),
1765            selection_bg: Style::default().bg(Color::Blue),
1766            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1767            gutter: None,
1768            search_bg: Style::default(),
1769            signs: &[],
1770            conceals: &[],
1771            spans: &[],
1772            search_pattern: None,
1773            non_text_style: Style::default(),
1774            diag_overlays: &[],
1775            colorcolumn_cols: &[],
1776            colorcolumn_style: Style::default(),
1777        };
1778        let term = run_render(view, 4, 1);
1779        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "d");
1780        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "g");
1781    }
1782
1783    fn make_wrap_view<'a>(
1784        b: &'a Buffer,
1785        viewport: &'a Viewport,
1786        resolver: &'a (impl StyleResolver + 'a),
1787        gutter: Option<Gutter>,
1788    ) -> BufferView<'a, impl StyleResolver + 'a> {
1789        BufferView {
1790            buffer: b,
1791            viewport,
1792            selection: None,
1793            resolver,
1794            cursor_line_bg: Style::default(),
1795            cursor_column_bg: Style::default(),
1796            selection_bg: Style::default().bg(Color::Blue),
1797            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1798            gutter,
1799            search_bg: Style::default(),
1800            signs: &[],
1801            conceals: &[],
1802            spans: &[],
1803            search_pattern: None,
1804            non_text_style: Style::default(),
1805            diag_overlays: &[],
1806            colorcolumn_cols: &[],
1807            colorcolumn_style: Style::default(),
1808        }
1809    }
1810
1811    #[test]
1812    fn wrap_segments_char_breaks_at_width() {
1813        let segs = wrap_segments("abcdefghij", 4, Wrap::Char);
1814        assert_eq!(segs, vec![(0, 4), (4, 8), (8, 10)]);
1815    }
1816
1817    #[test]
1818    fn wrap_segments_word_backs_up_to_whitespace() {
1819        let segs = wrap_segments("alpha beta gamma", 8, Wrap::Word);
1820        // First segment "alpha " ends after the space at idx 5.
1821        assert_eq!(segs[0], (0, 6));
1822        // Second segment "beta " ends after the space at idx 10.
1823        assert_eq!(segs[1], (6, 11));
1824        assert_eq!(segs[2], (11, 16));
1825    }
1826
1827    #[test]
1828    fn wrap_segments_word_falls_back_to_char_for_long_runs() {
1829        let segs = wrap_segments("supercalifragilistic", 5, Wrap::Word);
1830        // No whitespace anywhere — degrades to a hard char break.
1831        assert_eq!(segs, vec![(0, 5), (5, 10), (10, 15), (15, 20)]);
1832    }
1833
1834    #[test]
1835    fn wrap_char_paints_continuation_rows() {
1836        let b = Buffer::from_str("abcdefghij");
1837        let v = Viewport {
1838            top_row: 0,
1839            top_col: 0,
1840            width: 4,
1841            height: 3,
1842            wrap: Wrap::Char,
1843            text_width: 4,
1844            tab_width: 0,
1845        };
1846        let r = no_styles as fn(u32) -> Style;
1847        let view = make_wrap_view(&b, &v, &r, None);
1848        let term = run_render(view, 4, 3);
1849        // Row 0: "abcd"
1850        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1851        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "d");
1852        // Row 1: "efgh"
1853        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "e");
1854        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "h");
1855        // Row 2: "ij"
1856        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "i");
1857        assert_eq!(term.cell((1, 2)).unwrap().symbol(), "j");
1858    }
1859
1860    #[test]
1861    fn wrap_char_gutter_blank_on_continuation() {
1862        let b = Buffer::from_str("abcdefgh");
1863        let v = Viewport {
1864            top_row: 0,
1865            top_col: 0,
1866            width: 6,
1867            height: 3,
1868            wrap: Wrap::Char,
1869            // Text area = 6 - 3 (gutter width) = 3.
1870            text_width: 3,
1871            tab_width: 0,
1872        };
1873        let r = no_styles as fn(u32) -> Style;
1874        let gutter = Gutter {
1875            width: 3,
1876            style: Style::default().fg(Color::Yellow),
1877            line_offset: 0,
1878            ..Default::default()
1879        };
1880        let view = make_wrap_view(&b, &v, &r, Some(gutter));
1881        let term = run_render(view, 6, 3);
1882        // Row 0: "  1" + "abc"
1883        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1");
1884        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "a");
1885        // Row 1: blank gutter + "def"
1886        for x in 0..2 {
1887            assert_eq!(term.cell((x, 1)).unwrap().symbol(), " ");
1888        }
1889        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "d");
1890        assert_eq!(term.cell((5, 1)).unwrap().symbol(), "f");
1891    }
1892
1893    #[test]
1894    fn wrap_char_cursor_lands_on_correct_segment() {
1895        let mut b = Buffer::from_str("abcdefghij");
1896        let v = Viewport {
1897            top_row: 0,
1898            top_col: 0,
1899            width: 4,
1900            height: 3,
1901            wrap: Wrap::Char,
1902            text_width: 4,
1903            tab_width: 0,
1904        };
1905        // Cursor on 'g' (col 6) should land on row 1, col 2.
1906        b.set_cursor(crate::Position::new(0, 6));
1907        let r = no_styles as fn(u32) -> Style;
1908        let mut view = make_wrap_view(&b, &v, &r, None);
1909        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1910        let term = run_render(view, 4, 3);
1911        assert!(
1912            term.cell((2, 1))
1913                .unwrap()
1914                .modifier
1915                .contains(Modifier::REVERSED)
1916        );
1917    }
1918
1919    #[test]
1920    fn wrap_char_eol_cursor_placeholder_on_last_segment() {
1921        let mut b = Buffer::from_str("abcdef");
1922        let v = Viewport {
1923            top_row: 0,
1924            top_col: 0,
1925            width: 4,
1926            height: 3,
1927            wrap: Wrap::Char,
1928            text_width: 4,
1929            tab_width: 0,
1930        };
1931        // Past-end cursor at col 6.
1932        b.set_cursor(crate::Position::new(0, 6));
1933        let r = no_styles as fn(u32) -> Style;
1934        let mut view = make_wrap_view(&b, &v, &r, None);
1935        view.cursor_style = Style::default().add_modifier(Modifier::REVERSED);
1936        let term = run_render(view, 4, 3);
1937        // Last segment is row 1 ("ef"), placeholder at x = 6 - 4 = 2.
1938        assert!(
1939            term.cell((2, 1))
1940                .unwrap()
1941                .modifier
1942                .contains(Modifier::REVERSED)
1943        );
1944    }
1945
1946    #[test]
1947    fn wrap_word_breaks_at_whitespace() {
1948        let b = Buffer::from_str("alpha beta gamma");
1949        let v = Viewport {
1950            top_row: 0,
1951            top_col: 0,
1952            width: 8,
1953            height: 3,
1954            wrap: Wrap::Word,
1955            text_width: 8,
1956            tab_width: 0,
1957        };
1958        let r = no_styles as fn(u32) -> Style;
1959        let view = make_wrap_view(&b, &v, &r, None);
1960        let term = run_render(view, 8, 3);
1961        // Row 0: "alpha "
1962        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "a");
1963        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "a");
1964        // Row 1: "beta "
1965        assert_eq!(term.cell((0, 1)).unwrap().symbol(), "b");
1966        assert_eq!(term.cell((3, 1)).unwrap().symbol(), "a");
1967        // Row 2: "gamma"
1968        assert_eq!(term.cell((0, 2)).unwrap().symbol(), "g");
1969        assert_eq!(term.cell((4, 2)).unwrap().symbol(), "a");
1970    }
1971
1972    // 0.0.37 — `BufferView` lost `Buffer::spans` / `Buffer::search_pattern`
1973    // and now takes them as parameters. The tests below cover the new
1974    // shape: empty/missing parameters, multi-row spans, regex hlsearch,
1975    // and the interaction with cursor / selection / wrap.
1976
1977    fn view_with<'a>(
1978        b: &'a Buffer,
1979        viewport: &'a Viewport,
1980        resolver: &'a (impl StyleResolver + 'a),
1981        spans: &'a [Vec<Span>],
1982        search_pattern: Option<&'a regex::Regex>,
1983    ) -> BufferView<'a, impl StyleResolver + 'a> {
1984        BufferView {
1985            buffer: b,
1986            viewport,
1987            selection: None,
1988            resolver,
1989            cursor_line_bg: Style::default(),
1990            cursor_column_bg: Style::default(),
1991            selection_bg: Style::default().bg(Color::Blue),
1992            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
1993            gutter: None,
1994            search_bg: Style::default().bg(Color::Magenta),
1995            signs: &[],
1996            conceals: &[],
1997            spans,
1998            search_pattern,
1999            non_text_style: Style::default(),
2000            diag_overlays: &[],
2001            colorcolumn_cols: &[],
2002            colorcolumn_style: Style::default(),
2003        }
2004    }
2005
2006    #[test]
2007    fn empty_spans_param_renders_default_style() {
2008        let b = Buffer::from_str("hello");
2009        let v = vp(10, 1);
2010        let r = no_styles as fn(u32) -> Style;
2011        let view = view_with(&b, &v, &r, &[], None);
2012        let term = run_render(view, 10, 1);
2013        assert_eq!(term.cell((0, 0)).unwrap().symbol(), "h");
2014        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Reset);
2015    }
2016
2017    #[test]
2018    fn spans_param_paints_styled_byte_range() {
2019        let b = Buffer::from_str("abcdef");
2020        let v = vp(10, 1);
2021        let resolver = |id: u32| -> Style {
2022            if id == 3 {
2023                Style::default().fg(Color::Green)
2024            } else {
2025                Style::default()
2026            }
2027        };
2028        let spans = vec![vec![Span::new(0, 3, 3)]];
2029        let view = view_with(&b, &v, &resolver, &spans, None);
2030        let term = run_render(view, 10, 1);
2031        for x in 0..3 {
2032            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Green);
2033        }
2034        assert_ne!(term.cell((3, 0)).unwrap().fg, Color::Green);
2035    }
2036
2037    #[test]
2038    fn spans_param_handles_per_row_overlay() {
2039        let b = Buffer::from_str("abc\ndef");
2040        let v = vp(10, 2);
2041        let resolver = |id: u32| -> Style {
2042            if id == 1 {
2043                Style::default().fg(Color::Red)
2044            } else {
2045                Style::default().fg(Color::Green)
2046            }
2047        };
2048        let spans = vec![vec![Span::new(0, 3, 1)], vec![Span::new(0, 3, 2)]];
2049        let view = view_with(&b, &v, &resolver, &spans, None);
2050        let term = run_render(view, 10, 2);
2051        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2052        assert_eq!(term.cell((0, 1)).unwrap().fg, Color::Green);
2053    }
2054
2055    #[test]
2056    fn spans_param_rows_beyond_get_no_styling() {
2057        let b = Buffer::from_str("abc\ndef\nghi");
2058        let v = vp(10, 3);
2059        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
2060        // Only row 0 carries spans; rows 1 and 2 inherit default.
2061        let spans = vec![vec![Span::new(0, 3, 0)]];
2062        let view = view_with(&b, &v, &resolver, &spans, None);
2063        let term = run_render(view, 10, 3);
2064        assert_eq!(term.cell((0, 0)).unwrap().fg, Color::Red);
2065        assert_ne!(term.cell((0, 1)).unwrap().fg, Color::Red);
2066        assert_ne!(term.cell((0, 2)).unwrap().fg, Color::Red);
2067    }
2068
2069    #[test]
2070    fn search_pattern_none_disables_hlsearch() {
2071        let b = Buffer::from_str("foo bar foo");
2072        let v = vp(20, 1);
2073        let r = no_styles as fn(u32) -> Style;
2074        // No regex → no Magenta bg anywhere even though `search_bg` is set.
2075        let view = view_with(&b, &v, &r, &[], None);
2076        let term = run_render(view, 20, 1);
2077        for x in 0..11 {
2078            assert_ne!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2079        }
2080    }
2081
2082    #[test]
2083    fn search_pattern_regex_paints_match_bg() {
2084        use regex::Regex;
2085        let b = Buffer::from_str("xyz foo xyz");
2086        let v = vp(20, 1);
2087        let r = no_styles as fn(u32) -> Style;
2088        let pat = Regex::new("foo").unwrap();
2089        let view = view_with(&b, &v, &r, &[], Some(&pat));
2090        let term = run_render(view, 20, 1);
2091        // "foo" is at chars 4..7; bg is Magenta there only.
2092        assert_ne!(term.cell((3, 0)).unwrap().bg, Color::Magenta);
2093        for x in 4..7 {
2094            assert_eq!(term.cell((x, 0)).unwrap().bg, Color::Magenta);
2095        }
2096        assert_ne!(term.cell((7, 0)).unwrap().bg, Color::Magenta);
2097    }
2098
2099    #[test]
2100    fn search_pattern_unicode_columns_are_charwise() {
2101        use regex::Regex;
2102        // "tablé foo" — match "foo" must land on char column 6, not byte.
2103        let b = Buffer::from_str("tablé foo");
2104        let v = vp(20, 1);
2105        let r = no_styles as fn(u32) -> Style;
2106        let pat = Regex::new("foo").unwrap();
2107        let view = view_with(&b, &v, &r, &[], Some(&pat));
2108        let term = run_render(view, 20, 1);
2109        // "tablé" is 5 chars + space = 6, then "foo" at 6..9.
2110        assert_eq!(term.cell((6, 0)).unwrap().bg, Color::Magenta);
2111        assert_eq!(term.cell((8, 0)).unwrap().bg, Color::Magenta);
2112        assert_ne!(term.cell((5, 0)).unwrap().bg, Color::Magenta);
2113    }
2114
2115    #[test]
2116    fn spans_param_clamps_short_row_overlay() {
2117        // Row 0 has 3 chars; span past end shouldn't crash or smear.
2118        let b = Buffer::from_str("abc");
2119        let v = vp(10, 1);
2120        let resolver = |_: u32| -> Style { Style::default().fg(Color::Red) };
2121        let spans = vec![vec![Span::new(0, 100, 0)]];
2122        let view = view_with(&b, &v, &resolver, &spans, None);
2123        let term = run_render(view, 10, 1);
2124        for x in 0..3 {
2125            assert_eq!(term.cell((x, 0)).unwrap().fg, Color::Red);
2126        }
2127    }
2128
2129    #[test]
2130    fn spans_and_search_pattern_compose() {
2131        // hlsearch bg layers on top of the syntax span fg.
2132        use regex::Regex;
2133        let b = Buffer::from_str("foo");
2134        let v = vp(10, 1);
2135        let resolver = |_: u32| -> Style { Style::default().fg(Color::Green) };
2136        let spans = vec![vec![Span::new(0, 3, 0)]];
2137        let pat = Regex::new("foo").unwrap();
2138        let view = view_with(&b, &v, &resolver, &spans, Some(&pat));
2139        let term = run_render(view, 10, 1);
2140        let cell = term.cell((1, 0)).unwrap();
2141        assert_eq!(cell.fg, Color::Green);
2142        assert_eq!(cell.bg, Color::Magenta);
2143    }
2144
2145    /// Rows past the last buffer line paint `~` at the first text column
2146    /// (vim's NonText marker). The `non_text_style` fg is applied to those
2147    /// cells; all other cells on those rows stay default.
2148    #[test]
2149    fn tilde_marker_painted_past_eof() {
2150        // 5-line buffer rendered in a 10-row viewport.
2151        let b = Buffer::from_str("a\nb\nc\nd\ne");
2152        let v = vp(10, 10);
2153        let r = no_styles as fn(u32) -> Style;
2154        let non_text_fg = Color::DarkGray;
2155        let view = BufferView {
2156            buffer: &b,
2157            viewport: &v,
2158            selection: None,
2159            resolver: &r,
2160            cursor_line_bg: Style::default(),
2161            cursor_column_bg: Style::default(),
2162            selection_bg: Style::default().bg(Color::Blue),
2163            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2164            gutter: None,
2165            search_bg: Style::default(),
2166            signs: &[],
2167            conceals: &[],
2168            spans: &[],
2169            search_pattern: None,
2170            non_text_style: Style::default().fg(non_text_fg),
2171            diag_overlays: &[],
2172            colorcolumn_cols: &[],
2173            colorcolumn_style: Style::default(),
2174        };
2175        let term = run_render(view, 10, 10);
2176        // Rows 0-4 have content — first cell should NOT be `~`.
2177        for row in 0..5u16 {
2178            assert_ne!(
2179                term.cell((0, row)).unwrap().symbol(),
2180                "~",
2181                "row {row} is a content row, expected no tilde"
2182            );
2183        }
2184        // Rows 5-9 are past EOF — should have `~` at column 0 with non_text fg.
2185        for row in 5..10u16 {
2186            let cell = term.cell((0, row)).unwrap();
2187            assert_eq!(cell.symbol(), "~", "row {row} is past EOF, expected tilde");
2188            assert_eq!(
2189                cell.fg, non_text_fg,
2190                "row {row} tilde should use non_text_style fg"
2191            );
2192            // Rest of the row should be blank.
2193            for x in 1..10u16 {
2194                assert_eq!(
2195                    term.cell((x, row)).unwrap().symbol(),
2196                    " ",
2197                    "row {row} col {x} after tilde should be blank"
2198                );
2199            }
2200        }
2201    }
2202
2203    /// When a gutter is present, rows past EOF paint a blank gutter and
2204    /// `~` at the first text column (after the gutter).
2205    #[test]
2206    fn tilde_marker_with_gutter_past_eof() {
2207        let b = Buffer::from_str("a\nb");
2208        let v = vp(10, 5);
2209        let r = no_styles as fn(u32) -> Style;
2210        let non_text_fg = Color::DarkGray;
2211        let view = BufferView {
2212            buffer: &b,
2213            viewport: &v,
2214            selection: None,
2215            resolver: &r,
2216            cursor_line_bg: Style::default(),
2217            cursor_column_bg: Style::default(),
2218            selection_bg: Style::default().bg(Color::Blue),
2219            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2220            gutter: Some(Gutter {
2221                width: 4,
2222                style: Style::default().fg(Color::Yellow),
2223                line_offset: 0,
2224                numbers: GutterNumbers::Absolute,
2225                sign_column_width: 0,
2226            }),
2227            search_bg: Style::default(),
2228            signs: &[],
2229            conceals: &[],
2230            spans: &[],
2231            search_pattern: None,
2232            non_text_style: Style::default().fg(non_text_fg),
2233            diag_overlays: &[],
2234            colorcolumn_cols: &[],
2235            colorcolumn_style: Style::default(),
2236        };
2237        let term = run_render(view, 10, 5);
2238        // Rows 2-4 are past EOF.
2239        for row in 2..5u16 {
2240            // Gutter (cols 0-3) should be blank.
2241            for x in 0..4u16 {
2242                assert_eq!(
2243                    term.cell((x, row)).unwrap().symbol(),
2244                    " ",
2245                    "gutter col {x} on past-EOF row {row} should be blank"
2246                );
2247            }
2248            // Text area starts at col 4: should have `~`.
2249            let cell = term.cell((4, row)).unwrap();
2250            assert_eq!(
2251                cell.symbol(),
2252                "~",
2253                "past-EOF row {row}: expected tilde at text column"
2254            );
2255            assert_eq!(cell.fg, non_text_fg);
2256        }
2257    }
2258
2259    #[test]
2260    fn diag_overlay_paints_underline_on_range() {
2261        // Render "hello world" and apply a DiagOverlay from col 6 to 11.
2262        // The cells in that range must carry the UNDERLINED modifier; cells
2263        // outside must not.
2264        let b = Buffer::from_str("hello world");
2265        let v = vp(20, 2);
2266        let overlay = DiagOverlay {
2267            row: 0,
2268            col_start: 6,
2269            col_end: 11,
2270            style: Style::default().add_modifier(Modifier::UNDERLINED),
2271        };
2272        let view = BufferView {
2273            buffer: &b,
2274            viewport: &v,
2275            selection: None,
2276            resolver: &(no_styles as fn(u32) -> Style),
2277            cursor_line_bg: Style::default(),
2278            cursor_column_bg: Style::default(),
2279            selection_bg: Style::default().bg(Color::Blue),
2280            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2281            gutter: None,
2282            search_bg: Style::default(),
2283            signs: &[],
2284            conceals: &[],
2285            spans: &[],
2286            search_pattern: None,
2287            non_text_style: Style::default(),
2288            diag_overlays: &[overlay],
2289            colorcolumn_cols: &[],
2290            colorcolumn_style: Style::default(),
2291        };
2292        let term = run_render(view, 20, 2);
2293
2294        // Cols 0-5 ("hello ") must NOT be underlined.
2295        for x in 0u16..6 {
2296            let cell = term.cell((x, 0)).unwrap();
2297            assert!(
2298                !cell.modifier.contains(Modifier::UNDERLINED),
2299                "col {x} must not be underlined (outside overlay)"
2300            );
2301        }
2302        // Cols 6-10 ("world") must be underlined.
2303        for x in 6u16..11 {
2304            let cell = term.cell((x, 0)).unwrap();
2305            assert!(
2306                cell.modifier.contains(Modifier::UNDERLINED),
2307                "col {x} must be underlined (inside overlay)"
2308            );
2309        }
2310        // Col 11 (past end, space) must NOT be underlined.
2311        let cell = term.cell((11, 0)).unwrap();
2312        assert!(
2313            !cell.modifier.contains(Modifier::UNDERLINED),
2314            "col 11 must not be underlined (past overlay end)"
2315        );
2316    }
2317
2318    #[test]
2319    fn diag_overlay_out_of_viewport_is_ignored() {
2320        // Overlay on row 5, viewport height = 3 → must not panic or paint.
2321        let b = Buffer::from_str("a\nb\nc");
2322        let v = vp(10, 3);
2323        let overlay = DiagOverlay {
2324            row: 5,
2325            col_start: 0,
2326            col_end: 1,
2327            style: Style::default().add_modifier(Modifier::UNDERLINED),
2328        };
2329        let view = BufferView {
2330            buffer: &b,
2331            viewport: &v,
2332            selection: None,
2333            resolver: &(no_styles as fn(u32) -> Style),
2334            cursor_line_bg: Style::default(),
2335            cursor_column_bg: Style::default(),
2336            selection_bg: Style::default().bg(Color::Blue),
2337            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2338            gutter: None,
2339            search_bg: Style::default(),
2340            signs: &[],
2341            conceals: &[],
2342            spans: &[],
2343            search_pattern: None,
2344            non_text_style: Style::default(),
2345            diag_overlays: &[overlay],
2346            colorcolumn_cols: &[],
2347            colorcolumn_style: Style::default(),
2348        };
2349        // Must not panic.
2350        let _term = run_render(view, 10, 3);
2351    }
2352
2353    // ── T5: dedicated sign-column tests ──────────────────────────────────────
2354
2355    /// A sign on row 0 must render in the sign column (x=0) and NOT overwrite
2356    /// any digit of the line-number column. With 5-digit line count (max 13109
2357    /// lines), gutter.width=6 (5 digits + 1 spacer), sign_column_width=1:
2358    ///   x=0          → sign char (e.g. '~')
2359    ///   x=1..5       → digits "13109" right-aligned in 5 cells
2360    ///   x=6          → spacer ' '
2361    ///   x=7..        → text
2362    #[test]
2363    fn paint_signs_in_dedicated_column_does_not_overwrite_line_number() {
2364        // Build a buffer with enough lines that the max line number is 5 digits.
2365        // We don't need all 13109 lines — just enough rows to get a 5-digit
2366        // line_offset. We'll use line_offset to fake the large document.
2367        let b = Buffer::from_str("a\nb");
2368        // num_w = 6 (5 digits + 1 spacer), sign_w = 1, total = 7
2369        let v = vp(20, 2);
2370        let sign = Sign {
2371            row: 0,
2372            ch: '~',
2373            style: Style::default().fg(Color::Red),
2374            priority: 10,
2375        };
2376        let view = BufferView {
2377            buffer: &b,
2378            viewport: &v,
2379            selection: None,
2380            resolver: &(no_styles as fn(u32) -> Style),
2381            cursor_line_bg: Style::default(),
2382            cursor_column_bg: Style::default(),
2383            selection_bg: Style::default().bg(Color::Blue),
2384            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2385            gutter: Some(Gutter {
2386                width: 6, // 5 digit cells + 1 spacer
2387                style: Style::default(),
2388                line_offset: 13108, // row 0 displays as 13109
2389                sign_column_width: 1,
2390                ..Default::default()
2391            }),
2392            search_bg: Style::default(),
2393            signs: &[sign],
2394            conceals: &[],
2395            spans: &[],
2396            search_pattern: None,
2397            non_text_style: Style::default(),
2398            diag_overlays: &[],
2399            colorcolumn_cols: &[],
2400            colorcolumn_style: Style::default(),
2401        };
2402        let term = run_render(view, 20, 2);
2403        // Sign column (x=0) must contain the sign char '~'.
2404        assert_eq!(
2405            term.cell((0, 0)).unwrap().symbol(),
2406            "~",
2407            "sign column (x=0) must hold the sign char"
2408        );
2409        // Number column digits: right-aligned "13109" in 5 cells at x=1..5.
2410        assert_eq!(term.cell((1, 0)).unwrap().symbol(), "1", "x=1 must be '1'");
2411        assert_eq!(term.cell((2, 0)).unwrap().symbol(), "3", "x=2 must be '3'");
2412        assert_eq!(term.cell((3, 0)).unwrap().symbol(), "1", "x=3 must be '1'");
2413        assert_eq!(term.cell((4, 0)).unwrap().symbol(), "0", "x=4 must be '0'");
2414        assert_eq!(term.cell((5, 0)).unwrap().symbol(), "9", "x=5 must be '9'");
2415        // Spacer at x=6.
2416        assert_eq!(
2417            term.cell((6, 0)).unwrap().symbol(),
2418            " ",
2419            "x=6 must be spacer"
2420        );
2421        // Text 'a' at x=7.
2422        assert_eq!(
2423            term.cell((7, 0)).unwrap().symbol(),
2424            "a",
2425            "text must start at x=sign_w+num_w=7"
2426        );
2427    }
2428
2429    /// When sign_column_width=0 (no sign column), signs Vec is ignored and the
2430    /// layout collapses to [ number_padded | spacer | text ] as before.
2431    #[test]
2432    fn paint_signs_zero_sign_column_width_layout_collapses() {
2433        let b = Buffer::from_str("abc");
2434        let v = vp(10, 1);
2435        let sign = Sign {
2436            row: 0,
2437            ch: 'E',
2438            style: Style::default().fg(Color::Red),
2439            priority: 10,
2440        };
2441        let view = BufferView {
2442            buffer: &b,
2443            viewport: &v,
2444            selection: None,
2445            resolver: &(no_styles as fn(u32) -> Style),
2446            cursor_line_bg: Style::default(),
2447            cursor_column_bg: Style::default(),
2448            selection_bg: Style::default().bg(Color::Blue),
2449            cursor_style: Style::default().add_modifier(Modifier::REVERSED),
2450            // gutter width=3, sign_column_width=0 → text at x=3
2451            gutter: Some(Gutter {
2452                width: 3,
2453                style: Style::default(),
2454                line_offset: 0,
2455                sign_column_width: 0,
2456                ..Default::default()
2457            }),
2458            search_bg: Style::default(),
2459            signs: &[sign],
2460            conceals: &[],
2461            spans: &[],
2462            search_pattern: None,
2463            non_text_style: Style::default(),
2464            diag_overlays: &[],
2465            colorcolumn_cols: &[],
2466            colorcolumn_style: Style::default(),
2467        };
2468        let term = run_render(view, 10, 1);
2469        // No sign column: x=0 must be a number digit or space, NOT 'E'.
2470        assert_ne!(
2471            term.cell((0, 0)).unwrap().symbol(),
2472            "E",
2473            "with sign_column_width=0, sign char must not appear in the gutter"
2474        );
2475        // Text starts at x=3 (gutter.width).
2476        assert_eq!(
2477            term.cell((3, 0)).unwrap().symbol(),
2478            "a",
2479            "text must start at x=gutter.width when sign_column_width=0"
2480        );
2481    }
2482}