pmacs/pmacs-protocol/src/wire_grid.rs

330 lines
11 KiB
Rust

//! Shared cell-grid validation for every wire message that carries a
//! rectangular grid of [`Cell`]s.
//!
//! Bottom-panel Stage 2B (Q#BP15) factors this out of
//! [`crate::terminal`], which was the only such message until
//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary
//! the framing names:
//!
//! - **Shared** — the checked area, the visible-cell bound, the cell
//! count, cursor bounds, glyph legality, wide-continuation topology,
//! the aggregate glyph-byte budget, and the attachment rejection.
//! - **Terminal-only** — the 512 per-axis PTY caps, title/process
//! metadata, selection spans, and the `at_bottom == (scroll_offset ==
//! 0)` coupling.
//!
//! The per-axis caps are a [`WireGridLimits`] parameter rather than a
//! constant precisely because a panel does not inherit them: a 4K
//! surface at a small font is legitimately wider than 512 columns, and
//! the area bound is what keeps the encoding inside the transport
//! budget.
//!
//! The attachment rejection is deliberately **shared**, not
//! terminal-only, even though its terminal-side message reads "which
//! terminals never use". Panels render no attachments either, so
//! rejecting them here fails closed for both; classifying it as
//! terminal-only would let a panel ship a cell no frontend can paint.
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Aggregate glyph-byte ceiling shared by every wire grid.
///
/// A grid at the visible-cell bound where every cell carries a maximum
/// cluster would exceed the transport frame limit; this keeps the
/// encoded size bounded independently of the per-cell rule.
pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024;
/// Per-cell grapheme-cluster byte ceiling shared by every wire grid.
pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256;
/// Visible-cell ceiling shared by every wire grid.
///
/// This is the transport-safety bound, not a per-message policy: it is
/// what keeps `rows * cols * per-cell` inside the transport frame limit,
/// so both the terminal and the panel answer to it even though they
/// carry different per-axis caps.
pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144;
/// Bounds a particular wire grid enforces.
///
/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells`
/// is the shared area bound and is what actually keeps the encoding
/// inside the transport budget.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct WireGridLimits {
/// Inclusive row ceiling.
pub max_rows: u32,
/// Inclusive column ceiling.
pub max_cols: u32,
/// Inclusive `rows * cols` ceiling.
pub max_visible_cells: usize,
/// Inclusive aggregate glyph-byte ceiling.
pub max_glyph_bytes: usize,
}
/// Why a wire grid is not structurally valid.
///
/// Callers map these onto their own message-specific error types so
/// existing wire errors keep their exact variants and text.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum WireGridError {
/// Rows or columns are zero or above this grid's bounds.
Size {
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
/// Row bound in force.
max_rows: u32,
/// Column bound in force.
max_cols: u32,
},
/// The checked area exceeds the visible-cell bound.
Area {
/// Checked `rows * cols`.
area: usize,
/// Bound in force.
max: usize,
},
/// `cells.len()` disagrees with the declared area.
CellCount {
/// Declared area.
expected: usize,
/// Supplied cell count.
actual: usize,
},
/// The cursor lies outside the declared grid.
Cursor {
/// Cursor row.
row: u32,
/// Cursor column.
col: u32,
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
},
/// A cell's glyph is not legal in a wire grid.
Glyph {
/// Row-major cell index.
index: usize,
/// Why the glyph failed.
reason: &'static str,
},
/// A cell carries a frontend attachment, which no wire grid uses.
Attachment {
/// Row-major cell index.
index: usize,
},
/// Aggregate glyph bytes exceed the budget.
GlyphBudget {
/// Bound in force.
max: usize,
},
}
/// Declared cell area, checked against this grid's bounds.
///
/// Separate from [`validate_wire_grid`] because callers need the area
/// before they have cells to check against it.
pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result<usize, WireGridError> {
let rows = size.rows;
let cols = size.cols;
if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols {
return Err(WireGridError::Size {
rows,
cols,
max_rows: limits.max_rows,
max_cols: limits.max_cols,
});
}
// `checked_mul` rather than a bound-derived assumption: a panel's
// axis ceilings are large enough that the product genuinely can
// overflow, which the terminal's 512x512 could not.
let area = rows
.checked_mul(cols)
.and_then(|area| usize::try_from(area).ok())
.ok_or(WireGridError::Area {
area: usize::MAX,
max: limits.max_visible_cells,
})?;
if area > limits.max_visible_cells {
return Err(WireGridError::Area {
area,
max: limits.max_visible_cells,
});
}
Ok(area)
}
/// Check every structural rule shared by wire grids.
///
/// Pure: a rejected grid mutates nothing, so callers get atomic
/// rejection for free.
pub fn validate_wire_grid(
size: CellSize,
cells: &[Cell],
cursor: Option<CellCoord>,
limits: WireGridLimits,
) -> Result<(), WireGridError> {
let area = checked_area(size, limits)?;
if cells.len() != area {
return Err(WireGridError::CellCount {
expected: area,
actual: cells.len(),
});
}
if let Some(cursor) = cursor
&& (cursor.row >= size.rows || cursor.col >= size.cols)
{
return Err(WireGridError::Cursor {
row: cursor.row,
col: cursor.col,
rows: size.rows,
cols: size.cols,
});
}
validate_cells(size, cells, limits)
}
/// Glyph legality, wide-continuation topology, and the glyph budget.
fn validate_cells(
size: CellSize,
cells: &[Cell],
limits: WireGridLimits,
) -> Result<(), WireGridError> {
let cols = size.cols as usize;
let mut glyph_bytes = 0usize;
// Columns still owed to the preceding wide lead on this row.
let mut pending_continuation = false;
for (index, cell) in cells.iter().enumerate() {
if cell.attachment.is_some() {
return Err(WireGridError::Attachment { index });
}
let col = index % cols;
if col == 0 && pending_continuation {
// A wide lead in the final column would have to be completed
// on the next row, which is not a footprint a cell grid can
// express.
return Err(WireGridError::Glyph {
index: index - 1,
reason: "wide glyph has no continuation column on its row",
});
}
match &cell.glyph {
Glyph::Continuation => {
if !pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "continuation without a preceding wide glyph",
});
}
pending_continuation = false;
}
Glyph::Char(ch) => {
if pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = char_display_width(*ch).ok_or(WireGridError::Glyph {
index,
reason: "glyph is a control or zero-width character",
})?;
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?;
pending_continuation = width == 2;
}
Glyph::Cluster(bytes) => {
if pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = cluster_display_width(bytes, index)?;
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?;
pending_continuation = width == 2;
}
}
}
if pending_continuation {
return Err(WireGridError::Glyph {
index: cells.len() - 1,
reason: "wide glyph has no continuation column on its row",
});
}
Ok(())
}
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
pub(crate) fn char_display_width(ch: char) -> Option<usize> {
if ch.is_control() {
return None;
}
match UnicodeWidthChar::width(ch) {
Some(1) => Some(1),
Some(2) => Some(2),
_ => None,
}
}
/// Column width of a leading `Cluster` glyph.
///
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
/// when it writes the cluster: a base plus combining marks may measure
/// wider than two columns, and the screen occupies two. Clamping in one
/// place and measuring in another is how a frame that renders correctly
/// gets rejected on the wire.
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, WireGridError> {
if bytes.is_empty() {
return Err(WireGridError::Glyph {
index,
reason: "cluster is empty",
});
}
if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES {
return Err(WireGridError::Glyph {
index,
reason: "cluster exceeds the per-cluster byte limit",
});
}
let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph {
index,
reason: "cluster is not valid UTF-8",
})?;
if text.chars().any(char::is_control) {
return Err(WireGridError::Glyph {
index,
reason: "cluster carries a control character",
});
}
let width = UnicodeWidthStr::width(text);
if width == 0 {
return Err(WireGridError::Glyph {
index,
reason: "cluster occupies no columns",
});
}
Ok(width.min(2))
}
/// Accumulate glyph bytes against the aggregate budget.
fn add_glyph_bytes(
total: usize,
add: usize,
limits: WireGridLimits,
) -> Result<usize, WireGridError> {
let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget {
max: limits.max_glyph_bytes,
})?;
if next > limits.max_glyph_bytes {
return Err(WireGridError::GlyphBudget {
max: limits.max_glyph_bytes,
});
}
Ok(next)
}