//! Fixed-cell terminal paint planning (Vterm Stage 3).
//!
//! The document renderer derives geometry from shaped text: a glyph's
//! advance decides where the next glyph starts. A terminal cannot work
//! that way. Its column origins are defined by the CHILD, and the
//! frontend's font has no say in them — a wide glyph the font renders
//! 1.9 cells wide still occupies exactly two columns, and the cell after
//! it still starts at exactly `col * advance`.
//!
//! So this module resolves a [`TerminalFrame`] into a plan expressed in
//! CELLS, never pixels. Row/column rectangles own backgrounds,
//! underlines, selection, the cursor, and clipping; the renderer
//! multiplies by its own metrics at the end. That split is also what
//! makes the paint rules testable without a GPU: everything here is a
//! pure function of the frame plus two default colors.

use pmacs_protocol::{Cell, CellSize, Color, Glyph, Style, TerminalFrame, UnderlineStyle};

/// A resolved 24-bit color. The plan carries no `Default` sentinel:
/// resolution happens once, up front, because `reverse` swaps the
/// RESOLVED pair — a reversed default-on-default cell must come out as
/// dark-on-light, which is impossible if `Default` survives into the
/// swap.
pub type Rgb = [u8; 3];

/// The frontend defaults `Color::Default` resolves to.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalPalette {
    /// The GPU's plain-text color.
    pub default_fg: Rgb,
    /// The GPU's window background.
    pub default_bg: Rgb,
}

/// A half-open run of cells on one row: `[start_col, end_col)`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CellRun {
    /// Row within the frame.
    pub row: u32,
    /// Inclusive first column.
    pub start_col: u32,
    /// Exclusive last column.
    pub end_col: u32,
}

/// A background run with its resolved (post-`reverse`) color.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BackgroundRun {
    /// Cells covered.
    pub run: CellRun,
    /// Resolved fill.
    pub color: Rgb,
}

/// An underline run with its resolved color and form.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UnderlineRun {
    /// Cells covered.
    pub run: CellRun,
    /// Resolved stroke color.
    pub color: Rgb,
    /// Which underline form to draw.
    pub style: UnderlineStyle,
}

/// One shaped text run pinned to an explicit cell origin.
///
/// `cells` is the run's declared footprint. The renderer clips to it, so
/// a font whose glyph is wider than the cells the child allocated
/// overflows into a clip, never into the next column's origin.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TextRun {
    /// Row within the frame.
    pub row: u32,
    /// Origin column.
    pub col: u32,
    /// Declared width in cells.
    pub cells: u32,
    /// Text to shape.
    pub text: String,
    /// Resolved (post-`reverse`) foreground.
    pub color: Rgb,
    /// Bold via font attributes.
    pub bold: bool,
    /// Italic via font attributes.
    pub italic: bool,
}

/// Everything one terminal frame paints, in cell coordinates.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TerminalPaintPlan {
    /// The frame's declared grid.
    pub size: CellSize,
    /// Background fills, coalesced across adjacent equal colors.
    pub backgrounds: Vec<BackgroundRun>,
    /// Text runs in row-major order.
    pub runs: Vec<TextRun>,
    /// Underline strokes, coalesced across adjacent equal color+form.
    pub underlines: Vec<UnderlineRun>,
    /// Editor-owned selection wash, one run per selected row.
    pub selection: Vec<CellRun>,
    /// The child's cursor cell, when visible.
    pub cursor: Option<CellRun>,
}

/// Resolve a cell's foreground and background, applying `reverse` after
/// both defaults have been substituted.
fn resolved_colors(style: &Style, palette: TerminalPalette) -> (Rgb, Rgb) {
    let fg = resolve_color(style.fg, palette.default_fg);
    let bg = resolve_color(style.bg, palette.default_bg);
    if style.reverse { (bg, fg) } else { (fg, bg) }
}

/// Map one wire color through the frontend's defaults and palette.
fn resolve_color(color: Color, default: Rgb) -> Rgb {
    match color {
        Color::Default => default,
        Color::Rgb(r, g, b) => [r, g, b],
        Color::Indexed(index) => indexed_rgb(index),
    }
}

/// Standard xterm-style 256-color palette: 16 base colors, the 6×6×6
/// cube (16..=231), then the 24-step grayscale ramp (232..=255).
///
/// Deliberately the same table the document path's `indexed_to_glyphon`
/// uses. Two palettes in one frontend would make an indexed diagnostic
/// and an indexed terminal cell disagree on what "red" is.
pub fn indexed_rgb(index: u8) -> Rgb {
    const ANSI16: [Rgb; 16] = [
        [0, 0, 0],
        [205, 49, 49],
        [13, 188, 121],
        [229, 229, 16],
        [36, 114, 200],
        [188, 63, 188],
        [17, 168, 205],
        [229, 229, 229],
        [102, 102, 102],
        [241, 76, 76],
        [35, 209, 139],
        [245, 245, 67],
        [59, 142, 234],
        [214, 112, 214],
        [41, 184, 219],
        [255, 255, 255],
    ];
    if index < 16 {
        return ANSI16[index as usize];
    }
    if (16..=231).contains(&index) {
        const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
        let i = index - 16;
        return [
            STEPS[(i / 36) as usize],
            STEPS[((i / 6) % 6) as usize],
            STEPS[(i % 6) as usize],
        ];
    }
    let level = 8 + 10 * (index - 232);
    [level, level, level]
}

/// The style a cell paints with.
///
/// A continuation has no paint identity of its own: it is the second
/// column of the preceding glyph, so it inherits that lead's style. A
/// continuation that carried its own style would let the two halves of
/// one wide character disagree on background or underline.
fn paint_style(cells: &[Cell], index: usize, row_start: usize) -> &Style {
    if matches!(cells[index].glyph, Glyph::Continuation) && index > row_start {
        &cells[index - 1].style
    } else {
        &cells[index].style
    }
}

/// The text a cell contributes, or `None` for a continuation.
fn cell_text(cell: &Cell) -> Option<String> {
    match &cell.glyph {
        Glyph::Char(ch) => Some(ch.to_string()),
        // Validation already proved the cluster is UTF-8; a defensive
        // lossy decode keeps a future validator change from panicking
        // the renderer.
        Glyph::Cluster(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
        Glyph::Continuation => None,
    }
}

/// Whether a cell can join an ASCII run.
///
/// Only single-column ASCII qualifies. Everything else — clusters, wide
/// leads, non-ASCII — gets its own explicitly positioned run, because
/// only for ASCII in a monospace face does the shaped advance reliably
/// equal the cell advance.
fn ascii_runnable(cell: &Cell) -> bool {
    matches!(&cell.glyph, Glyph::Char(ch) if ch.is_ascii_graphic() || *ch == ' ')
}

/// Whether a cell is a wide lead (its continuation follows).
fn is_wide_lead(cells: &[Cell], index: usize) -> bool {
    cells
        .get(index + 1)
        .is_some_and(|next| matches!(next.glyph, Glyph::Continuation))
}

impl TerminalPaintPlan {
    /// Resolve a validated frame into cell-space paint data.
    ///
    /// The frame must already have passed
    /// [`TerminalFrame::validate`]; this assumes the cell count, the
    /// wide-continuation topology, and the selection spans are sound.
    #[must_use]
    pub fn build(frame: &TerminalFrame, palette: TerminalPalette) -> Self {
        let mut plan = Self::build_grid(frame.size, &frame.cells, frame.cursor, palette);
        // Selection is the one piece a panel grid does not have: it is a
        // terminal-only span list on the wire, so it stays out of the
        // shared derivation rather than being faked as empty inside it.
        plan.selection = frame
            .selection
            .iter()
            .map(|span| CellRun {
                row: span.row,
                start_col: span.start_col,
                end_col: span.end_col,
            })
            .collect();
        plan
    }

    /// The shared half: resolve any validated wire cell grid into
    /// cell-space paint data.
    ///
    /// Extracted for the bottom panel band, which paints the same cell
    /// vocabulary from [`pmacs_protocol::panel::PanelFrame`]. Both callers
    /// go through one row planner, so a panel and a terminal cannot come
    /// to disagree about how a wide-continuation pair or an attribute
    /// break is drawn.
    ///
    /// The grid must already have passed its own message's `validate`;
    /// this assumes the cell count, the wide-continuation topology, and
    /// the cursor bounds are sound.
    #[must_use]
    pub fn build_grid(
        size: CellSize,
        cells: &[Cell],
        cursor: Option<pmacs_protocol::CellCoord>,
        palette: TerminalPalette,
    ) -> Self {
        let cols = size.cols as usize;
        let mut plan = Self {
            size,
            ..Self::default()
        };
        if cols == 0 {
            return plan;
        }
        for row in 0..size.rows {
            let row_start = row as usize * cols;
            let row_cells = &cells[row_start..row_start + cols];
            plan.plan_row(row, row_cells, palette);
        }
        plan.cursor = cursor.map(|cursor| CellRun {
            row: cursor.row,
            start_col: cursor.col,
            end_col: cursor.col + 1,
        });
        plan
    }

    /// Plan one row's backgrounds, underlines, and text runs.
    #[allow(clippy::too_many_lines, reason = "one row's paint state machine")]
    fn plan_row(&mut self, row: u32, cells: &[Cell], palette: TerminalPalette) {
        // Backgrounds and underlines coalesce across the whole row;
        // text runs break on any attribute change AND on any glyph that
        // cannot be positioned by shaping.
        let mut bg_open: Option<(u32, Rgb)> = None;
        let mut ul_open: Option<(u32, Rgb, UnderlineStyle)> = None;
        let mut text_open: Option<(u32, String, Rgb, bool, bool)> = None;

        for col in 0..cells.len() {
            let style = paint_style(cells, col, 0);
            let (fg, bg) = resolved_colors(style, palette);
            let col_u32 = col as u32;

            match bg_open {
                Some((start, color)) if color != bg => {
                    self.backgrounds.push(BackgroundRun {
                        run: CellRun {
                            row,
                            start_col: start,
                            end_col: col_u32,
                        },
                        color,
                    });
                    bg_open = Some((col_u32, bg));
                }
                Some(_) => {}
                None => bg_open = Some((col_u32, bg)),
            }

            // A `Default` underline color follows the POST-reverse
            // foreground: on a reversed cell the underline must track
            // the color the glyph actually drew in, not the one the
            // child nominally set.
            let underline = (style.underline != UnderlineStyle::None).then(|| {
                let color = match style.underline_color {
                    Color::Default => fg,
                    other => resolve_color(other, fg),
                };
                (color, style.underline)
            });
            match (ul_open, underline) {
                (Some((start, color, form)), Some((next_color, next_form)))
                    if color != next_color || form != next_form =>
                {
                    self.underlines.push(UnderlineRun {
                        run: CellRun {
                            row,
                            start_col: start,
                            end_col: col_u32,
                        },
                        color,
                        style: form,
                    });
                    ul_open = Some((col_u32, next_color, next_form));
                }
                (Some((start, color, form)), None) => {
                    self.underlines.push(UnderlineRun {
                        run: CellRun {
                            row,
                            start_col: start,
                            end_col: col_u32,
                        },
                        color,
                        style: form,
                    });
                    ul_open = None;
                }
                (None, Some((color, form))) => ul_open = Some((col_u32, color, form)),
                (Some(_), Some(_)) | (None, None) => {}
            }

            let cell = &cells[col];
            let joinable = ascii_runnable(cell) && !is_wide_lead(cells, col);
            if joinable {
                match text_open.as_mut() {
                    Some((_, text, color, bold, italic))
                        if *color == fg && *bold == style.bold && *italic == style.italic =>
                    {
                        text.push_str(&cell_text(cell).unwrap_or_default());
                    }
                    _ => {
                        self.flush_text_run(row, text_open.take());
                        text_open = Some((
                            col_u32,
                            cell_text(cell).unwrap_or_default(),
                            fg,
                            style.bold,
                            style.italic,
                        ));
                    }
                }
                continue;
            }

            self.flush_text_run(row, text_open.take());
            let Some(text) = cell_text(cell) else {
                // A continuation draws nothing: its lead already
                // covers both columns.
                continue;
            };
            let cells_wide = if is_wide_lead(cells, col) { 2 } else { 1 };
            self.runs.push(TextRun {
                row,
                col: col_u32,
                cells: cells_wide,
                text,
                color: fg,
                bold: style.bold,
                italic: style.italic,
            });
        }

        let end = cells.len() as u32;
        if let Some((start, color)) = bg_open {
            self.backgrounds.push(BackgroundRun {
                run: CellRun {
                    row,
                    start_col: start,
                    end_col: end,
                },
                color,
            });
        }
        if let Some((start, color, form)) = ul_open {
            self.underlines.push(UnderlineRun {
                run: CellRun {
                    row,
                    start_col: start,
                    end_col: end,
                },
                color,
                style: form,
            });
        }
        self.flush_text_run(row, text_open);
    }

    fn flush_text_run(&mut self, row: u32, open: Option<(u32, String, Rgb, bool, bool)>) {
        let Some((col, text, color, bold, italic)) = open else {
            return;
        };
        let cells = text.chars().count() as u32;
        self.runs.push(TextRun {
            row,
            col,
            cells,
            text,
            color,
            bold,
            italic,
        });
    }
}

/// The whole-cell capacity of a frontend frame, for the bottom panel's
/// geometry declaration (Q#BP15a).
///
/// Two deliberate differences from [`cell_viewport`], the terminal
/// projection:
///
/// * **No per-axis cap.** A panel does not inherit the 512-column PTY
///   limit — a 4K surface at a small font is legitimately wider than that,
///   and the wire's *area* bound is what keeps the encoding inside the
///   transport budget. Clamping here would silently narrow a legal panel.
/// * **The virtual status row.** The daemon's layout area is
///   `total.rows - 1`, because its grid model reserves one row for the mode
///   line that this frontend paints as its own status band. Declaring only
///   the document rows would lose one row of layout on every frame.
///
/// `None` on any degenerate input — zero, negative, or non-finite metrics,
/// or a surface too small for one whole cell — so the caller fails closed
/// to zero usable geometry instead of computing an absurd row count.
#[must_use]
pub fn panel_cell_capacity(
    width_px: f32,
    height_px: f32,
    advance_px: f32,
    line_px: f32,
) -> Option<CellSize> {
    if !(advance_px.is_finite() && line_px.is_finite()) || advance_px <= 0.0 || line_px <= 0.0 {
        return None;
    }
    if !(width_px.is_finite() && height_px.is_finite()) || width_px <= 0.0 || height_px <= 0.0 {
        return None;
    }
    let cols = (width_px / advance_px).floor();
    let rows = (height_px / line_px).floor();
    if cols < 1.0 || rows < 1.0 {
        return None;
    }
    // `as u32` saturates at u32::MAX for an absurd finite ratio, which is
    // bounded rather than wrapping; the area check below then rejects it.
    let cols = cols as u32;
    let rows = rows as u32;
    // Shed rows rather than emit an area the wire must reject. The bound
    // is on the PANEL grid the daemon will derive, and the daemon's own
    // clamp repeats it — two independent clamps because neither side may
    // depend on the other having applied it.
    let max_rows =
        u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1))
            .unwrap_or(u32::MAX);
    let rows = rows.min(max_rows);
    if rows == 0 {
        return None;
    }
    // The virtual status row rides on top of the document rows.
    Some(CellSize::new(rows.saturating_add(1), cols))
}

/// The terminal cell viewport a drawable rectangle admits.
///
/// Rows and columns are `floor(extent / metric)`, clamped through the
/// shared protocol limits so an enormous window cannot declare a grid
/// the daemon would reject. A rectangle too small for one whole cell
/// yields `None`: a zero-area declaration is not sent at all, and the
/// next geometry change that produces a valid size sends one.
#[must_use]
pub fn cell_viewport(
    width_px: f32,
    height_px: f32,
    advance_px: f32,
    line_px: f32,
) -> Option<CellSize> {
    if !(advance_px.is_finite() && line_px.is_finite()) || advance_px <= 0.0 || line_px <= 0.0 {
        return None;
    }
    if !(width_px.is_finite() && height_px.is_finite()) || width_px <= 0.0 || height_px <= 0.0 {
        return None;
    }
    let cols = (width_px / advance_px).floor();
    let rows = (height_px / line_px).floor();
    if cols < 1.0 || rows < 1.0 {
        return None;
    }
    let cols = (cols as u32).min(u32::from(pmacs_protocol::MAX_TERMINAL_COLS));
    let rows = (rows as u32).min(u32::from(pmacs_protocol::MAX_TERMINAL_ROWS));
    // The area bound can still bite at the extremes (512x512 is exactly
    // the cap, but a future limit change need not keep that true), so
    // shed rows rather than emit a size the daemon must reject.
    let max_rows = (pmacs_protocol::MAX_TERMINAL_VISIBLE_CELLS / cols as usize) as u32;
    let rows = rows.min(max_rows);
    if rows == 0 {
        return None;
    }
    Some(CellSize::new(rows, cols))
}

/// Hit-test a pixel inside the terminal rectangle to a cell.
///
/// Returns `None` outside the declared grid, so the status band, the
/// padding past the last full column, and any point above the terminal
/// origin are never terminal hits.
#[must_use]
pub fn hit_test_cell(
    x_px: f32,
    y_px: f32,
    origin: (f32, f32),
    advance_px: f32,
    line_px: f32,
    size: CellSize,
) -> Option<pmacs_protocol::CellCoord> {
    if advance_px <= 0.0 || line_px <= 0.0 {
        return None;
    }
    let dx = x_px - origin.0;
    let dy = y_px - origin.1;
    if dx < 0.0 || dy < 0.0 {
        return None;
    }
    let col = (dx / advance_px).floor();
    let row = (dy / line_px).floor();
    if col < 0.0 || row < 0.0 {
        return None;
    }
    let col = col as u32;
    let row = row as u32;
    if row >= size.rows || col >= size.cols {
        return None;
    }
    Some(pmacs_protocol::CellCoord::new(row, col))
}

#[cfg(test)]
mod tests {
    use super::*;
    use pmacs_protocol::{BufferId, CellCoord, TerminalProcessState, TerminalSelectionSpan};

    const PALETTE: TerminalPalette = TerminalPalette {
        default_fg: [230, 230, 235],
        default_bg: [13, 13, 18],
    };

    fn styled(glyph: Glyph, style: Style) -> Cell {
        Cell {
            glyph,
            style,
            attachment: None,
        }
    }

    fn plain(ch: char) -> Cell {
        styled(Glyph::Char(ch), Style::default())
    }

    fn frame(rows: u32, cols: u32, cells: Vec<Cell>) -> TerminalFrame {
        TerminalFrame {
            buffer_id: BufferId::from_raw(1),
            size: CellSize::new(rows, cols),
            cells,
            cursor: None,
            title: None,
            screen_generation: 1,
            selection: Vec::new(),
            scroll_offset: 0,
            at_bottom: true,
            pid: 1,
            process: TerminalProcessState::Running,
        }
    }

    #[test]
    fn ascii_coalesces_by_attributes_and_keeps_explicit_origins() {
        let red = Style {
            fg: Color::Indexed(1),
            ..Style::default()
        };
        let cells = vec![
            plain('a'),
            plain('b'),
            styled(Glyph::Char('c'), red),
            plain('d'),
        ];
        let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
        assert_eq!(plan.runs.len(), 3);
        assert_eq!(plan.runs[0].text, "ab");
        assert_eq!(plan.runs[0].col, 0);
        assert_eq!(plan.runs[0].cells, 2);
        assert_eq!(plan.runs[1].text, "c");
        assert_eq!(plan.runs[1].col, 2);
        assert_eq!(plan.runs[1].color, indexed_rgb(1));
        // The run after the attribute change is positioned by its own
        // column, not by where the previous run's glyphs happened to end.
        assert_eq!(plan.runs[2].col, 3);
    }

    #[test]
    fn wide_lead_owns_two_cells_and_its_continuation_draws_nothing() {
        let cyan = Style {
            bg: Color::Rgb(0, 40, 40),
            ..Style::default()
        };
        let cells = vec![
            styled(Glyph::Char('\u{4e00}'), cyan),
            styled(Glyph::Continuation, Style::default()),
            plain('x'),
            plain('y'),
        ];
        let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
        let wide = &plan.runs[0];
        assert_eq!(wide.text, "\u{4e00}");
        assert_eq!(wide.col, 0);
        assert_eq!(wide.cells, 2, "a wide lead declares a two-cell footprint");
        assert_eq!(
            plan.runs[1].col, 2,
            "the run after a wide glyph starts at its own column"
        );
        assert!(
            plan.runs.iter().all(|run| run.col != 1),
            "a continuation contributes no text run"
        );
        // The continuation carries a DEFAULT style on the wire but must
        // paint with its lead's background.
        let covering = plan
            .backgrounds
            .iter()
            .find(|bg| bg.run.start_col == 0)
            .expect("lead background");
        assert_eq!(covering.color, [0, 40, 40]);
        assert_eq!(
            covering.run.end_col, 2,
            "the lead's background covers both of its columns"
        );
    }

    #[test]
    fn reverse_swaps_resolved_defaults_and_drives_the_underline_color() {
        let style = Style {
            reverse: true,
            underline: UnderlineStyle::Single,
            ..Style::default()
        };
        let plan =
            TerminalPaintPlan::build(&frame(1, 1, vec![styled(Glyph::Char('z'), style)]), PALETTE);
        assert_eq!(plan.backgrounds[0].color, PALETTE.default_fg);
        assert_eq!(plan.runs[0].color, PALETTE.default_bg);
        assert_eq!(
            plan.underlines[0].color, PALETTE.default_bg,
            "a default underline color follows the post-reverse foreground"
        );
    }

    #[test]
    fn explicit_underline_color_and_form_coalesce_then_break() {
        let curly = Style {
            underline: UnderlineStyle::Curly,
            underline_color: Color::Rgb(200, 0, 0),
            ..Style::default()
        };
        let mut dotted = curly;
        dotted.underline = UnderlineStyle::Dotted;
        let cells = vec![
            styled(Glyph::Char('a'), curly),
            styled(Glyph::Char('b'), curly),
            styled(Glyph::Char('c'), dotted),
            plain('d'),
        ];
        let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
        assert_eq!(plan.underlines.len(), 2);
        assert_eq!(plan.underlines[0].run.start_col, 0);
        assert_eq!(plan.underlines[0].run.end_col, 2);
        assert_eq!(plan.underlines[0].style, UnderlineStyle::Curly);
        assert_eq!(plan.underlines[0].color, [200, 0, 0]);
        assert_eq!(plan.underlines[1].run.start_col, 2);
        assert_eq!(plan.underlines[1].run.end_col, 3);
        assert_eq!(plan.underlines[1].style, UnderlineStyle::Dotted);
    }

    #[test]
    fn bold_italic_and_truecolor_reach_the_run() {
        let style = Style {
            bold: true,
            italic: true,
            fg: Color::Rgb(1, 2, 3),
            ..Style::default()
        };
        let plan =
            TerminalPaintPlan::build(&frame(1, 1, vec![styled(Glyph::Char('q'), style)]), PALETTE);
        assert!(plan.runs[0].bold);
        assert!(plan.runs[0].italic);
        assert_eq!(plan.runs[0].color, [1, 2, 3]);
    }

    #[test]
    fn selection_and_cursor_come_only_from_the_frame() {
        let mut f = frame(2, 4, vec![plain('.'); 8]);
        f.selection = vec![TerminalSelectionSpan {
            row: 1,
            start_col: 1,
            end_col: 3,
        }];
        f.cursor = Some(CellCoord::new(0, 2));
        let plan = TerminalPaintPlan::build(&f, PALETTE);
        assert_eq!(
            plan.selection,
            vec![CellRun {
                row: 1,
                start_col: 1,
                end_col: 3
            }]
        );
        assert_eq!(
            plan.cursor,
            Some(CellRun {
                row: 0,
                start_col: 2,
                end_col: 3
            })
        );

        // A frame with no cursor paints none: visibility is the child's
        // decision, never the frontend's.
        f.cursor = None;
        assert!(TerminalPaintPlan::build(&f, PALETTE).cursor.is_none());
    }

    #[test]
    fn cluster_cells_get_their_own_positioned_run() {
        let cells = vec![
            plain('a'),
            styled(
                Glyph::Cluster("e\u{301}".as_bytes().to_vec().into_boxed_slice()),
                Style::default(),
            ),
            plain('b'),
        ];
        let plan = TerminalPaintPlan::build(&frame(1, 3, cells), PALETTE);
        assert_eq!(plan.runs.len(), 3);
        assert_eq!(plan.runs[1].text, "e\u{301}");
        assert_eq!(plan.runs[1].col, 1);
        assert_eq!(plan.runs[1].cells, 1);
        assert_eq!(plan.runs[2].col, 2);
    }

    #[test]
    fn rows_never_share_runs_or_background_spans() {
        let plan = TerminalPaintPlan::build(&frame(2, 2, vec![plain('x'); 4]), PALETTE);
        assert_eq!(
            plan.runs.len(),
            2,
            "one run per row, never wrapped together"
        );
        assert_eq!(plan.runs[0].row, 0);
        assert_eq!(plan.runs[1].row, 1);
        assert!(plan.backgrounds.iter().all(|bg| bg.run.end_col <= 2));
    }

    #[test]
    fn cell_viewport_floors_clamps_and_refuses_a_degenerate_rectangle() {
        assert_eq!(
            cell_viewport(100.0, 50.0, 10.0, 20.0),
            Some(CellSize::new(2, 10))
        );
        // Partial cells are dropped, never rounded up into a column the
        // child would write past.
        assert_eq!(
            cell_viewport(109.0, 59.0, 10.0, 20.0),
            Some(CellSize::new(2, 10))
        );
        assert_eq!(cell_viewport(9.0, 20.0, 10.0, 20.0), None);
        assert_eq!(cell_viewport(100.0, 19.0, 10.0, 20.0), None);
        assert_eq!(cell_viewport(100.0, 50.0, 0.0, 20.0), None);
        assert_eq!(cell_viewport(f32::NAN, 50.0, 10.0, 20.0), None);
        let huge = cell_viewport(1_000_000.0, 1_000_000.0, 1.0, 1.0).expect("clamped");
        assert_eq!(huge.rows, u32::from(pmacs_protocol::MAX_TERMINAL_ROWS));
        assert_eq!(huge.cols, u32::from(pmacs_protocol::MAX_TERMINAL_COLS));
    }

    #[test]
    fn hit_test_yields_only_in_bounds_cells() {
        let size = CellSize::new(3, 4);
        assert_eq!(
            hit_test_cell(16.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
            Some(CellCoord::new(0, 0))
        );
        assert_eq!(
            hit_test_cell(16.0 + 25.0, 16.0 + 41.0, (16.0, 16.0), 10.0, 20.0, size),
            Some(CellCoord::new(2, 2))
        );
        // Above / left of the origin, and past the last declared cell —
        // the status band and the trailing padding are not terminal hits.
        assert_eq!(
            hit_test_cell(15.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
            None
        );
        assert_eq!(
            hit_test_cell(16.0, 15.0, (16.0, 16.0), 10.0, 20.0, size),
            None
        );
        assert_eq!(
            hit_test_cell(16.0 + 40.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
            None
        );
        assert_eq!(
            hit_test_cell(16.0, 16.0 + 60.0, (16.0, 16.0), 10.0, 20.0, size),
            None
        );
    }
}
