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:
parent
74301d1670
commit
640c5cd0d2
|
|
@ -8904,6 +8904,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
|||
InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments",
|
||||
InstanceMessage::TerminalFrame(_) => "TerminalFrame",
|
||||
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
|
||||
InstanceMessage::PanelFrame(_) => "PanelFrame",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
));
|
||||
}
|
||||
Loading…
Reference in New Issue