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