Skip to main content

rmux_sdk/
snapshot.rs

1//! Inert pane snapshot DTOs for SDK consumers.
2//!
3//! These types model an already-captured pane grid. They do not parse
4//! terminal output, resolve tmux targets, or depend on RMUX core/server
5//! internals.
6
7use std::fmt;
8
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11mod attrs;
12mod cell;
13mod color;
14mod cursor;
15mod glyph;
16
17pub use attrs::PaneAttributes;
18pub use cell::PaneCell;
19pub use color::PaneColor;
20pub use cursor::PaneCursor;
21pub use glyph::PaneGlyph;
22
23/// A captured pane grid in row-major cell order.
24///
25/// `revision` is a daemon-derived counter that changes whenever the captured
26/// pane state mutates — output, resize, clear, exit, or any other visible
27/// change. Consumers use it as the canonical "did the pane move?" signal;
28/// there is no separate `current_revision()` getter on the SDK pane handle,
29/// because the only authoritative point-in-time revision value is the one
30/// carried by a freshly captured snapshot (or by a revision-carrying
31/// [`PaneEvent`](crate::PaneEvent) variant).
32#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
33pub struct PaneSnapshot {
34    /// Visible pane width in terminal columns.
35    pub cols: u16,
36    /// Visible pane height in terminal rows.
37    pub rows: u16,
38    /// Row-major cells, with `row * cols + col` indexing.
39    pub cells: Vec<PaneCell>,
40    /// Captured cursor coordinates and state.
41    pub cursor: PaneCursor,
42    /// Daemon-derived revision counter for this captured pane state.
43    ///
44    /// The producer guarantees that when any observable pane field
45    /// (`cols`, `rows`, `cells`, `cursor`, the underlying process state)
46    /// changes between two captures, the revision changes too. Equal
47    /// revisions therefore mean "nothing observable changed". A captured
48    /// snapshot for an exited or no-longer-listed pane carries a revision
49    /// distinct from any prior live revision.
50    pub revision: u64,
51}
52
53impl PaneSnapshot {
54    /// Creates a snapshot after checking the row-major cell count.
55    ///
56    /// The expected cell count is `rows * cols`. Zero-sized dimensions are
57    /// allowed and therefore expect zero cells. The revision defaults to `0`;
58    /// use [`Self::with_revision`] to attach a daemon-derived revision.
59    pub fn new(
60        cols: u16,
61        rows: u16,
62        cells: Vec<PaneCell>,
63        cursor: PaneCursor,
64    ) -> Result<Self, PaneSnapshotShapeError> {
65        let snapshot = Self {
66            cols,
67            rows,
68            cells,
69            cursor,
70            revision: 0,
71        };
72        snapshot.validate_shape()?;
73        Ok(snapshot)
74    }
75
76    /// Returns a copy of this snapshot with the supplied revision.
77    #[must_use]
78    pub fn with_revision(mut self, revision: u64) -> Self {
79        self.revision = revision;
80        self
81    }
82
83    /// Returns the number of row-major cells implied by `rows * cols`.
84    #[must_use]
85    pub fn expected_cell_count(&self) -> usize {
86        expected_cell_count(self.cols, self.rows)
87    }
88
89    /// Returns whether `cells.len()` exactly matches `rows * cols`.
90    #[must_use]
91    pub fn is_row_major_shape(&self) -> bool {
92        self.cells.len() == self.expected_cell_count()
93    }
94
95    /// Checks the row-major cell-count invariant.
96    pub fn validate_shape(&self) -> Result<(), PaneSnapshotShapeError> {
97        let expected = self.expected_cell_count();
98        if self.cells.len() == expected {
99            Ok(())
100        } else {
101            Err(PaneSnapshotShapeError {
102                cols: self.cols,
103                rows: self.rows,
104                actual_cells: self.cells.len(),
105                expected_cells: expected,
106            })
107        }
108    }
109
110    /// Returns one cell by visible row and column.
111    #[must_use]
112    pub fn cell(&self, row: u16, col: u16) -> Option<&PaneCell> {
113        if row >= self.rows || col >= self.cols {
114            return None;
115        }
116        let index = usize::from(row)
117            .saturating_mul(usize::from(self.cols))
118            .saturating_add(usize::from(col));
119        self.cells.get(index)
120    }
121
122    /// Returns one row slice by visible row.
123    ///
124    /// Malformed snapshots with too few cells return `None` for incomplete
125    /// rows rather than panicking.
126    #[must_use]
127    pub fn row_cells(&self, row: u16) -> Option<&[PaneCell]> {
128        if row >= self.rows {
129            return None;
130        }
131
132        let cols = usize::from(self.cols);
133        let start = usize::from(row).checked_mul(cols)?;
134        let end = start.checked_add(cols)?;
135        self.cells.get(start..end)
136    }
137
138    /// Resolves the owning non-padding cell column for a visible position.
139    ///
140    /// If the addressed cell is not padding, its own column is returned. If it
141    /// is padding for a wide glyph, the leading glyph column is returned only
142    /// when that glyph's recorded display width spans the requested column.
143    #[must_use]
144    pub fn owning_cell_col(&self, row: u16, col: u16) -> Option<u16> {
145        let cell = self.cell(row, col)?;
146        if !cell.is_padding() {
147            return Some(col);
148        }
149
150        let mut owner = col;
151        while owner > 0 {
152            owner -= 1;
153            let candidate = self.cell(row, owner)?;
154            if !candidate.is_padding() {
155                let width = u16::from(candidate.glyph.width.max(1));
156                if owner.saturating_add(width) > col {
157                    return Some(owner);
158                }
159                return None;
160            }
161        }
162
163        None
164    }
165
166    /// Iterates visible, non-padding cells with their original row and column.
167    ///
168    /// Padding cells belonging to wide glyphs are skipped, while the leading
169    /// glyph keeps its original display column.
170    pub fn visible_cells(&self) -> impl Iterator<Item = (u16, u16, &PaneCell)> + '_ {
171        let cols = usize::from(self.cols);
172        let rows = usize::from(self.rows);
173        self.cells
174            .iter()
175            .enumerate()
176            .filter_map(move |(index, cell)| {
177                if cols == 0 || cell.is_padding() {
178                    return None;
179                }
180
181                let row = index / cols;
182                if row >= rows {
183                    return None;
184                }
185                let col = index % cols;
186                Some((row as u16, col as u16, cell))
187            })
188    }
189
190    /// Renders one visible row using RMUX core's lossy plain-text behavior.
191    ///
192    /// Padding cells are skipped and trailing space characters are trimmed.
193    /// Other whitespace and control-like payloads are preserved verbatim. If a
194    /// malformed snapshot ends partway through this row, the available cells
195    /// are rendered instead of panicking.
196    #[must_use]
197    pub fn visible_row_text(&self, row: u16) -> Option<String> {
198        self.lossy_row_cells(row).map(render_cells_lossy)
199    }
200
201    /// Renders one visible row, returning an empty string for out-of-bounds rows.
202    #[must_use]
203    pub fn row_text(&self, row: u16) -> String {
204        self.visible_row_text(row).unwrap_or_default()
205    }
206
207    /// Renders all visible rows using lossy plain-text behavior.
208    ///
209    /// Incomplete malformed rows render their available cells instead of
210    /// panicking.
211    #[must_use]
212    pub fn visible_lines(&self) -> Vec<String> {
213        (0..self.rows)
214            .map(|row| self.visible_row_text(row).unwrap_or_default())
215            .collect()
216    }
217
218    /// Renders all visible rows joined by `\n`.
219    ///
220    /// The returned string has no synthetic trailing newline.
221    #[must_use]
222    pub fn visible_text(&self) -> String {
223        self.visible_lines().join("\n")
224    }
225
226    fn lossy_row_cells(&self, row: u16) -> Option<&[PaneCell]> {
227        if row >= self.rows {
228            return None;
229        }
230
231        let cols = usize::from(self.cols);
232        if cols == 0 {
233            return Some(&[]);
234        }
235
236        let start = usize::from(row).checked_mul(cols)?;
237        if start >= self.cells.len() {
238            return Some(&[]);
239        }
240        let end = start.saturating_add(cols).min(self.cells.len());
241        Some(&self.cells[start..end])
242    }
243}
244
245impl Serialize for PaneSnapshot {
246    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
247    where
248        S: Serializer,
249    {
250        self.validate_shape().map_err(serde::ser::Error::custom)?;
251        PaneSnapshotFieldsRef {
252            cols: self.cols,
253            rows: self.rows,
254            cells: &self.cells,
255            cursor: &self.cursor,
256            revision: self.revision,
257        }
258        .serialize(serializer)
259    }
260}
261
262impl<'de> Deserialize<'de> for PaneSnapshot {
263    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
264    where
265        D: Deserializer<'de>,
266    {
267        let fields = PaneSnapshotFields::deserialize(deserializer)?;
268        Self::new(fields.cols, fields.rows, fields.cells, fields.cursor)
269            .map(|snapshot| snapshot.with_revision(fields.revision))
270            .map_err(serde::de::Error::custom)
271    }
272}
273
274#[derive(Serialize)]
275struct PaneSnapshotFieldsRef<'a> {
276    cols: u16,
277    rows: u16,
278    cells: &'a [PaneCell],
279    cursor: &'a PaneCursor,
280    revision: u64,
281}
282
283#[derive(Debug, Default, Deserialize)]
284#[serde(default)]
285struct PaneSnapshotFields {
286    cols: u16,
287    rows: u16,
288    cells: Vec<PaneCell>,
289    cursor: PaneCursor,
290    revision: u64,
291}
292
293/// Error returned when a snapshot's dimensions do not match its cell vector.
294#[derive(Debug, Clone, PartialEq, Eq, Hash)]
295pub struct PaneSnapshotShapeError {
296    cols: u16,
297    rows: u16,
298    actual_cells: usize,
299    expected_cells: usize,
300}
301
302impl PaneSnapshotShapeError {
303    /// Returns the snapshot column count.
304    #[must_use]
305    pub const fn cols(&self) -> u16 {
306        self.cols
307    }
308
309    /// Returns the snapshot row count.
310    #[must_use]
311    pub const fn rows(&self) -> u16 {
312        self.rows
313    }
314
315    /// Returns the actual number of cells supplied.
316    #[must_use]
317    pub const fn actual_cells(&self) -> usize {
318        self.actual_cells
319    }
320
321    /// Returns the expected `rows * cols` cell count.
322    #[must_use]
323    pub const fn expected_cells(&self) -> usize {
324        self.expected_cells
325    }
326}
327
328impl fmt::Display for PaneSnapshotShapeError {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        write!(
331            f,
332            "pane snapshot shape mismatch: {}x{} expects {} cells, got {}",
333            self.cols, self.rows, self.expected_cells, self.actual_cells
334        )
335    }
336}
337
338impl std::error::Error for PaneSnapshotShapeError {}
339
340fn expected_cell_count(cols: u16, rows: u16) -> usize {
341    usize::from(cols) * usize::from(rows)
342}
343
344fn render_cells_lossy(cells: &[PaneCell]) -> String {
345    let mut rendered = String::new();
346    for cell in cells {
347        if cell.is_padding() {
348            continue;
349        }
350        rendered.push_str(cell.text());
351    }
352    while rendered.ends_with(' ') {
353        rendered.pop();
354    }
355    rendered
356}