pmacs/pmacs-gpu/src/terminal.rs

831 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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
);
}
}