831 lines
29 KiB
Rust
831 lines
29 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|