1use std::borrow::Cow;
5
6use felis_grid::{
7 Cell, CursorStyle, Grapheme, HAlign, ScreenBuffer, Sizing, UnderlineStyle, VAlign,
8 char_cell_width,
9};
10use felis_shaping::{CellMetrics, SizingKey};
11
12pub use crate::appearance::{CursorPaint, PreeditSpan};
13use crate::{
14 SelectionRange,
15 appearance::{
16 CellAppearance, CursorMarkerKind, Disposition, FrameState, RowCells, RowFrame, resolve_cell,
17 },
18 display_list::{AtlasKind, DecoKind, Decoration, GlyphDraw, Rect, RowPaint},
19 glyphs::{
20 AtlasView, ClusterGlyph, GlyphSlot, ShapeFrame, ShapedCell, cluster_base_char,
21 cluster_block_cols, font_style_of, sizing_key_from,
22 },
23 overlay::{ConfirmOverlay, LinkPreviewOverlay, PreeditOverlay, SearchOverlay},
24 palette::{
25 CONFIRM_BAR_BG, PREEDIT_BG, ResolvedTheme, SCROLLBAR_THUMB, SCROLLBAR_TRACK, SEARCH_BAR_BG,
26 SEARCH_CURRENT_BG, SEARCH_MATCH_BG,
27 },
28};
29
30pub const CURSOR_MARKER_PX: f32 = 2.0;
31
32const LINK_UNDERLINE_PX: f32 = 1.0;
35
36const PREEDIT_UNDERLINE_PX: f32 = 2.0;
37
38const PREEDIT_ACTIVE_UNDERLINE_PX: f32 = 4.0;
41
42const SCROLLBAR_LANE_PX: f32 = 6.0;
43
44#[must_use]
49fn sizing_scale_for_layout(sizing: Sizing) -> f32 {
50 f32::from(sizing.scale().max(1))
51}
52
53#[must_use]
56fn sizing_glyph_scale(sizing: Sizing) -> f32 {
57 sizing_key_from(sizing).effective_scale()
58}
59
60#[must_use]
63pub fn cursor_rect_px(
64 metrics: CellMetrics,
65 origin_px: [f32; 2],
66 row: u16,
67 (first, last): (u16, u16),
68 style: CursorStyle,
69) -> [f32; 4] {
70 let (cw, ch) = (metrics.width as f32, metrics.height as f32);
71 let x = f32::from(first).mul_add(cw, origin_px[0]);
72 let y = f32::from(row).mul_add(ch, origin_px[1]);
73 let w = f32::from(last - first + 1) * cw;
74 match style {
75 CursorStyle::Block => [x, y, w, ch],
76 CursorStyle::Underline => [x, y + ch - CURSOR_MARKER_PX, w, CURSOR_MARKER_PX],
77 CursorStyle::Bar => [x, y, CURSOR_MARKER_PX, ch],
78 }
79}
80
81struct CellRef<'a> {
82 row: u16,
83 col: u16,
84 cell: &'a Cell,
85 attrs: &'a felis_grid::Attributes,
86 cells: &'a RowCells<'a>,
87 frame: &'a RowFrame,
88}
89
90pub struct CellPainter<'a, A: AtlasView> {
91 screen: &'a ScreenBuffer,
92 theme: &'a ResolvedTheme,
93 atlas: &'a A,
94 shape_frame: &'a ShapeFrame,
95 metrics: CellMetrics,
96 cw: f32,
97 ch: f32,
98 ascent: f32,
99 cursor_visible: bool,
100 viewport: u32,
101 viewport_max: u32,
102 frame: FrameState,
103}
104
105impl<'a, A: AtlasView> CellPainter<'a, A> {
106 #[must_use]
111 pub fn new(
112 screen: &'a ScreenBuffer,
113 metrics: CellMetrics,
114 theme: &'a ResolvedTheme,
115 atlas: &'a A,
116 shape_frame: &'a ShapeFrame,
117 ) -> Self {
118 Self {
119 screen,
120 theme,
121 atlas,
122 shape_frame,
123 metrics,
124 cw: metrics.width as f32,
125 ch: metrics.height as f32,
126 ascent: f32::from(u16::try_from(metrics.ascent).unwrap_or(u16::MAX)),
127 cursor_visible: false,
128 viewport: 0,
129 viewport_max: 0,
130 frame: FrameState {
131 cursor: CursorPaint::default(),
132 selection: None,
133 screen_reverse: false,
134 preedit: None,
135 reserved_bottom_rows: 0,
136 rows: screen.rows(),
137 },
138 }
139 }
140
141 #[must_use]
144 pub const fn with_reverse_video(mut self, on: bool) -> Self {
145 self.frame.screen_reverse = on;
146 self
147 }
148
149 #[must_use]
152 pub const fn with_cursor_visible(mut self, visible: bool) -> Self {
153 self.cursor_visible = visible;
154 self
155 }
156
157 #[must_use]
158 pub const fn with_selection(mut self, selection: Option<SelectionRange>) -> Self {
159 self.frame.selection = selection;
160 self
161 }
162
163 #[must_use]
166 pub const fn with_viewport(mut self, viewport: u32, viewport_max: u32) -> Self {
167 self.viewport = viewport;
168 self.viewport_max = viewport_max;
169 self
170 }
171
172 #[must_use]
175 pub const fn with_reserved_bottom_rows(mut self, rows: u16) -> Self {
176 self.frame.reserved_bottom_rows = rows;
177 self
178 }
179
180 #[must_use]
184 pub const fn with_preedit(mut self, span: Option<PreeditSpan>) -> Self {
185 self.frame.preedit = span;
186 self
187 }
188
189 pub fn extend_screen(self, out: &mut RowPaint) {
193 let painter = self.resolved();
194 let cells = usize::from(painter.screen.rows()) * usize::from(painter.screen.cols());
195 out.rects.reserve(cells);
196 out.glyphs.reserve(cells);
197 for r in 0..painter.screen.rows() {
198 painter.extend_row(out, r);
199 }
200 painter.push_scrollbar_lane(out);
201 }
202
203 #[must_use]
205 pub fn resolved(mut self) -> Self {
206 self.frame = FrameState::for_screen(
207 self.screen,
208 self.cursor_visible,
209 self.viewport,
210 self.frame.selection,
211 self.frame.screen_reverse,
212 self.frame.preedit,
213 self.frame.reserved_bottom_rows,
214 );
215 self
216 }
217
218 #[must_use]
220 pub const fn cursor(&self) -> CursorPaint {
221 self.frame.cursor
222 }
223
224 pub fn extend_row(&self, out: &mut RowPaint, r: u16) {
228 let Some(cells) = RowCells::of_screen(self.screen, r) else {
233 return;
234 };
235 let frame = self.frame.row(r, &cells);
236 if !frame.painted {
240 return;
241 }
242 for c in 0..cells.cols {
243 let look = resolve_cell(&cells, &frame, self.theme, c);
244 if look.disposition == Disposition::Skip {
245 continue;
246 }
247 let cell = cells.cell(c);
248 let at = CellRef {
249 row: r,
250 col: c,
251 cell,
252 attrs: cells.styles.resolve(cell.style),
253 cells: &cells,
254 frame: &frame,
255 };
256 self.push_cell_chrome(out, &at, &look);
257 if !look.glyph_visible {
261 continue;
262 }
263 let shaped = self.shape_frame.cell_at(r, c);
266 if matches!(shaped, ShapedCell::Trailing) {
267 continue;
268 }
269 if let ShapedCell::Cluster { start, len, fit_px } = shaped {
270 self.push_cluster_cell(out, &at, look.fg, (start, len), fit_px);
271 continue;
272 }
273 self.push_char_cell(out, &at, look.fg, shaped);
274 }
275 }
276
277 fn origin(&self, at: &CellRef<'_>) -> [f32; 2] {
278 [f32::from(at.col) * self.cw, f32::from(at.row) * self.ch]
279 }
280
281 fn push_cell_chrome(&self, out: &mut RowPaint, at: &CellRef<'_>, look: &CellAppearance) {
282 let origin = self.origin(at);
283 out.rects.push(Rect {
284 origin_px: origin,
285 size_px: [self.cw, self.ch],
286 color: look.bg,
287 });
288 if let Some(color) = look.link_underline {
291 out.rects.push(Rect {
292 origin_px: [origin[0], origin[1] + self.ch - LINK_UNDERLINE_PX],
293 size_px: [self.cw, LINK_UNDERLINE_PX],
294 color,
295 });
296 }
297 match look.cursor_marker {
300 Some((CursorMarkerKind::Underline, color)) => out.rects.push(Rect {
301 origin_px: [origin[0], origin[1] + self.ch - CURSOR_MARKER_PX],
302 size_px: [self.cw, CURSOR_MARKER_PX],
303 color,
304 }),
305 Some((CursorMarkerKind::Bar, color)) => out.rects.push(Rect {
306 origin_px: origin,
307 size_px: [CURSOR_MARKER_PX, self.ch],
308 color,
309 }),
310 None => {}
311 }
312 self.push_cell_decorations(&mut out.decos, at, look);
313 }
314
315 fn push_cluster_cell(
319 &self,
320 out: &mut RowPaint,
321 at: &CellRef<'_>,
322 fg_color: [f32; 4],
323 (start, len): (u32, u16),
324 fit_px: u16,
325 ) {
326 let sizing = self
330 .screen
331 .cell_sizing(at.row, at.col)
332 .copied()
333 .unwrap_or_default();
334 let base_sizing = sizing_key_from(sizing).with_style(font_style_of(at.attrs.flags));
335 let glyph_scale = sizing_glyph_scale(sizing);
336 let scaled_ascent = self.ascent * glyph_scale;
337 let pen = ClusterPen {
338 glyphs: self.shape_frame.cluster_slice(start, len),
339 base_sizing,
340 fit_px,
341 glyph_scale,
342 origin: [0.0, scaled_ascent],
343 color: fg_color,
344 };
345 let block_shift = self.cluster_block_shift(at, pen, sizing);
346 let origin = self.origin(at);
347 push_cluster_glyphs(
348 self.atlas,
349 self.metrics.height,
350 ClusterPen {
351 origin: [
352 origin[0] + block_shift[0],
353 origin[1] + block_shift[1] + scaled_ascent,
354 ],
355 ..pen
356 },
357 &mut out.glyphs,
358 );
359 }
360
361 fn cluster_block_shift(
367 &self,
368 at: &CellRef<'_>,
369 pen: ClusterPen<'_>,
370 sizing: Sizing,
371 ) -> [f32; 2] {
372 let fitted = pen.fit_px > 0;
373 if !fitted
374 && matches!(sizing.halign(), HAlign::Left)
375 && matches!(sizing.valign(), VAlign::Top)
376 {
377 return [0.0, 0.0];
378 }
379 let Some([ink_x0, ink_y0, ink_x1, ink_y1]) =
380 cluster_ink_box(self.atlas, self.metrics.height, pen)
381 else {
382 return [0.0, 0.0];
383 };
384 let ink_w = ink_x1 - ink_x0;
385 let ink_h = ink_y1 - ink_y0;
386 let layout_scale = sizing_scale_for_layout(sizing);
387 let block_w =
388 self.cw * f32::from(cluster_block_cols(self.screen, at.row, at.col)) * layout_scale;
389 let block_h = self.ch * layout_scale;
390 let centre_x = (block_w - ink_w) / 2.0 - ink_x0;
391 let centre_y = (block_h - ink_h) / 2.0 - ink_y0;
392 let shift_x = match sizing.halign() {
393 HAlign::Left if fitted => centre_x,
394 HAlign::Left => 0.0,
395 HAlign::Right => block_w - ink_x1,
396 HAlign::Center => centre_x,
397 };
398 let shift_y = match sizing.valign() {
399 VAlign::Top if fitted => centre_y,
400 VAlign::Top => 0.0,
401 VAlign::Bottom => block_h - ink_y1,
402 VAlign::Center => centre_y,
403 };
404 [shift_x, shift_y]
405 }
406
407 fn push_char_cell(
408 &self,
409 out: &mut RowPaint,
410 at: &CellRef<'_>,
411 fg_color: [f32; 4],
412 shaped: ShapedCell,
413 ) {
414 let shape_primary = match shaped {
415 ShapedCell::Primary {
416 glyph_id,
417 span_cols,
418 } => Some((glyph_id, span_cols)),
419 _ => None,
420 };
421 let glyph = match &at.cell.grapheme {
422 Grapheme::Empty | Grapheme::Spacer | Grapheme::SizedSpacer => return,
424 Grapheme::Char(c) if *c == felis_protocol::kitty_graphics::placeholder::PLACEHOLDER => {
427 return;
428 }
429 Grapheme::Ascii(b) => *b as char,
430 Grapheme::Char(c) => *c,
431 Grapheme::Cluster(id) => match cluster_base_char(self.screen, *id) {
437 Some(c) if c == felis_protocol::kitty_graphics::placeholder::PLACEHOLDER => {
438 return;
439 }
440 Some(c) => c,
441 None => return,
442 },
443 };
444 let sizing = self
450 .screen
451 .cell_sizing(at.row, at.col)
452 .copied()
453 .unwrap_or_default();
454 let sizing_key = sizing_key_from(sizing).with_style(font_style_of(at.attrs.flags));
457 let slot_lookup = match shape_primary {
458 Some((glyph_id, _span_cols)) => {
459 self.atlas
460 .glyph_id_slot(glyph_id, self.metrics.height, sizing_key)
461 }
462 None => self.atlas.slot(glyph, self.metrics.height, sizing_key),
463 };
464 let Some(slot) = slot_lookup else {
465 return;
466 };
467 let layout_scale = sizing_scale_for_layout(sizing);
471 let glyph_scale = sizing_glyph_scale(sizing);
472 let scaled_ascent = self.ascent * glyph_scale;
473 let bitmap_w = slot.size_px[0] as f32;
474 let bitmap_h = slot.size_px[1] as f32;
475 let override_w = if sizing.cell_width() > 0 {
477 f32::from(sizing.cell_width())
478 } else {
479 f32::from(char_cell_width(glyph).max(1))
480 };
481 let block_w = self.cw * override_w * layout_scale;
482 let block_h = self.ch * layout_scale;
483 let h_offset = match sizing.halign() {
486 HAlign::Left => slot.offset_px[0] as f32,
487 HAlign::Right => block_w - bitmap_w,
488 HAlign::Center => (block_w - bitmap_w) / 2.0,
489 };
490 let v_offset = match sizing.valign() {
491 VAlign::Top => scaled_ascent - slot.offset_px[1] as f32,
492 VAlign::Bottom => block_h - bitmap_h,
493 VAlign::Center => (block_h - bitmap_h) / 2.0,
494 };
495 let origin = self.origin(at);
496 let draw_origin = [origin[0] + h_offset, origin[1] + v_offset];
497 let quad = glyph_draw(slot, draw_origin, fg_color);
498 if shape_primary.is_some() && !slot.is_color {
503 self.push_sliced_by_cell(out, quad, at);
504 } else {
505 out.glyphs.push(quad);
506 }
507 }
508
509 pub fn push_scrollbar_lane(&self, out: &mut RowPaint) {
512 let rows_u32 = u32::from(self.screen.rows());
513 if self.viewport > 0 && self.viewport_max > rows_u32 {
514 let max_scroll = (self.viewport_max - rows_u32) as f32;
515 let total_w = f32::from(self.screen.cols()) * self.cw;
516 let total_h = f32::from(self.screen.rows()) * self.ch;
517 let lane_x = total_w - SCROLLBAR_LANE_PX;
518 out.rects.push(Rect {
520 origin_px: [lane_x, 0.0],
521 size_px: [SCROLLBAR_LANE_PX, total_h],
522 color: SCROLLBAR_TRACK,
523 });
524 let thumb_h_raw = total_h * (rows_u32 as f32) / (self.viewport_max as f32);
527 let thumb_h = thumb_h_raw.max(SCROLLBAR_LANE_PX).min(total_h);
528 let thumb_top_band = total_h - thumb_h;
529 let thumb_top = thumb_top_band * (max_scroll - self.viewport as f32) / max_scroll;
530 out.rects.push(Rect {
531 origin_px: [lane_x, thumb_top],
532 size_px: [SCROLLBAR_LANE_PX, thumb_h],
533 color: SCROLLBAR_THUMB,
534 });
535 }
536 }
537
538 fn push_cell_decorations(
541 &self,
542 out: &mut Vec<Decoration>,
543 at: &CellRef<'_>,
544 look: &CellAppearance,
545 ) {
546 let thickness = decoration_thickness(self.ch);
547 let origin = self.origin(at);
548 let ox = origin[0];
549 let oy = origin[1];
550 let cw = self.cw;
551 let solid = |out: &mut Vec<Decoration>, top: f32, color: [f32; 4]| {
552 out.push(Decoration {
553 origin_px: [ox, top.clamp(oy, oy + self.ch - thickness)],
554 size_px: [cw, thickness],
555 color,
556 kind: DecoKind::Solid,
557 thickness_px: thickness,
558 period_px: 0.0,
559 });
560 };
561
562 if let Some((style, color)) = look.underline {
563 let descent_band = (self.ch - self.ascent).max(thickness);
566 let underline_top = (oy + self.ascent + (descent_band * 0.3).round().max(1.0))
567 .min(oy + self.ch - thickness)
568 .max(oy);
569 match style {
570 UnderlineStyle::Single => solid(out, underline_top, color),
571 UnderlineStyle::Double => {
572 solid(out, underline_top, color);
573 solid(out, thickness.mul_add(-2.0, underline_top), color);
574 }
575 UnderlineStyle::Dotted => out.push(Decoration {
576 origin_px: [ox, underline_top],
577 size_px: [cw, thickness],
578 color,
579 kind: DecoKind::Dotted,
580 thickness_px: thickness,
581 period_px: (thickness * 3.0).max(3.0),
582 }),
583 UnderlineStyle::Dashed => out.push(Decoration {
584 origin_px: [ox, underline_top],
585 size_px: [cw, thickness],
586 color,
587 kind: DecoKind::Dashed,
588 thickness_px: thickness,
589 period_px: (cw * 0.75).max(6.0),
590 }),
591 UnderlineStyle::Curly => {
592 let amplitude = thickness;
594 let band_h = amplitude.mul_add(2.0, thickness);
595 let center = thickness.mul_add(0.5, underline_top);
596 let band_top = band_h
597 .mul_add(-0.5, center)
598 .clamp(oy, oy + self.ch - band_h);
599 out.push(Decoration {
600 origin_px: [ox, band_top],
601 size_px: [cw, band_h],
602 color,
603 kind: DecoKind::Curly,
604 thickness_px: thickness,
605 period_px: cw.max(thickness * 4.0),
606 });
607 }
608 }
609 }
610 if let Some(color) = look.strikethrough {
611 let strike_top = thickness.mul_add(-0.5, oy + (self.ascent * 0.6).round());
613 solid(out, strike_top, color);
614 }
615 if let Some(color) = look.overline {
616 solid(out, oy, color);
617 }
618 }
619
620 fn push_sliced_by_cell(&self, out: &mut RowPaint, quad: GlyphDraw, at: &CellRef<'_>) {
625 let x0 = quad.origin_px[0];
626 let x1 = x0 + quad.size_px[0];
627 if quad.size_px[0] <= 0.0 {
628 out.glyphs.push(quad);
629 return;
630 }
631 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
634 let col_first_raw = (x0 / self.cw).floor().max(0.0) as u16;
635 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
636 let col_last_raw = ((x1 / self.cw).ceil() - 1.0).max(0.0) as u16;
637 let col_last = col_last_raw.min(self.screen.cols().saturating_sub(1));
638 let col_first = col_first_raw.min(col_last);
639 if col_first == col_last {
640 out.glyphs.push(quad);
641 return;
642 }
643 let fg_at = |col: u16| -> [f32; 4] {
644 if col == at.col {
645 return quad.color;
646 }
647 resolve_cell(at.cells, at.frame, self.theme, col).fg
648 };
649 #[allow(clippy::float_cmp)]
653 let uniform = (col_first..=col_last).all(|col| fg_at(col) == quad.color);
654 if uniform {
655 out.glyphs.push(quad);
656 return;
657 }
658 let w = x1 - x0;
659 for col in col_first..=col_last {
660 let sx0 = if col == col_first {
661 x0
662 } else {
663 f32::from(col) * self.cw
664 };
665 let sx1 = if col == col_last {
666 x1
667 } else {
668 f32::from(col + 1) * self.cw
669 };
670 if sx1 <= sx0 {
671 continue;
672 }
673 let t0 = (sx0 - x0) / w;
674 let t1 = (sx1 - x0) / w;
675 let mut strip = GlyphDraw {
676 origin_px: [sx0, quad.origin_px[1]],
677 size_px: [sx1 - sx0, quad.size_px[1]],
678 color: fg_at(col),
679 ..quad
680 };
681 strip.crop_src_x(t0, t1);
682 out.glyphs.push(strip);
683 }
684 }
685}
686
687pub fn preedit_covered_cols(overlay: &PreeditOverlay, grid_cols: u16) -> Option<PreeditSpan> {
692 let (row, start) = overlay.anchor;
693 let col = overlay_cells(&overlay.text, start, grid_cols)
694 .last()?
695 .col_end();
696 Some(PreeditSpan {
697 row,
698 col_start: start,
699 col_end: col,
700 })
701}
702
703pub fn extend_preedit<A: AtlasView>(
708 overlay: &PreeditOverlay,
709 metrics: CellMetrics,
710 grid_rows: u16,
711 grid_cols: u16,
712 atlas: &A,
713 out: &mut RowPaint,
714) {
715 let (row, start) = overlay.anchor;
716 if row >= grid_rows {
717 return;
718 }
719 let cw = metrics.width as f32;
720 let ch = metrics.height as f32;
721 let active_range = overlay.cursor.filter(|(s, e)| e > s);
724 let first_glyph = out.glyphs.len();
725 for cell in overlay_cells(&overlay.text, start, grid_cols) {
726 let origin = [f32::from(cell.col) * cw, f32::from(row) * ch];
727 let span_px = [cw * f32::from(cell.width), ch];
728 let underline_px =
729 if active_range.is_some_and(|(s, e)| cell.bytes.start < e && cell.bytes.end > s) {
730 PREEDIT_ACTIVE_UNDERLINE_PX
731 } else {
732 PREEDIT_UNDERLINE_PX
733 };
734 out.rects.push(Rect {
735 origin_px: origin,
736 size_px: span_px,
737 color: PREEDIT_BG,
738 });
739 out.rects.push(Rect {
740 origin_px: [origin[0], origin[1] + ch - underline_px],
741 size_px: [span_px[0], underline_px],
742 color: theme_default_fg(),
743 });
744 push_overlay_glyphs(
745 atlas,
746 metrics,
747 &overlay.text[cell.bytes],
748 origin,
749 &mut out.glyphs,
750 );
751 }
752 clip_fg_right_of(&mut out.glyphs, first_glyph, f32::from(grid_cols) * cw);
753}
754
755struct OverlayCell {
757 bytes: std::ops::Range<usize>,
758 col: u16,
759 width: u8,
762 clipped: bool,
763}
764
765impl OverlayCell {
766 fn col_end(&self) -> u16 {
767 self.col + u16::from(self.width)
768 }
769}
770
771fn overlay_cells(text: &str, start: u16, grid_cols: u16) -> impl Iterator<Item = OverlayCell> {
776 let mut col = start;
777 felis_grid::text_cells(text).map_while(move |(bytes, natural)| {
778 let left = grid_cols.checked_sub(col).filter(|&left| left > 0)?;
779 let width = u8::try_from(left).map_or(natural, |left| natural.min(left));
780 let cell = OverlayCell {
781 bytes,
782 col,
783 width,
784 clipped: width < natural,
785 };
786 col = if cell.clipped {
787 grid_cols
788 } else {
789 cell.col_end()
790 };
791 Some(cell)
792 })
793}
794
795fn push_overlay_glyphs<A: AtlasView>(
799 atlas: &A,
800 metrics: CellMetrics,
801 cluster: &str,
802 origin: [f32; 2],
803 out: &mut Vec<GlyphDraw>,
804) {
805 let ascent = f32::from(u16::try_from(metrics.ascent).unwrap_or(u16::MAX));
806 let mut chars = cluster.chars();
807 let Some(base) = chars.next() else {
808 return;
809 };
810 if chars.next().is_some()
811 && let Some(glyphs) = atlas.overlay_cluster(cluster).filter(|g| !g.is_empty())
812 {
813 push_cluster_glyphs(
814 atlas,
815 metrics.height,
816 ClusterPen {
817 glyphs,
818 base_sizing: SizingKey::default(),
819 fit_px: 0,
820 glyph_scale: 1.0,
821 origin: [origin[0], origin[1] + ascent],
822 color: theme_default_fg(),
823 },
824 out,
825 );
826 return;
827 }
828 if let Some(slot) = atlas.slot(base, metrics.height, SizingKey::default()) {
829 out.push(glyph_draw(
830 slot,
831 [
832 origin[0] + slot.offset_px[0] as f32,
833 origin[1] + ascent - slot.offset_px[1] as f32,
834 ],
835 theme_default_fg(),
836 ));
837 }
838}
839
840#[derive(Clone, Copy)]
844struct ClusterPen<'g> {
845 glyphs: &'g [ClusterGlyph],
846 base_sizing: SizingKey,
847 fit_px: u16,
848 glyph_scale: f32,
849 origin: [f32; 2],
850 color: [f32; 4],
851}
852
853fn cluster_glyph_quads<'g, A: AtlasView>(
856 atlas: &'g A,
857 cell_height: u32,
858 pen: ClusterPen<'g>,
859) -> impl Iterator<Item = (GlyphSlot, [f32; 2])> + 'g {
860 pen.glyphs
861 .iter()
862 .scan(0.0f32, move |x, cg| {
863 let key = pen.base_sizing.with_font_id(usize::from(cg.font_id));
864 let slot = match pen.fit_px {
865 0 => atlas.glyph_id_slot(cg.glyph_id, cell_height, key),
866 px => atlas.fitted_glyph_id_slot(cg.glyph_id, px, cell_height, key),
867 };
868 let quad = slot.map(|slot| {
869 let x_base = pen.origin[0] + *x;
873 let gx = cg.x_offset_px.mul_add(pen.glyph_scale, x_base) + slot.offset_px[0] as f32;
874 let y_base = pen.origin[1] - slot.offset_px[1] as f32;
877 let gy = cg.y_offset_px.mul_add(-pen.glyph_scale, y_base);
878 (slot, [gx, gy])
879 });
880 *x = cg.advance_px.mul_add(pen.glyph_scale, *x);
881 Some(quad)
882 })
883 .flatten()
884}
885
886fn cluster_ink_box<A: AtlasView>(
888 atlas: &A,
889 cell_height: u32,
890 pen: ClusterPen<'_>,
891) -> Option<[f32; 4]> {
892 cluster_glyph_quads(atlas, cell_height, pen)
893 .map(|(slot, [x0, y0])| {
894 [
895 x0,
896 y0,
897 x0 + slot.size_px[0] as f32,
898 y0 + slot.size_px[1] as f32,
899 ]
900 })
901 .reduce(|[a, b, c, d], [x0, y0, x1, y1]| [a.min(x0), b.min(y0), c.max(x1), d.max(y1)])
902}
903
904const fn glyph_draw(slot: GlyphSlot, origin_px: [f32; 2], color: [f32; 4]) -> GlyphDraw {
905 let [x, y] = slot.atlas_px;
906 let [w, h] = slot.size_px;
907 GlyphDraw {
908 origin_px,
909 size_px: [w as f32, h as f32],
910 atlas: if slot.is_color {
911 AtlasKind::Color
912 } else {
913 AtlasKind::Mono
914 },
915 src_min_px: [x as f32, y as f32],
916 src_max_px: [(x + w) as f32, (y + h) as f32],
917 color,
918 }
919}
920
921fn push_cluster_glyphs<A: AtlasView>(
922 atlas: &A,
923 cell_height: u32,
924 pen: ClusterPen<'_>,
925 out: &mut Vec<GlyphDraw>,
926) {
927 out.extend(
928 cluster_glyph_quads(atlas, cell_height, pen)
929 .map(|(slot, origin_px)| glyph_draw(slot, origin_px, pen.color)),
930 );
931}
932
933const fn theme_default_fg() -> [f32; 4] {
936 [1.0, 1.0, 1.0, 1.0]
937}
938
939pub fn extend_search<A: AtlasView>(
944 overlay: &SearchOverlay,
945 metrics: CellMetrics,
946 grid_rows: u16,
947 grid_cols: u16,
948 draw_bar: bool,
949 atlas: &A,
950 out: &mut RowPaint,
951) {
952 if grid_rows == 0 || grid_cols == 0 {
953 return;
954 }
955 let cw = metrics.width as f32;
956 let ch = metrics.height as f32;
957
958 for hit in &overlay.visible_hits {
959 if hit.row >= grid_rows {
960 continue;
961 }
962 let col_start = hit.col_start.min(grid_cols);
963 let col_end = hit.col_end.min(grid_cols);
964 if col_end <= col_start {
965 continue;
966 }
967 let color = if hit.is_current {
968 SEARCH_CURRENT_BG
969 } else {
970 SEARCH_MATCH_BG
971 };
972 out.rects.push(Rect {
973 origin_px: [f32::from(col_start) * cw, f32::from(hit.row) * ch],
974 size_px: [f32::from(col_end - col_start) * cw, ch],
975 color,
976 });
977 }
978
979 if !draw_bar || overlay.label.is_empty() {
982 return;
983 }
984 push_bottom_bar(
985 &overlay.label,
986 SEARCH_BAR_BG,
987 metrics,
988 grid_rows,
989 grid_cols,
990 atlas,
991 out,
992 );
993}
994
995pub fn extend_confirm<A: AtlasView>(
997 overlay: &ConfirmOverlay,
998 metrics: CellMetrics,
999 grid_rows: u16,
1000 grid_cols: u16,
1001 atlas: &A,
1002 out: &mut RowPaint,
1003) {
1004 if grid_rows == 0 || grid_cols == 0 || overlay.label.is_empty() {
1005 return;
1006 }
1007 push_bottom_bar(
1008 &overlay.label,
1009 CONFIRM_BAR_BG,
1010 metrics,
1011 grid_rows,
1012 grid_cols,
1013 atlas,
1014 out,
1015 );
1016}
1017
1018pub fn extend_link_preview<A: AtlasView>(
1023 overlay: &LinkPreviewOverlay,
1024 metrics: CellMetrics,
1025 grid_rows: u16,
1026 grid_cols: u16,
1027 atlas: &A,
1028 out: &mut RowPaint,
1029) {
1030 if grid_rows == 0 || grid_cols == 0 || overlay.text.is_empty() {
1031 return;
1032 }
1033 push_bottom_bar(
1034 &fit_bar_text_to_cells(&overlay.text, grid_cols),
1035 SEARCH_BAR_BG,
1036 metrics,
1037 grid_rows,
1038 grid_cols,
1039 atlas,
1040 out,
1041 );
1042}
1043
1044pub fn clip_cells_above(out: &mut RowPaint, max_y: f32) {
1049 out.rects.retain_mut(|quad| {
1050 clip_extent_at(quad.origin_px[1], &mut quad.size_px[1], max_y).is_some()
1051 });
1052 out.glyphs.retain_mut(|quad| {
1053 let Some(kept) = clip_extent_at(quad.origin_px[1], &mut quad.size_px[1], max_y) else {
1054 return false;
1055 };
1056 quad.crop_src_y(0.0, kept);
1057 true
1058 });
1059 out.decos.retain_mut(|quad| {
1060 clip_extent_at(quad.origin_px[1], &mut quad.size_px[1], max_y).is_some()
1061 });
1062}
1063
1064fn clip_fg_right_of(quads: &mut Vec<GlyphDraw>, from: usize, max_x: f32) {
1067 let mut i = from;
1068 while i < quads.len() {
1069 let quad = &mut quads[i];
1070 let Some(kept) = clip_extent_at(quad.origin_px[0], &mut quad.size_px[0], max_x) else {
1071 quads.remove(i);
1072 continue;
1073 };
1074 quad.crop_src_x(0.0, kept);
1075 i += 1;
1076 }
1077}
1078
1079#[must_use]
1083pub fn clip_extent_at(origin: f32, extent: &mut f32, max: f32) -> Option<f32> {
1084 if *extent <= 0.0 {
1085 return None;
1086 }
1087 if origin + *extent <= max {
1088 return Some(1.0);
1089 }
1090 let visible = max - origin;
1091 if visible <= 0.0 {
1092 return None;
1093 }
1094 let kept = visible / *extent;
1095 *extent = visible;
1096 Some(kept)
1097}
1098
1099pub const BAR_ELLIPSIS: char = '…';
1104
1105fn fit_bar_text_to_cells(text: &str, cols: u16) -> Cow<'_, str> {
1110 let width: u32 = felis_grid::text_cells(text)
1111 .map(|(_, w)| u32::from(w))
1112 .sum();
1113 if width <= u32::from(cols) {
1114 return Cow::Borrowed(text);
1115 }
1116 let head_end = overlay_cells(text, 0, cols.saturating_sub(1))
1117 .take_while(|cell| !cell.clipped)
1118 .last()
1119 .map_or(0, |cell| cell.bytes.end);
1120 let mut head = text[..head_end].to_owned();
1121 head.push(BAR_ELLIPSIS);
1122 Cow::Owned(head)
1123}
1124
1125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1129pub enum BottomBarClaim {
1130 Free,
1132 Chrome,
1134 LinkPreview,
1136}
1137
1138impl BottomBarClaim {
1139 #[must_use]
1142 pub const fn reserved_rows(self) -> u16 {
1143 match self {
1144 Self::Free => 0,
1145 Self::Chrome | Self::LinkPreview => 1,
1146 }
1147 }
1148
1149 #[must_use]
1151 pub const fn draw_link_preview(self) -> bool {
1152 matches!(self, Self::LinkPreview)
1153 }
1154}
1155
1156#[must_use]
1161pub const fn bottom_bar_claim(
1162 chrome: ChromeBar,
1163 preedit_active: bool,
1164 link_preview_active: bool,
1165) -> BottomBarClaim {
1166 if !matches!(chrome, ChromeBar::None) {
1167 return BottomBarClaim::Chrome;
1168 }
1169 if link_preview_active && !preedit_active {
1170 return BottomBarClaim::LinkPreview;
1171 }
1172 BottomBarClaim::Free
1173}
1174
1175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1179pub enum ChromeBar {
1180 None,
1181 Search,
1182 Confirm,
1183}
1184
1185impl ChromeBar {
1186 #[must_use]
1187 pub const fn from_flags(search_active: bool, confirm_active: bool) -> Self {
1188 if confirm_active {
1189 Self::Confirm
1190 } else if search_active {
1191 Self::Search
1192 } else {
1193 Self::None
1194 }
1195 }
1196}
1197
1198fn push_bottom_bar<A: AtlasView>(
1201 label: &str,
1202 bar_bg: [f32; 4],
1203 metrics: CellMetrics,
1204 grid_rows: u16,
1205 grid_cols: u16,
1206 atlas: &A,
1207 out: &mut RowPaint,
1208) {
1209 let cw = metrics.width as f32;
1210 let ch = metrics.height as f32;
1211 let bar_row = grid_rows - 1;
1212 let bar_origin_y = f32::from(bar_row) * ch;
1213 out.rects.push(Rect {
1214 origin_px: [0.0, bar_origin_y],
1215 size_px: [f32::from(grid_cols) * cw, ch],
1216 color: bar_bg,
1217 });
1218
1219 let first_glyph = out.glyphs.len();
1222 for cell in overlay_cells(label, 0, grid_cols) {
1223 let origin = [f32::from(cell.col) * cw, bar_origin_y];
1224 push_overlay_glyphs(atlas, metrics, &label[cell.bytes], origin, &mut out.glyphs);
1225 }
1226 clip_fg_right_of(&mut out.glyphs, first_glyph, f32::from(grid_cols) * cw);
1227}
1228
1229fn decoration_thickness(ch: f32) -> f32 {
1231 (ch / 16.0).round().max(1.0)
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 #![allow(clippy::float_cmp)]
1237
1238 use std::collections::HashMap;
1239 #[cfg(not(windows))]
1240 use std::num::NonZeroU32;
1241
1242 #[cfg(not(windows))]
1243 use crate::glyphs::{GlyphIndex, GridWalker};
1244 use crate::{
1245 appearance::{cell_contains_bidi_override, dim_toward_bg},
1246 overlay::SearchHitSpan,
1247 palette::{BIDI_MARKER_BG, BIDI_MARKER_FG, SELECTION_BG, Theme, resolve_fg},
1248 };
1249 use felis_grid::{AttrFlags, Color, Grid};
1250 #[cfg(not(windows))]
1251 use felis_shaping::{FontStack, FontStyle, Shaper};
1252 use felis_vt::Parser;
1253
1254 use super::*;
1255
1256 struct MockAtlas {
1258 map: HashMap<char, GlyphSlot>,
1259 gid_map: HashMap<felis_shaping::GlyphId, GlyphSlot>,
1260 fitted_map: HashMap<(felis_shaping::GlyphId, u16), GlyphSlot>,
1261 clusters: HashMap<String, Vec<ClusterGlyph>>,
1262 }
1263
1264 impl MockAtlas {
1265 fn full(slot: GlyphSlot) -> Self {
1266 let mut map = HashMap::new();
1267 for b in 0x21u8..=0x7Eu8 {
1268 map.insert(b as char, slot);
1269 }
1270 Self {
1271 map,
1272 gid_map: HashMap::new(),
1273 fitted_map: HashMap::new(),
1274 clusters: HashMap::new(),
1275 }
1276 }
1277 }
1278
1279 impl AtlasView for MockAtlas {
1280 fn slot(&self, glyph: char, _height: u32, _sizing: SizingKey) -> Option<GlyphSlot> {
1281 self.map.get(&glyph).copied()
1282 }
1283
1284 fn glyph_id_slot(
1285 &self,
1286 glyph_id: felis_shaping::GlyphId,
1287 _cell_height_px: u32,
1288 _sizing: SizingKey,
1289 ) -> Option<GlyphSlot> {
1290 self.gid_map.get(&glyph_id).copied()
1291 }
1292
1293 fn fitted_glyph_id_slot(
1294 &self,
1295 glyph_id: felis_shaping::GlyphId,
1296 px: u16,
1297 _cell_height_px: u32,
1298 _sizing: SizingKey,
1299 ) -> Option<GlyphSlot> {
1300 self.fitted_map.get(&(glyph_id, px)).copied()
1301 }
1302
1303 fn overlay_cluster(&self, text: &str) -> Option<&[ClusterGlyph]> {
1304 self.clusters.get(text).map(Vec::as_slice)
1305 }
1306
1307 fn sheet_side(&self, _sheet: AtlasKind) -> u32 {
1308 80
1309 }
1310 }
1311
1312 fn metrics() -> CellMetrics {
1313 CellMetrics {
1314 width: 8,
1315 height: 16,
1316 ascent: 12,
1317 }
1318 }
1319
1320 fn slot() -> GlyphSlot {
1321 GlyphSlot {
1322 atlas_px: [0, 0],
1323 size_px: [6, 10],
1324 offset_px: [1, 10],
1325 is_color: false,
1326 }
1327 }
1328
1329 fn drive(rows: u16, cols: u16, bytes: &[u8]) -> Grid {
1330 let mut g = Grid::new(rows, cols);
1331 let mut p = Parser::new();
1332 p.advance(&mut g, bytes);
1333 g
1334 }
1335
1336 fn build_instances<A: AtlasView>(
1337 screen: &ScreenBuffer,
1338 metrics: CellMetrics,
1339 theme: &Theme,
1340 atlas: &A,
1341 ) -> RowPaint {
1342 build_instances_with_reverse(screen, metrics, theme, atlas, false)
1343 }
1344
1345 fn build_instances_with_reverse<A: AtlasView>(
1346 screen: &ScreenBuffer,
1347 metrics: CellMetrics,
1348 theme: &Theme,
1349 atlas: &A,
1350 screen_reverse: bool,
1351 ) -> RowPaint {
1352 let mut buffers = RowPaint::default();
1353 CellPainter::new(
1354 screen,
1355 metrics,
1356 &ResolvedTheme::new(theme),
1357 atlas,
1358 &ShapeFrame::empty(),
1359 )
1360 .with_cursor_visible(true)
1361 .with_viewport(0, u32::from(screen.rows()))
1362 .with_reverse_video(screen_reverse)
1363 .extend_screen(&mut buffers);
1364 buffers
1365 }
1366
1367 #[test]
1368 fn cursor_rect_spans_both_columns_of_a_wide_character() {
1369 let metrics = CellMetrics {
1370 width: 10,
1371 height: 20,
1372 ascent: 16,
1373 };
1374 let span = (3, 4);
1375 assert_eq!(
1376 cursor_rect_px(metrics, [5.0, 0.0], 1, span, CursorStyle::Block),
1377 [35.0, 20.0, 20.0, 20.0]
1378 );
1379 assert_eq!(
1380 cursor_rect_px(metrics, [5.0, 0.0], 1, span, CursorStyle::Underline),
1381 [35.0, 40.0 - CURSOR_MARKER_PX, 20.0, CURSOR_MARKER_PX]
1382 );
1383 assert_eq!(
1384 cursor_rect_px(metrics, [5.0, 0.0], 1, span, CursorStyle::Bar),
1385 [35.0, 20.0, CURSOR_MARKER_PX, 20.0]
1386 );
1387 }
1388
1389 #[test]
1390 fn osc_66_valign_bottom_drops_bitmap_to_block_bottom() {
1391 let g = drive(1, 4, b"\x1b]66;v=1;A\x07");
1394 let atlas = MockAtlas::full(slot());
1395 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1396 assert_eq!(buffers.glyphs.len(), 1);
1397 assert_eq!(buffers.glyphs[0].origin_px, [1.0, 6.0]);
1398 }
1399
1400 #[test]
1401 fn osc_66_halign_right_with_w_override_pushes_bitmap_to_block_right_edge() {
1402 let g = drive(1, 4, b"\x1b]66;w=3:h=1;A\x07");
1405 let atlas = MockAtlas::full(slot());
1406 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1407 assert_eq!(buffers.glyphs.len(), 1);
1408 assert_eq!(buffers.glyphs[0].origin_px, [18.0, 2.0]);
1409 }
1410
1411 #[test]
1412 fn osc_66_center_alignment_centers_the_bitmap_in_the_block() {
1413 let g = drive(1, 4, b"\x1b]66;w=3:h=2:v=2;A\x07");
1416 let atlas = MockAtlas::full(slot());
1417 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1418 assert_eq!(buffers.glyphs.len(), 1);
1419 assert_eq!(buffers.glyphs[0].origin_px, [9.0, 3.0]);
1420 }
1421
1422 #[test]
1423 fn osc_66_fractional_scale_shrinks_the_glyph_without_shrinking_the_block() {
1424 let atlas = MockAtlas::full(slot());
1425 let integer = drive(2, 4, b"\x1b]66;s=2;A\x07");
1428 let integer = build_instances(integer.screen(), metrics(), &Theme::default(), &atlas);
1429 assert_eq!(integer.glyphs.len(), 1);
1430 assert_eq!(integer.glyphs[0].origin_px, [1.0, 14.0]);
1431
1432 let fractional = drive(2, 4, b"\x1b]66;s=2:n=1:d=2;A\x07");
1435 let fractional = build_instances(fractional.screen(), metrics(), &Theme::default(), &atlas);
1436 assert_eq!(fractional.glyphs.len(), 1);
1437 assert_eq!(fractional.glyphs[0].origin_px, [1.0, 2.0]);
1438
1439 let bottom = drive(2, 4, b"\x1b]66;s=2:n=1:d=2:v=1;A\x07");
1442 let bottom = build_instances(bottom.screen(), metrics(), &Theme::default(), &atlas);
1443 assert_eq!(bottom.glyphs.len(), 1);
1444 assert_eq!(bottom.glyphs[0].origin_px, [1.0, 22.0]);
1445 }
1446
1447 #[test]
1448 fn osc_66_sized_spacer_cells_emit_bg_but_not_fg() {
1449 let g = drive(2, 4, b"\x1b]66;s=2;X\x07");
1452 let atlas = MockAtlas::full(slot());
1453 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1454 assert_eq!(buffers.rects.len(), 8);
1455 assert_eq!(buffers.glyphs.len(), 1);
1456 let inst = &buffers.glyphs[0];
1457 assert_eq!(inst.origin_px, [1.0, 14.0]);
1458 assert_eq!(inst.size_px, [6.0, 10.0]);
1459 }
1460
1461 #[track_caller]
1463 fn assert_px_close(got: f32, want: f32) {
1464 assert!(
1465 (got - want).abs() < 1e-5,
1466 "source px mismatch: got {got}, want {want}"
1467 );
1468 }
1469
1470 fn ligature_slot(left: i32) -> GlyphSlot {
1472 GlyphSlot {
1473 atlas_px: [0, 0],
1474 size_px: [24, 10],
1475 offset_px: [left, 10],
1476 is_color: false,
1477 }
1478 }
1479
1480 fn build_shaped(screen: &ScreenBuffer, atlas: &MockAtlas, frame: &ShapeFrame) -> RowPaint {
1483 let mut buffers = RowPaint::default();
1484 CellPainter::new(
1485 screen,
1486 metrics(),
1487 &ResolvedTheme::new(&Theme::default()),
1488 atlas,
1489 frame,
1490 )
1491 .with_cursor_visible(false)
1492 .with_viewport(0, u32::from(screen.rows()))
1493 .extend_screen(&mut buffers);
1494 buffers
1495 }
1496
1497 #[test]
1501 fn shaped_quad_slices_per_cell_when_colors_differ() {
1502 let g = drive(1, 3, b"<-\x1b[31m>");
1503 let mut atlas = MockAtlas::full(slot());
1504 atlas.gid_map.insert(42, ligature_slot(0));
1505 let frame = ShapeFrame::from_cells(
1506 vec![
1507 ShapedCell::Primary {
1508 glyph_id: 42,
1509 span_cols: 3,
1510 },
1511 ShapedCell::Trailing,
1512 ShapedCell::Trailing,
1513 ],
1514 3,
1515 );
1516 let buffers = build_shaped(g.screen(), &atlas, &frame);
1517 assert_eq!(buffers.glyphs.len(), 3, "one strip per covered cell");
1518 for (i, inst) in buffers.glyphs.iter().enumerate() {
1519 let i_f = i as f32;
1520 assert_eq!(inst.origin_px, [8.0 * i_f, 2.0]);
1521 assert_eq!(inst.size_px, [8.0, 10.0]);
1522 assert_px_close(inst.src_min_px[0], 8.0 * i_f);
1523 assert_px_close(inst.src_max_px[0], 8.0 * (i_f + 1.0));
1524 assert_eq!(inst.src_min_px[1], 0.0);
1525 assert_eq!(inst.src_max_px[1], 10.0);
1526 }
1527 let red = resolve_fg(Color::Indexed(1), &ResolvedTheme::new(&Theme::default()));
1528 assert_eq!(buffers.glyphs[0].color, buffers.glyphs[1].color);
1529 assert_eq!(buffers.glyphs[2].color, red);
1530 assert_ne!(buffers.glyphs[0].color, red);
1531 }
1532
1533 #[test]
1535 fn shaped_quad_stays_unsliced_when_colors_match() {
1536 let g = drive(1, 3, b"<->");
1537 let mut atlas = MockAtlas::full(slot());
1538 atlas.gid_map.insert(42, ligature_slot(0));
1539 let frame = ShapeFrame::from_cells(
1540 vec![
1541 ShapedCell::Primary {
1542 glyph_id: 42,
1543 span_cols: 3,
1544 },
1545 ShapedCell::Trailing,
1546 ShapedCell::Trailing,
1547 ],
1548 3,
1549 );
1550 let buffers = build_shaped(g.screen(), &atlas, &frame);
1551 assert_eq!(buffers.glyphs.len(), 1, "uniform colors must not slice");
1552 let inst = &buffers.glyphs[0];
1553 assert_eq!(inst.origin_px, [0.0, 2.0]);
1554 assert_eq!(inst.size_px, [24.0, 10.0]);
1555 assert_eq!(inst.src_min_px, [0.0, 0.0]);
1556 assert_eq!(inst.src_max_px, [24.0, 10.0]);
1557 }
1558
1559 #[test]
1562 fn shaped_quad_with_negative_bearing_slices_back_across_cells() {
1563 let g = drive(1, 3, b"\x1b[31m<\x1b[39m->");
1564 let mut atlas = MockAtlas::full(slot());
1565 atlas.gid_map.insert(43, ligature_slot(-16));
1566 let frame = ShapeFrame::from_cells(
1567 vec![
1568 ShapedCell::Primary {
1571 glyph_id: 41,
1572 span_cols: 1,
1573 },
1574 ShapedCell::Primary {
1575 glyph_id: 41,
1576 span_cols: 1,
1577 },
1578 ShapedCell::Primary {
1579 glyph_id: 43,
1580 span_cols: 1,
1581 },
1582 ],
1583 3,
1584 );
1585 let buffers = build_shaped(g.screen(), &atlas, &frame);
1586 assert_eq!(buffers.glyphs.len(), 3, "one strip per reached-back cell");
1587 let red = resolve_fg(Color::Indexed(1), &ResolvedTheme::new(&Theme::default()));
1588 assert_eq!(buffers.glyphs[0].origin_px, [0.0, 2.0]);
1589 assert_eq!(
1590 buffers.glyphs[0].color, red,
1591 "reached-back cell keeps its own red"
1592 );
1593 assert_eq!(
1594 buffers.glyphs[1].color, buffers.glyphs[2].color,
1595 "default-fg cells match"
1596 );
1597 assert_ne!(buffers.glyphs[1].color, red);
1598 }
1599
1600 #[test]
1604 fn cluster_cell_emits_one_instance_per_glyph_stacked_by_gpos() {
1605 let g = drive(1, 4, b"x");
1606 let mut atlas = MockAtlas::full(slot());
1607 atlas.gid_map.insert(100, slot());
1608 atlas.gid_map.insert(200, slot());
1609 let frame = ShapeFrame::from_cluster_cells(
1610 vec![
1611 ShapedCell::Cluster {
1612 start: 0,
1613 len: 2,
1614 fit_px: 0,
1615 },
1616 ShapedCell::None,
1617 ShapedCell::None,
1618 ShapedCell::None,
1619 ],
1620 4,
1621 vec![
1622 ClusterGlyph {
1623 glyph_id: 100,
1624 font_id: 0,
1625 advance_px: 8.0,
1626 x_offset_px: 0.0,
1627 y_offset_px: 0.0,
1628 },
1629 ClusterGlyph {
1630 glyph_id: 200,
1631 font_id: 0,
1632 advance_px: 0.0,
1633 x_offset_px: -8.0,
1634 y_offset_px: 4.0,
1635 },
1636 ],
1637 );
1638 let buffers = build_shaped(g.screen(), &atlas, &frame);
1639 assert_eq!(buffers.glyphs.len(), 2, "one instance per cluster glyph");
1640 assert_eq!(buffers.glyphs[0].origin_px, [1.0, 2.0]);
1641 assert_eq!(buffers.glyphs[1].origin_px, [1.0, -2.0]);
1642 assert_eq!(
1643 buffers.glyphs[0].origin_px[0], buffers.glyphs[1].origin_px[0],
1644 "mark shares the base's x column",
1645 );
1646 assert!(
1647 buffers.glyphs[1].origin_px[1] < buffers.glyphs[0].origin_px[1],
1648 "mark sits above its base (smaller screen y)",
1649 );
1650 }
1651
1652 #[test]
1655 fn sized_cluster_scales_pen_and_gpos_by_glyph_scale() {
1656 let g = drive(2, 4, b"\x1b]66;s=2;X\x07");
1657 let mut atlas = MockAtlas::full(slot());
1658 atlas.gid_map.insert(100, slot());
1659 atlas.gid_map.insert(200, slot());
1660 let glyphs = vec![
1661 ClusterGlyph {
1662 glyph_id: 100,
1663 font_id: 0,
1664 advance_px: 8.0,
1665 x_offset_px: 0.0,
1666 y_offset_px: 0.0,
1667 },
1668 ClusterGlyph {
1669 glyph_id: 200,
1670 font_id: 0,
1671 advance_px: 0.0,
1672 x_offset_px: -8.0,
1673 y_offset_px: 4.0,
1674 },
1675 ];
1676 let mut cells = vec![ShapedCell::None; 8];
1677 cells[0] = ShapedCell::Cluster {
1678 start: 0,
1679 len: 2,
1680 fit_px: 0,
1681 };
1682 let frame = ShapeFrame::from_cluster_cells(cells, 4, glyphs);
1683 let buffers = build_shaped(g.screen(), &atlas, &frame);
1684 assert_eq!(buffers.glyphs.len(), 2, "one instance per cluster glyph");
1685 assert_eq!(buffers.glyphs[0].origin_px, [1.0, 14.0]);
1686 assert_eq!(buffers.glyphs[1].origin_px, [1.0, 6.0]);
1687 let rise = buffers.glyphs[0].origin_px[1] - buffers.glyphs[1].origin_px[1];
1688 assert!(
1689 (rise - 8.0).abs() < f32::EPSILON,
1690 "mark rises 2× the unscaled 4 px"
1691 );
1692 assert_eq!(
1693 buffers.glyphs[0].origin_px[0], buffers.glyphs[1].origin_px[0],
1694 "mark still shares the base's x column at scale",
1695 );
1696 }
1697
1698 #[test]
1701 fn sized_cluster_alignment_uses_the_union_ink_box() {
1702 let g = drive(1, 4, b"\x1b]66;w=2:h=1:v=1;X\x07");
1703 let mut atlas = MockAtlas::full(slot());
1704 atlas.gid_map.insert(100, slot());
1705 atlas.gid_map.insert(
1706 200,
1707 GlyphSlot {
1708 atlas_px: [0, 0],
1709 size_px: [10, 12],
1710 offset_px: [0, 11],
1711 is_color: false,
1712 },
1713 );
1714 let glyphs = vec![
1715 ClusterGlyph {
1716 glyph_id: 100,
1717 font_id: 0,
1718 advance_px: 8.0,
1719 x_offset_px: 0.0,
1720 y_offset_px: 0.0,
1721 },
1722 ClusterGlyph {
1723 glyph_id: 200,
1724 font_id: 0,
1725 advance_px: 0.0,
1726 x_offset_px: -8.0,
1727 y_offset_px: 0.0,
1728 },
1729 ];
1730 let mut cells = vec![ShapedCell::None; 4];
1731 cells[0] = ShapedCell::Cluster {
1732 start: 0,
1733 len: 2,
1734 fit_px: 0,
1735 };
1736 let frame = ShapeFrame::from_cluster_cells(cells, 4, glyphs);
1737 let buffers = build_shaped(g.screen(), &atlas, &frame);
1738 assert_eq!(buffers.glyphs.len(), 2, "one instance per cluster glyph");
1739 assert_eq!(
1743 buffers.glyphs[0].origin_px,
1744 [7.0, 5.0],
1745 "base rides the shift"
1746 );
1747 assert_eq!(
1748 buffers.glyphs[1].origin_px,
1749 [6.0, 4.0],
1750 "the mark's ink ends flush with the block's right/bottom edges",
1751 );
1752 }
1753
1754 #[test]
1759 fn a_cluster_block_spans_the_cells_the_grid_gives_it() {
1760 let cols = |bytes: &[u8], col| {
1761 let g = drive(2, 8, bytes);
1762 assert!(!matches!(
1763 g.screen().cell(0, col).map(|c| c.grapheme),
1764 Some(Grapheme::Empty)
1765 ));
1766 cluster_block_cols(g.screen(), 0, col)
1767 };
1768 assert_eq!(cols("\u{2764}\u{FE0F}".as_bytes(), 0), 2, "widened");
1769 assert_eq!(cols("\x1b[8G\u{2764}\u{FE0F}".as_bytes(), 7), 1, "refused");
1770 assert_eq!(
1771 cols("\x1b]66;w=3;e\u{301}\x07".as_bytes(), 0),
1772 3,
1773 "OSC 66 w"
1774 );
1775 assert_eq!(
1776 cols("\x1b]66;s=2;\u{2764}\u{FE0F}\x07".as_bytes(), 0),
1777 2,
1778 "sized, VS16 in the run"
1779 );
1780 assert_eq!(
1781 cols("\x1b]66;s=2;\u{2764}\x07\u{FE0F}".as_bytes(), 0),
1782 1,
1783 "sized, VS16 printed after"
1784 );
1785 assert_eq!(
1786 cols("\x1b]66;s=2;\u{5B57}\u{301}\x07".as_bytes(), 0),
1787 2,
1788 "sized, wide base"
1789 );
1790 }
1791
1792 #[test]
1795 fn a_centred_cluster_centres_in_its_grid_span() {
1796 let g = drive(1, 4, "\x1b]66;h=2;\u{2764}\u{FE0F}\x07".as_bytes());
1797 assert_eq!(g.screen().char_span(0, 0), (0, 1), "the grid widened it");
1798 let mut atlas = MockAtlas::full(slot());
1799 atlas.gid_map.insert(100, slot());
1800 let glyph = ClusterGlyph {
1801 glyph_id: 100,
1802 font_id: 0,
1803 advance_px: 8.0,
1804 x_offset_px: 0.0,
1805 y_offset_px: 0.0,
1806 };
1807 let mut cells = vec![ShapedCell::None; 4];
1808 cells[0] = ShapedCell::Cluster {
1809 start: 0,
1810 len: 1,
1811 fit_px: 0,
1812 };
1813 let frame = ShapeFrame::from_cluster_cells(cells, 4, vec![glyph]);
1814 let buffers = build_shaped(g.screen(), &atlas, &frame);
1815 assert_eq!(buffers.glyphs[0].origin_px[0], 5.0);
1817 }
1818
1819 #[test]
1823 fn a_fitted_cluster_draws_its_fitted_slots_centred() {
1824 let g = drive(1, 4, b"x");
1825 let mut atlas = MockAtlas::full(slot());
1826 atlas.gid_map.insert(100, slot());
1827 atlas.fitted_map.insert(
1828 (100, 9),
1829 GlyphSlot {
1830 size_px: [4, 6],
1831 offset_px: [3, 9],
1832 ..slot()
1833 },
1834 );
1835 let glyph = ClusterGlyph {
1836 glyph_id: 100,
1837 font_id: 0,
1838 advance_px: 8.0,
1839 x_offset_px: 0.0,
1840 y_offset_px: 0.0,
1841 };
1842 let mut cells = vec![ShapedCell::None; 4];
1843 cells[0] = ShapedCell::Cluster {
1844 start: 0,
1845 len: 1,
1846 fit_px: 9,
1847 };
1848 let frame = ShapeFrame::from_cluster_cells(cells, 4, vec![glyph]);
1849 let buffers = build_shaped(g.screen(), &atlas, &frame);
1850 assert_eq!(buffers.glyphs.len(), 1);
1851 assert_eq!(buffers.glyphs[0].size_px, [4.0, 6.0], "the fitted slot");
1852 assert_eq!(buffers.glyphs[0].origin_px, [2.0, 5.0]);
1854 }
1855
1856 #[test]
1859 fn single_glyph_cluster_matches_char_placement() {
1860 let char_grid = drive(1, 4, b"A");
1861 let atlas = MockAtlas::full(slot());
1862 let char_buffers =
1863 build_instances(char_grid.screen(), metrics(), &Theme::default(), &atlas);
1864 let char_inst = char_buffers.glyphs[0];
1865 let g = drive(1, 4, b"x");
1866 let mut cluster_atlas = MockAtlas::full(slot());
1867 cluster_atlas.gid_map.insert(100, slot());
1868 let frame = ShapeFrame::from_cluster_cells(
1869 vec![
1870 ShapedCell::Cluster {
1871 start: 0,
1872 len: 1,
1873 fit_px: 0,
1874 },
1875 ShapedCell::None,
1876 ShapedCell::None,
1877 ShapedCell::None,
1878 ],
1879 4,
1880 vec![ClusterGlyph {
1881 glyph_id: 100,
1882 font_id: 0,
1883 advance_px: 8.0,
1884 x_offset_px: 0.0,
1885 y_offset_px: 0.0,
1886 }],
1887 );
1888 let buffers = build_shaped(g.screen(), &cluster_atlas, &frame);
1889 assert_eq!(
1890 buffers.glyphs.len(),
1891 1,
1892 "single-glyph cluster emits one instance"
1893 );
1894 assert_eq!(buffers.glyphs[0].origin_px, char_inst.origin_px);
1895 assert_eq!(buffers.glyphs[0].size_px, char_inst.size_px);
1896 assert_eq!(buffers.glyphs[0].src_min_px, char_inst.src_min_px);
1897 assert_eq!(buffers.glyphs[0].src_max_px, char_inst.src_max_px);
1898 }
1899
1900 #[test]
1901 fn empty_grid_emits_one_bg_per_cell_and_no_fg() {
1902 let g = drive(2, 3, b"");
1903 let atlas = MockAtlas::full(slot());
1904 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1905 assert_eq!(buffers.rects.len(), 6);
1906 assert_eq!(buffers.glyphs, Vec::<GlyphDraw>::new());
1907 let last = buffers.rects.last().unwrap();
1908 assert_eq!(last.origin_px, [16.0, 16.0]);
1909 assert_eq!(last.size_px, [8.0, 16.0]);
1910 }
1911
1912 #[test]
1913 fn ascii_glyph_emits_a_fg_instance_at_baseline_offset() {
1914 let g = drive(1, 3, b"A");
1915 let atlas = MockAtlas::full(slot());
1916 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1917 assert_eq!(buffers.rects.len(), 3);
1918 assert_eq!(buffers.glyphs.len(), 1);
1919 let inst = &buffers.glyphs[0];
1920 assert_eq!(inst.origin_px, [1.0, 2.0]);
1921 assert_eq!(inst.size_px, [6.0, 10.0]);
1922 assert_eq!(inst.src_min_px, [0.0, 0.0]);
1923 assert_eq!(inst.src_max_px, [6.0, 10.0]);
1924 }
1925
1926 #[test]
1927 fn reverse_video_swaps_fg_and_bg_colors() {
1928 let g = drive(1, 2, b"\x1b[?25lA\x1b[7mB");
1930 let atlas = MockAtlas::full(slot());
1931 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1932 let theme = Theme::default();
1933 assert_eq!(buffers.glyphs[0].color, theme.fg);
1934 assert_eq!(buffers.rects[0].color, theme.bg);
1935 assert_eq!(buffers.glyphs[1].color, theme.bg);
1936 assert_eq!(buffers.rects[1].color, theme.fg);
1937 }
1938
1939 #[test]
1941 fn decscnm_xors_with_sgr_reverse_over_the_resolved_pair() {
1942 let g = drive(1, 4, b"\x1b[?25lA\x1b[7mB\x1b[0;38;5;1;48;5;4mC\x1b[7mD");
1943 let atlas = MockAtlas::full(slot());
1944 let theme = Theme::default();
1945 let resolved = ResolvedTheme::new(&theme);
1946 let pal = |i: u8| resolve_fg(Color::Indexed(i), &resolved);
1947 let (dfg, dbg) = (theme.fg, theme.bg);
1948 let (efg, ebg) = (pal(1), pal(4));
1949
1950 let plain = build_instances_with_reverse(g.screen(), metrics(), &theme, &atlas, false);
1951 let pairs: Vec<_> = (0..4)
1952 .map(|i| (plain.glyphs[i].color, plain.rects[i].color))
1953 .collect();
1954 assert_eq!(
1955 pairs,
1956 vec![(dfg, dbg), (dbg, dfg), (efg, ebg), (ebg, efg)],
1957 "without DECSCNM only SGR 7 swaps",
1958 );
1959
1960 let reversed = build_instances_with_reverse(g.screen(), metrics(), &theme, &atlas, true);
1961 let pairs: Vec<_> = (0..4)
1962 .map(|i| (reversed.glyphs[i].color, reversed.rects[i].color))
1963 .collect();
1964 assert_eq!(
1965 pairs,
1966 vec![(dbg, dfg), (dfg, dbg), (ebg, efg), (efg, ebg)],
1967 "DECSCNM flips every cell, SGR 7 cells back to normal",
1968 );
1969 }
1970
1971 #[test]
1972 fn atlas_miss_drops_only_the_fg_instance() {
1973 let mut atlas_map = HashMap::new();
1974 atlas_map.insert('A', slot());
1975 let atlas = MockAtlas {
1976 map: atlas_map,
1977 gid_map: HashMap::new(),
1978 fitted_map: HashMap::new(),
1979 clusters: HashMap::new(),
1980 };
1981 let g = drive(1, 2, b"AB");
1982 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1983 assert_eq!(buffers.rects.len(), 2, "every cell still has a bg");
1984 assert_eq!(buffers.glyphs.len(), 1, "B produced no glyph instance");
1985 }
1986
1987 #[test]
1988 fn sgr_indexed_color_is_resolved_via_palette() {
1989 let g = drive(1, 1, b"\x1b[?25l\x1b[31mZ");
1990 let atlas = MockAtlas::full(slot());
1991 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
1992 let expected = resolve_fg(Color::Indexed(1), &ResolvedTheme::new(&Theme::default()));
1993 assert_eq!(buffers.glyphs[0].color, expected);
1994 }
1995
1996 #[test]
1997 fn cursor_cell_renders_as_reverse_video_block() {
1998 let g = drive(1, 3, b"A");
1999 let atlas = MockAtlas::full(slot());
2000 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2001 let theme = Theme::default();
2002 assert_eq!(g.cursor().row, 0);
2003 assert_eq!(g.cursor().col, 1);
2004 assert_eq!(
2005 buffers.rects[1].color, theme.fg,
2006 "cursor cell bg must be the theme fg (reverse video)"
2007 );
2008 assert_eq!(buffers.rects[0].color, theme.bg);
2009 assert_eq!(buffers.glyphs[0].color, theme.fg);
2010 }
2011
2012 #[test]
2013 fn hidden_cursor_skips_reverse_video_swap() {
2014 let g = drive(1, 3, b"\x1b[?25lA");
2016 let atlas = MockAtlas::full(slot());
2017 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2018 let theme = Theme::default();
2019 assert!(!g.cursor().visible);
2020 assert_eq!(buffers.rects[1].color, theme.bg);
2021 }
2022
2023 #[test]
2024 fn bidi_override_cell_paints_with_warning_marker() {
2025 let mut g = Grid::new(1, 4);
2028 let mut p = Parser::new();
2029 p.advance(&mut g, "\x1b[?25lA\u{202E}B".as_bytes());
2030 let atlas = MockAtlas::full(slot());
2031 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2032 let mut marked = false;
2033 for r in 0..g.rows() {
2034 for c in 0..g.cols() {
2035 let Some(cell) = g.cell(r, c) else { continue };
2036 if cell_contains_bidi_override(cell.grapheme, g.screen().cluster_table()) {
2037 let idx = usize::from(r) * usize::from(g.cols()) + usize::from(c);
2038 assert_eq!(
2039 buffers.rects[idx].color, BIDI_MARKER_BG,
2040 "bidi-override cell at ({r},{c}) must paint with marker bg"
2041 );
2042 marked = true;
2043 }
2044 }
2045 }
2046 assert!(
2047 marked,
2048 "parser should have placed the RLO in some cell — neither Char nor Cluster found"
2049 );
2050 }
2051
2052 #[test]
2053 fn bidi_override_at_column_zero_marks_the_character_after_it() {
2054 let g = drive(1, 4, "\x1b[?25l\u{202E}AB".as_bytes());
2055 let atlas = MockAtlas::full(slot());
2056 let theme = Theme::default();
2057 let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
2058 assert_eq!(buffers.rects[0].color, BIDI_MARKER_BG);
2059 assert_eq!(buffers.rects[1].color, theme.bg);
2060 }
2061
2062 #[test]
2063 fn ordinary_text_does_not_trigger_bidi_marker() {
2064 let mut g = Grid::new(1, 8);
2066 let mut p = Parser::new();
2067 p.advance(&mut g, b"\x1b[?25lhello");
2068 let atlas = MockAtlas::full(slot());
2069 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2070 let theme = Theme::default();
2071 for inst in &buffers.rects {
2072 assert_ne!(
2073 inst.color, BIDI_MARKER_BG,
2074 "non-bidi text must not paint the warning marker"
2075 );
2076 }
2077 assert_eq!(buffers.rects[0].color, theme.bg);
2078 }
2079
2080 #[test]
2081 fn bidi_marker_overrides_cursor_swap() {
2082 let g = drive(1, 4, "A\u{202E}\x1b[1;1H".as_bytes());
2084 assert!(cell_contains_bidi_override(
2085 g.cell(0, 0).unwrap().grapheme,
2086 g.screen().cluster_table()
2087 ));
2088 let atlas = MockAtlas::full(slot());
2089 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2090 assert_eq!(buffers.rects[0].color, BIDI_MARKER_BG);
2091 assert!(
2092 !buffers.glyphs.is_empty(),
2093 "the `A` under the cursor draws a glyph"
2094 );
2095 assert!(
2096 buffers.glyphs.iter().all(|f| f.color == BIDI_MARKER_FG),
2097 "the glyph on the cursor cell keeps the marker fg"
2098 );
2099 }
2100
2101 #[test]
2102 fn underline_cursor_appends_a_thin_bar_at_the_bottom_of_the_cell() {
2103 let g = drive(1, 3, b"\x1b[4 qA");
2104 let atlas = MockAtlas::full(slot());
2105 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2106 let theme = Theme::default();
2107 assert_eq!(g.cursor().col, 1);
2108 assert_eq!(buffers.rects[0].color, theme.bg, "cell 0 normal");
2109 assert_eq!(buffers.rects[1].color, theme.bg, "cursor cell bg unchanged");
2110 let marker = buffers
2111 .rects
2112 .iter()
2113 .find(|b| b.size_px[1] == CURSOR_MARKER_PX)
2114 .expect("underline marker present");
2115 assert_eq!(marker.size_px, [metrics().width as f32, CURSOR_MARKER_PX]);
2116 let expected_y = metrics().height as f32 - CURSOR_MARKER_PX;
2117 assert_eq!(marker.origin_px[1], expected_y);
2118 }
2119
2120 #[test]
2121 fn bar_cursor_appends_a_thin_vertical_strip_at_the_left_of_the_cell() {
2122 let g = drive(1, 3, b"\x1b[6 qA");
2123 let atlas = MockAtlas::full(slot());
2124 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2125 let marker = buffers
2126 .rects
2127 .iter()
2128 .find(|b| b.size_px[0] == CURSOR_MARKER_PX)
2129 .expect("bar marker present");
2130 assert_eq!(marker.size_px, [CURSOR_MARKER_PX, metrics().height as f32]);
2131 let cell_origin = [metrics().width as f32 * 1.0, 0.0];
2132 assert_eq!(marker.origin_px, cell_origin);
2133 }
2134
2135 #[test]
2136 fn block_cursor_emits_no_marker_quad() {
2137 let g = drive(1, 3, b"AB"); let atlas = MockAtlas::full(slot());
2139 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2140 assert_eq!(buffers.rects.len(), 3);
2141 }
2142
2143 #[test]
2144 fn underline_cursor_uses_theme_cursor_color_when_configured() {
2145 let theme = Theme {
2146 cursor: Some([0.5, 0.7, 0.9, 1.0]),
2147 ..Theme::default()
2148 };
2149 let g = drive(1, 3, b"\x1b[4 qA");
2150 let atlas = MockAtlas::full(slot());
2151 let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
2152 let marker = buffers
2153 .rects
2154 .iter()
2155 .find(|b| b.size_px[1] == CURSOR_MARKER_PX)
2156 .expect("underline marker present");
2157 assert_eq!(marker.color, [0.5, 0.7, 0.9, 1.0]);
2158 }
2159
2160 #[test]
2161 fn bar_cursor_uses_theme_cursor_color_when_configured() {
2162 let theme = Theme {
2163 cursor: Some([0.5, 0.7, 0.9, 1.0]),
2164 ..Theme::default()
2165 };
2166 let g = drive(1, 3, b"\x1b[6 qA");
2167 let atlas = MockAtlas::full(slot());
2168 let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
2169 let marker = buffers
2170 .rects
2171 .iter()
2172 .find(|b| b.size_px[0] == CURSOR_MARKER_PX)
2173 .expect("bar marker present");
2174 assert_eq!(marker.color, [0.5, 0.7, 0.9, 1.0]);
2175 }
2176
2177 #[test]
2178 fn bidi_marker_overrides_underline_cursor_marker() {
2179 let mut g = Grid::new(1, 4);
2182 let mut p = Parser::new();
2183 p.advance(&mut g, "\x1b[4 qA\u{202E}\x1b[1;1H".as_bytes());
2184 let atlas = MockAtlas::full(slot());
2185 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2186 let marker = buffers
2187 .rects
2188 .iter()
2189 .find(|b| b.size_px[1] == CURSOR_MARKER_PX);
2190 assert!(
2191 marker.is_none(),
2192 "bidi marker must suppress the underline marker"
2193 );
2194 }
2195
2196 #[test]
2197 fn bidi_marker_overrides_bar_cursor_marker() {
2198 let mut g = Grid::new(1, 4);
2199 let mut p = Parser::new();
2200 p.advance(&mut g, "\x1b[6 qA\u{202E}\x1b[1;1H".as_bytes());
2201 let atlas = MockAtlas::full(slot());
2202 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2203 let marker = buffers
2204 .rects
2205 .iter()
2206 .find(|b| b.size_px[0] == CURSOR_MARKER_PX);
2207 assert!(
2208 marker.is_none(),
2209 "bidi marker must suppress the bar marker too"
2210 );
2211 }
2212
2213 #[test]
2214 fn unfocused_window_suppresses_the_cursor_block() {
2215 let g = drive(1, 3, b"AB");
2216 let atlas = MockAtlas::full(slot());
2217 let theme = Theme::default();
2218 let mut buffers = RowPaint::default();
2219 CellPainter::new(
2220 g.screen(),
2221 metrics(),
2222 &ResolvedTheme::new(&theme),
2223 &atlas,
2224 &ShapeFrame::empty(),
2225 )
2226 .with_cursor_visible(false)
2227 .with_viewport(0, u32::from(g.rows()))
2228 .extend_screen(&mut buffers);
2229 assert_eq!(buffers.rects.len(), 3);
2230 for inst in &buffers.rects {
2231 assert_eq!(inst.color, theme.bg, "no cell painted with cursor swap");
2232 }
2233 }
2234
2235 #[test]
2236 fn reserved_bottom_rows_keeps_bar_row_free_of_cells() {
2237 let g = drive(2, 3, b"AB\r\nCD");
2240 let atlas = MockAtlas::full(slot());
2241 let theme = Theme::default();
2242 let mut buffers = RowPaint::default();
2243 CellPainter::new(
2244 g.screen(),
2245 metrics(),
2246 &ResolvedTheme::new(&theme),
2247 &atlas,
2248 &ShapeFrame::empty(),
2249 )
2250 .with_cursor_visible(true)
2251 .with_viewport(0, u32::from(g.rows()))
2252 .with_reserved_bottom_rows(1)
2253 .extend_screen(&mut buffers);
2254 assert_eq!(
2255 buffers.rects.len(),
2256 3,
2257 "bottom row's bg cells must be suppressed"
2258 );
2259 let bar_y = f32::from(g.rows() - 1) * metrics().height as f32;
2260 for inst in &buffers.rects {
2261 assert!(
2262 inst.origin_px[1] < bar_y,
2263 "bg cell leaked onto the reserved bar row"
2264 );
2265 }
2266 for inst in &buffers.glyphs {
2267 assert!(
2268 inst.origin_px[1] < bar_y,
2269 "fg glyph leaked onto the reserved bar row"
2270 );
2271 }
2272 }
2273
2274 #[test]
2275 fn preedit_overlay_span_is_reserved_from_the_cell_walk() {
2276 let g = drive(1, 5, b"ABCDE");
2279 let atlas = MockAtlas::full(slot());
2280 let theme = Theme::default();
2281 let mut buffers = RowPaint::default();
2282 let overlay = PreeditOverlay {
2283 anchor: (0, 1),
2284 text: "あ".to_string(),
2285 cursor: None,
2286 };
2287 let span = preedit_covered_cols(&overlay, g.cols());
2288 assert_eq!(
2289 span,
2290 Some(PreeditSpan {
2291 row: 0,
2292 col_start: 1,
2293 col_end: 3,
2294 }),
2295 "wide char covers cols 1 and 2",
2296 );
2297 CellPainter::new(
2298 g.screen(),
2299 metrics(),
2300 &ResolvedTheme::new(&theme),
2301 &atlas,
2302 &ShapeFrame::empty(),
2303 )
2304 .with_cursor_visible(true)
2305 .with_viewport(0, u32::from(g.rows()))
2306 .with_preedit(span)
2307 .extend_screen(&mut buffers);
2308 let cw = metrics().width as f32;
2309 let leaked = |x: f32| {
2310 let col = (x / cw).round() as u16;
2311 (col == 1 || col == 2).then_some(col)
2312 };
2313 for inst in &buffers.rects {
2314 assert!(
2315 leaked(inst.origin_px[0]).is_none(),
2316 "cell bg leaked under the pre-edit overlay at col {:?}",
2317 leaked(inst.origin_px[0]),
2318 );
2319 }
2320 for inst in &buffers.glyphs {
2321 assert!(
2322 leaked(inst.origin_px[0]).is_none(),
2323 "glyph leaked under the pre-edit overlay at col {:?}",
2324 leaked(inst.origin_px[0]),
2325 );
2326 }
2327 }
2328
2329 #[test]
2330 fn preedit_span_matches_painted_extent() {
2331 let atlas = MockAtlas::full(slot());
2334 let overlay = PreeditOverlay {
2335 anchor: (0, 1),
2336 text: "あxい".to_string(), cursor: None,
2338 };
2339 let cols = 6; let mut buffers = RowPaint::default();
2341 extend_preedit(&overlay, metrics(), 1, cols, &atlas, &mut buffers);
2342 let cw = metrics().width as f32;
2343 let painted_end = buffers
2344 .rects
2345 .iter()
2346 .map(|b| b.origin_px[0] + b.size_px[0])
2347 .fold(0.0_f32, f32::max);
2348 let span_end = preedit_covered_cols(&overlay, cols)
2349 .expect("non-empty overlay")
2350 .col_end;
2351 assert_eq!(
2352 f32::from(span_end) * cw,
2353 painted_end,
2354 "reserved span must end where the painted overlay ends",
2355 );
2356 }
2357
2358 #[test]
2359 fn unfocused_window_suppresses_underline_marker_too() {
2360 let g = drive(1, 3, b"\x1b[4 qA");
2361 let atlas = MockAtlas::full(slot());
2362 let mut buffers = RowPaint::default();
2363 CellPainter::new(
2364 g.screen(),
2365 metrics(),
2366 &ResolvedTheme::new(&Theme::default()),
2367 &atlas,
2368 &ShapeFrame::empty(),
2369 )
2370 .with_cursor_visible(false)
2371 .with_viewport(0, u32::from(g.rows()))
2372 .extend_screen(&mut buffers);
2373 let marker = buffers
2374 .rects
2375 .iter()
2376 .find(|b| b.size_px[1] == CURSOR_MARKER_PX);
2377 assert!(
2378 marker.is_none(),
2379 "underline marker must drop when unfocused"
2380 );
2381 }
2382
2383 #[test]
2384 fn unfocused_window_suppresses_bar_marker_too() {
2385 let g = drive(1, 3, b"\x1b[6 qA");
2386 let atlas = MockAtlas::full(slot());
2387 let mut buffers = RowPaint::default();
2388 CellPainter::new(
2389 g.screen(),
2390 metrics(),
2391 &ResolvedTheme::new(&Theme::default()),
2392 &atlas,
2393 &ShapeFrame::empty(),
2394 )
2395 .with_cursor_visible(false)
2396 .with_viewport(0, u32::from(g.rows()))
2397 .extend_screen(&mut buffers);
2398 let marker = buffers
2399 .rects
2400 .iter()
2401 .find(|b| b.size_px[0] == CURSOR_MARKER_PX);
2402 assert!(marker.is_none(), "bar marker must drop when unfocused");
2403 }
2404
2405 #[test]
2406 fn selection_highlight_paints_cells_inside_the_range() {
2407 let g = drive(1, 5, b"\x1b[?25l"); let atlas = MockAtlas::full(slot());
2409 let theme = Theme::default();
2410 let mut buffers = RowPaint::default();
2411 CellPainter::new(
2412 g.screen(),
2413 metrics(),
2414 &ResolvedTheme::new(&theme),
2415 &atlas,
2416 &ShapeFrame::empty(),
2417 )
2418 .with_cursor_visible(true)
2419 .with_selection(Some(SelectionRange {
2420 start: (0, 1),
2421 end: (0, 3),
2422 rectangle: false,
2423 }))
2424 .with_viewport(0, u32::from(g.rows()))
2425 .extend_screen(&mut buffers);
2426 assert_eq!(buffers.rects.len(), 5);
2427 assert_eq!(buffers.rects[0].color, theme.bg, "cell 0 outside selection");
2428 for (i, buffer) in buffers.rects.iter().enumerate().skip(1).take(3) {
2429 assert_eq!(buffer.color, SELECTION_BG, "cell {i} should be highlighted");
2430 }
2431 assert_eq!(buffers.rects[4].color, theme.bg, "cell 4 outside selection");
2432 }
2433
2434 #[test]
2435 fn selection_highlight_clips_to_boundary_columns_across_rows() {
2436 let g = drive(3, 4, b"\x1b[?25l");
2437 let atlas = MockAtlas::full(slot());
2438 let theme = Theme::default();
2439 let mut buffers = RowPaint::default();
2440 CellPainter::new(
2441 g.screen(),
2442 metrics(),
2443 &ResolvedTheme::new(&theme),
2444 &atlas,
2445 &ShapeFrame::empty(),
2446 )
2447 .with_cursor_visible(true)
2448 .with_selection(Some(SelectionRange {
2449 start: (0, 2),
2450 end: (2, 1),
2451 rectangle: false,
2452 }))
2453 .with_viewport(0, u32::from(g.rows()))
2454 .extend_screen(&mut buffers);
2455 let inside = |idx: usize| matches!(idx, 2..=9);
2456 for (idx, inst) in buffers.rects.iter().enumerate() {
2457 if inside(idx) {
2458 assert_eq!(inst.color, SELECTION_BG, "idx {idx} should be highlighted");
2459 } else {
2460 assert_eq!(inst.color, theme.bg, "idx {idx} outside selection");
2461 }
2462 }
2463 }
2464
2465 #[test]
2466 fn no_selection_leaves_every_cell_on_theme_bg() {
2467 let g = drive(1, 5, b"\x1b[?25l");
2468 let atlas = MockAtlas::full(slot());
2469 let theme = Theme::default();
2470 let mut buffers = RowPaint::default();
2471 CellPainter::new(
2472 g.screen(),
2473 metrics(),
2474 &ResolvedTheme::new(&theme),
2475 &atlas,
2476 &ShapeFrame::empty(),
2477 )
2478 .with_cursor_visible(true)
2479 .with_viewport(0, u32::from(g.rows()))
2480 .extend_screen(&mut buffers);
2481 assert_eq!(buffers.rects.len(), 5);
2482 for (i, inst) in buffers.rects.iter().enumerate() {
2483 assert_eq!(inst.color, theme.bg, "cell {i}");
2484 }
2485 }
2486
2487 #[test]
2488 fn bidi_marker_wins_over_selection_highlight() {
2489 let mut g = Grid::new(1, 4);
2492 let mut p = Parser::new();
2493 p.advance(&mut g, "\x1b[?25lA\u{202E}B".as_bytes());
2494 let atlas = MockAtlas::full(slot());
2495 let theme = Theme::default();
2496 let mut buffers = RowPaint::default();
2497 CellPainter::new(
2498 g.screen(),
2499 metrics(),
2500 &ResolvedTheme::new(&theme),
2501 &atlas,
2502 &ShapeFrame::empty(),
2503 )
2504 .with_cursor_visible(true)
2505 .with_selection(Some(SelectionRange {
2506 start: (0, 0),
2507 end: (0, 3),
2508 rectangle: false,
2509 }))
2510 .with_viewport(0, u32::from(g.rows()))
2511 .extend_screen(&mut buffers);
2512 for r in 0..g.rows() {
2513 for c in 0..g.cols() {
2514 let Some(cell) = g.cell(r, c) else { continue };
2515 if cell_contains_bidi_override(cell.grapheme, g.screen().cluster_table()) {
2516 let idx = usize::from(r) * usize::from(g.cols()) + usize::from(c);
2517 assert_eq!(
2518 buffers.rects[idx].color, BIDI_MARKER_BG,
2519 "bidi cell at ({r},{c}) must keep marker bg",
2520 );
2521 }
2522 }
2523 }
2524 }
2525
2526 #[test]
2527 fn cursor_uses_theme_cursor_color_when_configured() {
2528 let theme = Theme {
2529 cursor: Some([0.5, 0.7, 0.9, 1.0]),
2530 ..Theme::default()
2531 };
2532 let g = drive(1, 3, b"AB");
2533 let atlas = MockAtlas::full(slot());
2534 let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
2535 assert_eq!(g.cursor().col, 2);
2536 assert_eq!(buffers.rects[2].color, [0.5, 0.7, 0.9, 1.0]);
2537 }
2538
2539 #[test]
2540 fn cursor_glyph_renders_in_reversed_fg_color() {
2541 let g = drive(1, 3, b"AB\x1b[1;1H");
2542 let atlas = MockAtlas::full(slot());
2543 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2544 let theme = Theme::default();
2545 assert_eq!(g.cursor().row, 0);
2546 assert_eq!(g.cursor().col, 0);
2547 assert_eq!(buffers.rects[0].color, theme.fg);
2548 assert_eq!(
2549 buffers.glyphs[0].color, theme.bg,
2550 "cursor cell glyph must paint in reversed fg color"
2551 );
2552 }
2553
2554 #[test]
2555 fn extend_preedit_empty_text_is_noop() {
2556 let mut out = RowPaint::default();
2557 let overlay = PreeditOverlay {
2558 anchor: (0, 0),
2559 text: String::new(),
2560 cursor: None,
2561 };
2562 let atlas = MockAtlas::full(slot());
2563 extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
2564 assert_eq!(out.rects, Vec::<Rect>::new());
2565 assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
2566 }
2567
2568 #[test]
2569 fn extend_preedit_emits_bg_underline_glyph_per_ascii_char() {
2570 let mut out = RowPaint::default();
2571 let overlay = PreeditOverlay {
2572 anchor: (1, 2),
2573 text: "ab".to_owned(),
2574 cursor: None,
2575 };
2576 let atlas = MockAtlas::full(slot());
2577 extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
2578 assert_eq!(out.rects.len(), 4);
2579 assert_eq!(out.glyphs.len(), 2);
2580 assert_eq!(out.rects[0].origin_px, [16.0, 16.0]);
2581 assert_eq!(out.rects[0].size_px, [8.0, 16.0]);
2582 assert_eq!(out.rects[0].color, PREEDIT_BG);
2583 assert_eq!(out.rects[1].origin_px, [16.0, 30.0]);
2584 assert_eq!(out.rects[1].size_px[1], PREEDIT_UNDERLINE_PX);
2585 assert_eq!(out.rects[2].origin_px, [24.0, 16.0]);
2586 }
2587
2588 #[test]
2589 fn extend_preedit_advances_two_cells_for_wide_glyph() {
2590 let mut out = RowPaint::default();
2591 let overlay = PreeditOverlay {
2592 anchor: (0, 0),
2593 text: "あい".to_owned(),
2594 cursor: None,
2595 };
2596 let atlas = MockAtlas::full(slot());
2597 extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
2598 assert_eq!(out.rects[0].origin_px, [0.0, 0.0]);
2599 assert_eq!(out.rects[0].size_px, [16.0, 16.0]);
2600 assert_eq!(out.rects[2].origin_px, [16.0, 0.0]);
2601 assert_eq!(out.rects[2].size_px, [16.0, 16.0]);
2602 }
2603
2604 #[test]
2605 fn extend_preedit_anchor_out_of_range_is_noop() {
2606 let mut out = RowPaint::default();
2607 let overlay = PreeditOverlay {
2608 anchor: (10, 0),
2609 text: "x".to_owned(),
2610 cursor: None,
2611 };
2612 let atlas = MockAtlas::full(slot());
2613 extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
2614 assert_eq!(out.rects, Vec::<Rect>::new());
2615 }
2616
2617 #[test]
2618 fn extend_preedit_col_at_or_past_grid_cols_is_noop() {
2619 let mut out = RowPaint::default();
2622 let overlay = PreeditOverlay {
2623 anchor: (0, 80),
2624 text: "abc".to_owned(),
2625 cursor: None,
2626 };
2627 let atlas = MockAtlas::full(slot());
2628 extend_preedit(&overlay, metrics(), 5, 80, &atlas, &mut out);
2629 assert!(out.rects.is_empty(), "no bg pushed when col >= grid_cols");
2630 assert!(
2631 out.glyphs.is_empty(),
2632 "no glyph pushed when col >= grid_cols"
2633 );
2634 }
2635
2636 #[test]
2637 fn extend_preedit_active_segment_paints_thicker_underline() {
2638 let mut out = RowPaint::default();
2639 let overlay = PreeditOverlay {
2640 anchor: (0, 0),
2641 text: "abcde".to_owned(),
2642 cursor: Some((1, 3)),
2643 };
2644 let atlas = MockAtlas::full(slot());
2645 extend_preedit(&overlay, metrics(), 1, 80, &atlas, &mut out);
2646 assert!(
2647 (out.rects[1].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
2648 "char 0 'a' outside active range: default underline",
2649 );
2650 assert!(
2651 (out.rects[3].size_px[1] - PREEDIT_ACTIVE_UNDERLINE_PX).abs() < f32::EPSILON,
2652 "char 1 'b' inside active range: thicker underline",
2653 );
2654 assert!(
2655 (out.rects[5].size_px[1] - PREEDIT_ACTIVE_UNDERLINE_PX).abs() < f32::EPSILON,
2656 "char 2 'c' inside active range: thicker underline",
2657 );
2658 assert!(
2659 (out.rects[7].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
2660 "char 3 'd' outside active range: default underline",
2661 );
2662 }
2663
2664 #[test]
2665 fn extend_preedit_empty_active_range_falls_back_to_default_underline() {
2666 let mut out = RowPaint::default();
2668 let overlay = PreeditOverlay {
2669 anchor: (0, 0),
2670 text: "ab".to_owned(),
2671 cursor: Some((1, 1)),
2672 };
2673 let atlas = MockAtlas::full(slot());
2674 extend_preedit(&overlay, metrics(), 1, 80, &atlas, &mut out);
2675 for i in [1, 3] {
2676 assert!(
2677 (out.rects[i].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
2678 "no thicker underline when cursor is a single point",
2679 );
2680 }
2681 }
2682
2683 #[test]
2684 fn extend_preedit_active_segment_handles_multibyte_chars() {
2685 let mut out = RowPaint::default();
2687 let overlay = PreeditOverlay {
2688 anchor: (0, 0),
2689 text: "あいう".to_owned(),
2690 cursor: Some((3, 6)),
2691 };
2692 let atlas = MockAtlas::full(slot());
2693 extend_preedit(&overlay, metrics(), 1, 80, &atlas, &mut out);
2694 assert!(
2695 (out.rects[1].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
2696 "あ outside active range: default underline",
2697 );
2698 assert!(
2699 (out.rects[3].size_px[1] - PREEDIT_ACTIVE_UNDERLINE_PX).abs() < f32::EPSILON,
2700 "い inside active range: thicker underline",
2701 );
2702 assert!(
2703 (out.rects[5].size_px[1] - PREEDIT_UNDERLINE_PX).abs() < f32::EPSILON,
2704 "う outside active range: default underline",
2705 );
2706 }
2707
2708 #[test]
2709 fn extend_preedit_clips_at_right_edge_of_row() {
2710 let mut out = RowPaint::default();
2711 let overlay = PreeditOverlay {
2712 anchor: (0, 4),
2713 text: "abc".to_owned(),
2714 cursor: None,
2715 };
2716 let atlas = MockAtlas::full(slot());
2717 extend_preedit(&overlay, metrics(), 1, 5, &atlas, &mut out);
2718 assert_eq!(out.rects.len(), 2);
2719 assert_eq!(out.glyphs.len(), 1);
2720 }
2721
2722 #[test]
2723 fn extend_preedit_clips_wide_glyph_at_right_edge_to_one_cell() {
2724 let mut out = RowPaint::default();
2727 let overlay = PreeditOverlay {
2728 anchor: (0, 4),
2729 text: "あ".to_owned(),
2730 cursor: None,
2731 };
2732 let atlas = MockAtlas::full(slot());
2733 extend_preedit(&overlay, metrics(), 1, 5, &atlas, &mut out);
2734 assert_eq!(out.rects.len(), 2, "bg quad + underline only");
2737 assert_eq!(out.rects[0].size_px, [8.0, 16.0], "bg clipped to 1 cell");
2738 assert_eq!(out.rects[1].size_px[0], 8.0, "underline clipped to 1 cell");
2739 }
2740
2741 #[test]
2742 fn link_cells_emit_an_extra_underline_instance() {
2743 use crate::palette::LINK_UNDERLINE;
2744 let g = drive(
2745 1,
2746 4,
2747 b"\x1b[?25l\x1b]8;;https://example.com\x07ab\x1b]8;;\x07cd",
2748 );
2749 let atlas = MockAtlas::full(slot());
2750 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2751 assert_eq!(
2752 buffers.rects.len(),
2753 6,
2754 "two link cells each add an underline"
2755 );
2756 assert_eq!(buffers.rects[1].color, LINK_UNDERLINE);
2757 assert_eq!(buffers.rects[1].size_px[1], LINK_UNDERLINE_PX);
2758 assert_eq!(buffers.rects[3].color, LINK_UNDERLINE);
2759 assert_eq!(buffers.rects[4].color, Theme::default().bg);
2760 assert_eq!(buffers.rects[5].color, Theme::default().bg);
2761 }
2762
2763 #[test]
2765 fn disallowed_scheme_emits_no_link_underline_instance() {
2766 use crate::palette::LINK_UNDERLINE;
2767 let g = drive(
2768 1,
2769 6,
2770 b"\x1b[?25l\x1b]8;;javascript:alert(1)\x07evil\x1b]8;;\x07ok",
2771 );
2772 let atlas = MockAtlas::full(slot());
2773 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2774 assert!(
2775 buffers.rects.iter().all(|b| b.color != LINK_UNDERLINE),
2776 "javascript: scheme leaked past dispatch_osc_8 and produced a link underline",
2777 );
2778 }
2779
2780 #[test]
2783 fn disallowed_scheme_after_safe_open_clears_underline_run() {
2784 use crate::palette::LINK_UNDERLINE;
2785 let g = drive(
2786 1,
2787 8,
2788 b"\x1b[?25l\x1b]8;;https://good\x07safe\x1b]8;;javascript:bad\x07evil\x1b]8;;\x07",
2789 );
2790 let atlas = MockAtlas::full(slot());
2791 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2792 let underline_count = buffers
2793 .rects
2794 .iter()
2795 .filter(|b| b.color == LINK_UNDERLINE)
2796 .count();
2797 assert_eq!(
2798 underline_count, 4,
2799 "underline run leaked past the javascript: pen-clear into `evil`",
2800 );
2801 }
2802
2803 #[test]
2804 fn link_underline_skipped_on_bidi_marker_cells() {
2805 use crate::palette::{BIDI_MARKER_BG, LINK_UNDERLINE};
2808 let g = drive(
2809 1,
2810 3,
2811 "\x1b[?25l\x1b]8;;https://example.test/\u{0007}A\u{202E}\x1b]8;;\u{0007}".as_bytes(),
2812 );
2813 let atlas = MockAtlas::full(slot());
2814 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
2815 assert_eq!(buffers.rects[0].color, BIDI_MARKER_BG);
2816 assert!(
2817 buffers.rects.iter().all(|b| b.color != LINK_UNDERLINE),
2818 "link underline must not paint over bidi marker",
2819 );
2820 }
2821
2822 #[test]
2823 fn rectangle_selection_clips_every_row_to_column_range() {
2824 let g = drive(3, 4, b"\x1b[?25l");
2825 let atlas = MockAtlas::full(slot());
2826 let theme = Theme::default();
2827 let mut buffers = RowPaint::default();
2828 CellPainter::new(
2829 g.screen(),
2830 metrics(),
2831 &ResolvedTheme::new(&theme),
2832 &atlas,
2833 &ShapeFrame::empty(),
2834 )
2835 .with_cursor_visible(true)
2836 .with_selection(Some(SelectionRange {
2837 start: (0, 1),
2838 end: (2, 2),
2839 rectangle: true,
2840 }))
2841 .with_viewport(0, u32::from(g.rows()))
2842 .extend_screen(&mut buffers);
2843 let inside = |r: u16, c: u16| (1..=2).contains(&c) && (0..=2).contains(&r);
2844 for r in 0..3u16 {
2845 for c in 0..4u16 {
2846 let idx = (usize::from(r) * 4) + usize::from(c);
2847 let want_highlight = inside(r, c);
2848 let got = buffers.rects[idx].color;
2849 if want_highlight {
2850 assert_eq!(got, SELECTION_BG, "({r},{c}) should be highlighted");
2851 } else {
2852 assert_eq!(got, theme.bg, "({r},{c}) should NOT be highlighted");
2853 }
2854 }
2855 }
2856 }
2857
2858 #[test]
2859 fn extend_preedit_atlas_miss_still_paints_bg_and_underline() {
2860 let mut out = RowPaint::default();
2861 let overlay = PreeditOverlay {
2862 anchor: (0, 0),
2863 text: "?".to_owned(),
2864 cursor: None,
2865 };
2866 let atlas = MockAtlas {
2867 map: HashMap::new(),
2868 gid_map: HashMap::new(),
2869 fitted_map: HashMap::new(),
2870 clusters: HashMap::new(),
2871 };
2872 extend_preedit(&overlay, metrics(), 1, 5, &atlas, &mut out);
2873 assert_eq!(out.rects.len(), 2, "bg + underline survive an atlas miss");
2874 assert!(out.glyphs.is_empty(), "fg pass skipped on atlas miss");
2875 }
2876
2877 #[test]
2878 fn extend_search_empty_overlay_is_noop() {
2879 let mut out = RowPaint::default();
2882 let overlay = SearchOverlay::default();
2883 let atlas = MockAtlas::full(slot());
2884 extend_search(&overlay, metrics(), 5, 80, true, &atlas, &mut out);
2885 assert_eq!(out.rects, Vec::<Rect>::new());
2886 assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
2887 }
2888
2889 #[test]
2890 fn extend_search_keeps_its_highlights_when_it_loses_the_bar_row() {
2891 let mut out = RowPaint::default();
2895 let overlay = SearchOverlay {
2896 label: "search: ab".to_owned(),
2897 visible_hits: vec![SearchHitSpan {
2898 row: 2,
2899 col_start: 3,
2900 col_end: 7,
2901 is_current: true,
2902 }],
2903 };
2904 let atlas = MockAtlas::full(slot());
2905 extend_search(&overlay, metrics(), 5, 80, false, &atlas, &mut out);
2906 assert_eq!(out.rects.len(), 1, "the highlight, and no bar background");
2907 assert_eq!(out.rects[0].color, SEARCH_CURRENT_BG);
2908 assert!(out.glyphs.is_empty(), "no label glyphs without the bar");
2909 }
2910
2911 #[test]
2912 fn extend_search_paints_match_bg_for_visible_hit() {
2913 let mut out = RowPaint::default();
2914 let overlay = SearchOverlay {
2915 label: String::new(),
2916 visible_hits: vec![SearchHitSpan {
2917 row: 2,
2918 col_start: 3,
2919 col_end: 7,
2920 is_current: false,
2921 }],
2922 };
2923 let atlas = MockAtlas::full(slot());
2924 extend_search(&overlay, metrics(), 5, 80, true, &atlas, &mut out);
2925 assert_eq!(out.rects.len(), 1);
2926 assert_eq!(out.rects[0].origin_px, [24.0, 32.0]);
2927 assert_eq!(out.rects[0].size_px, [32.0, 16.0]);
2928 assert_eq!(out.rects[0].color, SEARCH_MATCH_BG);
2929 }
2930
2931 #[test]
2932 fn extend_search_current_hit_paints_in_vivid_color() {
2933 let mut out = RowPaint::default();
2934 let overlay = SearchOverlay {
2935 label: String::new(),
2936 visible_hits: vec![SearchHitSpan {
2937 row: 0,
2938 col_start: 0,
2939 col_end: 2,
2940 is_current: true,
2941 }],
2942 };
2943 let atlas = MockAtlas::full(slot());
2944 extend_search(&overlay, metrics(), 5, 80, true, &atlas, &mut out);
2945 assert_eq!(out.rects.len(), 1);
2946 assert_eq!(out.rects[0].color, SEARCH_CURRENT_BG);
2947 }
2948
2949 #[test]
2950 fn extend_search_clips_hit_past_grid_edges() {
2951 let mut out = RowPaint::default();
2954 let overlay = SearchOverlay {
2955 label: String::new(),
2956 visible_hits: vec![
2957 SearchHitSpan {
2958 row: 99,
2959 col_start: 0,
2960 col_end: 4,
2961 is_current: false,
2962 },
2963 SearchHitSpan {
2964 row: 1,
2965 col_start: 6,
2966 col_end: 999,
2967 is_current: false,
2968 },
2969 ],
2970 };
2971 let atlas = MockAtlas::full(slot());
2972 extend_search(&overlay, metrics(), 5, 8, true, &atlas, &mut out);
2973 assert_eq!(out.rects.len(), 1);
2974 assert_eq!(out.rects[0].size_px, [16.0, 16.0]);
2975 }
2976
2977 #[test]
2978 fn extend_search_paints_bar_at_bottom_row_with_label_glyphs() {
2979 let mut out = RowPaint::default();
2980 let overlay = SearchOverlay {
2981 label: "ab".to_owned(),
2982 visible_hits: vec![],
2983 };
2984 let atlas = MockAtlas::full(slot());
2985 extend_search(&overlay, metrics(), 3, 10, true, &atlas, &mut out);
2986 assert_eq!(out.rects.len(), 1, "1 bar bg quad");
2987 assert_eq!(out.rects[0].origin_px, [0.0, 32.0]);
2988 assert_eq!(out.rects[0].size_px, [80.0, 16.0]);
2989 assert_eq!(out.rects[0].color, SEARCH_BAR_BG);
2990 assert_eq!(out.glyphs.len(), 2, "1 fg quad per label char");
2991 }
2992
2993 #[test]
2994 fn extend_search_label_clips_at_right_edge() {
2995 let mut out = RowPaint::default();
2996 let overlay = SearchOverlay {
2997 label: "abcdef".to_owned(),
2998 visible_hits: vec![],
2999 };
3000 let atlas = MockAtlas::full(slot());
3001 extend_search(&overlay, metrics(), 2, 3, true, &atlas, &mut out);
3002 assert_eq!(out.glyphs.len(), 3);
3003 }
3004
3005 #[test]
3006 fn extend_search_zero_grid_is_noop() {
3007 let mut out = RowPaint::default();
3008 let overlay = SearchOverlay {
3009 label: "find: x".to_owned(),
3010 visible_hits: vec![SearchHitSpan {
3011 row: 0,
3012 col_start: 0,
3013 col_end: 1,
3014 is_current: false,
3015 }],
3016 };
3017 let atlas = MockAtlas::full(slot());
3018 extend_search(&overlay, metrics(), 0, 80, true, &atlas, &mut out);
3019 assert_eq!(out.rects, Vec::<Rect>::new());
3020 assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
3021 let mut out2 = RowPaint::default();
3022 extend_search(&overlay, metrics(), 5, 0, true, &atlas, &mut out2);
3023 assert_eq!(out2.rects, Vec::<Rect>::new());
3024 assert_eq!(out2.glyphs, Vec::<GlyphDraw>::new());
3025 }
3026
3027 #[test]
3028 fn extend_link_preview_paints_bar_at_bottom_row_with_search_bar_color() {
3029 let mut out = RowPaint::default();
3032 let overlay = LinkPreviewOverlay {
3033 text: "https://e".to_owned(),
3034 };
3035 let atlas = MockAtlas::full(slot());
3036 extend_link_preview(&overlay, metrics(), 3, 10, &atlas, &mut out);
3037 assert_eq!(out.rects.len(), 1, "1 bar bg quad");
3038 assert_eq!(out.rects[0].origin_px, [0.0, 32.0]);
3039 assert_eq!(out.rects[0].color, SEARCH_BAR_BG);
3040 assert_eq!(out.glyphs.len(), overlay.text.chars().count());
3041 }
3042
3043 #[test]
3044 fn a_glyph_reaching_down_into_the_chrome_row_is_cut_at_its_top_edge() {
3045 let mut out = RowPaint::default();
3049 out.glyphs.push(GlyphDraw {
3050 origin_px: [0.0, 16.0],
3051 size_px: [8.0, 32.0],
3052 atlas: AtlasKind::Mono,
3053 src_min_px: [0.0, 0.0],
3054 src_max_px: [8.0, 16.0],
3055 color: [1.0; 4],
3056 });
3057 out.glyphs.push(GlyphDraw {
3058 origin_px: [0.0, 34.0],
3059 size_px: [8.0, 10.0],
3060 atlas: AtlasKind::Mono,
3061 src_min_px: [0.0, 0.0],
3062 src_max_px: [8.0, 16.0],
3063 color: [1.0; 4],
3064 });
3065 clip_cells_above(&mut out, 32.0);
3066 assert_eq!(
3067 out.glyphs.len(),
3068 1,
3069 "the quad starting inside the row is gone"
3070 );
3071 assert_eq!(out.glyphs[0].size_px[1], 16.0);
3072 assert!((out.glyphs[0].src_max_px[1] - 8.0).abs() < f32::EPSILON);
3074 }
3075
3076 #[test]
3077 fn link_preview_wider_than_the_grid_ends_in_an_ellipsis() {
3078 let mut out = RowPaint::default();
3082 let overlay = LinkPreviewOverlay {
3083 text: "https://example.com/very/long/path".to_owned(),
3084 };
3085 let mut atlas = MockAtlas::full(slot());
3088 atlas.map.insert(BAR_ELLIPSIS, slot());
3089 extend_link_preview(&overlay, metrics(), 3, 10, &atlas, &mut out);
3090 assert_eq!(fit_bar_text_to_cells(&overlay.text, 10), "https://e…");
3091 assert_eq!(out.glyphs.len(), 10, "one quad per column");
3092 let cw = metrics().width as f32;
3095 let offset_x = slot().offset_px[0] as f32;
3096 assert_eq!(out.glyphs[9].origin_px[0], 9.0f32.mul_add(cw, offset_x));
3097 }
3098
3099 #[test]
3100 fn link_preview_within_the_grid_is_left_verbatim() {
3101 assert_eq!(
3102 fit_bar_text_to_cells("https://e", 10),
3103 Cow::Borrowed("https://e")
3104 );
3105 assert_eq!(
3107 fit_bar_text_to_cells("https://ex", 10),
3108 Cow::Borrowed("https://ex")
3109 );
3110 }
3111
3112 #[test]
3113 fn link_preview_never_puts_half_a_wide_glyph_in_the_last_cell() {
3114 assert_eq!(fit_bar_text_to_cells("ab漢字", 5), "ab漢…");
3117 assert_eq!(fit_bar_text_to_cells("漢字漢", 5), "漢字…");
3118 assert_eq!(fit_bar_text_to_cells("漢字", 1), "…");
3120 }
3121
3122 #[test]
3123 fn bottom_bar_claim_gives_the_row_to_search_and_confirm_over_the_preview() {
3124 for (search, confirm) in [(true, false), (false, true), (true, true)] {
3127 assert_eq!(
3128 bottom_bar_claim(ChromeBar::from_flags(search, confirm), false, true),
3129 BottomBarClaim::Chrome
3130 );
3131 }
3132 }
3133
3134 #[test]
3135 fn bottom_bar_claim_gives_the_row_to_an_ime_composition_over_the_preview() {
3136 assert_eq!(
3141 bottom_bar_claim(ChromeBar::None, true, true),
3142 BottomBarClaim::Free
3143 );
3144 }
3145
3146 #[test]
3147 fn bottom_bar_claim_draws_the_preview_when_no_other_bar_is_open() {
3148 assert_eq!(
3149 bottom_bar_claim(ChromeBar::None, false, true),
3150 BottomBarClaim::LinkPreview
3151 );
3152 }
3153
3154 #[test]
3155 fn bottom_bar_claim_reserves_nothing_with_every_bar_closed() {
3156 assert_eq!(
3157 bottom_bar_claim(ChromeBar::None, false, false),
3158 BottomBarClaim::Free
3159 );
3160 }
3161
3162 #[test]
3163 fn extend_link_preview_empty_text_is_noop() {
3164 let mut out = RowPaint::default();
3165 let overlay = LinkPreviewOverlay::default();
3166 let atlas = MockAtlas::full(slot());
3167 extend_link_preview(&overlay, metrics(), 3, 10, &atlas, &mut out);
3168 assert_eq!(out.rects, Vec::<Rect>::new());
3169 assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
3170 }
3171
3172 #[test]
3173 fn extend_link_preview_zero_grid_is_noop() {
3174 let mut out = RowPaint::default();
3175 let overlay = LinkPreviewOverlay {
3176 text: "https://example.com".to_owned(),
3177 };
3178 let atlas = MockAtlas::full(slot());
3179 extend_link_preview(&overlay, metrics(), 0, 80, &atlas, &mut out);
3180 assert_eq!(out.rects, Vec::<Rect>::new());
3181 assert_eq!(out.glyphs, Vec::<GlyphDraw>::new());
3182 }
3183
3184 #[test]
3187 fn extend_screen_viewport_zero_paints_same_bg_as_live_path() {
3188 let g = drive(2, 4, b"\x1b[?25lAB\r\nCD");
3189 let atlas = MockAtlas::full(slot());
3190 let theme = Theme::default();
3191 let mut buffers = RowPaint::default();
3192 CellPainter::new(
3193 g.screen(),
3194 metrics(),
3195 &ResolvedTheme::new(&theme),
3196 &atlas,
3197 &ShapeFrame::empty(),
3198 )
3199 .with_cursor_visible(true)
3200 .with_viewport(0, u32::from(g.rows()))
3201 .extend_screen(&mut buffers);
3202 assert_eq!(
3203 buffers.rects.len(),
3204 8,
3205 "viewport=0 must paint cells only — no scrollbar lane",
3206 );
3207 for inst in &buffers.rects {
3208 assert_ne!(inst.color, SCROLLBAR_TRACK);
3209 assert_ne!(inst.color, SCROLLBAR_THUMB);
3210 }
3211 }
3212
3213 #[test]
3216 fn extend_screen_paints_scrollbar_lane_while_browsing() {
3217 let mut g = Grid::new(2, 4);
3218 let mut p = Parser::new();
3219 p.advance(&mut g, b"\x1b[?25l");
3220 p.advance(&mut g, b"row1\r\nrow2\r\nrow3");
3221 assert!(!g.scrollback().is_empty());
3222 let atlas = MockAtlas::full(slot());
3223 let theme = Theme::default();
3224 let mut buffers = RowPaint::default();
3225 CellPainter::new(
3226 g.screen(),
3227 metrics(),
3228 &ResolvedTheme::new(&theme),
3229 &atlas,
3230 &ShapeFrame::empty(),
3231 )
3232 .with_cursor_visible(true)
3233 .with_viewport(1, 3)
3234 .extend_screen(&mut buffers);
3235 assert_eq!(
3236 buffers.rects.len(),
3237 8 + 2,
3238 "browse mode must add a track and a thumb to the cell quads",
3239 );
3240 let last_two = &buffers.rects[buffers.rects.len() - 2..];
3241 assert_eq!(last_two[0].color, SCROLLBAR_TRACK, "track painted first");
3242 assert_eq!(last_two[1].color, SCROLLBAR_THUMB, "thumb wins on top");
3243 let lane_x = (g.cols() as f32).mul_add(metrics().width as f32, -SCROLLBAR_LANE_PX);
3244 assert_eq!(last_two[0].origin_px[0], lane_x);
3245 assert_eq!(last_two[1].origin_px[0], lane_x);
3246 }
3247
3248 #[test]
3251 fn extend_screen_hides_cursor_while_viewport_above_zero() {
3252 let mut g = Grid::new(2, 4);
3253 let mut p = Parser::new();
3254 p.advance(&mut g, b"row1\r\nrow2\r\nrow3");
3255 let atlas = MockAtlas::full(slot());
3256 let theme = Theme::default();
3257 let mut buffers_live = RowPaint::default();
3258 let mut buffers_browse = RowPaint::default();
3259 CellPainter::new(
3260 g.screen(),
3261 metrics(),
3262 &ResolvedTheme::new(&theme),
3263 &atlas,
3264 &ShapeFrame::empty(),
3265 )
3266 .with_cursor_visible(true)
3267 .with_viewport(0, 3)
3268 .extend_screen(&mut buffers_live);
3269 CellPainter::new(
3270 g.screen(),
3271 metrics(),
3272 &ResolvedTheme::new(&theme),
3273 &atlas,
3274 &ShapeFrame::empty(),
3275 )
3276 .with_cursor_visible(true)
3277 .with_viewport(1, 3)
3278 .extend_screen(&mut buffers_browse);
3279 let cursor = g.cursor();
3280 let idx = usize::from(cursor.row) * usize::from(g.cols()) + usize::from(cursor.col);
3281 assert_eq!(
3282 buffers_live.rects[idx].color, theme.fg,
3283 "live cursor paints with the swap (cursor bg = theme fg)",
3284 );
3285 assert_ne!(
3286 buffers_browse.rects[idx].color, theme.fg,
3287 "cursor must hide in browse mode — no swap on the cursor cell",
3288 );
3289 }
3290
3291 fn deco_for(sgr: &[u8]) -> Vec<Decoration> {
3292 let mut bytes = sgr.to_vec();
3293 bytes.push(b'A');
3294 let g = drive(1, 4, &bytes);
3295 let atlas = MockAtlas::full(slot());
3296 build_instances(g.screen(), metrics(), &Theme::default(), &atlas).decos
3297 }
3298
3299 #[test]
3300 fn single_underline_emits_one_solid_line_in_the_descender_band() {
3301 let deco = deco_for(b"\x1b[4m");
3302 assert_eq!(deco.len(), 1);
3303 let d = deco[0];
3304 assert_eq!(d.kind, DecoKind::Solid);
3305 assert_eq!(d.size_px[0], metrics().width as f32, "spans the cell width");
3306 assert_eq!(
3307 d.color,
3308 Theme::default().fg,
3309 "default underline color is the cell fg"
3310 );
3311 let ascent = metrics().ascent as f32;
3312 let ch = metrics().height as f32;
3313 assert!(
3314 d.origin_px[1] >= ascent && d.origin_px[1] <= ch - d.thickness_px,
3315 "underline sits in the descender band, got y={}",
3316 d.origin_px[1],
3317 );
3318 }
3319
3320 #[test]
3321 fn double_underline_emits_two_stacked_lines() {
3322 let deco = deco_for(b"\x1b[21m");
3323 assert_eq!(deco.len(), 2);
3324 assert!(deco.iter().all(|d| d.kind == DecoKind::Solid));
3325 assert_ne!(
3326 deco[0].origin_px[1], deco[1].origin_px[1],
3327 "two distinct rows"
3328 );
3329 }
3330
3331 #[test]
3332 fn curly_dotted_dashed_underlines_pick_the_procedural_kinds() {
3333 assert_eq!(deco_for(b"\x1b[4:3m")[0].kind, DecoKind::Curly);
3334 assert_eq!(deco_for(b"\x1b[4:4m")[0].kind, DecoKind::Dotted);
3335 assert_eq!(deco_for(b"\x1b[4:5m")[0].kind, DecoKind::Dashed);
3336 let curly = deco_for(b"\x1b[4:3m")[0];
3337 assert!(
3338 curly.size_px[1] > curly.thickness_px,
3339 "curly band must exceed the thickness so the wave fits",
3340 );
3341 let dotted = deco_for(b"\x1b[4:4m")[0];
3342 assert_eq!(
3343 dotted.size_px[1], dotted.thickness_px,
3344 "dotted stays one line tall"
3345 );
3346 assert!(
3347 dotted.period_px > 0.0,
3348 "a dotted pattern needs a non-zero period"
3349 );
3350 }
3351
3352 #[test]
3353 fn underline_color_uses_sgr_58_when_set() {
3354 let deco = deco_for(b"\x1b[4;58:2::255:0:0m");
3355 assert_eq!(deco.len(), 1);
3356 let want = crate::palette::srgb_to_linear_rgba([255, 0, 0], 1.0);
3357 assert_eq!(
3358 deco[0].color, want,
3359 "SGR 58 underline color must be honored"
3360 );
3361 assert_ne!(deco[0].color, Theme::default().fg);
3362 }
3363
3364 #[test]
3365 fn strikethrough_emits_a_mid_cell_line_in_the_fg_color() {
3366 let deco = deco_for(b"\x1b[9m");
3367 assert_eq!(deco.len(), 1);
3368 let d = deco[0];
3369 assert_eq!(d.kind, DecoKind::Solid);
3370 assert_eq!(d.color, Theme::default().fg);
3371 let ascent = metrics().ascent as f32;
3372 assert!(
3373 d.origin_px[1] < ascent,
3374 "strike crosses the glyph body, above the baseline"
3375 );
3376 }
3377
3378 #[test]
3379 fn overline_emits_a_line_at_the_cell_top() {
3380 let deco = deco_for(b"\x1b[53m");
3381 assert_eq!(deco.len(), 1);
3382 assert_eq!(deco[0].kind, DecoKind::Solid);
3383 assert_eq!(deco[0].origin_px[1], 0.0, "overline seats at the cell top");
3384 assert_eq!(deco[0].color, Theme::default().fg);
3385 }
3386
3387 #[test]
3388 fn faint_dims_the_glyph_toward_the_background() {
3389 let theme = Theme::default();
3390 let atlas = MockAtlas::full(slot());
3391 let plain = build_instances(drive(1, 4, b"A").screen(), metrics(), &theme, &atlas);
3392 let faint = build_instances(drive(1, 4, b"\x1b[2mA").screen(), metrics(), &theme, &atlas);
3393 assert_eq!(plain.glyphs.len(), 1);
3394 assert_eq!(faint.glyphs.len(), 1);
3395 assert_eq!(
3396 plain.glyphs[0].color, theme.fg,
3397 "baseline glyph paints the theme fg"
3398 );
3399 assert_eq!(
3400 faint.glyphs[0].color,
3401 dim_toward_bg(theme.fg, theme.bg),
3402 "faint glyph mixes the fg toward the bg",
3403 );
3404 assert_ne!(
3405 faint.glyphs[0].color, theme.fg,
3406 "faint must actually change the color"
3407 );
3408 }
3409
3410 #[test]
3411 fn conceal_hides_the_glyph_but_keeps_the_background() {
3412 let theme = Theme::default();
3413 let atlas = MockAtlas::full(slot());
3414 let buffers = build_instances(drive(1, 4, b"\x1b[8mA").screen(), metrics(), &theme, &atlas);
3415 assert!(
3416 buffers.glyphs.is_empty(),
3417 "concealed glyph must leave no ink"
3418 );
3419 assert!(
3420 !buffers.rects.is_empty(),
3421 "the cell background must still paint"
3422 );
3423 }
3424
3425 #[test]
3426 fn conceal_hides_the_glyph_yet_keeps_the_underline() {
3427 let deco = deco_for(b"\x1b[4;8m");
3429 let atlas = MockAtlas::full(slot());
3430 let buffers = build_instances(
3431 drive(1, 4, b"\x1b[4;8mA").screen(),
3432 metrics(),
3433 &Theme::default(),
3434 &atlas,
3435 );
3436 assert!(buffers.glyphs.is_empty(), "conceal still hides the glyph");
3437 assert_eq!(deco.len(), 1, "the underline survives conceal");
3438 }
3439
3440 #[test]
3441 fn a_link_cell_yields_the_underline_slot_but_keeps_strikethrough() {
3442 use std::num::NonZeroU16;
3443 let mut g = Grid::new(1, 4);
3444 let style = g.style_table_mut().intern(felis_grid::Attributes {
3445 flags: AttrFlags::UNDERLINE | AttrFlags::STRIKETHROUGH,
3446 ..felis_grid::Attributes::default()
3447 });
3448 g.set_cell(
3449 0,
3450 0,
3451 Cell {
3452 grapheme: Grapheme::Ascii(b'A'),
3453 style,
3454 link: NonZeroU16::new(1),
3455 sizing: None,
3456 },
3457 );
3458 let atlas = MockAtlas::full(slot());
3459 let deco = build_instances(g.screen(), metrics(), &Theme::default(), &atlas).decos;
3460 assert_eq!(
3461 deco.len(),
3462 1,
3463 "link suppresses the SGR underline but not the strike"
3464 );
3465 assert!(
3466 deco[0].origin_px[1] < metrics().ascent as f32,
3467 "the surviving decoration is the strikethrough",
3468 );
3469 }
3470
3471 #[test]
3472 fn a_bidi_override_cell_suppresses_every_decoration() {
3473 let mut g = Grid::new(1, 4);
3474 let style = g.style_table_mut().intern(felis_grid::Attributes {
3475 flags: AttrFlags::UNDERLINE | AttrFlags::STRIKETHROUGH | AttrFlags::OVERLINE,
3476 ..felis_grid::Attributes::default()
3477 });
3478 g.set_cell(
3479 0,
3480 0,
3481 Cell {
3482 grapheme: Grapheme::Char('\u{202e}'), style,
3484 link: None,
3485 sizing: None,
3486 },
3487 );
3488 let atlas = MockAtlas::full(slot());
3489 let deco = build_instances(g.screen(), metrics(), &Theme::default(), &atlas).decos;
3490 assert!(
3491 deco.is_empty(),
3492 "a bidi-override cell drops every decoration"
3493 );
3494 }
3495
3496 proptest::proptest! {
3497 #[test]
3501 fn fitted_bar_text_never_overflows_its_budget(
3502 text in "\\PC{0,40}",
3503 cols in 1u16..40,
3504 ) {
3505 let fitted = fit_bar_text_to_cells(&text, cols);
3506 let width: u32 = felis_grid::text_cells(&fitted)
3507 .map(|(_, w)| u32::from(w))
3508 .sum();
3509 proptest::prop_assert!(
3510 width <= u32::from(cols),
3511 "{fitted:?} occupies {width} cells, past {cols}"
3512 );
3513 if fitted.as_ref() != text {
3514 let head = fitted
3515 .strip_suffix(BAR_ELLIPSIS)
3516 .expect("truncated text ends in the truncation mark");
3517 proptest::prop_assert!(text.starts_with(head));
3518 }
3519 }
3520 }
3521
3522 fn cell_bgs(buffers: &RowPaint) -> Vec<[f32; 4]> {
3525 let m = metrics();
3526 buffers
3527 .rects
3528 .iter()
3529 .filter(|b| b.size_px == [m.width as f32, m.height as f32])
3530 .map(|b| b.color)
3531 .collect()
3532 }
3533
3534 fn fg_in_col(buffers: &RowPaint, col: u16) -> Option<&GlyphDraw> {
3535 let cw = metrics().width as f32;
3536 let left = f32::from(col) * cw;
3537 buffers
3538 .glyphs
3539 .iter()
3540 .find(|q| q.origin_px[0] >= left && q.origin_px[0] < left + cw)
3541 }
3542
3543 fn build_with_selection(screen: &ScreenBuffer, range: SelectionRange) -> RowPaint {
3544 let theme = Theme::default();
3545 let atlas = MockAtlas::full(slot());
3546 let mut buffers = RowPaint::default();
3547 CellPainter::new(
3548 screen,
3549 metrics(),
3550 &ResolvedTheme::new(&theme),
3551 &atlas,
3552 &ShapeFrame::empty(),
3553 )
3554 .with_cursor_visible(true)
3555 .with_selection(Some(range))
3556 .with_viewport(0, u32::from(screen.rows()))
3557 .extend_screen(&mut buffers);
3558 buffers
3559 }
3560
3561 #[test]
3562 fn block_cursor_on_a_wide_character_covers_both_halves() {
3563 let theme = Theme::default();
3564 let mut atlas = MockAtlas::full(slot());
3565 atlas.map.insert('字', slot());
3566 for to_col in [b"\x1b[2G", b"\x1b[3G"] {
3568 let g = drive(1, 6, &["a字b".as_bytes(), to_col].concat());
3569 let buffers = build_instances(g.screen(), metrics(), &theme, &atlas);
3570 let bgs = cell_bgs(&buffers);
3571 assert_eq!(bgs[0], theme.bg);
3572 assert_eq!(bgs[1], theme.fg, "lead under the cursor");
3573 assert_eq!(bgs[2], theme.fg, "Spacer under the cursor");
3574 assert_eq!(bgs[3], theme.bg);
3575 let glyph = fg_in_col(&buffers, 1).expect("字 draws");
3576 assert_eq!(glyph.color, theme.bg, "the glyph takes the swapped fg");
3577 }
3578 }
3579
3580 #[test]
3581 fn underline_cursor_on_a_wide_character_spans_both_halves() {
3582 let g = drive(1, 6, "\x1b[4 qa字b\x1b[3G".as_bytes());
3583 let atlas = MockAtlas::full(slot());
3584 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
3585 let cw = metrics().width as f32;
3586 let xs: Vec<f32> = buffers
3587 .rects
3588 .iter()
3589 .filter(|b| b.size_px == [cw, CURSOR_MARKER_PX])
3590 .map(|b| b.origin_px[0])
3591 .collect();
3592 assert_eq!(xs, [cw, 2.0 * cw]);
3593 }
3594
3595 #[test]
3596 fn bar_cursor_on_a_wide_character_sits_at_its_left_edge() {
3597 let g = drive(1, 6, "\x1b[6 qa字b\x1b[3G".as_bytes());
3598 let atlas = MockAtlas::full(slot());
3599 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
3600 let xs: Vec<f32> = buffers
3601 .rects
3602 .iter()
3603 .filter(|b| b.size_px[0] == CURSOR_MARKER_PX)
3604 .map(|b| b.origin_px[0])
3605 .collect();
3606 assert_eq!(xs, [metrics().width as f32]);
3607 }
3608
3609 #[test]
3610 fn a_selection_touching_either_half_highlights_the_whole_character() {
3611 let g = drive(1, 6, "\x1b[?25la字b".as_bytes());
3612 let theme = Theme::default();
3613 let ends_on_lead = build_with_selection(
3614 g.screen(),
3615 SelectionRange {
3616 start: (0, 0),
3617 end: (0, 1),
3618 rectangle: false,
3619 },
3620 );
3621 assert_eq!(
3622 cell_bgs(&ends_on_lead)[..4],
3623 [SELECTION_BG, SELECTION_BG, SELECTION_BG, theme.bg]
3624 );
3625 let starts_on_spacer = build_with_selection(
3626 g.screen(),
3627 SelectionRange {
3628 start: (0, 2),
3629 end: (0, 3),
3630 rectangle: false,
3631 },
3632 );
3633 assert_eq!(
3634 cell_bgs(&starts_on_spacer)[..5],
3635 [theme.bg, SELECTION_BG, SELECTION_BG, SELECTION_BG, theme.bg]
3636 );
3637 }
3638
3639 #[test]
3640 fn a_rectangle_selection_widens_per_row_to_the_characters_it_touches() {
3641 let g = drive(2, 4, "\x1b[?25la字b\r\n字cd".as_bytes());
3642 let theme = Theme::default();
3643 let buffers = build_with_selection(
3644 g.screen(),
3645 SelectionRange {
3646 start: (0, 1),
3647 end: (1, 1),
3648 rectangle: true,
3649 },
3650 );
3651 let bgs = cell_bgs(&buffers);
3652 assert_eq!(bgs[..4], [theme.bg, SELECTION_BG, SELECTION_BG, theme.bg]);
3653 assert_eq!(bgs[4..], [SELECTION_BG, SELECTION_BG, theme.bg, theme.bg]);
3654 }
3655
3656 #[test]
3657 fn a_wide_cluster_carrying_a_bidi_override_is_marked_on_both_halves() {
3658 let g = drive(1, 6, "\x1b[?25la字\u{202E}b".as_bytes());
3659 let atlas = MockAtlas::full(slot());
3660 let buffers = build_instances(g.screen(), metrics(), &Theme::default(), &atlas);
3661 let bgs = cell_bgs(&buffers);
3662 assert_eq!(bgs[1], BIDI_MARKER_BG);
3663 assert_eq!(bgs[2], BIDI_MARKER_BG);
3664 assert_ne!(bgs[3], BIDI_MARKER_BG);
3665 }
3666
3667 #[test]
3668 fn a_preedit_over_the_spacer_hides_the_lead_glyph_but_keeps_its_background() {
3669 let g = drive(1, 6, "\x1b[?25la\x1b[41m字\x1b[mb".as_bytes());
3670 let theme = Theme::default();
3671 let atlas = MockAtlas::full(slot());
3672 let mut buffers = RowPaint::default();
3673 CellPainter::new(
3674 g.screen(),
3675 metrics(),
3676 &ResolvedTheme::new(&theme),
3677 &atlas,
3678 &ShapeFrame::empty(),
3679 )
3680 .with_cursor_visible(true)
3681 .with_viewport(0, u32::from(g.rows()))
3682 .with_preedit(Some(PreeditSpan {
3683 row: 0,
3684 col_start: 2,
3685 col_end: 4,
3686 }))
3687 .extend_screen(&mut buffers);
3688 assert!(fg_in_col(&buffers, 0).is_some(), "a draws");
3689 assert!(
3690 fg_in_col(&buffers, 1).is_none(),
3691 "字 would ink under the overlay"
3692 );
3693 let cw = metrics().width as f32;
3694 assert!(
3695 buffers
3696 .rects
3697 .iter()
3698 .any(|b| b.origin_px[0] == cw && b.size_px[0] == cw && b.color != theme.bg),
3699 "the lead keeps its red background"
3700 );
3701 }
3702
3703 fn preedit(text: &str, col: u16) -> PreeditOverlay {
3704 PreeditOverlay {
3705 anchor: (0, col),
3706 text: text.to_owned(),
3707 cursor: None,
3708 }
3709 }
3710
3711 #[test]
3712 fn preedit_gives_a_cluster_the_cells_the_grid_gives_it() {
3713 for (text, cells) in [
3714 ("e\u{301}x", 2),
3715 ("❤\u{FE0F}", 2),
3716 ("👍🏻", 2),
3717 ("🇯🇵", 2),
3718 ("a字", 3),
3719 ] {
3720 let span = preedit_covered_cols(&preedit(text, 1), 20).expect("non-empty");
3721 assert_eq!(span.col_end - span.col_start, cells, "{text:?}");
3722 }
3723 }
3724
3725 #[test]
3726 fn preedit_draws_a_cluster_with_its_shaped_glyphs() {
3727 let mut atlas = MockAtlas::full(slot());
3728 for (id, flag) in [(1, "🇯🇵"), (2, "🇯🇲")] {
3729 atlas.clusters.insert(
3730 flag.to_owned(),
3731 vec![ClusterGlyph {
3732 glyph_id: id,
3733 font_id: 0,
3734 advance_px: 16.0,
3735 x_offset_px: 0.0,
3736 y_offset_px: 0.0,
3737 }],
3738 );
3739 atlas.gid_map.insert(
3740 id,
3741 GlyphSlot {
3742 atlas_px: [u32::from(id), 0],
3743 ..slot()
3744 },
3745 );
3746 }
3747 let src_of = |text: &str| {
3748 let mut out = RowPaint::default();
3749 extend_preedit(&preedit(text, 0), metrics(), 1, 10, &atlas, &mut out);
3750 assert_eq!(out.glyphs.len(), 1, "{text:?} draws one shaped glyph");
3751 out.glyphs[0].src_min_px
3752 };
3753 assert_ne!(src_of("🇯🇵"), src_of("🇯🇲"));
3754 }
3755
3756 #[test]
3757 fn overlay_glyphs_never_draw_past_the_right_edge() {
3758 let mut atlas = MockAtlas::full(slot());
3759 let wide = GlyphSlot {
3760 size_px: [14, 10],
3761 ..slot()
3762 };
3763 atlas.map.insert('字', wide);
3764 let cols = 5;
3765 let edge = f32::from(cols) * metrics().width as f32;
3766 let mut out = RowPaint::default();
3767 extend_preedit(&preedit("ab字", 2), metrics(), 1, cols, &atlas, &mut out);
3768 extend_search(
3769 &SearchOverlay {
3770 label: "abcd字".to_owned(),
3771 visible_hits: Vec::new(),
3772 },
3773 metrics(),
3774 2,
3775 cols,
3776 true,
3777 &atlas,
3778 &mut out,
3779 );
3780 assert_eq!(
3781 out.glyphs.len(),
3782 3 + 5,
3783 "each clipped 字 keeps its visible half"
3784 );
3785 for q in &out.glyphs {
3786 assert!(q.origin_px[0] + q.size_px[0] <= edge, "{q:?}");
3787 }
3788 }
3789
3790 #[cfg(not(windows))]
3793 struct Painted {
3794 index: GlyphIndex,
3795 frame: ShapeFrame,
3796 glyphs: Vec<GlyphDraw>,
3797 }
3798
3799 #[cfg(not(windows))]
3800 fn paint_pinned(bytes: &[u8], cols: u16) -> Painted {
3801 let stack = FontStack::pinned_test_stack();
3802 let mut grid = Grid::new(1, cols);
3803 Parser::new().advance(&mut grid, bytes);
3804 let mut index = GlyphIndex::new(stack, 18.0, NonZeroU32::new(1024).unwrap());
3805 let mut frame = ShapeFrame::empty();
3806 GridWalker::default().walk(
3807 &mut index,
3808 grid.screen(),
3809 &mut Shaper::new(),
3810 &[],
3811 &mut frame,
3812 );
3813 let theme = Theme::default();
3814 let mut out = RowPaint::default();
3815 CellPainter::new(
3816 grid.screen(),
3817 index.cell_metrics(),
3818 &ResolvedTheme::new(&theme),
3819 &index,
3820 &frame,
3821 )
3822 .with_viewport(0, 1)
3823 .extend_screen(&mut out);
3824 Painted {
3825 index,
3826 frame,
3827 glyphs: out.glyphs,
3828 }
3829 }
3830
3831 #[cfg(not(windows))]
3832 fn cluster_fit_px_at(p: &Painted, col: u16) -> u16 {
3833 match p.frame.cell_at(0, col) {
3834 ShapedCell::Cluster { fit_px, .. } => fit_px,
3835 other => panic!("col {col} is not a cluster: {other:?}"),
3836 }
3837 }
3838
3839 #[cfg(not(windows))]
3841 fn ink(fg: &[GlyphDraw]) -> [f32; 4] {
3842 fg.iter()
3843 .map(|q| {
3844 let [x, y] = q.origin_px;
3845 [x, y, x + q.size_px[0], y + q.size_px[1]]
3846 })
3847 .reduce(|[a, b, c, d], [x0, y0, x1, y1]| [a.min(x0), b.min(y0), c.max(x1), d.max(y1)])
3848 .expect("the cluster painted")
3849 }
3850
3851 #[cfg(not(windows))]
3855 #[test]
3856 fn a_cluster_wider_than_its_cells_fills_and_centres_in_them() {
3857 let repros: [(&str, &[u8], u16, u16); 3] = [
3858 ("non-RGI ZWJ", "\u{1F408}\u{200D}\u{1F408}".as_bytes(), 0, 2),
3859 ("modifier", "\u{1F600}\u{1F3FB}".as_bytes(), 0, 2),
3860 ("refused widen", "\x1b[4G\u{2764}\u{FE0F}".as_bytes(), 3, 1),
3861 ];
3862 for (name, bytes, col, cells) in repros {
3863 let p = paint_pinned(bytes, 4);
3864 let fit_px = f32::from(cluster_fit_px_at(&p, col));
3865 assert_ne!(fit_px, 0.0, "{name}: fitted");
3866 let filled = |ink: f32, block: f32| ink >= block * fit_px / (fit_px + 1.0) - 1.0;
3869 let m = p.index.cell_metrics();
3870 let (cw, ch) = (m.width as f32, m.height as f32);
3871 let (bx0, bx1) = (f32::from(col) * cw, f32::from(col + cells) * cw);
3872 let [x0, y0, x1, y1] = ink(&p.glyphs);
3873 assert!(
3874 x0 >= bx0 - 1.0 && x1 <= bx1 + 1.0 && y0 >= -1.0 && y1 <= ch + 1.0,
3875 "{name}: ink {x0}..{x1} x {y0}..{y1} inside {bx0}..{bx1} x 0..{ch}"
3876 );
3877 assert!(
3878 ((x0 - bx0) - (bx1 - x1)).abs() <= 1.0 && (y0 - (ch - y1)).abs() <= 1.0,
3879 "{name}: ink {x0}..{x1} x {y0}..{y1} centred in {bx0}..{bx1} x 0..{ch}"
3880 );
3881 assert!(
3882 filled(x1 - x0, bx1 - bx0) || filled(y1 - y0, ch),
3883 "{name}: ink {x0}..{x1} x {y0}..{y1} fills an axis"
3884 );
3885 }
3886 }
3887
3888 #[cfg(not(windows))]
3891 #[test]
3892 fn a_one_glyph_cluster_fits_as_its_bare_char_does() {
3893 let cluster = paint_pinned("\x1b[4G\u{263A}\u{FE0F}".as_bytes(), 4);
3894 let bare = paint_pinned("\x1b[4G\u{263A}".as_bytes(), 4);
3895 let [a0, b0, a1, b1] = ink(&cluster.glyphs);
3896 let [c0, d0, c1, d1] = ink(&bare.glyphs);
3897 assert!(
3898 ((a1 - a0) - (c1 - c0)).abs() <= 1.0 && ((b1 - b0) - (d1 - d0)).abs() <= 1.0,
3899 "cluster {}x{} vs bare {}x{}",
3900 a1 - a0,
3901 b1 - b0,
3902 c1 - c0,
3903 d1 - d0,
3904 );
3905 }
3906
3907 #[cfg(not(windows))]
3911 #[test]
3912 fn a_fitted_cluster_advances_its_pen_at_the_fitted_size() {
3913 let cats = "\u{1F408}\u{200D}\u{1F408}";
3914 let stack = FontStack::pinned_test_stack();
3915 let mut grid = Grid::new(2, 8);
3916 Parser::new().advance(
3917 &mut grid,
3918 format!("\x1b]66;w=1:s=2:n=3:d=4;{cats}\x07").as_bytes(),
3919 );
3920 let mut index = GlyphIndex::new(stack, 18.0, NonZeroU32::new(1024).unwrap());
3921 let mut frame = ShapeFrame::empty();
3922 GridWalker::default().walk(
3923 &mut index,
3924 grid.screen(),
3925 &mut Shaper::new(),
3926 &[],
3927 &mut frame,
3928 );
3929 let ShapedCell::Cluster { start, len, fit_px } = frame.cell_at(0, 0) else {
3930 panic!("not a cluster: {:?}", frame.cell_at(0, 0));
3931 };
3932 assert_ne!(fit_px, 0, "fitted");
3933 let native = Shaper::new().shape_cluster(index.stack(), 18.0, FontStyle::REGULAR, cats);
3934 let pool = frame.cluster_slice(start, len);
3935 let expected = native.glyphs[0].advance_px * f32::from(fit_px) / (18.0 * 1.5);
3936 assert!(
3937 (pool[0].advance_px - expected).abs() < 1e-3,
3938 "pool advance {} vs {expected}",
3939 pool[0].advance_px
3940 );
3941 let theme = Theme::default();
3942 let mut out = RowPaint::default();
3943 CellPainter::new(
3944 grid.screen(),
3945 index.cell_metrics(),
3946 &ResolvedTheme::new(&theme),
3947 &index,
3948 &frame,
3949 )
3950 .with_viewport(0, 2)
3951 .extend_screen(&mut out);
3952 let [first, second] = out.glyphs.as_slice() else {
3953 panic!("two cats: {} quads", out.glyphs.len());
3954 };
3955 let step = second.origin_px[0] - first.origin_px[0];
3956 let fitted_advance = native.glyphs[0].advance_px * f32::from(fit_px) / 18.0;
3957 assert!(
3958 (step - fitted_advance).abs() < 1e-3,
3959 "step {step} vs the fitted advance {fitted_advance}"
3960 );
3961 }
3962}