Skip to main content

felis_render_core/
appearance.rs

1//! How a cell looks: its colors after reverse video, the cursor, the
2//! bidi warning, selection and faint, and which decorations it carries.
3//! Resolved per row of cells, so a client holding only decoded
4//! scrollback rows resolves them the same way the cell painter does.
5
6use felis_grid::{
7    AttrFlags, Cell, ClusterTable, Color, CursorStyle, Grapheme, ScreenBuffer, StyleTable,
8    UnderlineStyle, bidi,
9};
10
11use crate::{
12    SelectionRange,
13    palette::{
14        BIDI_MARKER_BG, BIDI_MARKER_FG, LINK_UNDERLINE, ResolvedTheme, SELECTION_BG, resolve_bg,
15        resolve_fg,
16    },
17};
18
19/// Columns in `cells.len()..cols` read as [`Cell::BLANK`].
20#[derive(Debug, Clone, Copy)]
21pub struct RowCells<'a> {
22    pub cells: &'a [Cell],
23    pub cols: u16,
24    pub styles: &'a StyleTable,
25    pub clusters: &'a ClusterTable,
26}
27
28impl<'a> RowCells<'a> {
29    /// The row's content up to its occupancy watermark, so the stale
30    /// tail past it reads blank as [`ScreenBuffer::cell`] does.
31    #[must_use]
32    pub fn of_screen(screen: &'a ScreenBuffer, row: u16) -> Option<Self> {
33        Some(Self {
34            cells: screen.row_content(row)?,
35            cols: screen.cols(),
36            styles: screen.style_table(),
37            clusters: screen.cluster_table(),
38        })
39    }
40
41    #[must_use]
42    pub fn cell(&self, col: u16) -> &'a Cell {
43        self.cells.get(usize::from(col)).unwrap_or(&Cell::BLANK)
44    }
45
46    /// [`ScreenBuffer::char_span`] over this row.
47    #[must_use]
48    pub fn char_span(&self, col: u16) -> (u16, u16) {
49        let is_spacer = |c: u16| c < self.cols && matches!(self.cell(c).grapheme, Grapheme::Spacer);
50        if col > 0 && is_spacer(col) {
51            (col - 1, col)
52        } else if is_spacer(col.saturating_add(1)) {
53            (col, col + 1)
54        } else {
55            (col, col)
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct PreeditSpan {
62    pub row: u16,
63    pub col_start: u16,
64    /// One past the last column the composition covers.
65    pub col_end: u16,
66}
67
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct CursorPaint {
70    cell: Option<(u16, u16)>,
71    last_col: u16,
72    style: CursorStyle,
73}
74
75impl CursorPaint {
76    /// The row and first column of the character under the cursor.
77    #[must_use]
78    pub const fn cell(&self) -> Option<(u16, u16)> {
79        self.cell
80    }
81}
82
83/// The frame-wide inputs every row of a screen reads.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct FrameState {
86    pub cursor: CursorPaint,
87    pub selection: Option<SelectionRange>,
88    /// `?5` DECSCNM, xor'd against each cell's SGR 7.
89    pub screen_reverse: bool,
90    pub preedit: Option<PreeditSpan>,
91    pub reserved_bottom_rows: u16,
92    pub rows: u16,
93}
94
95impl FrameState {
96    /// `pending_wrap` can bump the cursor column past the right edge for
97    /// one print; the in-bounds check hides it then. Focus loss hides
98    /// the cursor, and so does `viewport > 0` (Kitty / Alacritty's
99    /// cursor-in-scrollback behavior): the live row has scrolled off the
100    /// visible window.
101    #[must_use]
102    pub fn for_screen(
103        screen: &ScreenBuffer,
104        cursor_visible: bool,
105        viewport: u32,
106        selection: Option<SelectionRange>,
107        screen_reverse: bool,
108        preedit: Option<PreeditSpan>,
109        reserved_bottom_rows: u16,
110    ) -> Self {
111        let cursor = screen.cursor();
112        let in_bounds = cursor.row < screen.rows() && cursor.col < screen.cols();
113        let (first, last) = screen.char_span(cursor.row, cursor.col);
114        Self {
115            cursor: CursorPaint {
116                cell: (cursor.visible && in_bounds && cursor_visible && viewport == 0)
117                    .then_some((cursor.row, first)),
118                last_col: last,
119                style: screen.cursor_style(),
120            },
121            selection,
122            screen_reverse,
123            preedit,
124            reserved_bottom_rows,
125            rows: screen.rows(),
126        }
127    }
128
129    #[must_use]
130    pub fn row(&self, row: u16, cells: &RowCells<'_>) -> RowFrame {
131        RowFrame {
132            cursor: match self.cursor.cell {
133                Some((r, first)) if r == row => Some(RowCursor {
134                    first,
135                    last: self.cursor.last_col,
136                    style: self.cursor.style,
137                }),
138                _ => None,
139            },
140            selection: selection_cols(self.selection, row, cells.cols),
141            preedit: self
142                .preedit
143                .filter(|span| span.row == row)
144                .map(|span| (span.col_start, span.col_end)),
145            screen_reverse: self.screen_reverse,
146            painted: row < self.rows.saturating_sub(self.reserved_bottom_rows),
147        }
148    }
149}
150
151/// One row's share of a [`FrameState`].
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct RowFrame {
154    pub cursor: Option<RowCursor>,
155    /// Inclusive columns.
156    pub selection: Option<(u16, u16)>,
157    /// `[start, end)` columns.
158    pub preedit: Option<(u16, u16)>,
159    pub screen_reverse: bool,
160    /// `false` for a row under the reserved bottom rows.
161    pub painted: bool,
162}
163
164impl RowFrame {
165    /// A row no frame overlay touches, such as a scrollback row.
166    #[must_use]
167    pub const fn plain(screen_reverse: bool) -> Self {
168        Self {
169            cursor: None,
170            selection: None,
171            preedit: None,
172            screen_reverse,
173            painted: true,
174        }
175    }
176}
177
178/// The columns of the character under the cursor, both halves of a
179/// wide one.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct RowCursor {
182    pub first: u16,
183    pub last: u16,
184    pub style: CursorStyle,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum Disposition {
189    Paint,
190    /// Under the pre-edit overlay, or on an unpainted row: the overlay
191    /// is opaque, but a cell's glyph would draw through it in the later
192    /// fg pass.
193    Skip,
194}
195
196/// A `Block` cursor folds into the cell's fg and bg instead.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum CursorMarkerKind {
199    Underline,
200    Bar,
201}
202
203/// Colors are linear RGBA and are resolved under either disposition: a
204/// glyph inking across cells takes each neighbor's fg.
205#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct CellAppearance {
207    pub disposition: Disposition,
208    pub fg: [f32; 4],
209    pub bg: [f32; 4],
210    /// `bg` is the theme background with no cell, cursor, selection or
211    /// warning color over it.
212    pub bg_is_ground: bool,
213    /// CONCEAL (SGR 8) hides the glyph, and so does a wide character
214    /// half under the pre-edit overlay, whose glyph inks across both
215    /// halves. SECURITY: this must gate every fg draw.
216    pub glyph_visible: bool,
217    pub bidi_warning: bool,
218    pub cursor_marker: Option<(CursorMarkerKind, [f32; 4])>,
219    pub link_underline: Option<[f32; 4]>,
220    pub underline: Option<(UnderlineStyle, [f32; 4])>,
221    pub strikethrough: Option<[f32; 4]>,
222    pub overline: Option<[f32; 4]>,
223}
224
225#[must_use]
226pub fn resolve_cell(
227    row: &RowCells<'_>,
228    frame: &RowFrame,
229    theme: &ResolvedTheme,
230    col: u16,
231) -> CellAppearance {
232    let cell = row.cell(col);
233    let attrs = row.styles.resolve(cell.style);
234    let disposition = match frame.preedit {
235        _ if !frame.painted => Disposition::Skip,
236        Some((start, end)) if col >= start && col < end => Disposition::Skip,
237        _ => Disposition::Paint,
238    };
239    let glyph_under_preedit = frame.preedit.is_some_and(|(start, end)| {
240        let (first, last) = row.char_span(col);
241        last >= start && first < end
242    });
243    // A wide character is painted as one: its Spacer takes the
244    // lead's bidi warning, and the cursor or a selection on either
245    // half covers both.
246    let is_bidi = match cell.grapheme {
247        Grapheme::Spacer if col > 0 => {
248            cell_contains_bidi_override(row.cell(col - 1).grapheme, row.clusters)
249        }
250        g => cell_contains_bidi_override(g, row.clusters),
251    };
252    let (mut fg, mut bg) =
253        resolve_pair(attrs.fg, attrs.bg, theme, attrs.flags, frame.screen_reverse);
254    let cursor = frame
255        .cursor
256        .filter(|cursor| (cursor.first..=cursor.last).contains(&col));
257    let mut cursor_marker =
258        cursor.and_then(|cursor| apply_cursor_style(cursor, col, theme, &mut fg, &mut bg));
259    let mut selected = false;
260    // After the cursor swap so the security signal wins on the
261    // cursor cell too (CVE-2021-42574).
262    if is_bidi {
263        bg = BIDI_MARKER_BG;
264        fg = BIDI_MARKER_FG;
265        // The marker would obstruct the warning.
266        cursor_marker = None;
267    } else if let Some((lo, hi)) = frame.selection {
268        let (first, last) = row.char_span(col);
269        selected = (lo..=hi).contains(&first) || (lo..=hi).contains(&last);
270        if selected {
271            bg = SELECTION_BG;
272        }
273    }
274    // After cursor / bidi / selection so it dims the effective fg,
275    // and never the bidi warning.
276    let faint = attrs.flags.contains(AttrFlags::FAINT) && !is_bidi;
277    if faint {
278        fg = dim_toward_bg(fg, bg);
279    }
280    let block_cursor = cursor.is_some_and(|cursor| cursor.style == CursorStyle::Block);
281    // The decoration pass draws after the glyph pass, so precedence
282    // against the bg-pass overlays is enforced by suppression, not
283    // z-order: a bidi cell drops every decoration, and the SGR
284    // underline yields its slot to the link underline and the cursor
285    // marker.
286    let decorated = !is_bidi;
287    let has_link = cell.link.is_some();
288    CellAppearance {
289        disposition,
290        fg,
291        bg,
292        bg_is_ground: matches!(attrs.bg, Color::Default)
293            && attrs.flags.contains(AttrFlags::REVERSE) == frame.screen_reverse
294            && !block_cursor
295            && !is_bidi
296            && !selected,
297        glyph_visible: !attrs.flags.contains(AttrFlags::CONCEAL) && !glyph_under_preedit,
298        bidi_warning: is_bidi,
299        cursor_marker,
300        link_underline: (decorated && has_link).then_some(LINK_UNDERLINE),
301        underline: (decorated
302            && attrs.flags.contains(AttrFlags::UNDERLINE)
303            && !has_link
304            && cursor.is_none())
305        .then(|| {
306            (
307                attrs.underline_style,
308                underline_deco_color(attrs.underline_color, fg, bg, theme, faint),
309            )
310        }),
311        strikethrough: (decorated && attrs.flags.contains(AttrFlags::STRIKETHROUGH)).then_some(fg),
312        overline: (decorated && attrs.flags.contains(AttrFlags::OVERLINE)).then_some(fg),
313    }
314}
315
316/// Replaces `out` with columns `0..row.cols`.
317pub fn resolve_row(
318    row: &RowCells<'_>,
319    frame: &RowFrame,
320    theme: &ResolvedTheme,
321    out: &mut Vec<CellAppearance>,
322) {
323    out.clear();
324    out.extend((0..row.cols).map(|col| resolve_cell(row, frame, theme, col)));
325}
326
327fn apply_cursor_style(
328    cursor: RowCursor,
329    col: u16,
330    theme: &ResolvedTheme,
331    fg_color: &mut [f32; 4],
332    bg_color: &mut [f32; 4],
333) -> Option<(CursorMarkerKind, [f32; 4])> {
334    let original_bg = *bg_color;
335    let cursor_color = theme.cursor.unwrap_or(*fg_color);
336    match cursor.style {
337        CursorStyle::Block => {
338            // With `theme.cursor`: bg = cursor color, glyph = original
339            // cell bg for contrast. Without: reverse-video swap.
340            if theme.cursor.is_some() {
341                *bg_color = cursor_color;
342                *fg_color = original_bg;
343            } else {
344                std::mem::swap(fg_color, bg_color);
345            }
346            None
347        }
348        CursorStyle::Underline => Some((CursorMarkerKind::Underline, cursor_color)),
349        CursorStyle::Bar if cursor.first != col => None,
350        CursorStyle::Bar => Some((CursorMarkerKind::Bar, cursor_color)),
351    }
352}
353
354/// The selected columns of `row`, inclusive and clipped to `cols`. Mirrors
355/// `Selection::contains` in `felis-client-core` but stays local so this
356/// crate takes no UI-state dep (docs/explanation/architecture/overview.md
357/// "Workspace: the crate-boundary decision record").
358const fn selection_cols(
359    selection: Option<SelectionRange>,
360    row: u16,
361    cols: u16,
362) -> Option<(u16, u16)> {
363    let Some(sel) = selection else {
364        return None;
365    };
366    let (sr, sc) = sel.start;
367    let (er, ec) = sel.end;
368    if row < sr || row > er {
369        return None;
370    }
371    let lo = if sel.rectangle || row == sr { sc } else { 0 };
372    let hi = if sel.rectangle || row == er {
373        ec
374    } else {
375        u16::MAX
376    };
377    let last = cols.saturating_sub(1);
378    let hi = if hi < last { hi } else { last };
379    if lo > hi { None } else { Some((lo, hi)) }
380}
381
382/// The nine UAX #9 explicit-formatting characters. Cluster cells walk
383/// every codepoint so a base plus combining RLO surfaces.
384pub(crate) fn cell_contains_bidi_override(grapheme: Grapheme, clusters: &ClusterTable) -> bool {
385    match grapheme {
386        Grapheme::Empty | Grapheme::Spacer | Grapheme::SizedSpacer | Grapheme::Ascii(_) => false,
387        Grapheme::Char(c) => bidi::is_override(c),
388        // (docs/explanation/data-model/grid-and-cells.md "Cluster
389        // interning"); an unresolved handle carries no override.
390        Grapheme::Cluster(id) => clusters
391            .get(id)
392            .is_some_and(|s| bidi::iter_contains_override(s.chars())),
393    }
394}
395
396/// The XOR makes `?5` compose with SGR 7 the way every modern terminal
397/// treats it. Both halves swap the *resolved* colors, so an indexed or
398/// direct-RGB pair swaps too.
399fn resolve_pair(
400    fg: Color,
401    bg: Color,
402    theme: &ResolvedTheme,
403    flags: AttrFlags,
404    screen_reverse: bool,
405) -> ([f32; 4], [f32; 4]) {
406    if flags.contains(AttrFlags::REVERSE) == screen_reverse {
407        (resolve_fg(fg, theme), resolve_bg(bg, theme))
408    } else {
409        (resolve_bg(bg, theme), resolve_fg(fg, theme))
410    }
411}
412
413/// A 50/50 mix roughly halves perceived brightness on a dark theme,
414/// matching kitty's dim intensity.
415const FAINT_FG_WEIGHT: f32 = 0.5;
416
417/// Mixes toward the *cell* bg, not black, so a dimmed glyph stays
418/// legible on a light or colored background; alpha is untouched.
419pub(crate) fn dim_toward_bg(fg: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
420    let w = FAINT_FG_WEIGHT;
421    [
422        fg[0].mul_add(w, bg[0] * (1.0 - w)),
423        fg[1].mul_add(w, bg[1] * (1.0 - w)),
424        fg[2].mul_add(w, bg[2] * (1.0 - w)),
425        fg[3],
426    ]
427}
428
429/// An explicit SGR 58 color is dimmed too when the cell is faint so the
430/// whole cell reads at one intensity.
431fn underline_deco_color(
432    underline_color: Color,
433    fg_color: [f32; 4],
434    bg_color: [f32; 4],
435    theme: &ResolvedTheme,
436    faint: bool,
437) -> [f32; 4] {
438    match underline_color {
439        Color::Default => fg_color,
440        other => {
441            let base = resolve_fg(other, theme);
442            if faint {
443                dim_toward_bg(base, bg_color)
444            } else {
445                base
446            }
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    #![allow(clippy::float_cmp, clippy::unwrap_used)]
454
455    use felis_grid::{Grid, RowEncode, decode_row, encode_row};
456    use felis_vt::Parser;
457    use proptest::prelude::*;
458
459    use super::*;
460    use crate::palette::Theme;
461
462    fn drive(rows: u16, cols: u16, bytes: &[u8]) -> Grid {
463        let mut g = Grid::new(rows, cols);
464        Parser::new().advance(&mut g, bytes);
465        g
466    }
467
468    fn theme() -> ResolvedTheme {
469        ResolvedTheme::new(&Theme::default())
470    }
471
472    fn live_frame(g: &Grid, row: u16) -> RowFrame {
473        let state = FrameState::for_screen(g.screen(), true, 0, None, false, None, 0);
474        state.row(row, &RowCells::of_screen(g.screen(), row).unwrap())
475    }
476
477    fn resolve_at(g: &Grid, frame: &RowFrame, row: u16, col: u16) -> CellAppearance {
478        resolve_cell(
479            &RowCells::of_screen(g.screen(), row).unwrap(),
480            frame,
481            &theme(),
482            col,
483        )
484    }
485
486    #[test]
487    fn selection_cols_is_none_on_every_row_when_nothing_is_selected() {
488        for row in 0..4u16 {
489            assert_eq!(selection_cols(None, row, 4), None);
490        }
491    }
492
493    proptest! {
494        /// `selection_cols` mirrors
495        /// `felis_client_core::Selection::contains`; the crates cannot
496        /// depend on each other, so each is pinned against the same
497        /// stated predicates.
498        #[test]
499        fn selection_cols_matches_its_stated_ranges(
500            rectangle: bool,
501            ar in 0u16..30,
502            ac in 0u16..30,
503            er in 0u16..30,
504            ec in 0u16..30,
505            r  in 0u16..30,
506            c  in 0u16..30,
507        ) {
508            // `SelectionRange` arrives normalized (client-core's
509            // `Selection::range` does it).
510            let (start, end) = if rectangle {
511                ((ar.min(er), ac.min(ec)), (ar.max(er), ac.max(ec)))
512            } else if (ar, ac) <= (er, ec) {
513                ((ar, ac), (er, ec))
514            } else {
515                ((er, ec), (ar, ac))
516            };
517            let range = SelectionRange { start, end, rectangle };
518
519            let want = if rectangle {
520                r >= start.0 && r <= end.0 && c >= start.1 && c <= end.1
521            } else {
522                let scalar = |(r, c): (u16, u16)| u32::from(r) * 1_000 + u32::from(c);
523                scalar(start) <= scalar((r, c)) && scalar((r, c)) <= scalar(end)
524            };
525            let got = selection_cols(Some(range), r, 30).is_some_and(|(lo, hi)| lo <= c && c <= hi);
526            prop_assert_eq!(got, want);
527        }
528    }
529
530    const TOKENS: &[&str] = &[
531        "a",
532        "b",
533        " ",
534        "漢",
535        "\u{202E}",
536        "e\u{301}",
537        "\x1b[7m",
538        "\x1b[2m",
539        "\x1b[8m",
540        "\x1b[9m",
541        "\x1b[53m",
542        "\x1b[4:3m",
543        "\x1b[58;5;3m",
544        "\x1b[38;2;1;2;3m",
545        "\x1b[48;5;9m",
546        "\x1b[m",
547        "\x1b]8;;https://example.com\x1b\\",
548        "\x1b]8;;\x1b\\",
549        "\r\n",
550        "\x1b[K",
551        "\x1b[2K",
552        "\x1b[H",
553    ];
554
555    proptest! {
556        /// A scrollback row reaches a client only as a packed row decoded
557        /// into the client's own style table; it must resolve exactly as
558        /// the live row it was encoded from.
559        #[test]
560        fn a_decoded_row_resolves_like_the_live_row_it_came_from(
561            input in proptest::collection::vec(proptest::sample::select(TOKENS), 0..80),
562            screen_reverse: bool,
563            cursor_visible: bool,
564            sel in (0u16..4, 0u16..12, 0u16..4, 0u16..12, any::<bool>()),
565        ) {
566            let g = drive(4, 12, input.concat().as_bytes());
567            let screen = g.screen();
568            let (sr, sc, er, ec, rectangle) = sel;
569            let selection = Some(SelectionRange {
570                start: (sr.min(er), sc.min(ec)),
571                end: (sr.max(er), sc.max(ec)),
572                rectangle,
573            });
574            let state = FrameState::for_screen(
575                screen, cursor_visible, 0, selection, screen_reverse, None, 0,
576            );
577            let theme = theme();
578            let (mut want, mut got) = (Vec::new(), Vec::new());
579            for r in 0..screen.rows() {
580                let live = RowCells::of_screen(screen, r).unwrap();
581                let bytes = encode_row(
582                    RowEncode {
583                        cells: live.cells,
584                        pad_to: usize::from(screen.cols()),
585                        sized_cells: &[],
586                        soft_wrap_continued: false,
587                    },
588                    screen.style_table(),
589                )
590                .unwrap();
591                let mut styles = StyleTable::new();
592                let decoded = decode_row(&bytes, &mut styles).unwrap();
593                let scrollback = RowCells {
594                    cells: &decoded.cells,
595                    cols: screen.cols(),
596                    styles: &styles,
597                    clusters: screen.cluster_table(),
598                };
599                for frame in [RowFrame::plain(screen_reverse), state.row(r, &live)] {
600                    resolve_row(&live, &frame, &theme, &mut want);
601                    resolve_row(&scrollback, &frame, &theme, &mut got);
602                    prop_assert_eq!(&got, &want, "row {}", r);
603                }
604            }
605        }
606    }
607
608    #[test]
609    fn bidi_warning_drops_every_marker_and_decoration_and_wins_over_a_block_cursor() {
610        for style in ["2", "4", "6"] {
611            let input = format!(
612                "\x1b]8;;https://example.com\x1b\\\x1b[4;9;53mA\u{202E}\x1b[m\x1b]8;;\x1b\\\x1b[1;1H\x1b[{style} q"
613            );
614            let g = drive(1, 4, input.as_bytes());
615            let look = resolve_at(&g, &live_frame(&g, 0), 0, 0);
616            assert!(look.bidi_warning, "style {style}");
617            assert_eq!((look.fg, look.bg), (BIDI_MARKER_FG, BIDI_MARKER_BG));
618            assert_eq!(look.cursor_marker, None);
619            assert_eq!(look.link_underline, None);
620            assert_eq!(look.underline, None);
621            assert_eq!(look.strikethrough, None);
622            assert_eq!(look.overline, None);
623            assert!(!look.bg_is_ground);
624        }
625    }
626
627    #[test]
628    fn faint_dims_an_explicit_underline_color_but_never_the_bidi_warning() {
629        let theme = theme();
630        let g = drive(1, 4, b"\x1b[2;4;58;5;1mA");
631        let look = resolve_at(&g, &RowFrame::plain(false), 0, 0);
632        let red = resolve_fg(Color::Indexed(1), &theme);
633        assert_eq!(look.fg, dim_toward_bg(theme.fg, theme.bg));
634        assert_eq!(
635            look.underline,
636            Some((UnderlineStyle::Single, dim_toward_bg(red, theme.bg)))
637        );
638
639        let g = drive(1, 4, "\x1b[2mA\u{202E}".as_bytes());
640        let look = resolve_at(&g, &RowFrame::plain(false), 0, 0);
641        assert_eq!(look.fg, BIDI_MARKER_FG);
642    }
643
644    #[test]
645    fn a_bar_cursor_marks_only_the_lead_of_a_wide_character() {
646        for cursor_col in [1, 2] {
647            let g = drive(1, 4, format!("漢\x1b[1;{cursor_col}H\x1b[6 q").as_bytes());
648            let frame = live_frame(&g, 0);
649            let color = theme().fg;
650            assert_eq!(
651                resolve_at(&g, &frame, 0, 0).cursor_marker,
652                Some((CursorMarkerKind::Bar, color)),
653                "cursor on column {cursor_col}"
654            );
655            assert_eq!(resolve_at(&g, &frame, 0, 1).cursor_marker, None);
656        }
657    }
658
659    #[test]
660    fn a_cursor_of_any_style_suppresses_the_sgr_underline() {
661        for style in ["2", "4", "6"] {
662            let g = drive(1, 4, format!("\x1b[4mAB\x1b[1;1H\x1b[{style} q").as_bytes());
663            let frame = live_frame(&g, 0);
664            assert_eq!(
665                resolve_at(&g, &frame, 0, 0).underline,
666                None,
667                "style {style}"
668            );
669            assert!(resolve_at(&g, &frame, 0, 1).underline.is_some());
670        }
671    }
672
673    #[test]
674    fn a_column_under_the_preedit_is_skipped_with_its_colors_resolved() {
675        let g = drive(1, 4, b"\x1b[31;42mABCD");
676        let frame = RowFrame {
677            preedit: Some((1, 3)),
678            ..RowFrame::plain(false)
679        };
680        for col in 0..4 {
681            let look = resolve_at(&g, &frame, 0, col);
682            let plain = resolve_at(&g, &RowFrame::plain(false), 0, col);
683            let want = if (1..3).contains(&col) {
684                Disposition::Skip
685            } else {
686                Disposition::Paint
687            };
688            assert_eq!(look.disposition, want, "col {col}");
689            assert_eq!((look.fg, look.bg), (plain.fg, plain.bg), "col {col}");
690        }
691    }
692
693    #[test]
694    fn the_stale_tail_past_the_occupancy_watermark_reads_blank() {
695        let g = drive(2, 4, b"\x1b[2;1H\x1b[41mXYZ\x1b[m\x1b[T");
696        assert!(
697            matches!(
698                g.screen().row_cells(0).unwrap()[0].grapheme,
699                Grapheme::Ascii(b'X')
700            ),
701            "the scroll recycles the old row behind a zero watermark"
702        );
703        let theme = theme();
704        let look = resolve_at(&g, &RowFrame::plain(false), 0, 0);
705        assert_eq!((look.fg, look.bg), (theme.fg, theme.bg));
706        assert!(look.bg_is_ground);
707    }
708
709    #[test]
710    fn a_slice_shorter_than_the_row_pads_with_blank_cells() {
711        let g = drive(1, 2, b"\x1b[41mXY");
712        let screen = g.screen();
713        let row = RowCells {
714            cols: 5,
715            ..RowCells::of_screen(screen, 0).unwrap()
716        };
717        let theme = theme();
718        let mut out = Vec::new();
719        resolve_row(&row, &RowFrame::plain(true), &theme, &mut out);
720        assert_eq!(out.len(), 5);
721        for look in &out[2..] {
722            assert_eq!((look.fg, look.bg), (theme.bg, theme.fg));
723            assert!(!look.bg_is_ground, "screen reverse paints the bg");
724        }
725    }
726}