1use 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#[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 #[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 #[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 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 #[must_use]
78 pub const fn cell(&self) -> Option<(u16, u16)> {
79 self.cell
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct FrameState {
86 pub cursor: CursorPaint,
87 pub selection: Option<SelectionRange>,
88 pub screen_reverse: bool,
90 pub preedit: Option<PreeditSpan>,
91 pub reserved_bottom_rows: u16,
92 pub rows: u16,
93}
94
95impl FrameState {
96 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct RowFrame {
154 pub cursor: Option<RowCursor>,
155 pub selection: Option<(u16, u16)>,
157 pub preedit: Option<(u16, u16)>,
159 pub screen_reverse: bool,
160 pub painted: bool,
162}
163
164impl RowFrame {
165 #[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#[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 Skip,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum CursorMarkerKind {
199 Underline,
200 Bar,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct CellAppearance {
207 pub disposition: Disposition,
208 pub fg: [f32; 4],
209 pub bg: [f32; 4],
210 pub bg_is_ground: bool,
213 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 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 if is_bidi {
263 bg = BIDI_MARKER_BG;
264 fg = BIDI_MARKER_FG;
265 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 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 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
316pub 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 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
354const 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
382pub(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 Grapheme::Cluster(id) => clusters
391 .get(id)
392 .is_some_and(|s| bidi::iter_contains_override(s.chars())),
393 }
394}
395
396fn 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
413const FAINT_FG_WEIGHT: f32 = 0.5;
416
417pub(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
429fn 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 #[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 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 #[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}