pmacs/src/instance_render.rs

415 lines
16 KiB
Rust

// instance_render.rs --- Instance-side cell-buffer ownership and paint-and-diff.
//! Instance-side rendering (T M5.2).
//!
//! Spec §sec:m5-remote, §sec:v01-remote-scope deliverable 1.
//!
//! The `prev`/`next` cell buffers live on the instance, not on the frontend.
//! [`paint_frame`](crate::editor::paint_frame) writes into `next`; the diff
//! against `prev` produces a [`InstanceMessage::CellDelta`] the transport
//! ships to the frontend. The frontend (whether in-process TUI or network)
//! consumes the messages and emits whatever it needs to display them.
//!
//! This factoring is the architectural payoff promised by the spec
//! (§sec:remote): a frontend that diffs full grids locally cannot exist
//! over a network without making the network case pathologically expensive.
//! Doing the diff once, on the instance side, is what makes the SSH
//! transport (T M5.7) cheap.
use crate::cell::{Cell, CellGrid, CellSize, diff};
use crate::editor::{EditorState, paint_frame};
use crate::protocol::{CursorState, InstanceMessage};
/// Owns the cell buffers and runs the paint-and-diff cycle.
pub struct RenderState {
size: CellSize,
/// Last frame as shipped to the frontend. After [`Self::render_frame`],
/// this is the most recent painted state.
prev: Vec<Cell>,
/// Next frame being assembled. Cleared between frames so each render
/// starts from a blank slate (windows that shrink on resize do not
/// leak old contents).
next: Vec<Cell>,
/// True until the first frame is emitted, and after every resize.
/// Tells [`Self::render_frame`] to mark the next [`InstanceMessage::CellDelta`]
/// as a full-grid sync. Remote frontends use this flag to know they
/// must blank their local buffer before applying the deltas.
needs_full_grid: bool,
}
impl RenderState {
/// Construct a render state for a grid of the given dimensions.
#[must_use]
pub fn new(size: CellSize) -> Self {
let cells = (size.rows as usize) * (size.cols as usize);
Self {
size,
prev: vec![Cell::default(); cells],
next: vec![Cell::default(); cells],
needs_full_grid: true,
}
}
/// Current grid size in cells.
#[must_use]
pub fn size(&self) -> CellSize {
self.size
}
/// Resize the grid. Reallocates `prev`/`next` and flags the next
/// emitted [`InstanceMessage::CellDelta`] as a full-grid sync, since
/// the old buffer's contents are no longer applicable.
///
/// No-op if `new_size` matches the current size.
pub fn resize(&mut self, new_size: CellSize) {
if new_size == self.size {
return;
}
self.size = new_size;
let cells = (new_size.rows as usize) * (new_size.cols as usize);
self.prev = vec![Cell::default(); cells];
self.next = vec![Cell::default(); cells];
self.needs_full_grid = true;
}
/// Force the next emitted [`InstanceMessage::CellDelta`] to be a
/// full-grid sync, even when the buffer has not changed shape.
/// Used on fresh attach when a frontend connects to an
/// already-running instance and needs an authoritative starting
/// state.
pub fn force_full_grid_resync(&mut self) {
self.needs_full_grid = true;
}
/// Paint one frame and return the messages to ship.
///
/// Returns a `CellDelta` (with the changed spans) followed by a
/// `Cursor` message. Returns an empty vec if the grid is too small
/// to render meaningfully (`rows < 2` or `cols == 0`).
///
/// `other_presences` (T M10.9): other attached frontends' cursor
/// and selection snapshots, with their assigned color slots.
/// The overlay paint pass modifies cells in `next` AFTER the
/// main paint and BEFORE the diff. Empty slice → no overlays
/// (in-process TUI use; M10.6/7 daemon use).
pub fn render_frame(
&mut self,
state: &EditorState,
other_presences: &[crate::overlay_paint::OtherPresence],
) -> Vec<InstanceMessage> {
if self.size.rows < 2 || self.size.cols == 0 {
return Vec::new();
}
let cursor_coord = {
let mut grid = CellGrid {
cells: &mut self.next,
stride: self.size.cols,
size: self.size,
};
let coord = paint_frame(state, &mut grid, self.size);
// T M10.9 — overlay paint after main paint, before diff.
// Modifies cells in `next`; diff captures the changes
// as ordinary style updates.
crate::overlay_paint::paint_other_frontend_overlays(
state,
&mut grid,
self.size,
other_presences,
);
coord
};
// Full-grid sync semantics (T M5.3): when `needs_full_grid` is
// set, the `prev` buffer no longer represents what the consumer
// sees on screen — either it is freshly-allocated (just-resized
// or just-constructed), or a new frontend is attaching to a
// running instance whose `prev` reflects the previous
// frontend's view, not the new one's. Compare `next` against an
// all-default grid so the consumer receives every non-default
// cell of the current screen, regardless of what the previous
// frontend was shown.
let spans = if self.needs_full_grid {
let blank = vec![Cell::default(); self.next.len()];
diff(&blank, &self.next, self.size.cols, self.size)
} else {
diff(&self.prev, &self.next, self.size.cols, self.size)
};
let full_grid = self.needs_full_grid;
self.needs_full_grid = false;
std::mem::swap(&mut self.prev, &mut self.next);
for cell in &mut self.next {
*cell = Cell::default();
}
let cursor_state = cursor_coord.map(|coord| CursorState {
coord,
visible: true,
});
vec![
InstanceMessage::CellDelta { spans, full_grid },
InstanceMessage::Cursor(cursor_state),
]
}
}
#[cfg(test)]
mod tests {
// Acceptance home for T M5.2 (TUI re-architected as protocol consumer)
// and T M5.3 (cell-delta diffing happens instance-side). The M5.2
// contract — `RenderState` lives instance-side, `Frontend` becomes
// a transport sink — is structural and verified by the existing
// `paint_frame` tests passing unchanged. The M5.3 contract is
// exercised by the `m5_3_*`-prefixed tests below: full-grid sync on
// fresh attach, differential subsequent frames, fresh full-grid on
// re-attach. See tests/INDEX.md for the full M5.x → coverage map.
use super::*;
use crate::cell::CellCoord;
use crate::editor::EditorState;
use crate::protocol::FrontendId;
fn empty_state() -> EditorState {
EditorState::new()
}
#[test]
fn new_allocates_full_buffers() {
let r = RenderState::new(CellSize::new(24, 80));
assert_eq!(r.prev.len(), 24 * 80);
assert_eq!(r.next.len(), 24 * 80);
assert!(r.needs_full_grid);
}
#[test]
fn render_returns_cell_delta_and_cursor() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. }));
assert!(matches!(msgs[1], InstanceMessage::Cursor(_)));
}
#[test]
fn first_frame_is_full_grid_sync() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
_ => panic!("expected CellDelta first"),
}
}
#[test]
fn second_frame_is_differential() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid),
_ => panic!("expected CellDelta first"),
}
}
#[test]
fn unchanged_state_produces_empty_spans_after_first_frame() {
let state = empty_state();
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&state, &[]);
let msgs = r.render_frame(&state, &[]);
match &msgs[0] {
InstanceMessage::CellDelta { spans, .. } => assert!(
spans.is_empty(),
"expected no changes between identical frames; got {spans:?}"
),
_ => unreachable!(),
}
}
#[test]
fn resize_reallocates_and_flags_full_grid() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
assert!(!r.needs_full_grid);
r.resize(CellSize::new(40, 120));
assert_eq!(r.size(), CellSize::new(40, 120));
assert_eq!(r.prev.len(), 40 * 120);
assert_eq!(r.next.len(), 40 * 120);
assert!(r.needs_full_grid);
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
_ => unreachable!(),
}
}
#[test]
fn resize_to_same_size_is_noop() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
assert!(!r.needs_full_grid);
r.resize(CellSize::new(24, 80));
// No reallocation, no full-grid flip.
assert!(!r.needs_full_grid);
}
#[test]
fn force_full_grid_resync_flips_flag() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
assert!(!r.needs_full_grid);
r.force_full_grid_resync();
assert!(r.needs_full_grid);
}
#[test]
fn too_small_grid_returns_empty_messages() {
// rows < 2 means we can't paint a text-area + status row.
let mut r = RenderState::new(CellSize::new(1, 80));
assert!(r.render_frame(&empty_state(), &[]).is_empty());
let mut r = RenderState::new(CellSize::new(24, 0));
assert!(r.render_frame(&empty_state(), &[]).is_empty());
}
#[test]
fn cursor_message_carries_coord_when_paint_returns_one() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[1] {
InstanceMessage::Cursor(Some(cs)) => {
assert!(cs.visible);
// Empty *scratch* buffer puts the cursor at row 0, col 0.
assert_eq!(cs.coord, CellCoord::new(0, 0));
}
InstanceMessage::Cursor(None) => {
// Acceptable too — empty state may suppress cursor.
}
_ => panic!("expected Cursor message"),
}
}
// -------------------------------------------------------------------
// T M5.3 acceptance criteria
//
// Spec §sec:v01-remote-scope deliverable 2:
// 1. A fresh attach receives a full-grid CellDelta on the first frame.
// 2. A subsequent paint with one changed line emits only the changed
// cells (not a full grid).
// 3. Detach + re-attach produces a fresh full-grid sync (the instance
// does not preserve frontend-specific state).
// -------------------------------------------------------------------
#[test]
fn m5_3_fresh_attach_receives_full_grid_celldelta() {
// Criterion 1: the first frame after construction is a full-grid
// CellDelta carrying every non-default cell.
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(*full_grid, "first frame must be flagged full_grid=true");
// Empty *scratch* still paints a status row + cursor.
assert!(
!spans.is_empty(),
"fresh attach must surface the current screen, got empty spans"
);
}
_ => panic!("expected CellDelta as the first message"),
}
}
#[test]
fn m5_3_differential_frame_after_small_edit_is_proportionally_small() {
// Criterion 2: a state change that only affects a small region
// of the grid produces a delta that touches only a small fraction
// of cells, not the full grid.
use crate::editor::EditorState;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let size = CellSize::new(24, 80);
let mut state = EditorState::new();
let mut r = RenderState::new(size);
// Seat the prev buffer.
let _ = r.render_frame(&state, &[]);
// Single character insert.
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
},
);
let msgs = r.render_frame(&state, &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(!*full_grid, "differential frame must not flag full_grid");
let changed: usize = spans.iter().map(|s| s.cells.len()).sum();
let total = (size.rows * size.cols) as usize;
assert!(
changed * 10 < total,
"differential delta must be much smaller than full grid; \
got {changed} changed cells of {total} total"
);
}
_ => panic!("expected CellDelta first"),
}
}
#[test]
fn m5_3_force_full_grid_resync_replays_full_screen_against_blank() {
// Criterion 3: detach + re-attach produces a fresh full-grid
// sync. The instance does not preserve frontend-specific state,
// so the next frame after `force_full_grid_resync` reflects the
// full screen against a blank baseline (not against what the
// previous frontend saw).
let size = CellSize::new(5, 20);
let mut r = RenderState::new(size);
// First render: seats prev with the painted frame.
let first = r.render_frame(&empty_state(), &[]);
let baseline_changed: usize = match &first[0] {
InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(),
_ => unreachable!(),
};
assert!(baseline_changed > 0, "baseline must paint some cells");
// A second render with no state change normally produces zero
// spans (the state matches prev exactly).
let unchanged = r.render_frame(&empty_state(), &[]);
match &unchanged[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(!*full_grid);
assert!(spans.is_empty(), "unchanged state should diff to nothing");
}
_ => unreachable!(),
}
// Now simulate detach + re-attach: the new frontend has no idea
// what's on screen. force_full_grid_resync flags the next frame
// for full sync.
r.force_full_grid_resync();
let resync = r.render_frame(&empty_state(), &[]);
match &resync[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(*full_grid, "post-resync frame must be full_grid=true");
let resync_changed: usize = spans.iter().map(|s| s.cells.len()).sum();
assert_eq!(
resync_changed, baseline_changed,
"fresh-attach resync must surface the same number of cells \
as the original full-grid render; \
baseline={baseline_changed}, resync={resync_changed}"
);
}
_ => panic!("expected CellDelta first"),
}
}
}