diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a26f340..32a9fba 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8904,6 +8904,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", + InstanceMessage::PanelFrame(_) => "PanelFrame", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index ce2d2c5..82cdd5b 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -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, +}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 971a78e..4103cd4 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -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. v6–v19 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 v6–v20 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`]. diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs new file mode 100644 index 0000000..d6e19d7 --- /dev/null +++ b/pmacs-protocol/src/panel.rs @@ -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, + /// 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, + /// 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 }, + } +} diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f094d7..b1b0016 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -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 { - 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 = 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 { - 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 { - 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 { - 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; diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs new file mode 100644 index 0000000..9c8f8dc --- /dev/null +++ b/pmacs-protocol/src/wire_grid.rs @@ -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 { + 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, + 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 { + 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 { + 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 { + 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) +} diff --git a/src/daemon.rs b/src/daemon.rs index 84716eb..eef6095 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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)" + ); + } } } diff --git a/src/frontend.rs b/src/frontend.rs index 8fcfb47..6b8755f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs new file mode 100644 index 0000000..31a95a7 --- /dev/null +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -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 + }) + )); +}