Skip to main content

felis_render_core/
painter.rs

1//! The cell painter: turns the screen and the frame's overlays into
2//! the display list of [`crate::display_list`].
3
4use 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
32/// One pixel less than the cursor marker so a cursor underline on a
33/// link cell still visually wins.
34const LINK_UNDERLINE_PX: f32 = 1.0;
35
36const PREEDIT_UNDERLINE_PX: f32 = 2.0;
37
38/// The Mozc / fcitx / iBus convention: a thicker bar under the segment
39/// being edited.
40const PREEDIT_ACTIVE_UNDERLINE_PX: f32 = 4.0;
41
42const SCROLLBAR_LANE_PX: f32 = 6.0;
43
44/// Cell count is governed by the integer `s` (and `w`) alone; `n/d`
45/// scales the bitmap, not the cell count (Kitty: "the fractional scale
46/// does not affect the number of cells the text occupies"). Matches
47/// the screen's `dispatch_osc_66` block stamping.
48#[must_use]
49fn sizing_scale_for_layout(sizing: Sizing) -> f32 {
50    f32::from(sizing.scale().max(1))
51}
52
53/// Delegated to [`SizingKey::effective_scale`] so glyphs are placed at
54/// exactly the scale the rasterizer sized their bitmaps with.
55#[must_use]
56fn sizing_glyph_scale(sizing: Sizing) -> f32 {
57    sizing_key_from(sizing).effective_scale()
58}
59
60/// `(x, y, width, height)` in pixels of the cursor the cell pass paints
61/// over the columns `span` covers.
62#[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    /// Overlays default to absent and layer on through `with_*`.
107    ///
108    /// The provided `shape_frame` determines whether cells render via glyph IDs, clusters,
109    /// or the per-char fallback.
110    #[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    /// `?5` DECSCNM as the daemon reports it, xor'd against each cell's
142    /// SGR 7.
143    #[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    /// True only while focused and the blink clock is in its visible
150    /// phase; both hide the cursor identically, so they arrive folded.
151    #[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    /// `viewport` rows above the live bottom out of `viewport_max`; `0`
164    /// is the live bottom.
165    #[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    /// Rows trimmed off the bottom so bottom-anchored chrome (the search
173    /// bar) owns them without the cells doubling through.
174    #[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    /// The span (from [`preedit_covered_cols`]) whose cells are skipped
181    /// so the underlying glyph does not draw through the opaque overlay
182    /// in the later fg pass.
183    #[must_use]
184    pub const fn with_preedit(mut self, span: Option<PreeditSpan>) -> Self {
185        self.frame.preedit = span;
186        self
187    }
188
189    /// Appends into caller-owned scratch (kept across frames so the
190    /// steady state allocates nothing); callers clear first for a fresh
191    /// frame. An atlas miss skips only the fg quad; the bg still lands.
192    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    /// Fixes the cursor paint, which every row reads.
204    #[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    /// Resolved by [`Self::resolved`]; meaningless before.
219    #[must_use]
220    pub const fn cursor(&self) -> CursorPaint {
221        self.frame.cursor
222    }
223
224    /// Everything a row emits depends on that row's cells and the
225    /// painter's frame-wide inputs alone, which is what lets
226    /// a renderer's row cache repaint a row in isolation.
227    pub fn extend_row(&self, out: &mut RowPaint, r: u16) {
228        // Straight off the shadow's screen: the shadow has no
229        // scrollback ring, so when viewport > 0 the daemon ships
230        // composed RowDeltas against composed-view rows. Composing
231        // here (`cell_at_viewport`) would fall back to live cells.
232        let Some(cells) = RowCells::of_screen(self.screen, r) else {
233            return;
234        };
235        let frame = self.frame.row(r, &cells);
236        // Cell draws must not land under the bottom chrome: a glyph
237        // rides the fg pass and would draw on top of the bar. Capping the
238        // walk is the only fix; there is no fg occlusion across passes.
239        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            // CONCEAL (SGR 8). SECURITY: this must gate every fg push.
258            // The bg / cursor / selection / decoration draws above
259            // stay (a concealed cell reads as a space).
260            if !look.glyph_visible {
261                continue;
262            }
263            // Shape-frame lookup precedes char-path decoding so a
264            // `Trailing` cell skips its fg entirely.
265            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        // Before the cursor marker so a cursor underline on a link cell
289        // wins.
290        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        // Pushed after the cell's bg quad so it draws over the bg but
298        // under the glyph.
299        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    /// One draw per shaped glyph at the cell origin plus pen advance
316    /// and GPOS offset; a single-glyph cluster places identically to the
317    /// equivalent `Char` cell.
318    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        // `shape_clusters` primed each glyph with the sized key, so
327        // emission rebuilds the same key and scales pen / GPOS by the
328        // same `glyph_scale`.
329        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    /// Top/Left needs no shift: the early-out keeps default-sized output
362    /// byte-identical rather than relying on the ink formula's float
363    /// reduction. The other alignments align the union ink box, so a mark
364    /// past its base stays inside the block. Top/Left centres a fitted
365    /// cluster, as `fitted_bitmap` centres a fitted char.
366    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            // A spacer is the right half of a wide glyph its neighbor drew.
423            Grapheme::Empty | Grapheme::Spacer | Grapheme::SizedSpacer => return,
424            // The image-tile pass paints placeholders; a glyph here would
425            // render tofu under the image.
426            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            // Reached only when the shape frame carries no `Cluster` entry:
432            // shaping degenerately produced no glyphs (`shape_clusters` then
433            // primed the base char at the cell's sizing) or the handle no
434            // longer resolves (docs/explanation/data-model/grid-and-cells.md
435            // "Cluster interning").
436            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        // REQ-603: the screen reserves a `per_char_w × scale × scale`
445        // block per sized char via `Grapheme::SizedSpacer` continuation
446        // cells, which short-circuit above, so only the primary's glyph
447        // is emitted (rasterized once at scale, spilling into the spanned
448        // slots) while each spacer still paints its bg quad.
449        let sizing = self
450            .screen
451            .cell_sizing(at.row, at.col)
452            .copied()
453            .unwrap_or_default();
454        // `populate` / `apply_shaped_run` key the styled-face slot the
455        // same way.
456        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        // `layout_scale` (= `s`) matches the screen's block stamping in
468        // `dispatch_osc_66`; `glyph_scale` (= `s × n/d`, or `s`) is what
469        // swash rasterized against and scales the baseline.
470        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        // `w == 0` falls back to the char's natural cell width.
476        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        // `Top` / `Left` are the spec defaults and must reproduce the
484        // baseline + left-bearing math exactly.
485        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        // Shape runs do not split at color changes (`next_shape_run`),
499        // so a glyph inking across cells is sliced per cell with each
500        // strip's own color (kitty's per-cell sprite behavior). Color
501        // bitmaps skip this: the shader ignores their tint.
502        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    /// Absent at the live bottom. Gated on `viewport_max > rows` as
510    /// defense-in-depth: the daemon should already have clamped.
511    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            // Track first so the thumb wins at overlap.
519            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            // Floored at SCROLLBAR_LANE_PX so a long scrollback still
525            // leaves a grabbable indicator.
526            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    /// Underlines sit in the descender band, strikethrough at ~x-height,
539    /// overline at the top edge.
540    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            // Clamped so a font whose ascent nearly fills the cell keeps
564            // the line on-cell.
565            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                    // Taller band so the sine can swing ± its amplitude.
593                    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            // 0.6 of the ascent reads as through the middle of most glyphs.
612            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    /// Shape runs do not end at color boundaries (`next_shape_run`), and
621    /// one draw carries one tint, so a glyph covering
622    /// differently-colored cells is split into per-cell strips (kitty's
623    /// per-cell sprites). A uniform-color run emits the original quad.
624    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        // Overflow past either edge stays attached to the boundary
632        // column's slice, so clamping never drops pixels.
633        #[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        // Both sides come from the same palette lookups, so equal
650        // colors are equal bit patterns; an epsilon would blur distinct
651        // palette entries.
652        #[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
687/// The fg pass runs after the bg pass, so a cell's glyph can only be
688/// hidden by not emitting it. The walk mirrors
689/// `extend_preedit` so the skipped span matches the painted
690/// span (`preedit_span_matches_painted_extent` pins the two).
691pub 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
703/// Stops before the first cluster that does not fit the row: pre-edit
704/// panels stay on one line in every platform IME. Cells whose glyph
705/// misses the atlas still get bg + underline; the renderer primes the
706/// overlay text for the next frame.
707pub 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    // Mozc / fcitx / iBus surface an active segment on multi-segment
722    // compositions.
723    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
755/// One cell of overlay text placed on the grid.
756struct OverlayCell {
757    bytes: std::ops::Range<usize>,
758    col: u16,
759    /// Columns drawn: the cluster's width, or fewer when the row's right
760    /// edge clips it.
761    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
771/// Lays `text` out from `start` the way the grid would print it. A wide
772/// cluster reaching past `grid_cols` is clipped to the columns left and
773/// ends the walk: shown in part rather than wrapped, which would lie
774/// about where the committed text lands, or dropped.
775fn 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
795/// Pre-edit and the bars have no OSC 66 surface, so every glyph takes
796/// the default sizing key. A cluster draws its shaped glyphs, or its
797/// base char before the renderer has shaped it.
798fn 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/// A shaped cluster's glyphs and where its pen starts: `origin` is the
841/// left edge on the baseline. A non-zero `fit_px` reads the glyphs'
842/// fitted slots.
843#[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
853/// Each placed glyph's slot and top-left corner; a glyph with no slot
854/// still advances the pen, so later glyphs keep their place.
855fn 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                // `slot.offset_px` already comes from the scaled raster, so
870                // it is not re-scaled. `mul_add(scale, base)` keeps the
871                // default path (`scale == 1`) byte-identical to `b + x`.
872                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                // swash reports GPOS y in font space (y-up); screen space
875                // is y-down.
876                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
886/// The union of the placed glyphs' ink as `[x0, y0, x1, y1]`.
887fn 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
933/// Hard-coded rather than `theme.fg` so the overlay stays high-contrast
934/// against `PREEDIT_BG` whatever the theme.
935const fn theme_default_fg() -> [f32; 4] {
936    [1.0, 1.0, 1.0, 1.0]
937}
938
939/// Appended after the cell pass so highlights and bar paint on top.
940/// Atlas misses in the label are skipped; the renderer primes the
941/// label before painting. Highlights on the bar row are still
942/// emitted but covered by the bar.
943pub 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    // The hit highlights are the search's own rows and always paint;
980    // `draw_bar` is only about the one row the bars contend for.
981    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
995/// Same emission contract as [`extend_search`]'s bar step.
996pub 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
1018/// Same emission contract as [`extend_search`]'s bar step.
1019/// Reuses [`SEARCH_BAR_BG`] rather than [`CONFIRM_BAR_BG`]: the preview
1020/// is informational, not a destructive prompt, so it must not read like
1021/// one.
1022pub 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
1044/// Cuts the cell pass off at `max_y`, the first pixel row owned by client chrome.
1045///
1046/// Glyphs anchored above reserved rows can reach into them via tall bitmaps or OSC 66
1047/// runs (`docs/reference/protocols/kitty-text-sizing.md`), sharing the foreground pass with chrome.
1048pub 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
1064/// Cuts the glyph quads from `from` on at `max_x`, the window's right
1065/// edge, so overlay text never draws past it.
1066fn 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/// Shrinks `extent`, along one axis, so the quad ends at `max`,
1080/// reporting the fraction of it that survived, or `None` when nothing
1081/// does.
1082#[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
1099/// Truncation indicator for chrome bars.
1100///
1101/// Public so the renderer primes it in the glyph atlas. Synthesized after overlay text
1102/// population; unprimed glyphs are skipped silently, which would omit the truncation mark.
1103pub const BAR_ELLIPSIS: char = '…';
1104
1105/// Fits `text` into `cols` display cells, ending in [`BAR_ELLIPSIS`] when truncated.
1106///
1107/// Measured in cells rather than chars to handle wide glyphs, ensuring the ellipsis occupies
1108/// a full cell rather than half of a two-cell character.
1109fn 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/// How the bottom-row overlays share the one row they all draw into.
1126/// Reserving the row without anything to draw in it is not a state:
1127/// the cell painter would leave a blank row.
1128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1129pub enum BottomBarClaim {
1130    /// Nothing holds the row; the cell painter uses every row.
1131    Free,
1132    /// A chrome bar holds it.
1133    Chrome,
1134    /// The link preview holds it.
1135    LinkPreview,
1136}
1137
1138impl BottomBarClaim {
1139    /// Rows the cell painter must leave unpainted, so an overlay's
1140    /// opaque background never has a grid glyph showing through it.
1141    #[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    /// Whether the link preview may emit into that row.
1150    #[must_use]
1151    pub const fn draw_link_preview(self) -> bool {
1152        matches!(self, Self::LinkPreview)
1153    }
1154}
1155
1156/// Resolves bottom-row overlay precedence (`docs/explanation/input.md` "Link preview").
1157///
1158/// Passive link previews yield to questions, active searches, or IME preedits to prevent
1159/// conflicting overlays from drawing over each other. Pure logic testable without a wgpu device.
1160#[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/// Which deliberate chrome bar holds the bottom row, if either (the
1176/// resolved winner, not the raw state). A chord can arm confirmation while
1177/// a search composes; `Confirm` takes precedence because the next keystroke answers it.
1178#[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
1198/// Shared by the search and confirmation bars so the two cannot drift
1199/// in geometry.
1200fn 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    // Whitespace advances the column without an fg emission, like the
1220    // cell pass's atlas-miss handling.
1221    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
1229/// One px at a ~16 px cell, two at ~32 px.
1230fn 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    /// Whitespace returns `None`, mimicking swash.
1257    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        // `v=1` (bottom) puts the 10 px bitmap's top at
1392        // `block_h - bitmap_h = 16 - 10 = 6`.
1393        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        // `w=3, h=1`: block 24 px wide, bitmap 6 px, so its left edge sits
1403        // at 18; v=Top keeps the baseline placement at 2.
1404        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        // `w=3, h=2, v=2`: centering puts the 6×10 bitmap at
1414        // `((24 - 6) / 2, (16 - 10) / 2) = (9, 3)`.
1415        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        // `s=2` alone: the glyph rasterizes at 2×, so the v=Top baseline
1426        // sits at `ascent × 2 - offset_px[1] = 24 - 10 = 14`.
1427        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        // `s=2:n=1:d=2`: effective glyph scale `s × n/d = 1`, so the
1433        // baseline drops back to 2.
1434        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        // The block still measures `s` cells: v=1 anchors at
1440        // `ch × s - bitmap_h = 32 - 10 = 22`.
1441        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        // REQ-603: only the primary feeds the fg pass; the three
1450        // SizedSpacers still emit bg quads.
1451        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    /// The slice arithmetic accumulates one f32 rounding step.
1462    #[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    /// 24 px of ink spanning three 8 px cells.
1471    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    /// Cursor hidden so the Block-cursor fg swap cannot leak into the
1481    /// color assertions.
1482    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    /// A shaped ligature quad covering differently-colored cells splits
1498    /// into per-cell strips (kitty's behavior; fish recolors the `>` of
1499    /// `<->`).
1500    #[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    /// Uniform colors take the unsliced fast path.
1534    #[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    /// Monaspace-style pieces reach back across earlier cells via
1560    /// negative left bearing; the slices carry those cells' own colors.
1561    #[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                // Blank lead pieces have no atlas slot, as in the real
1569                // pipeline.
1570                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    /// A combining mark (advance 0, reaching-back GPOS x, positive GPOS
1601    /// y) lands over its base. Hand-rolled `ClusterGlyph`s so the
1602    /// placement cannot drift with a real font's GPOS.
1603    #[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    /// A sized (OSC 66 `s=2`) composited cluster scales pen advance and
1653    /// GPOS by the glyph scale and lifts the baseline to `ascent × scale`.
1654    #[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    /// Non-default alignment aligns the cluster's union ink box, not the
1699    /// base glyph's.
1700    #[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        // Union ink box [0,1]..[10,13]: Right shifts 16 − 10 = 6, Bottom
1740        // 16 − 13 = 3 (base-referenced alignment would shift 9 and
1741        // overflow by 3 px).
1742        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    /// The block is the grid's span, not the base char's width: VS16
1755    /// widens a width-1 heart to two cells unless the last column or an
1756    /// OSC 66 block placed before it refuses it, and OSC 66 `w`
1757    /// overrides both; `s` scales the block after.
1758    #[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    /// A centred cluster centres in the two cells a widened VS16 heart
1793    /// takes, where its width-1 base would centre it in one.
1794    #[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        // Ink 6 px wide from x = 1: (16 − 6) / 2 − 1 = 4, plus the bearing.
1816        assert_eq!(buffers.glyphs[0].origin_px[0], 5.0);
1817    }
1818
1819    /// A fitted cluster reads the slots rasterized at its fitted size,
1820    /// never the native ones, and Top/Left centres its ink in the block
1821    /// as a fitted char's bitmap is centred.
1822    #[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        // An 8×16 cell centres a 4×6 ink at (2, 5).
1853        assert_eq!(buffers.glyphs[0].origin_px, [2.0, 5.0]);
1854    }
1855
1856    /// A single-glyph cluster places identically to the equivalent
1857    /// `Char` cell.
1858    #[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        // Cursor hidden so its reverse-video pass does not stack on SGR 7.
1929        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    /// DECSCNM and SGR 7 compose by XOR on the *resolved* pair.
1940    #[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        // `?25l`: the cursor cell renders like any other empty cell.
2015        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        // The cell carrying U+202E paints the marker whether the parser
2026        // places it as its own cell or folds it into a Cluster.
2027        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        // RTL script content is text, not an attack vector.
2065        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        // The cursor's reverse-video pass must not mask the marker.
2083        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"); // default style stays Block
2138        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        // The bidi warning paints the whole cell; the marker must not
2180        // also fire.
2181        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        // Cells under the bar must not be emitted, or their glyphs
2238        // double through the label.
2239        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        // Cells under the composition overlay must not be emitted, or
2277        // their glyph draws through it in the later fg pass.
2278        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        // The reserved span and the painted span walk the same clamping
2332        // formula; pin them to the same right edge.
2333        let atlas = MockAtlas::full(slot());
2334        let overlay = PreeditOverlay {
2335            anchor: (0, 1),
2336            text: "あxい".to_string(), // 2 + 1 + 2 = 5 cells from col 1
2337            cursor: None,
2338        };
2339        let cols = 6; // col 1 + 5 cells == exactly the right edge
2340        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"); // hide cursor for isolation
2408        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        // A bidi-override cell inside a selection paints the marker, or
2490        // an attacker could hide behind the selection tint.
2491        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        // A daemon-side cursor briefly reporting `col == cols` must not
2620        // push off-screen instances.
2621        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        // start == end is a position, not a range.
2667        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        // Each char is 3 bytes; (3, 6) covers exactly the second.
2686        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        // A wide glyph at the last cell clips rather than wrapping (which
2725        // would lie about where the committed char lands) or vanishing.
2726        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        // MockAtlas only knows ASCII, so the fg pass is empty; the bg /
2735        // underline geometry carries the clip.
2736        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    /// REQ-910: a `javascript:` OSC 8 emits no `LINK_UNDERLINE`.
2764    #[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    /// `https://good` then `javascript:bad`: the bad open clears the pen
2781    /// rather than inheriting the safe URI.
2782    #[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        // The bidi marker drops the link underline so the warning stays
2806        // unobstructed.
2807        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        // The App can push a fresh-but-empty overlay between
2880        // Off and Composing without polluting the frame.
2881        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        // A chord can arm a confirmation while a search is composing.
2892        // The question takes the row; the hit highlights are the
2893        // search's own rows and stay.
2894        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        // The daemon's viewport edits and the client's hit list can race
2952        // during a rapid scroll; a panic there would lose a frame.
2953        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        // Same neutral chrome as the search bar: a hover preview is
3030        // informational, not the destructive-prompt CONFIRM_BAR_BG.
3031        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        // An OSC 66 run scaled across rows is bottom-aligned inside its
3046        // block, so a producer can aim ink into the reserved row from
3047        // the row above; it rides the same pass as the bar's own text.
3048        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        // Half the height kept, so half the source `v` span.
3073        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        // The whole point of the preview is that the user reads the true
3079        // target; a cut that looks like the end of the URI would be read
3080        // as one.
3081        let mut out = RowPaint::default();
3082        let overlay = LinkPreviewOverlay {
3083            text: "https://example.com/very/long/path".to_owned(),
3084        };
3085        // The renderer primes the ellipsis alongside the text, so
3086        // the atlas answers for it here too.
3087        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        // The mark occupies the last cell, so a clip is visible rather
3093        // than a URI that merely stops.
3094        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        // Exactly filling the row is not truncation.
3106        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        // A two-cell glyph with one cell left would otherwise get its
3115        // span clamped to one while its full quad painted past the bar.
3116        assert_eq!(fit_bar_text_to_cells("ab漢字", 5), "ab漢…");
3117        assert_eq!(fit_bar_text_to_cells("漢字漢", 5), "漢字…");
3118        // A single column can hold nothing but the truncation mark.
3119        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        // The preview's bg is opaque, so drawing it into a row a search
3125        // or confirm bar already claimed would hide that bar's text.
3126        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        // A preedit anchored on the bottom row emits its glyphs before
3137        // the preview's, and every fg quad draws after every bg one, so
3138        // the two would overprint. The composition is the deliberate
3139        // interaction, so the preview yields the row entirely.
3140        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    /// `extend_screen` at viewport=0 is byte-identical to the live
3185    /// path.
3186    #[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    /// While `viewport > 0` a track and thumb paint along the right
3214    /// edge, track behind thumb.
3215    #[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    /// While browsing, the cursor would smear onto whichever scrollback
3249    /// row landed at its `(row, col)`, so it is hidden.
3250    #[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        // Conceal suppresses the glyph, not the cell's decorations.
3428        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}'), // RIGHT-TO-LEFT OVERRIDE
3483                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        /// No fitted bar text overflows its budget, and a truncated one
3498        /// is a prefix of the original plus the mark: a wide glyph must
3499        /// not be half-drawn into the cell the mark needs.
3500        #[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    /// The per-cell background quads, without the cursor or link markers
3523    /// pushed between them.
3524    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        // Addressed onto the lead, then onto the Spacer.
3567        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    /// A one-row grid fed `bytes`, walked and painted against the pinned
3791    /// set.
3792    #[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    /// The fg quads' union as `[x0, y0, x1, y1]`.
3840    #[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    /// Each repro is the only ink on its row, its cluster at `col` with
3852    /// `cells` cells: a ZWJ pair the face has no glyph for, a modifier on a
3853    /// base that takes none, and a VS16 widen refused at the last column.
3854    #[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            // The size is floored to a whole pixel, which costs up to one
3867            // part in `fit_px + 1` of the block.
3868            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    /// The fit is the bare char's: a cluster of one glyph shrinks to the
3889    /// size `fitted_bitmap` gives that glyph alone.
3890    #[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    /// Under an OSC 66 fractional scale a fitted cluster's pen shrinks
3908    /// with its glyphs: the second cat sits one advance at the fitted
3909    /// size after the first, not one advance at the native size.
3910    #[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}