Skip to main content

justerm_core/
serialize.rs

1//! Issue #6 — binary, reference-based wire format for a damage frame.
2//!
3//! `encode` a [`Frame`] to bytes, `decode` them back; the round-trip is the
4//! contract. Reference-based (colour refs, Unicode scalars — never resolved RGB
5//! or atlas ids) so the engine stays theme- and font-agnostic; the consumer's
6//! adapter resolves references before handing cells to the renderer. Format spec
7//! and rationale: `docs/architecture.md` §Serialization + ADR-0005.
8
9use crate::cell::{Cell, CellFlags};
10use crate::color::Color;
11use crate::cursor::CursorShape;
12use crate::damage::ScrollOp;
13use crate::input::MouseEvents;
14use crate::selection::SelectionSpan;
15use core::num::NonZeroU32;
16use std::collections::BTreeMap;
17
18/// Wire magic ("juSTerm") + format version. A new feature bumps `VERSION`.
19const MAGIC: [u8; 2] = *b"JT";
20const VERSION: u8 = 13; // v13 adds a per-span underline-colour group: sparse (col, Color) pairs for cells drawing a coloured underline (SGR 58, #520); v12 adds a fifth overlay group: the consumer-designated active search match's spans (#428); v11 adds a fourth overlay group: every live marker's absolute buffer line for the overview ruler (#120 S3); v10 adds a marker kind discriminant + optional i32 exit to the overlay marker group (#159); v9 adds the alt-screen flag in the header (#149); v8 adds the mouse wanted-events mask in the header (#129/ADR-0016); v7 overlay marker group (#118/ADR-0015); v6 overlay selection + search-match spans (#108/ADR-0014); v5 scroll position (#112/ADR-0013); v4 cursor shape+blink (#81); v3 cursor row/col/visibility (#38)
21
22/// The wire-format version (the gating `VERSION` byte), exposed so a binding can
23/// assert at load that its decoder matches the backend encoder (#34/ADR-0008).
24pub const WIRE_VERSION: u8 = VERSION;
25
26/// Whether a frame redraws everything or just its spans.
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub enum FrameKind {
29    /// Every row is present (resize / alt-screen clear).
30    Full,
31    /// Only the listed spans changed since the consumer's ack.
32    Partial,
33}
34
35/// A damaged column run on one line, with its cells.
36///
37/// `combining` and `links` map a span-relative column to its frame-local index
38/// (1-based) — `combining` into [`Frame::side_table`], `links` into
39/// [`Frame::link_table`]. These are the per-cell `extra`/`link` references lifted
40/// out of the cell now that combining clusters (#45) and hyperlinks (#46) live in
41/// per-row maps. A column is present iff its cell carries the matching bit; on the
42/// wire they are the cell record's `extra`/`link` fields, so the bytes are
43/// unchanged.
44#[derive(Clone, PartialEq, Eq, Debug)]
45pub struct Span {
46    pub line: u16,
47    pub left: u16,
48    pub right: u16,
49    pub cells: Vec<Cell>,
50    pub combining: BTreeMap<usize, NonZeroU32>,
51    pub links: BTreeMap<usize, NonZeroU32>,
52    /// Underline colours (SGR 58, #520): span-relative column → the `Color`
53    /// reference the cell's coloured underline draws in. Sparse — only cells that
54    /// carry a non-default underline colour appear (gated on the `UNDERLINE`
55    /// attribute at parse time). Unlike `combining`/`links` this is a colour
56    /// reference, not a side-table index, so it ships inline (no `_table` on the
57    /// [`Frame`]). Kept off the per-cell record so a plain-text frame pays nothing
58    /// (ADR-0020: no inert per-cell payload).
59    pub ucolors: BTreeMap<usize, Color>,
60}
61
62/// A stable handle to a buffer line, handed out by `Engine::add_marker` (#118).
63/// Monotonic per engine. The consumer attaches a decoration to the id; the frame
64/// reports where the marker currently sits, and `TermEvent::MarkerDisposed`
65/// signals when its line has left the buffer.
66#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
67pub struct MarkerId(pub u32);
68
69/// What a marker means (#158). A plain `add_marker` decoration carries no
70/// semantics ([`MarkerKind::Plain`]); OSC 133 shell-integration marks carry the
71/// command-boundary role (prompt/command/output start, or command finished with
72/// its optional exit code). The engine only *parses and anchors* these — the
73/// success/failure colour, earcon and prompt-to-prompt navigation are consumer
74/// policy (ADR-0017), driven off the kind + exit the wire (#159) carries.
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
76pub enum MarkerKind {
77    /// A `add_marker` decoration anchor (#118) — no OSC-133 semantics.
78    Plain,
79    /// OSC `133;A` — the shell prompt begins here.
80    PromptStart,
81    /// OSC `133;B` — the typed command begins here (the prompt ended).
82    CommandStart,
83    /// OSC `133;C` — the command was submitted; its output begins here.
84    OutputStart,
85    /// OSC `133;D[;exit]` — the command finished, with its exit code if reported
86    /// (absent, empty or non-numeric → `None`).
87    CommandFinished(Option<i32>),
88}
89
90/// A marker projected onto the viewport (#118): its id, the row it sits on, and
91/// its kind (#159). Only markers visible in the current viewport are reported; an
92/// off-screen marker is omitted but still alive (death comes via `MarkerDisposed`,
93/// not absence — so the consumer can tell "scrolled away" from "gone"). The kind
94/// carries the OSC 133 command-boundary role + exit code so the consumer can drive
95/// prompt-to-prompt navigation and success/fail signals (#160).
96#[derive(Clone, Copy, PartialEq, Eq, Debug)]
97pub struct MarkerPosition {
98    pub id: MarkerId,
99    pub row: usize,
100    pub kind: MarkerKind,
101}
102
103/// A marker's absolute buffer line (#120 S3, v11). Unlike [`MarkerPosition`],
104/// this is reported for EVERY live marker — on-screen or not — so a frame-mode
105/// consumer can place overview-ruler marks buffer-relatively (dividing by
106/// `scrollback + rows`), the whole point of a ruler being to show off-viewport
107/// anchors. The consumer joins `id` with its decoration registry; the ruler mark's
108/// colour is the consumer's (theme-agnostic), so no kind/exit rides here.
109#[derive(Clone, Copy, PartialEq, Eq, Debug)]
110pub struct MarkerLine {
111    pub id: MarkerId,
112    /// Absolute buffer line, in the same `[0, scrollback_len + rows)` frame the
113    /// header's `scrollback_len`/`display_offset` use.
114    pub line: u32,
115}
116
117/// Interaction overlays projected onto the viewport (#108): highlight spans the
118/// engine carries on the frame so a frame-mode consumer can paint them without
119/// an in-process model query. Positions only — highlight colour is the
120/// consumer's (theme-agnostic). Coordinates are viewport rows/cols, re-projected
121/// by `frame()` against the scroll offset so the engine stays the single
122/// anchoring authority.
123#[derive(Clone, PartialEq, Eq, Debug, Default)]
124pub struct Overlay {
125    /// The live selection projected onto visible rows (`selection_range`).
126    pub selection: Vec<SelectionSpan>,
127    /// The search highlights projected onto visible rows. Search matches
128    /// are consumer-owned (next/prev navigation holds the `Vec<Match>`), so the
129    /// consumer hands the highlight set back via `set_search_highlights` and the
130    /// engine projects it here — mirroring how the engine-owned selection rides.
131    pub matches: Vec<SelectionSpan>,
132    /// Engine-owned markers visible in this viewport (#118): persistent line
133    /// anchors for decorations. Unlike the selection (cleared on a screen swap)
134    /// and search highlights (invalidated on output), markers re-anchor through
135    /// buffer mutation and survive an alt-screen excursion; only their viewport
136    /// position rides here.
137    pub markers: Vec<MarkerPosition>,
138    /// Every live marker's absolute buffer line (#120 S3, v11), on-screen or not —
139    /// the overview ruler needs off-viewport anchors, which `markers` (viewport-
140    /// only) can't supply. A superset of `markers` by id; different frame of
141    /// reference (absolute line, not viewport row).
142    pub marker_lines: Vec<MarkerLine>,
143    /// The *active* (current) search match's spans (#428, v12): the member of
144    /// `matches` the consumer designated via `set_active_search_highlight`
145    /// (which match is active is consumer policy — next/prev navigation).
146    /// Projected by the same mechanism as `matches`, and *also* present there —
147    /// the renderer's highlight ranking resolves the overlap (#424), not
148    /// exclusion here. Empty when nothing is designated.
149    pub active_match: Vec<SelectionSpan>,
150}
151
152/// One serialized damage cycle: the decoded logical form that `encode`/`decode`
153/// round-trip. `side_table` holds this frame's grapheme clusters (referenced by
154/// each cell's frame-local `extra`); `link_table` holds its OSC 8 hyperlink URIs
155/// (referenced by each cell's frame-local `link`).
156#[derive(Clone, PartialEq, Eq, Debug)]
157pub struct Frame {
158    pub cols: u16,
159    pub rows: u16,
160    pub kind: FrameKind,
161    /// Cursor row/col in screen coordinates (0-based), and whether the engine
162    /// shows it (DECTCEM). Rides in the header because the cursor moves with
163    /// almost every frame (#38). *Drawing* the cursor — cell-invert / overlay —
164    /// stays the consumer's renderer adapter; the engine only reports state.
165    pub cursor_row: u16,
166    pub cursor_col: u16,
167    pub cursor_visible: bool,
168    /// The caret shape (DECSCUSR #89) and whether it blinks (att610 ?12, #81).
169    /// Reported for the renderer; drawing/animation stays the consumer's.
170    pub cursor_shape: CursorShape,
171    pub cursor_blink: bool,
172    /// Viewport scroll position (#112 / ADR-0013), for the consumer's scrollbar.
173    /// `display_offset` = lines scrolled up from the bottom (0 = following the
174    /// live screen); `scrollback_len` = history lines (total = `+ rows`). Ride in
175    /// the header like the cursor — per-frame viewport state, not cell content.
176    pub display_offset: u32,
177    pub scrollback_len: u32,
178    /// The mouse tracking mode as a *wanted-events* mask (#129): which mouse
179    /// event categories the app asked to receive, so the consumer routes an event
180    /// to the app (bit set) or keeps it local. `empty()` = no reporting. Rides the
181    /// header like the cursor — per-frame mode state the consumer reads, not cell
182    /// content. Positions/encoding never cross; the backend encodes via
183    /// `encode_mouse`.
184    pub mouse_events: MouseEvents,
185    /// Whether the alternate screen (`?1049`/`?47`) is active (#149). Buffer-global
186    /// state a frame-mode consumer can't derive from viewport damage — the
187    /// accessibility announce policy (#119) gates on it (suppress TUI repaints).
188    /// Rides the header like the cursor scalars (ADR-0014).
189    pub alt_screen: bool,
190    pub scroll: Option<ScrollOp>,
191    pub spans: Vec<Span>,
192    pub side_table: Vec<Vec<char>>,
193    pub link_table: Vec<String>,
194    /// Interaction overlays for this viewport (#108): selection, search
195    /// highlights, the active match, and markers — see [`Overlay`].
196    pub overlay: Overlay,
197}
198
199/// Why a byte buffer could not be decoded into a [`Frame`].
200#[derive(Clone, Copy, PartialEq, Eq, Debug)]
201pub enum DecodeError {
202    /// Ran out of bytes mid-field.
203    Truncated,
204    /// First two bytes are not the wire magic.
205    BadMagic,
206    /// Unsupported format version.
207    BadVersion(u8),
208    /// A tag/kind byte held a value outside its defined set.
209    BadTag,
210    /// A span's `left` was past its `right` (would underflow the cell count).
211    BadSpan,
212}
213
214/// Serialize a frame to the binary wire format.
215pub fn encode(frame: &Frame) -> Vec<u8> {
216    let mut out = Vec::new();
217    out.extend_from_slice(&MAGIC);
218    out.push(VERSION);
219    out.push(frame.scroll.is_some() as u8);
220    out.push(match frame.kind {
221        FrameKind::Full => 0,
222        FrameKind::Partial => 1,
223    });
224    out.extend_from_slice(&frame.cols.to_le_bytes());
225    out.extend_from_slice(&frame.rows.to_le_bytes());
226    out.extend_from_slice(&frame.cursor_row.to_le_bytes());
227    out.extend_from_slice(&frame.cursor_col.to_le_bytes());
228    out.push(frame.cursor_visible as u8);
229    out.push(match frame.cursor_shape {
230        CursorShape::Block => 0,
231        CursorShape::Underline => 1,
232        CursorShape::Bar => 2,
233    });
234    out.push(frame.cursor_blink as u8);
235    out.extend_from_slice(&frame.display_offset.to_le_bytes());
236    out.extend_from_slice(&frame.scrollback_len.to_le_bytes());
237    // Mouse wanted-events mask (#129): one byte in the header, like the cursor
238    // scalars. Off = 0.
239    out.push(frame.mouse_events.bits());
240    // Alt-screen flag (#149): one byte in the header, like the cursor scalars.
241    out.push(frame.alt_screen as u8);
242    if let Some(s) = frame.scroll {
243        out.extend_from_slice(&(s.top as u16).to_le_bytes());
244        out.extend_from_slice(&(s.bottom as u16).to_le_bytes());
245        out.extend_from_slice(&(s.count as i16).to_le_bytes());
246    }
247    out.extend_from_slice(&(frame.spans.len() as u16).to_le_bytes());
248    for span in &frame.spans {
249        out.extend_from_slice(&span.line.to_le_bytes());
250        out.extend_from_slice(&span.left.to_le_bytes());
251        out.extend_from_slice(&span.right.to_le_bytes());
252        for (col, cell) in span.cells.iter().enumerate() {
253            // The grapheme and hyperlink indices now ride on the span (per
254            // column), not the cell.
255            let extra = span.combining.get(&col).map_or(0, |n| n.get() as u16);
256            let link = span.links.get(&col).map_or(0, |n| n.get() as u16);
257            out.extend_from_slice(&encode_cell_record(cell, extra, link));
258        }
259    }
260    out.extend_from_slice(&(frame.side_table.len() as u16).to_le_bytes());
261    for cluster in &frame.side_table {
262        out.extend_from_slice(&(cluster.len() as u16).to_le_bytes());
263        for &ch in cluster {
264            out.extend_from_slice(&(ch as u32).to_le_bytes());
265        }
266    }
267    // Hyperlink side-table: each URI as a length-prefixed UTF-8 byte run (#26).
268    out.extend_from_slice(&(frame.link_table.len() as u16).to_le_bytes());
269    for uri in &frame.link_table {
270        out.extend_from_slice(&(uri.len() as u16).to_le_bytes());
271        out.extend_from_slice(uri.as_bytes());
272    }
273    // Underline-colour group (SGR 58, #520, v13): one sparse map per span, in span
274    // order, so the column keys need no span index — the decoder reads exactly
275    // `span_count` maps and attaches each to its span. Each entry is `(col u16,
276    // colour u32)`, the colour packed by the same `encode_color` as fg/bg. A frame
277    // with no coloured underlines pays 2 bytes per span (the zero count).
278    for span in &frame.spans {
279        out.extend_from_slice(&(span.ucolors.len() as u16).to_le_bytes());
280        for (&col, &color) in &span.ucolors {
281            out.extend_from_slice(&(col as u16).to_le_bytes());
282            out.extend_from_slice(&encode_color(color).to_le_bytes());
283        }
284    }
285    // Overlay section (#108): each group is a u16 count then that many
286    // `(row, left, right)` u16 viewport triples. Selection first, then search
287    // matches. Append-only, version-gated — a future group (markers, #118) adds
288    // a third count here at the next version bump.
289    encode_overlay_spans(&mut out, &frame.overlay.selection);
290    encode_overlay_spans(&mut out, &frame.overlay.matches);
291    // Third overlay group (#118): markers as `(id u32, row u16)` pairs — a
292    // different record shape from the span groups (a marker is a line anchor,
293    // not a column run). v10 (#159) appends a kind discriminant (u8, like
294    // `cursor_shape`), and — only for `CommandFinished` — a presence byte + i32
295    // exit code (the presence pattern mirrors the header's `scroll` option).
296    out.extend_from_slice(&(frame.overlay.markers.len() as u16).to_le_bytes());
297    for m in &frame.overlay.markers {
298        out.extend_from_slice(&m.id.0.to_le_bytes());
299        out.extend_from_slice(&(m.row as u16).to_le_bytes());
300        out.push(match m.kind {
301            MarkerKind::Plain => 0,
302            MarkerKind::PromptStart => 1,
303            MarkerKind::CommandStart => 2,
304            MarkerKind::OutputStart => 3,
305            MarkerKind::CommandFinished(_) => 4,
306        });
307        if let MarkerKind::CommandFinished(exit) = m.kind {
308            out.push(exit.is_some() as u8);
309            out.extend_from_slice(&exit.unwrap_or(0).to_le_bytes());
310        }
311    }
312    // Fourth overlay group (#120 S3, v11): every live marker's absolute buffer
313    // line as `(id u32, line u32)` pairs — a superset of the viewport marker group
314    // above, for placing overview-ruler marks off-viewport. Count-prefixed like the
315    // others.
316    out.extend_from_slice(&(frame.overlay.marker_lines.len() as u16).to_le_bytes());
317    for m in &frame.overlay.marker_lines {
318        out.extend_from_slice(&m.id.0.to_le_bytes());
319        out.extend_from_slice(&m.line.to_le_bytes());
320    }
321    // Fifth overlay group (#428, v12): the active search match's spans, same
322    // count + `(row, left, right)` shape as the selection/match groups. Appended
323    // at the tail so the section stays append-only.
324    encode_overlay_spans(&mut out, &frame.overlay.active_match);
325    out
326}
327
328/// Encode one overlay group: a u16 span count, then each span as three u16s
329/// (`row`, `left`, `right`) in viewport coordinates.
330fn encode_overlay_spans(out: &mut Vec<u8>, spans: &[SelectionSpan]) {
331    out.extend_from_slice(&(spans.len() as u16).to_le_bytes());
332    for s in spans {
333        out.extend_from_slice(&(s.row as u16).to_le_bytes());
334        out.extend_from_slice(&(s.left as u16).to_le_bytes());
335        out.extend_from_slice(&(s.right as u16).to_le_bytes());
336    }
337}
338
339/// Length in bytes of one fixed-width wire cell record (see
340/// [`encode_cell_record`]).
341pub const CELL_RECORD_LEN: usize = 18;
342
343/// Encode one [`Cell`] to its fixed 18-byte little-endian record:
344/// `c` u32 (Unicode scalar) · `fg` u32 · `bg` u32 · `flags` u16 · `extra` u16
345/// (frame-local grapheme index, 0 = none) · `link` u16 (frame-local hyperlink
346/// index, 0 = none). Width derives from `flags`.
347///
348/// `extra` and `link` are passed in rather than read from the cell: combining
349/// clusters (#45) and hyperlinks (#46) now live in per-row maps, so both indices
350/// ride on the [`Span`], not the cell. The wire bytes are unchanged.
351///
352/// This is the single definition of the cell record layout — [`encode`] writes
353/// it per span cell, and an alternate consumer (the WASM decoder, #34/ADR-0008)
354/// reuses it to lay decoded cells out flat without re-implementing the layout,
355/// so the two cannot drift.
356pub fn encode_cell_record(cell: &Cell, extra: u16, link: u16) -> [u8; CELL_RECORD_LEN] {
357    let mut r = [0u8; CELL_RECORD_LEN];
358    r[0..4].copy_from_slice(&(cell.c() as u32).to_le_bytes());
359    r[4..8].copy_from_slice(&encode_color(cell.fg()).to_le_bytes());
360    r[8..12].copy_from_slice(&encode_color(cell.bg()).to_le_bytes());
361    r[12..14].copy_from_slice(&cell.flags().bits().to_le_bytes());
362    r[14..16].copy_from_slice(&extra.to_le_bytes());
363    r[16..18].copy_from_slice(&link.to_le_bytes());
364    r
365}
366
367/// A colour reference as a tagged u32: high byte = tag
368/// (0 = Default, 1 = Indexed, 2 = Rgb), low 24 bits = payload. The tag is
369/// mandatory so `Default`, `Indexed(0)`, and `Rgb(0,0,0)` stay distinct.
370///
371/// Public so an alternate consumer (the WASM decoder's structure-of-arrays
372/// `fg`/`bg` columns, #35) reuses this single definition of the colour-ref
373/// encoding instead of re-implementing the tag packing — no drift.
374pub fn encode_color(c: Color) -> u32 {
375    match c {
376        Color::Default => 0,
377        Color::Indexed(i) => (1 << 24) | i as u32,
378        Color::Rgb(r, g, b) => (2 << 24) | (r as u32) << 16 | (g as u32) << 8 | b as u32,
379    }
380}
381
382/// Deserialize the binary wire format back into a [`Frame`].
383pub fn decode(bytes: &[u8]) -> Result<Frame, DecodeError> {
384    let mut r = Reader::new(bytes);
385    if r.take(2)? != MAGIC {
386        return Err(DecodeError::BadMagic);
387    }
388    let version = r.u8()?;
389    if version != VERSION {
390        return Err(DecodeError::BadVersion(version));
391    }
392    let has_scroll = r.u8()? != 0;
393    let kind = match r.u8()? {
394        0 => FrameKind::Full,
395        1 => FrameKind::Partial,
396        _ => return Err(DecodeError::BadTag),
397    };
398    let cols = r.u16()?;
399    let rows = r.u16()?;
400    let cursor_row = r.u16()?;
401    let cursor_col = r.u16()?;
402    let cursor_visible = r.u8()? != 0;
403    let cursor_shape = match r.u8()? {
404        0 => CursorShape::Block,
405        1 => CursorShape::Underline,
406        2 => CursorShape::Bar,
407        _ => return Err(DecodeError::BadTag),
408    };
409    let cursor_blink = r.u8()? != 0;
410    let display_offset = r.u32()?;
411    let scrollback_len = r.u32()?;
412    let mouse_events = MouseEvents::from_bits_retain(r.u8()?);
413    let alt_screen = r.u8()? != 0;
414    let scroll = if has_scroll {
415        let top = r.u16()? as usize;
416        let bottom = r.u16()? as usize;
417        let count = (r.u16()? as i16) as isize;
418        Some(ScrollOp { top, bottom, count })
419    } else {
420        None
421    };
422    let span_count = r.u16()?;
423    let mut spans = Vec::with_capacity(span_count as usize);
424    for _ in 0..span_count {
425        let line = r.u16()?;
426        let left = r.u16()?;
427        let right = r.u16()?;
428        if right < left {
429            return Err(DecodeError::BadSpan);
430        }
431        // Widen before the arithmetic: `right - left + 1` in `u16` overflows
432        // when `right == u16::MAX` (e.g. left=0, right=65535), panicking under
433        // overflow checks. `right >= left` is enforced just above, so the
434        // subtraction in `usize` cannot underflow.
435        let n = right as usize - left as usize + 1;
436        let mut cells = Vec::with_capacity(n);
437        let mut combining = BTreeMap::new();
438        let mut links = BTreeMap::new();
439        for col in 0..n {
440            let (cell, extra, link) = decode_cell(&mut r)?;
441            if let Some(idx) = NonZeroU32::new(extra as u32) {
442                combining.insert(col, idx);
443            }
444            if let Some(idx) = NonZeroU32::new(link as u32) {
445                links.insert(col, idx);
446            }
447            cells.push(cell);
448        }
449        spans.push(Span {
450            line,
451            left,
452            right,
453            cells,
454            combining,
455            links,
456            ucolors: BTreeMap::new(),
457        });
458    }
459    let side_table_count = r.u16()?;
460    let mut side_table = Vec::with_capacity(side_table_count as usize);
461    for _ in 0..side_table_count {
462        let len = r.u16()?;
463        let mut cluster = Vec::with_capacity(len as usize);
464        for _ in 0..len {
465            cluster.push(char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?);
466        }
467        side_table.push(cluster);
468    }
469    let link_count = r.u16()?;
470    let mut link_table = Vec::with_capacity(link_count as usize);
471    for _ in 0..link_count {
472        let len = r.u16()? as usize;
473        let bytes = r.take(len)?;
474        link_table.push(String::from_utf8_lossy(bytes).into_owned());
475    }
476    // Underline-colour group (v13, #520): inverse of the encode above — one sparse
477    // map per span, in span order, so each attaches to `spans[i]` positionally.
478    for span in &mut spans {
479        let count = r.u16()?;
480        for _ in 0..count {
481            let col = r.u16()? as usize;
482            let color = decode_color(r.u32()?)?;
483            span.ucolors.insert(col, color);
484        }
485    }
486    // Overlay section (#108): selection group then match group, each a count +
487    // `(row, left, right)` triples (inverse of `encode_overlay_spans`).
488    let selection = decode_overlay_spans(&mut r)?;
489    let matches = decode_overlay_spans(&mut r)?;
490    // Third group (#118): marker `(id u32, row u16)` records, each followed by a
491    // kind discriminant (v10, #159) and — for `CommandFinished` — a presence byte
492    // + i32 exit (inverse of the marker encode loop).
493    let marker_count = r.u16()?;
494    let mut markers = Vec::with_capacity(marker_count as usize);
495    for _ in 0..marker_count {
496        let id = MarkerId(r.u32()?);
497        let row = r.u16()? as usize;
498        let kind = match r.u8()? {
499            0 => MarkerKind::Plain,
500            1 => MarkerKind::PromptStart,
501            2 => MarkerKind::CommandStart,
502            3 => MarkerKind::OutputStart,
503            4 => {
504                // Always read presence + i32 (the encoder writes both); a 0
505                // presence means the exit bytes are padding to discard.
506                let present = r.u8()? != 0;
507                let exit = r.u32()? as i32;
508                MarkerKind::CommandFinished(present.then_some(exit))
509            }
510            _ => return Err(DecodeError::BadTag),
511        };
512        markers.push(MarkerPosition { id, row, kind });
513    }
514    // Fourth group (#120 S3, v11): every live marker's `(id u32, line u32)` — the
515    // absolute-line superset for the overview ruler (inverse of the encode loop).
516    let marker_line_count = r.u16()?;
517    let mut marker_lines = Vec::with_capacity(marker_line_count as usize);
518    for _ in 0..marker_line_count {
519        let id = MarkerId(r.u32()?);
520        let line = r.u32()?;
521        marker_lines.push(MarkerLine { id, line });
522    }
523    // Fifth group (#428, v12): the active search match's spans (inverse of the
524    // tail `encode_overlay_spans` call).
525    let active_match = decode_overlay_spans(&mut r)?;
526    let overlay = Overlay {
527        selection,
528        matches,
529        markers,
530        marker_lines,
531        active_match,
532    };
533    Ok(Frame {
534        cols,
535        rows,
536        kind,
537        cursor_row,
538        cursor_col,
539        cursor_visible,
540        cursor_shape,
541        cursor_blink,
542        display_offset,
543        scrollback_len,
544        mouse_events,
545        alt_screen,
546        scroll,
547        spans,
548        side_table,
549        link_table,
550        overlay,
551    })
552}
553
554/// Decode one overlay group: a u16 span count, then that many `(row, left,
555/// right)` u16 triples back into viewport [`SelectionSpan`]s (inverse of
556/// [`encode_overlay_spans`]).
557fn decode_overlay_spans(r: &mut Reader) -> Result<Vec<SelectionSpan>, DecodeError> {
558    let count = r.u16()?;
559    let mut spans = Vec::with_capacity(count as usize);
560    for _ in 0..count {
561        let row = r.u16()? as usize;
562        let left = r.u16()? as usize;
563        let right = r.u16()? as usize;
564        spans.push(SelectionSpan { row, left, right });
565    }
566    Ok(spans)
567}
568
569/// Decode one 18-byte cell record (inverse of [`encode_cell_record`]), returning
570/// the cell and its raw `extra` grapheme index and `link` index (0 = none). A
571/// non-zero index sets the corresponding presence bit; the caller records the
572/// indices on the span.
573fn decode_cell(r: &mut Reader) -> Result<(Cell, u16, u16), DecodeError> {
574    let c = char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?;
575    let fg = decode_color(r.u32()?)?;
576    let bg = decode_color(r.u32()?)?;
577    let flags = CellFlags::from_bits_retain(r.u16()?);
578    let extra = r.u16()?;
579    let link = r.u16()?;
580    let mut cell = Cell::from_parts(c, fg, bg, flags);
581    cell.set_combined(extra != 0);
582    cell.set_linked(link != 0);
583    Ok((cell, extra, link))
584}
585
586/// Decode a tagged-u32 colour reference (inverse of [`encode_color`]).
587fn decode_color(v: u32) -> Result<Color, DecodeError> {
588    let payload = v & 0x00FF_FFFF;
589    match v >> 24 {
590        0 => Ok(Color::Default),
591        1 => Ok(Color::Indexed(payload as u8)),
592        2 => Ok(Color::Rgb(
593            (payload >> 16) as u8,
594            (payload >> 8) as u8,
595            payload as u8,
596        )),
597        _ => Err(DecodeError::BadTag),
598    }
599}
600
601/// A little-endian cursor over the wire bytes.
602struct Reader<'a> {
603    bytes: &'a [u8],
604    pos: usize,
605}
606
607impl<'a> Reader<'a> {
608    fn new(bytes: &'a [u8]) -> Self {
609        Reader { bytes, pos: 0 }
610    }
611
612    fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
613        let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
614        let slice = self
615            .bytes
616            .get(self.pos..end)
617            .ok_or(DecodeError::Truncated)?;
618        self.pos = end;
619        Ok(slice)
620    }
621
622    fn u8(&mut self) -> Result<u8, DecodeError> {
623        Ok(self.take(1)?[0])
624    }
625
626    fn u16(&mut self) -> Result<u16, DecodeError> {
627        let b = self.take(2)?;
628        Ok(u16::from_le_bytes([b[0], b[1]]))
629    }
630
631    fn u32(&mut self) -> Result<u32, DecodeError> {
632        let b = self.take(4)?;
633        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
634    }
635}