// 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, FrontendId, InstanceMessage}; use crate::terminal::TerminalSnapshot; use crate::window::WindowId; use std::collections::HashMap; /// 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, /// 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, /// 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, frontend_id: FrontendId, terminal_snapshots: &HashMap, other_presences: &[crate::overlay_paint::OtherPresence], ) -> Vec { 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, frontend_id, terminal_snapshots, &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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); 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, FrontendId::LOCAL, &HashMap::new(), &[]); let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]) .is_empty() ); let mut r = RenderState::new(CellSize::new(24, 0)); assert!( r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) .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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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, FrontendId::LOCAL, &HashMap::new(), &[]); // 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, FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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(), FrontendId::LOCAL, &HashMap::new(), &[]); 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"), } } }