feat(panel): bottom-panel Stage 2B — the v21 protocol layer

Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and
factors the cell-grid validator so a panel frame shares the terminal's
rules without inheriting its PTY caps.

- `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after
  `InitialTargetResult`; `Absent` is an explicit authoritative state, not
  silence, because the receiver retains its last valid frame.
- `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
  appended after `TerminalPointer`. Geometry is valid without a side
  window — gating it on panel presence would deadlock the first open,
  since the daemon needs columns before it can paint a first frame.
- `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area,
  visible-cell bound, cell count, cursor bounds, glyph legality,
  wide-continuation topology, the aggregate glyph budget, and the
  attachment rejection. The 512 per-axis caps, metadata, selection spans,
  and the at_bottom/scroll_offset coupling stay terminal-only.
- The attachment rejection is deliberately shared despite its
  terminal-side wording: panels render no attachments either, so sharing
  it fails closed for both.

Both byte pins were falsified by revert: moving `PanelFrame` ahead of
`InitialTargetResult` shifts it 27 -> 28 and fails; moving the three
events ahead of `TerminalPointer` shifts it 12 -> 15 and fails.

The factoring changed no terminal acceptance — all 17 terminal tests pass
unchanged. It did surface a pre-existing coverage gap: those tests pin
the row cap but never the column cap, so widening `max_cols` to u32::MAX
left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not`
now covers that direction.

The daemon projection, the epoch state machine, and the GPU band are
later slices of this stage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
This commit is contained in:
Levi Neuwirth 2026-07-26 16:52:54 -04:00
parent 74301d1670
commit 640c5cd0d2
9 changed files with 1081 additions and 194 deletions

View File

@ -8904,6 +8904,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments",
InstanceMessage::TerminalFrame(_) => "TerminalFrame",
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
InstanceMessage::PanelFrame(_) => "PanelFrame",
}
}

View File

@ -40,8 +40,10 @@ pub mod cell;
pub mod crdt;
pub mod ids;
pub mod message;
pub mod panel;
pub mod terminal;
pub mod transport;
pub mod wire_grid;
/// Logical display columns between fixed buffer-text tab stops.
///
@ -67,9 +69,14 @@ pub use message::{
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
};
pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload};
pub use terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};
pub use wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES, WireGridError, WireGridLimits,
checked_area, validate_wire_grid,
};

View File

@ -442,6 +442,69 @@ pub enum FrontendEvent {
/// Modifiers held during the gesture.
mods: Modifiers,
},
/// Bottom panel Stage 2 (protocol v21): the frontend's authoritative
/// cell-equivalent layout capacity (Q#BP15a).
///
/// Valid **without** a side window — the daemon needs columns before
/// it can paint a first panel frame, so gating this on panel
/// presence would deadlock the first open. "Without" refers to
/// side-window presence only; the protocol and session gates still
/// apply, and the event is accepted only from an authenticated,
/// negotiated panel-capable semantic session.
///
/// Sent immediately after attach acceptance and refreshed on window
/// resize, font change, and scale change. `geometry_epoch` is
/// frontend-owned because a font or scale change can invalidate an
/// old panel frame while `total` is **identical**, which daemon-side
/// value dedup cannot detect.
FrontendCellGeometry {
/// Which frontend declared this (untrusted; checked against the
/// transport source).
frontend_id: FrontendId,
/// Monotonic frontend-owned declaration id; `0` is reserved for
/// "never declared" and is rejected on the wire.
geometry_epoch: u64,
/// Whole-cell capacity of the frontend's frame.
total: CellSize,
},
/// Bottom panel Stage 2 (protocol v21): requested fixed panel rows
/// from a divider drag (Q#BP15a).
///
/// Rows are the only size component; the epochs are identities, not
/// geometry. Accepted only for the currently visible `Present` panel
/// matching both the latest geometry declaration and the current
/// presentation epoch, then clamped by Q#BP2's interactive
/// preference.
PanelResizeRows {
/// Which frontend produced the drag (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this request is measured against.
geometry_epoch: u64,
/// Presentation identity this request addresses.
panel_epoch: u64,
/// Requested fixed panel rows.
rows: u32,
},
/// Bottom panel Stage 2 (protocol v21): a pointer gesture a semantic
/// frontend hit-tested to a panel CELL (Q#BP16).
///
/// Carries both epochs so a gesture aimed at a panel that has since
/// been replaced or reopened cannot be applied to its successor.
/// Unlike [`Self::Pointer`], accepting this **activates the panel**.
PanelPointer {
/// Which frontend produced the gesture (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this gesture was hit-tested against.
geometry_epoch: u64,
/// Presentation identity this gesture addresses.
panel_epoch: u64,
/// Cell the pointer is over, within the declared panel grid.
coord: CellCoord,
/// Which gesture step this is.
kind: MouseKind,
/// Modifiers held during the gesture.
mods: Modifiers,
},
}
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
@ -488,7 +551,10 @@ impl FrontendEvent {
| Self::Pointer { frontend_id, .. }
| Self::MenuPointer { frontend_id, .. }
| Self::TerminalResize { frontend_id, .. }
| Self::TerminalPointer { frontend_id, .. } => *frontend_id,
| Self::TerminalPointer { frontend_id, .. }
| Self::FrontendCellGeometry { frontend_id, .. }
| Self::PanelResizeRows { frontend_id, .. }
| Self::PanelPointer { frontend_id, .. } => *frontend_id,
}
}
}
@ -1143,6 +1209,20 @@ pub enum InstanceMessage {
/// Appended after [`Self::TerminalFrame`], the final v19 variant, so no
/// legacy postcard discriminant moves.
InitialTargetResult(InitialTargetResult),
/// Bottom panel Stage 2 (protocol v21): the daemon's painted
/// projection of one side window, or its authoritative absence
/// (Q#BP15).
///
/// `Absent` is sent on close **and** on hide: the receiver retains
/// its last valid frame, so silence would leave a stale band on
/// screen indefinitely. `Absent` is duplicate-suppressed like any
/// payload, and applying it clears the last declared panel size and
/// presentation epoch before any later event can validate against
/// them.
///
/// Appended after [`Self::InitialTargetResult`], the final v20
/// variant, so no existing postcard discriminant moves.
PanelFrame(crate::panel::PanelFramePayload),
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1565,7 +1645,17 @@ pub enum ResourceBody {
/// handshake extension is read only from v20 semantic sessions; the result is
/// sent only when such a session requested a target. v6v19 handshakes and
/// message discriminants remain unchanged.
pub const PROTOCOL_VERSION: u32 = 20;
///
/// Bottom panel Stage 2 (Q#BP9): bumped 20 → 21 for
/// [`InstanceMessage::PanelFrame`] and
/// [`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`].
/// All four are appended after their enum's previous final variant, so
/// no v6v20 discriminant moves and the encoding of every existing
/// message is byte-identical. The new traffic is gated in both
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
/// a side window, because denying only the events would leave its
/// window invisible.
pub const PROTOCOL_VERSION: u32 = 21;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1643,8 +1733,12 @@ pub const PROTOCOL_VERSION: u32 = 20;
/// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic
/// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy
/// and non-semantic sessions retain their existing handshake shape.
///
/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. v21 peers
/// may exchange panel traffic; v20 peers interoperate with it simply
/// absent, and are never placed in a side window.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

213
pmacs-protocol/src/panel.rs Normal file
View File

@ -0,0 +1,213 @@
//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16).
//!
//! A panel frame is the daemon's painted projection of one side window.
//! It shares [`crate::wire_grid`]'s cell rules with
//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: 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.
//!
//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative
//! and must be sent on close *and* on hide, because the receiver
//! retains its last valid frame: silence would leave a stale band on
//! screen indefinitely.
use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;
use crate::wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid,
};
/// Shared visible-cell ceiling for a panel grid.
///
/// Identical to the terminal bound: it is the transport-safety limit,
/// not a PTY policy, so both messages answer to it.
pub const MAX_PANEL_VISIBLE_CELLS: usize = 262_144;
/// Bounds a panel frame enforces on its cell grid.
///
/// The per-axis ceilings are the area bound itself rather than 512: any
/// axis larger than the area bound is already rejected by the area
/// check, so this expresses "no independent per-axis policy" without
/// leaving the multiplication unchecked.
const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits {
max_rows: MAX_PANEL_VISIBLE_CELLS as u32,
max_cols: MAX_PANEL_VISIBLE_CELLS as u32,
max_visible_cells: MAX_PANEL_VISIBLE_CELLS,
max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES,
};
/// The daemon's painted projection of one side window.
///
/// `panel_epoch` is opaque and monotonic per frontend: stable across
/// ordinary frames of one continuously present window/buffer, and
/// changed on buffer replacement, new side-window creation, and every
/// `Absent` → `Present` transition. That is what stops a stale
/// `PanelPointer` from addressing a reopened panel as if it were the
/// old one (Q#BP16).
///
/// `geometry_epoch` answers a *frontend* declaration and moves whenever
/// the frontend declares new effective cell geometry — including a font
/// or scale change that leaves [`CellSize`] identical, which is exactly
/// the case daemon-side value dedup cannot see (Q#BP2S1).
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PanelFrame {
/// Buffer this frame projects.
pub buffer_id: BufferId,
/// Presentation identity, monotonic per frontend.
pub panel_epoch: u64,
/// The frontend geometry declaration this frame answers.
pub geometry_epoch: u64,
/// Panel grid dimensions in cells.
pub size: CellSize,
/// Row-major cells; exactly `size.area()` entries.
pub cells: Vec<Cell>,
/// Panel caret, or `None` when the panel shows no cursor.
///
/// `paint_frame` returns the cursor separately from the cells, so a
/// frame carrying cells alone would lose the caret.
pub cursor: Option<CellCoord>,
/// Whether the panel owns focus.
///
/// Presentation and focus-chrome routing only (Q#BP14b) — the
/// *keys* decision is `DispatchIdle` (Q#BP14a).
pub focused: bool,
}
/// Explicit panel presence.
///
/// `Absent` is authoritative rather than implied by silence, and is
/// duplicate-suppressed like any other payload.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum PanelFramePayload {
/// A panel is visible and this is its current frame.
Present(PanelFrame),
/// No panel is visible; clear any retained frame.
Absent,
}
/// Why a [`PanelFrame`] is not structurally valid.
///
/// Validation is atomic: the frame is rejected whole and the receiver
/// retains its previous valid frame.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PanelFrameError {
/// Rows or columns are zero or above the area-derived bounds.
#[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")]
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 shared visible-cell bound.
#[error("panel area {area} exceeds the visible-cell bound {max}")]
Area {
/// Checked `rows * cols`.
area: usize,
/// Shared visible-cell bound.
max: usize,
},
/// `cells.len()` disagrees with the declared area.
#[error("panel frame carries {actual} cells for a {expected}-cell area")]
CellCount {
/// Declared area.
expected: usize,
/// Supplied cell count.
actual: usize,
},
/// The cursor lies outside the declared grid.
#[error("panel cursor ({row},{col}) is outside the {rows}x{cols} grid")]
Cursor {
/// Cursor row.
row: u32,
/// Cursor column.
col: u32,
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
},
/// A cell's glyph is not a legal wire glyph.
#[error("panel cell {index} has an invalid glyph: {reason}")]
Glyph {
/// Row-major cell index.
index: usize,
/// Why the glyph failed.
reason: &'static str,
},
/// A cell carries a frontend attachment, which panels never use.
#[error("panel cell {index} carries an attachment")]
Attachment {
/// Row-major cell index.
index: usize,
},
/// Aggregate glyph bytes exceed the shared budget.
#[error("panel frame glyph bytes exceed the aggregate bound {max}")]
GlyphBudget {
/// Shared aggregate bound.
max: usize,
},
/// An epoch is zero, which is reserved for "never declared".
#[error("panel {field} epoch is zero, which is reserved for 'never declared'")]
ZeroEpoch {
/// Which epoch was zero.
field: &'static str,
},
}
impl PanelFrame {
/// Check every structural rule a panel frame must satisfy.
///
/// Pure: a rejected frame mutates nothing, so callers get atomic
/// rejection for free.
pub fn validate(&self) -> Result<(), PanelFrameError> {
if self.panel_epoch == 0 {
return Err(PanelFrameError::ZeroEpoch { field: "panel" });
}
if self.geometry_epoch == 0 {
return Err(PanelFrameError::ZeroEpoch { field: "geometry" });
}
validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS)
.map_err(panel_grid_error)
}
}
/// Map a shared wire-grid failure onto this message's error type.
fn panel_grid_error(error: WireGridError) -> PanelFrameError {
match error {
WireGridError::Size {
rows,
cols,
max_rows,
max_cols,
} => PanelFrameError::Size {
rows,
cols,
max_rows,
max_cols,
},
WireGridError::Area { area, max } => PanelFrameError::Area { area, max },
WireGridError::CellCount { expected, actual } => {
PanelFrameError::CellCount { expected, actual }
}
WireGridError::Cursor {
row,
col,
rows,
cols,
} => PanelFrameError::Cursor {
row,
col,
rows,
cols,
},
WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason },
WireGridError::Attachment { index } => PanelFrameError::Attachment { index },
WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max },
}
}

View File

@ -12,13 +12,18 @@
//! that single structural policy. A second implementation of these rules
//! in a frontend is a bug, not a convenience.
//!
//! This module owns the crate's only `unicode-width` use: glyph column
//! width and wide-continuation topology cannot be checked without it.
//! Glyph column width and wide-continuation topology moved to
//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the
//! crate's only non-test `unicode-width` use: those rules are shared
//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps,
//! metadata, selection spans, and the `at_bottom`/`scroll_offset`
//! coupling stay here, because a panel does not inherit them.
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[cfg(test)]
use unicode_width::UnicodeWidthStr;
// ---------------------------------------------------------------------------
// Shared limits
@ -217,6 +222,58 @@ pub enum TerminalFrameError {
},
}
/// Bounds a terminal frame enforces on its cell grid.
///
/// The per-axis caps are the PTY-specific half of the split: a panel
/// frame shares every other rule but not these, because a panel is
/// sized by the frontend's surface rather than by a pty window size.
const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits {
max_rows: MAX_TERMINAL_ROWS as u32,
max_cols: MAX_TERMINAL_COLS as u32,
max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS,
max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES,
};
/// Map a shared wire-grid failure onto this message's error type.
///
/// The variants and their text are unchanged by the Stage 2B factoring:
/// every existing terminal-frame assertion still observes exactly what
/// it observed before.
fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError {
use crate::wire_grid::WireGridError;
match error {
WireGridError::Size {
rows,
cols,
max_rows,
max_cols,
} => TerminalFrameError::Size {
rows,
cols,
max_rows,
max_cols,
},
WireGridError::Area { area, max } => TerminalFrameError::Area { area, max },
WireGridError::CellCount { expected, actual } => {
TerminalFrameError::CellCount { expected, actual }
}
WireGridError::Cursor {
row,
col,
rows,
cols,
} => TerminalFrameError::Cursor {
row,
col,
rows,
cols,
},
WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason },
WireGridError::Attachment { index } => TerminalFrameError::Attachment { index },
WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max },
}
}
impl TerminalFrame {
/// Check every structural rule a terminal frame must satisfy.
///
@ -224,23 +281,13 @@ impl TerminalFrame {
/// by a frontend after decode. It is pure: a rejected frame mutates
/// nothing, so callers get atomic rejection for free.
pub fn validate(&self) -> Result<(), TerminalFrameError> {
let area = self.checked_area()?;
if self.cells.len() != area {
return Err(TerminalFrameError::CellCount {
expected: area,
actual: self.cells.len(),
});
}
if let Some(cursor) = self.cursor
&& (cursor.row >= self.size.rows || cursor.col >= self.size.cols)
{
return Err(TerminalFrameError::Cursor {
row: cursor.row,
col: cursor.col,
rows: self.size.rows,
cols: self.size.cols,
});
}
crate::wire_grid::validate_wire_grid(
self.size,
&self.cells,
self.cursor,
TERMINAL_GRID_LIMITS,
)
.map_err(terminal_grid_error)?;
if let Some(title) = &self.title {
validate_metadata("title", title)?;
}
@ -249,7 +296,6 @@ impl TerminalFrame {
TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?,
TerminalProcessState::Running | TerminalProcessState::Exited(_) => {}
}
self.validate_cells()?;
self.validate_selection()?;
if self.at_bottom != (self.scroll_offset == 0) {
return Err(TerminalFrameError::BottomState {
@ -260,107 +306,6 @@ impl TerminalFrame {
Ok(())
}
/// Declared cell area, checked against both shared bounds.
fn checked_area(&self) -> Result<usize, TerminalFrameError> {
let rows = self.size.rows;
let cols = self.size.cols;
if rows == 0
|| cols == 0
|| rows > u32::from(MAX_TERMINAL_ROWS)
|| cols > u32::from(MAX_TERMINAL_COLS)
{
return Err(TerminalFrameError::Size {
rows,
cols,
max_rows: u32::from(MAX_TERMINAL_ROWS),
max_cols: u32::from(MAX_TERMINAL_COLS),
});
}
// Both factors are bounded above by 512, so the product cannot
// overflow; `checked_mul` keeps that an assertion rather than an
// assumption a later bound change could quietly break.
let area = rows
.checked_mul(cols)
.and_then(|area| usize::try_from(area).ok())
.ok_or(TerminalFrameError::Area {
area: usize::MAX,
max: MAX_TERMINAL_VISIBLE_CELLS,
})?;
if area > MAX_TERMINAL_VISIBLE_CELLS {
return Err(TerminalFrameError::Area {
area,
max: MAX_TERMINAL_VISIBLE_CELLS,
});
}
Ok(area)
}
/// Glyph legality, wide-continuation topology, and the glyph budget.
fn validate_cells(&self) -> Result<(), TerminalFrameError> {
let cols = self.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 self.cells.iter().enumerate() {
if cell.attachment.is_some() {
return Err(TerminalFrameError::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
// terminal grid can express.
return Err(TerminalFrameError::Glyph {
index: index - 1,
reason: "wide glyph has no continuation column on its row",
});
}
match &cell.glyph {
Glyph::Continuation => {
if !pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "continuation without a preceding wide glyph",
});
}
pending_continuation = false;
}
Glyph::Char(ch) => {
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph {
index,
reason: "glyph is a control or zero-width character",
})?;
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?;
pending_continuation = width == 2;
}
Glyph::Cluster(bytes) => {
if pending_continuation {
return Err(TerminalFrameError::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())?;
pending_continuation = width == 2;
}
}
}
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index: self.cells.len() - 1,
reason: "wide glyph has no continuation column on its row",
});
}
Ok(())
}
/// One nonempty in-bounds span per row, strictly increasing by row.
fn validate_selection(&self) -> Result<(), TerminalFrameError> {
let mut previous_row: Option<u32> = None;
@ -395,73 +340,6 @@ impl TerminalFrame {
}
}
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
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, TerminalFrameError> {
if bytes.is_empty() {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster is empty",
});
}
if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster exceeds the per-cluster byte limit",
});
}
let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph {
index,
reason: "cluster is not valid UTF-8",
})?;
if text.chars().any(char::is_control) {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster carries a control character",
});
}
let width = UnicodeWidthStr::width(text);
if width == 0 {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster occupies no columns",
});
}
Ok(width.min(2))
}
/// Accumulate glyph bytes under the aggregate bound with checked addition.
fn add_glyph_bytes(total: usize, add: usize) -> Result<usize, TerminalFrameError> {
let next = total
.checked_add(add)
.ok_or(TerminalFrameError::GlyphBudget {
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
})?;
if next > MAX_TERMINAL_FRAME_GLYPH_BYTES {
return Err(TerminalFrameError::GlyphBudget {
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
});
}
Ok(next)
}
/// Length and control-character rules shared by title and process text.
fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> {
if text.len() > MAX_TERMINAL_METADATA_BYTES {
@ -482,7 +360,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::{Color, Style, UnderlineStyle};
// `Glyph` is no longer used by this module's production code — the
// glyph rules moved to `crate::wire_grid` — but these tests still
// construct frames cell by cell.
use crate::cell::{Color, Glyph, Style, UnderlineStyle};
use crate::message::InstanceMessage;
use crate::transport::MAX_FRAME_BYTES;

View File

@ -0,0 +1,321 @@
//! 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;
/// 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(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)
}

View File

@ -3313,6 +3313,20 @@ fn apply_event(
(grid terminals resize through the Stage 2 layout path)"
);
}
FrontendEvent::FrontendCellGeometry { .. }
| FrontendEvent::PanelResizeRows { .. }
| FrontendEvent::PanelPointer { .. } => {
// Bottom panel Stage 2 — panel declarations belong to
// negotiated panel-capable semantic sessions and are routed
// by the authenticated source in `handle_dispatcher_event`.
// A grid session has no panel band at all, so one arriving
// here is a protocol violation; drop it rather than letting
// a payload-trusted id reach a view.
eprintln!(
"pmacs daemon: panel declaration from a grid session; dropping \
(grid sessions negotiate no panel band)"
);
}
}
}

View File

@ -439,6 +439,11 @@ impl Frontend {
// Q#GT4 — this pre-window semantic bootstrap result cannot
// legitimately reach the grid TUI.
| InstanceMessage::InitialTargetResult(_)
// Q#BP15 — the panel band is painted by the GPU frontend;
// the grid TUI renders its side windows through the cell
// grid and negotiates no panel capability, so this cannot
// legitimately reach here.
| InstanceMessage::PanelFrame(_)
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -0,0 +1,351 @@
//! Bottom-panel Stage 2B — the v21 protocol slice.
//!
//! Covers parent acceptance 37 (round-trip plus the two byte pins) and
//! the shared/terminal-only validator split of Q#BP15. The daemon
//! projection, the epoch state machine, and the GPU band are later
//! slices of this stage and are not exercised here.
use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Glyph, Style};
use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind};
use pmacs_protocol::panel::{PanelFrame, PanelFrameError, PanelFramePayload};
use pmacs_protocol::terminal::{
MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState,
};
use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS};
fn cell(ch: char) -> Cell {
Cell {
glyph: Glyph::Char(ch),
style: Style::default(),
attachment: None,
}
}
fn panel_frame(rows: u32, cols: u32) -> PanelFrame {
PanelFrame {
buffer_id: BufferId::from_raw(9),
panel_epoch: 3,
geometry_epoch: 5,
size: CellSize::new(rows, cols),
cells: vec![cell(' '); (rows * cols) as usize],
cursor: Some(CellCoord::new(0, 0)),
focused: true,
}
}
fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
TerminalFrame {
buffer_id: BufferId::from_raw(9),
size: CellSize::new(rows, cols),
cells: vec![cell(' '); (rows * cols) as usize],
cursor: Some(CellCoord::new(0, 0)),
title: None,
screen_generation: 1,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 1,
process: TerminalProcessState::Running,
}
}
// ---------------------------------------------------------------------------
// 37 — version and round-trip
// ---------------------------------------------------------------------------
#[test]
fn the_panel_stage_takes_protocol_v21() {
assert_eq!(PROTOCOL_VERSION, 21);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
// v20 stays supported: a v20 peer interoperates with panel traffic
// simply absent rather than being refused the handshake.
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
}
#[test]
fn a_present_panel_frame_round_trips_with_both_epochs() {
let frame = panel_frame(2, 3);
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone()));
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else {
panic!("expected a Present panel frame, got {decoded:?}");
};
// Both epochs must survive: they are the identities every later
// panel event validates against, so a frame that round-trips its
// cells but drops an epoch would silently accept stale input.
assert_eq!(got.panel_epoch, frame.panel_epoch);
assert_eq!(got.geometry_epoch, frame.geometry_epoch);
assert_eq!(got.buffer_id, frame.buffer_id);
assert_eq!(got.size, frame.size);
assert_eq!(got.cells, frame.cells);
assert_eq!(got.cursor, frame.cursor);
assert_eq!(got.focused, frame.focused);
}
#[test]
fn an_absent_panel_payload_round_trips_as_its_own_state() {
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert!(matches!(
decoded,
InstanceMessage::PanelFrame(PanelFramePayload::Absent)
));
// Absent must be distinguishable from a Present frame carrying no
// cells: it is authoritative, and conflating the two would make
// "hide the band" indistinguishable from "paint an empty band".
let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1)));
assert_ne!(
postcard::to_allocvec(&empty_present).expect("encode"),
bytes
);
}
#[test]
fn the_three_panel_events_round_trip() {
let fid = FrontendId(4);
let events = vec![
FrontendEvent::FrontendCellGeometry {
frontend_id: fid,
geometry_epoch: 1,
total: CellSize::new(40, 120),
},
FrontendEvent::PanelResizeRows {
frontend_id: fid,
geometry_epoch: 2,
panel_epoch: 7,
rows: 12,
},
FrontendEvent::PanelPointer {
frontend_id: fid,
geometry_epoch: 2,
panel_epoch: 7,
coord: CellCoord::new(3, 9),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
},
];
for event in events {
let bytes = postcard::to_allocvec(&event).expect("encode");
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, event);
assert_eq!(decoded.frontend_id(), fid);
}
}
// ---------------------------------------------------------------------------
// 37 — byte pins on the previous final variant of each extended enum
// ---------------------------------------------------------------------------
#[test]
fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() {
// `InitialTargetResult` was the final v20 variant. Its encoding must
// be byte-identical after `PanelFrame` is appended; if the new
// variant were inserted anywhere earlier, this leading discriminant
// byte would shift and every v20 peer would misread the wire.
let msg = InstanceMessage::InitialTargetResult(
pmacs_protocol::message::InitialTargetResult::Opened {
buffer_id: BufferId::from_raw(1),
},
);
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes[0], 27,
"InitialTargetResult must stay discriminant 27; got {bytes:?}"
);
// And the appended variant must be the next one, not a reused slot.
let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
let panel_bytes = postcard::to_allocvec(&panel).expect("encode");
assert_eq!(panel_bytes[0], 28);
}
#[test]
fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() {
// `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`.
let event = FrontendEvent::TerminalPointer {
frontend_id: FrontendId(2),
buffer_id: BufferId::from_raw(3),
coord: CellCoord::new(1, 1),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
};
let bytes = postcard::to_allocvec(&event).expect("encode");
assert_eq!(
bytes[0], 12,
"TerminalPointer must stay discriminant 12; got {bytes:?}"
);
// The three appended events take the next three slots, in order.
let fid = FrontendId(2);
for (expected, event) in [
(
13u8,
FrontendEvent::FrontendCellGeometry {
frontend_id: fid,
geometry_epoch: 1,
total: CellSize::new(1, 1),
},
),
(
14,
FrontendEvent::PanelResizeRows {
frontend_id: fid,
geometry_epoch: 1,
panel_epoch: 1,
rows: 1,
},
),
(
15,
FrontendEvent::PanelPointer {
frontend_id: fid,
geometry_epoch: 1,
panel_epoch: 1,
coord: CellCoord::new(0, 0),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
},
),
] {
let bytes = postcard::to_allocvec(&event).expect("encode");
assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}");
}
}
// ---------------------------------------------------------------------------
// 39 — the shared/terminal-only validator split
// ---------------------------------------------------------------------------
#[test]
fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() {
let wide = MAX_TERMINAL_COLS as u32 + 1;
// The panel does not inherit the PTY per-axis cap: a 4K surface at a
// small font is legitimately this wide, and the area bound is what
// keeps the encoding inside the transport budget.
let panel = panel_frame(1, wide);
assert_eq!(panel.validate(), Ok(()));
// The terminal keeps it, and reports the axis that failed.
let terminal = terminal_frame(1, wide);
assert!(matches!(
terminal.validate(),
Err(TerminalFrameError::Size { cols, max_cols, .. })
if cols == wide && max_cols == MAX_TERMINAL_COLS as u32
));
}
#[test]
fn a_panel_still_answers_to_the_shared_area_bound() {
// Removing the per-axis cap must not remove the area bound: that is
// the check that actually bounds the encoded size.
let huge = panel_frame(1, 1);
let mut huge = huge;
huge.size = CellSize::new(1024, 1024);
huge.cells = vec![cell(' '); 1];
assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. })));
}
#[test]
fn a_panel_cell_carrying_an_attachment_is_rejected() {
// The attachment rejection is SHARED, not terminal-only, even though
// the terminal-side message says "which terminals never use":
// panels render no attachments either, so a shared rejection fails
// closed for both.
let mut frame = panel_frame(1, 2);
frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell {
image_id: 1,
sub_x: 0,
sub_y: 0,
});
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Attachment { index: 1 })
));
}
#[test]
fn panel_glyph_topology_matches_the_terminal_rules() {
// A wide lead with no continuation column on its row is rejected the
// same way for both messages — the topology rule is shared.
let mut frame = panel_frame(1, 1);
frame.cells[0] = cell('\u{4e00}');
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Glyph { .. })
));
let mut terminal = terminal_frame(1, 1);
terminal.cells[0] = cell('\u{4e00}');
assert!(matches!(
terminal.validate(),
Err(TerminalFrameError::Glyph { .. })
));
}
#[test]
fn a_panel_cursor_outside_its_grid_is_rejected() {
let mut frame = panel_frame(2, 2);
frame.cursor = Some(CellCoord::new(2, 0));
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Cursor {
row: 2,
rows: 2,
..
})
));
}
#[test]
fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() {
// Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame
// carrying it could otherwise match a receiver that has declared
// nothing yet.
let mut frame = panel_frame(1, 1);
frame.panel_epoch = 0;
assert!(matches!(
frame.validate(),
Err(PanelFrameError::ZeroEpoch { field: "panel" })
));
let mut frame = panel_frame(1, 1);
frame.geometry_epoch = 0;
assert!(matches!(
frame.validate(),
Err(PanelFrameError::ZeroEpoch { field: "geometry" })
));
}
#[test]
fn terminal_frames_are_unchanged_by_the_factoring() {
// The shared validator must not have altered terminal acceptance:
// a valid frame still validates, and each terminal-only rule still
// reports its own variant.
assert_eq!(terminal_frame(3, 4).validate(), Ok(()));
let mut bad_bottom = terminal_frame(1, 1);
bad_bottom.at_bottom = false;
bad_bottom.scroll_offset = 0;
assert!(matches!(
bad_bottom.validate(),
Err(TerminalFrameError::BottomState { .. })
));
let mut bad_meta = terminal_frame(1, 1);
bad_meta.title = Some("\u{7}".into());
assert!(matches!(
bad_meta.validate(),
Err(TerminalFrameError::Metadata { field: "title", .. })
));
let mut bad_count = terminal_frame(2, 2);
bad_count.cells.pop();
assert!(matches!(
bad_count.validate(),
Err(TerminalFrameError::CellCount {
expected: 4,
actual: 3
})
));
}