1use 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#[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 #[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 #[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 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 #[must_use]
80 pub const fn cell(&self) -> Option<(u16, u16)> {
81 self.cell
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct FrameState {
88 pub cursor: CursorPaint,
89 pub selection: Option<SelectionRange>,
90 pub screen_reverse: bool,
92 pub preedit: Option<PreeditSpan>,
93 pub reserved_bottom_rows: u16,
94 pub rows: u16,
95}
96
97impl FrameState {
98 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct RowFrame {
156 pub cursor: Option<RowCursor>,
157 pub selection: Option<(u16, u16)>,
159 pub preedit: Option<(u16, u16)>,
161 pub screen_reverse: bool,
162 pub painted: bool,
164}
165
166impl RowFrame {
167 #[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#[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 Skip,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum CursorMarkerKind {
201 Underline,
202 Bar,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq)]
208pub struct CellAppearance {
209 pub disposition: Disposition,
210 pub fg: [f32; 4],
211 pub bg: [f32; 4],
212 pub bg_is_ground: bool,
215 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 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 if is_bidi {
265 bg = BIDI_MARKER_BG;
266 fg = BIDI_MARKER_FG;
267 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 let hidden = theme.minimum_contrast > 1.0
279 && theme.contrast_ground(fg)[..3] == theme.contrast_ground(bg)[..3];
280 let faint = attrs.flags.contains(AttrFlags::FAINT) && !is_bidi;
283 if faint {
284 fg = dim_toward_bg(fg, bg);
285 }
286 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 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
326pub 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 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
364const 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
392pub(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 Grapheme::Cluster(id) => clusters
401 .get(id)
402 .is_some_and(|s| bidi::iter_contains_override(s.chars())),
403 }
404}
405
406fn 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
423const FAINT_FG_WEIGHT: f32 = 0.5;
426
427pub(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
439fn 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 #[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 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 #[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 #[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}