Skip to main content

blit_remote/
lib.rs

1use std::collections::BTreeMap;
2
3use lz4_flex::{compress_prepend_size, decompress_size_prepended};
4use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
5
6pub const CELL_SIZE: usize = 12;
7const TITLE_PRESENT: u16 = 1 << 15;
8const OPS_PRESENT: u16 = 1 << 14;
9const STRINGS_PRESENT: u16 = 1 << 13;
10const LINE_FLAGS_PRESENT: u16 = 1 << 12;
11const TITLE_LEN_MASK: u16 = LINE_FLAGS_PRESENT - 1;
12
13/// Per-row flag: this row's content continues on the next row (line wrap).
14pub const ROW_FLAG_WRAPPED: u8 = 1 << 0;
15
16/// Sentinel value for content_len indicating the cell's text lives in the
17/// overflow string table.  Bytes 8-11 then hold an FNV-1a hash of the full
18/// UTF-8 string (for diff correctness), and the actual string is stored in
19/// `FrameState::overflow` keyed by cell index.
20const CONTENT_OVERFLOW: u8 = 7;
21
22const ENABLE_SCROLL_OPS: bool = true;
23const MODE_ECHO: u16 = 1 << 9;
24const MODE_ICANON: u16 = 1 << 10;
25
26const OP_COPY_RECT: u8 = 0x01;
27const OP_FILL_RECT: u8 = 0x02;
28const OP_PATCH_CELLS: u8 = 0x03;
29
30pub const C2S_INPUT: u8 = 0x00;
31/// Desired viewport size(s): [0x01][pty_id:2][rows:2][cols:2]...
32/// Clients may batch multiple PTY resize entries in one message. The server
33/// mediates these per-client desired sizes into each PTY's effective size.
34/// A `rows, cols` pair of `0, 0` clears this client's desired size for that PTY.
35pub const C2S_RESIZE: u8 = 0x01;
36pub const C2S_SCROLL: u8 = 0x02;
37pub const C2S_ACK: u8 = 0x03;
38pub const C2S_DISPLAY_RATE: u8 = 0x04;
39pub const C2S_CLIENT_METRICS: u8 = 0x05;
40/// Application-level keepalive: [0x08].  No payload.
41/// Sent periodically by the client; the server treats it as a no-op
42/// (but its arrival resets any server-side receive timeout).
43pub const C2S_PING: u8 = 0x08;
44/// Mouse event: [0x06][pty_id:2][type:1][button:1][col:2][row:2]
45/// type: 0=down, 1=up, 2=move
46/// button: 0=left, 1=mid, 2=right, 3=release, 64=wheel_up, 65=wheel_down
47/// The server generates the correct escape sequence based on mouse_mode and mouse_encoding.
48pub const C2S_MOUSE: u8 = 0x06;
49/// Restart an exited PTY: [0x07][pty_id:2]
50/// Server spawns a new shell in the same PTY slot, preserving the pty_id.
51pub const C2S_RESTART: u8 = 0x07;
52pub const C2S_CREATE: u8 = 0x10;
53pub const C2S_FOCUS: u8 = 0x11;
54pub const C2S_CLOSE: u8 = 0x12;
55pub const C2S_SUBSCRIBE: u8 = 0x13;
56pub const C2S_UNSUBSCRIBE: u8 = 0x14;
57pub const C2S_SEARCH: u8 = 0x15;
58pub const C2S_CREATE_AT: u8 = 0x16;
59pub const C2S_CREATE_N: u8 = 0x17;
60/// Generic create: [0x18][nonce:2][rows:2][cols:2][features:1][tag_len:2][tag:N][...optional fields]
61/// Features: bit 0 = has src_pty_id (2 bytes after tag), bit 1 = has command (remaining bytes after src_pty_id if present)
62/// Server responds with S2C_CREATED_N using the same nonce.
63pub const C2S_CREATE2: u8 = 0x18;
64pub const CREATE2_HAS_SRC_PTY: u8 = 1 << 0;
65pub const CREATE2_HAS_COMMAND: u8 = 1 << 1;
66/// Read text from a PTY's scrollback + viewport: [0x19][nonce:2][pty_id:2][offset:4][limit:4][flags:1]
67/// offset: number of lines to skip from the top (oldest = 0), or from the end if READ_TAIL is set
68/// limit: max lines to return (0 = all)
69/// flags: bit 0 = include ANSI styling, bit 1 = offset counts from the end
70/// Server responds with S2C_TEXT using the same nonce.
71pub const C2S_READ: u8 = 0x19;
72pub const READ_ANSI: u8 = 1 << 0;
73pub const READ_TAIL: u8 = 1 << 1;
74/// Copy text from a range of absolute row/col positions in scrollback + viewport:
75/// [0x1B][nonce:2][pty_id:2][start_tail:4][start_col:2][end_tail:4][end_col:2][flags:1]
76/// start_tail/end_tail: physical row distance from the bottom (0 = last row).
77/// start is the earlier position (closer to top), so start_tail >= end_tail.
78/// flags: reserved (0 for now).
79/// Server responds with S2C_TEXT using the same nonce.
80pub const C2S_COPY_RANGE: u8 = 0x1B;
81/// Send a signal to a PTY's session leader: [0x1A][pty_id:2][signal:4]
82/// signal is a raw libc signal number (e.g. SIGTERM=15, SIGKILL=9).
83pub const C2S_KILL: u8 = 0x1A;
84
85/// Keyboard input for a Wayland surface: [0x20][surface_id:2][data:N]
86/// data contains evdev keycodes encoded as [keycode:4][pressed:1] sequences.
87pub const C2S_SURFACE_INPUT: u8 = 0x20;
88/// Pointer motion/button for a Wayland surface: [0x21][surface_id:2][type:1][button:1][x:2][y:2]
89/// type: 0=down, 1=up, 2=move
90/// x,y: pixel coordinates relative to the surface origin
91pub const C2S_SURFACE_POINTER: u8 = 0x21;
92/// Pointer axis/scroll for a Wayland surface: [0x22][surface_id:2][axis:1][value_x100:4_signed]
93/// axis: 0=vertical, 1=horizontal
94/// value_x100: scroll amount * 100 (signed, positive = down/right)
95pub const C2S_SURFACE_POINTER_AXIS: u8 = 0x22;
96/// Resize a Wayland surface: [0x23][surface_id:2][width:2][height:2][scale_120:2]
97/// scale_120: device pixel ratio in 1/120th units (120 = 1×, 240 = 2×).
98pub const C2S_SURFACE_RESIZE: u8 = 0x23;
99/// Set keyboard/pointer focus to a Wayland surface: [0x24][surface_id:2]
100pub const C2S_SURFACE_FOCUS: u8 = 0x24;
101/// Set clipboard content:
102/// [0x25][mime_len:2][mime:N][data_len:4][data:N]
103pub const C2S_CLIPBOARD_SET: u8 = 0x25;
104/// Request a list of all compositor surfaces: [0x26]
105pub const C2S_SURFACE_LIST: u8 = 0x26;
106/// Request a screenshot of a surface:
107/// [0x27][surface_id:2]              — legacy (defaults to PNG lossless)
108/// [0x27][surface_id:2][format:1][quality:1] — extended
109/// format: 0 = PNG, 1 = AVIF.  quality: 0 = lossless, 1–100 = lossy (AVIF only).
110pub const C2S_SURFACE_CAPTURE: u8 = 0x27;
111pub const CAPTURE_FORMAT_PNG: u8 = 0;
112pub const CAPTURE_FORMAT_AVIF: u8 = 1;
113/// Subscribe to surface frame updates:
114/// [0x28][surface_id:2]                                        — legacy (server defaults)
115/// [0x28][surface_id:2][codec:1][quality:1]                    — extended
116/// [0x28][surface_id:2][codec:1][quality:1][width:2][height:2] — scaled
117///
118/// codec: CODEC_SUPPORT_* bitmask restricting which codecs the server may use
119///        for this surface.  0 = use connection-level default (from C2S_CLIENT_FEATURES).
120///
121/// quality: desired compression quality for this surface.
122///   0 = server default, 1 = low, 2 = medium, 3 = high, 4 = ultra.
123///   10–255 = custom AV1 quantizer (wire value IS the quantizer).
124///   See `SURFACE_QUALITY_*` constants.
125///
126/// width, height: optional fixed target size (in pixels) for this subscription.
127///   When both are nonzero, the server encodes this surface at exactly
128///   `width × height` for this client by scaling the native frame down
129///   server-side, independent of the compositor's surface size.  Such
130///   "scaled" subscriptions are excluded from the server's surface-size
131///   mediation (they never pull the compositor surface smaller for other
132///   viewers).  Both 0 (or fields absent) means the client participates in
133///   mediation via C2S_SURFACE_RESIZE like today.
134///
135/// Re-subscribing to an already-subscribed surface updates the codec/quality
136/// preferences and/or scaled size and forces encoder recreation.
137pub const C2S_SURFACE_SUBSCRIBE: u8 = 0x28;
138
139/// Quality values for the `quality` byte in C2S_SURFACE_SUBSCRIBE.
140/// 0 means "use server default" (from BLIT_SURFACE_QUALITY env var).
141pub const SURFACE_QUALITY_DEFAULT: u8 = 0;
142pub const SURFACE_QUALITY_LOW: u8 = 1;
143pub const SURFACE_QUALITY_MEDIUM: u8 = 2;
144pub const SURFACE_QUALITY_HIGH: u8 = 3;
145pub const SURFACE_QUALITY_ULTRA: u8 = 4;
146/// Unsubscribe from surface frame updates: [0x29][surface_id:2]
147pub const C2S_SURFACE_UNSUBSCRIBE: u8 = 0x29;
148/// Acknowledge receipt of a surface video frame: [0x2A]
149pub const C2S_SURFACE_ACK: u8 = 0x2A;
150/// Request close of a Wayland surface (sends xdg_toplevel close event):
151/// [0x2B][surface_id:2]
152pub const C2S_SURFACE_CLOSE: u8 = 0x2B;
153/// Request a list of MIME types available on the clipboard: [0x2C]
154/// Server responds with S2C_CLIPBOARD_LIST.
155pub const C2S_CLIPBOARD_LIST: u8 = 0x2C;
156/// Client feature/capability advertisement: [0x2D][payload:N]
157/// Currently defined payload bytes:
158///   [0] codec_support — bitmask of CODEC_SUPPORT_* flags the client can
159///       decode.  0 = accept anything (legacy).
160/// Sent once after connection when capability probing completes.  The
161/// message is extensible: the server ignores trailing bytes it doesn't
162/// understand, and missing bytes default to 0.
163pub const C2S_CLIENT_FEATURES: u8 = 0x2D;
164/// Composed text input for a Wayland surface (UTF-8):
165/// [0x2F][surface_id:2][text:N]
166/// The server synthesises the corresponding evdev key sequences (US-QWERTY)
167/// for ASCII characters.  Non-ASCII characters are delivered via
168/// zwp_text_input_v3 commit_string when available.
169pub const C2S_SURFACE_TEXT: u8 = 0x2F;
170/// Read clipboard content for a specific MIME type:
171/// [0x2E][mime_len:2][mime:N]
172/// Server responds with S2C_CLIPBOARD_CONTENT (0x25) containing the data.
173pub const C2S_CLIPBOARD_GET: u8 = 0x2E;
174/// Request server shutdown: [0x0F].  No payload.
175/// The server broadcasts S2C_QUIT to all connected clients and exits.
176pub const C2S_QUIT: u8 = 0x0F;
177
178pub const S2C_UPDATE: u8 = 0x00;
179pub const S2C_CREATED: u8 = 0x01;
180pub const S2C_CLOSED: u8 = 0x02;
181pub const S2C_LIST: u8 = 0x03;
182pub const S2C_TITLE: u8 = 0x04;
183pub const S2C_SEARCH_RESULTS: u8 = 0x05;
184pub const S2C_CREATED_N: u8 = 0x06;
185pub const S2C_HELLO: u8 = 0x07;
186/// The PTY's subprocess has exited but the terminal state is retained.
187/// Clients can still read/scroll the last frame. Send C2S_CLOSE to dismiss.
188/// Wire: [0x08][pty_id:2][exit_status:4]
189/// exit_status: WEXITSTATUS if normal exit, negative signal number if signalled,
190///              EXIT_STATUS_UNKNOWN if not yet collected.
191pub const S2C_EXITED: u8 = 0x08;
192pub const EXIT_STATUS_UNKNOWN: i32 = i32::MIN;
193/// Sent after the initial burst (HELLO, LIST, TITLE*, EXITED*) is complete.
194/// Clients can use this to know when the initial state has been fully transmitted.
195pub const S2C_READY: u8 = 0x09;
196/// Application-level keepalive: [0x0B].  No payload.
197/// Sent periodically by the server so clients can detect dead connections
198/// even when no other traffic is flowing (e.g. idle terminal, WebRTC).
199pub const S2C_PING: u8 = 0x0B;
200/// Server is shutting down: [0x0C].  No payload.
201/// Broadcast to all connected clients before the server exits.
202pub const S2C_QUIT: u8 = 0x0C;
203/// Text response: [0x0A][nonce:2][pty_id:2][total_lines:4][offset:4][text:N]
204/// nonce: echoed from C2S_READ request
205/// total_lines: total available lines (scrollback + viewport rows)
206/// offset: the offset that was requested
207/// text: UTF-8 text, lines separated by \n
208pub const S2C_TEXT: u8 = 0x0A;
209
210/// A new Wayland toplevel surface was created:
211/// [0x20][surface_id:2][parent_id:2][width:2][height:2][title_len:2][title:N][app_id_len:2][app_id:N]
212/// parent_id: 0 = no parent (top-level), non-zero = dialog/child of that surface
213pub const S2C_SURFACE_CREATED: u8 = 0x20;
214/// A Wayland surface was destroyed: [0x21][surface_id:2]
215pub const S2C_SURFACE_DESTROYED: u8 = 0x21;
216/// An encoded video frame for a Wayland surface:
217/// [0x22][surface_id:2][timestamp:4][flags:1][width:2][height:2][data:N]
218/// flags: bit 0 = keyframe, bits 1-2 = codec (0 = H.264, 1 = AV1).
219/// timestamp: milliseconds since compositor session start.
220pub const S2C_SURFACE_FRAME: u8 = 0x22;
221/// A Wayland surface's title changed: [0x23][surface_id:2][title:N]
222pub const S2C_SURFACE_TITLE: u8 = 0x23;
223/// A Wayland surface was resized by the app: [0x24][surface_id:2][width:2][height:2]
224pub const S2C_SURFACE_RESIZED: u8 = 0x24;
225/// A Wayland surface's app_id changed: [0x28][surface_id:2][app_id:N]
226pub const S2C_SURFACE_APP_ID: u8 = 0x28;
227/// Clipboard content (response to C2S_CLIPBOARD_GET or unsolicited broadcast on change):
228/// [0x25][mime_len:2][mime:N][data_len:4][data:N]
229pub const S2C_CLIPBOARD_CONTENT: u8 = 0x25;
230/// List of all compositor surfaces:
231/// [0x26][count:2] repeated{ [surface_id:2][parent_id:2][width:2][height:2][title_len:2][title:N][app_id_len:2][app_id:N] }
232pub const S2C_SURFACE_LIST: u8 = 0x26;
233/// Screenshot of a surface: [0x27][surface_id:2][width:4][height:4][image_data:N]
234/// image_data is PNG or AVIF depending on the request format.
235/// If the surface was not found or has no buffer, width=0 and height=0 with empty data.
236pub const S2C_SURFACE_CAPTURE: u8 = 0x27;
237
238/// Cursor shape changed for a surface: [0x29][surface_id:2][shape_len:1][shape:N]
239/// shape is a CSS cursor keyword (e.g. "default", "pointer", "text").
240pub const S2C_SURFACE_CURSOR: u8 = 0x29;
241
242/// Encoder backend for a surface: [0x2A][surface_id:2][name:N]
243/// name is a short ASCII string like "h264-nvenc", "h264-vaapi", "h264-software", etc.
244/// Sent when a new encoder is created for a surface (initial subscribe or resize).
245pub const S2C_SURFACE_ENCODER: u8 = 0x2A;
246
247/// List of MIME types available on the clipboard:
248/// [0x2C][count:2] repeated{ [mime_len:2][mime:N] }
249pub const S2C_CLIPBOARD_LIST: u8 = 0x2C;
250
251// -- Audio forwarding ---------------------------------------------------
252
253/// Subscribe to audio: [0x30][bitrate_kbps:2]
254/// Audio is per-compositor (one mixed stream), not per-surface.
255/// The server begins sending S2C_AUDIO_FRAME.
256/// bitrate_kbps: 0 = server default.
257pub const C2S_AUDIO_SUBSCRIBE: u8 = 0x30;
258/// Unsubscribe from audio: [0x31]
259pub const C2S_AUDIO_UNSUBSCRIBE: u8 = 0x31;
260/// An encoded audio frame (Opus) from the compositor's mixed output:
261/// [0x30][timestamp:4][flags:1][data:N]
262/// timestamp: sample offset in 48 kHz ticks from an arbitrary epoch.
263/// flags: bits 1-2 = codec (0 = Opus). Other bits reserved.
264pub const S2C_AUDIO_FRAME: u8 = 0x30;
265
266pub const AUDIO_FRAME_CODEC_MASK: u8 = 0b110;
267pub const AUDIO_FRAME_CODEC_OPUS: u8 = 0 << 1;
268
269/// A fragment of a larger S2C message: [0x2B][flags:1][chunk:N]
270///
271/// Large bulk messages (video keyframes, terminal snapshots) are split
272/// into multiple fragments so audio frames can be written between them
273/// on the same stream.  Without this the writer task would hold the
274/// socket for the full duration of a multi-hundred-KB write, starving
275/// audio delivery and producing audible gaps on the client.
276///
277/// Flags:
278///   bit 0 (FRAGMENT_FLAG_LAST) — this is the last fragment; the receiver
279///     should concatenate all fragments of this message (in order) and
280///     dispatch the reassembled buffer as if it were a single message.
281///
282/// Fragments of different messages do NOT interleave: TCP preserves
283/// order and the server only splits one message at a time, so the
284/// receiver can use a single pending-reassembly buffer with no fragment
285/// id or sequence number.  S2C_AUDIO_FRAME messages may appear between
286/// fragments and are handled normally — they don't contribute to the
287/// reassembly buffer.
288pub const S2C_FRAGMENT: u8 = 0x2B;
289pub const FRAGMENT_FLAG_LAST: u8 = 1 << 0;
290
291pub const SURFACE_FRAME_FLAG_KEYFRAME: u8 = 1 << 0;
292pub const SURFACE_FRAME_CODEC_MASK: u8 = 0b110;
293pub const SURFACE_FRAME_CODEC_H264: u8 = 0 << 1;
294pub const SURFACE_FRAME_CODEC_AV1: u8 = 1 << 1;
295pub const SURFACE_FRAME_CODEC_PNG: u8 = 2 << 1;
296
297/// Bitmask for client-supported codecs in C2S_CLIENT_FEATURES and
298/// C2S_SURFACE_SUBSCRIBE.  0 means "accept anything".
299pub const CODEC_SUPPORT_H264: u8 = 1 << 0;
300pub const CODEC_SUPPORT_AV1: u8 = 1 << 1;
301pub const CODEC_SUPPORT_H264_444: u8 = 1 << 2;
302pub const CODEC_SUPPORT_AV1_444: u8 = 1 << 3;
303
304pub const FEATURE_CREATE_NONCE: u32 = 1 << 0;
305pub const FEATURE_RESTART: u32 = 1 << 1;
306pub const FEATURE_RESIZE_BATCH: u32 = 1 << 2;
307pub const FEATURE_COPY_RANGE: u32 = 1 << 3;
308pub const FEATURE_COMPOSITOR: u32 = 1 << 4;
309pub const FEATURE_AUDIO: u32 = 1 << 5;
310
311#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
312pub enum Color {
313    #[default]
314    Default,
315    Indexed(u8),
316    Rgb(u8, u8, u8),
317}
318
319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
320pub struct CellStyle {
321    pub fg: Color,
322    pub bg: Color,
323    pub bold: bool,
324    pub dim: bool,
325    pub italic: bool,
326    pub underline: bool,
327    pub inverse: bool,
328}
329
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
331pub struct Rect {
332    pub row: u16,
333    pub col: u16,
334    pub rows: u16,
335    pub cols: u16,
336}
337
338impl Rect {
339    pub const fn new(row: u16, col: u16, rows: u16, cols: u16) -> Self {
340        Self {
341            row,
342            col,
343            rows,
344            cols,
345        }
346    }
347}
348
349#[derive(Clone, Debug, Default, PartialEq, Eq)]
350pub struct FrameState {
351    rows: u16,
352    cols: u16,
353    cells: Vec<u8>,
354    cursor_row: u16,
355    cursor_col: u16,
356    mode: u16,
357    title: String,
358    /// Overflow strings for cells whose content exceeds 4 bytes.
359    /// Keyed by flat cell index (row * cols + col).
360    overflow: BTreeMap<usize, String>,
361    /// Per-row flags. `ROW_FLAG_WRAPPED` means the row continues on the next.
362    line_flags: Vec<u8>,
363    /// Total scrollback lines available for this PTY.
364    scrollback_lines: u32,
365}
366
367impl FrameState {
368    pub fn new(rows: u16, cols: u16) -> Self {
369        let total = rows as usize * cols as usize;
370        Self {
371            rows,
372            cols,
373            cells: vec![0; total * CELL_SIZE],
374            cursor_row: 0,
375            cursor_col: 0,
376            mode: 0,
377            title: String::new(),
378            overflow: BTreeMap::new(),
379            line_flags: vec![0; rows as usize],
380            scrollback_lines: 0,
381        }
382    }
383
384    pub fn from_parts(
385        rows: u16,
386        cols: u16,
387        cursor_row: u16,
388        cursor_col: u16,
389        mode: u16,
390        title: impl Into<String>,
391        cells: Vec<u8>,
392    ) -> Self {
393        let mut state = Self::new(rows, cols);
394        if cells.len() == state.cells.len() {
395            state.cells = cells;
396        }
397        state.cursor_row = cursor_row;
398        state.cursor_col = cursor_col;
399        state.mode = mode;
400        state.title = title.into();
401        state
402    }
403
404    pub fn rows(&self) -> u16 {
405        self.rows
406    }
407
408    pub fn cols(&self) -> u16 {
409        self.cols
410    }
411
412    pub fn cursor_row(&self) -> u16 {
413        self.cursor_row
414    }
415
416    pub fn cursor_col(&self) -> u16 {
417        self.cursor_col
418    }
419
420    pub fn mode(&self) -> u16 {
421        self.mode
422    }
423
424    pub fn title(&self) -> &str {
425        &self.title
426    }
427
428    pub fn cells(&self) -> &[u8] {
429        &self.cells
430    }
431
432    pub fn cells_mut(&mut self) -> &mut [u8] {
433        &mut self.cells
434    }
435
436    pub fn overflow(&self) -> &BTreeMap<usize, String> {
437        &self.overflow
438    }
439
440    pub fn overflow_mut(&mut self) -> &mut BTreeMap<usize, String> {
441        &mut self.overflow
442    }
443
444    pub fn line_flags(&self) -> &[u8] {
445        &self.line_flags
446    }
447
448    pub fn line_flags_mut(&mut self) -> &mut Vec<u8> {
449        &mut self.line_flags
450    }
451
452    pub fn scrollback_lines(&self) -> u32 {
453        self.scrollback_lines
454    }
455
456    pub fn set_scrollback_lines(&mut self, lines: u32) {
457        self.scrollback_lines = lines;
458    }
459
460    pub fn is_wrapped(&self, row: u16) -> bool {
461        self.line_flags.get(row as usize).copied().unwrap_or(0) & ROW_FLAG_WRAPPED != 0
462    }
463
464    pub fn set_wrapped(&mut self, row: u16, wrapped: bool) {
465        if let Some(flags) = self.line_flags.get_mut(row as usize) {
466            if wrapped {
467                *flags |= ROW_FLAG_WRAPPED;
468            } else {
469                *flags &= !ROW_FLAG_WRAPPED;
470            }
471        }
472    }
473
474    /// Returns the text content of a cell, resolving overflow if needed.
475    pub fn cell_content(&self, row: u16, col: u16) -> &str {
476        if row >= self.rows || col >= self.cols {
477            return "";
478        }
479        let flat = row as usize * self.cols as usize + col as usize;
480        let idx = flat * CELL_SIZE;
481        let f1 = self.cells[idx + 1];
482        if f1 & 4 != 0 {
483            return ""; // wide continuation
484        }
485        let content_len = ((f1 >> 3) & 7) as usize;
486        if content_len == CONTENT_OVERFLOW as usize {
487            if let Some(s) = self.overflow.get(&flat) {
488                return s.as_str();
489            }
490            return "";
491        }
492        if content_len == 0 {
493            return " ";
494        }
495        std::str::from_utf8(&self.cells[idx + 8..idx + 8 + content_len]).unwrap_or(" ")
496    }
497
498    pub fn resize(&mut self, rows: u16, cols: u16) {
499        if rows == self.rows && cols == self.cols {
500            return;
501        }
502        self.rows = rows;
503        self.cols = cols;
504        self.cells = vec![0; rows as usize * cols as usize * CELL_SIZE];
505        self.overflow.clear();
506        self.line_flags = vec![0; rows as usize];
507        self.cursor_row = self.cursor_row.min(rows.saturating_sub(1));
508        self.cursor_col = self.cursor_col.min(cols.saturating_sub(1));
509    }
510
511    pub fn set_cursor(&mut self, row: u16, col: u16) {
512        self.cursor_row = row.min(self.rows.saturating_sub(1));
513        self.cursor_col = col.min(self.cols.saturating_sub(1));
514    }
515
516    pub fn set_mode(&mut self, mode: u16) {
517        self.mode = mode;
518    }
519
520    pub fn set_title(&mut self, title: impl Into<String>) -> bool {
521        let title = title.into();
522        if self.title == title {
523            return false;
524        }
525        self.title = title;
526        true
527    }
528
529    pub fn clear(&mut self, style: CellStyle) {
530        for row in 0..self.rows {
531            for col in 0..self.cols {
532                self.set_blank_cell(row, col, style);
533            }
534        }
535    }
536
537    pub fn fill_rect(&mut self, rect: Rect, ch: char, style: CellStyle) {
538        let row_end = rect.row.saturating_add(rect.rows).min(self.rows);
539        let col_end = rect.col.saturating_add(rect.cols).min(self.cols);
540        for row in rect.row..row_end {
541            let mut col = rect.col;
542            while col < col_end {
543                let width = self.set_cell(row, col, ch, style);
544                if width == 0 {
545                    break;
546                }
547                col = col.saturating_add(width);
548            }
549        }
550    }
551
552    pub fn write_text(&mut self, row: u16, col: u16, text: &str, style: CellStyle) -> u16 {
553        if row >= self.rows || col >= self.cols {
554            return col;
555        }
556        let mut cur_col = col;
557        for ch in text.chars() {
558            if cur_col >= self.cols {
559                break;
560            }
561            let width = self.set_cell(row, cur_col, ch, style);
562            if width == 0 {
563                continue;
564            }
565            cur_col = cur_col.saturating_add(width);
566        }
567        cur_col
568    }
569
570    pub fn write_wrapped_text(&mut self, rect: Rect, text: &str, style: CellStyle) -> usize {
571        if rect.rows == 0 || rect.cols == 0 {
572            return 0;
573        }
574        let lines = wrap_text_lines(text, rect.cols as usize);
575        let max_rows = rect.rows.min(self.rows.saturating_sub(rect.row));
576        for (idx, line) in lines.iter().take(max_rows as usize).enumerate() {
577            let row = rect.row + idx as u16;
578            self.write_text(row, rect.col, line, style);
579        }
580        lines.len()
581    }
582
583    pub fn write_scrolling_text<S: AsRef<str>>(
584        &mut self,
585        rect: Rect,
586        lines: &[S],
587        offset_from_bottom: usize,
588        style: CellStyle,
589    ) {
590        if rect.rows == 0 || rect.cols == 0 {
591            return;
592        }
593        let mut wrapped = Vec::with_capacity(lines.len());
594        for line in lines {
595            let line = line.as_ref();
596            let out = wrap_text_lines(line, rect.cols as usize);
597            if out.is_empty() {
598                wrapped.push(String::new());
599            } else {
600                wrapped.extend(out);
601            }
602        }
603        let visible = rect.rows as usize;
604        let end = wrapped.len().saturating_sub(offset_from_bottom);
605        let start = end.saturating_sub(visible);
606        for row in 0..rect.rows {
607            self.fill_rect(
608                Rect::new(rect.row + row, rect.col, 1, rect.cols),
609                ' ',
610                style,
611            );
612        }
613        for (idx, line) in wrapped[start..end].iter().enumerate() {
614            self.write_text(rect.row + idx as u16, rect.col, line, style);
615        }
616    }
617
618    pub fn get_text(&self, start_row: u16, start_col: u16, end_row: u16, end_col: u16) -> String {
619        let mut result = String::new();
620        if self.rows == 0 || self.cols == 0 {
621            return result;
622        }
623        for row in start_row..=end_row.min(self.rows.saturating_sub(1)) {
624            let c0 = if row == start_row { start_col } else { 0 };
625            let c1 = if row == end_row {
626                end_col
627            } else {
628                self.cols - 1
629            };
630            let mut line = String::new();
631            let mut col = c0;
632            while col <= c1.min(self.cols - 1) {
633                line.push_str(self.cell_content(row, col));
634                col += 1;
635            }
636            result.push_str(line.trim_end());
637            if row < end_row.min(self.rows.saturating_sub(1)) && !self.is_wrapped(row) {
638                result.push('\n');
639            }
640        }
641        result
642    }
643
644    pub fn get_all_text(&self) -> String {
645        if self.rows == 0 || self.cols == 0 {
646            return String::new();
647        }
648        self.get_text(0, 0, self.rows - 1, self.cols - 1)
649    }
650
651    fn cell_style(&self, row: u16, col: u16) -> CellStyle {
652        if row >= self.rows || col >= self.cols {
653            return CellStyle::default();
654        }
655        let idx = self.cell_offset(row, col);
656        let f0 = self.cells[idx];
657        let f1 = self.cells[idx + 1];
658        let fg_type = f0 & 3;
659        let bg_type = (f0 >> 2) & 3;
660        let fg = match fg_type {
661            1 => Color::Indexed(self.cells[idx + 2]),
662            2 => Color::Rgb(
663                self.cells[idx + 2],
664                self.cells[idx + 3],
665                self.cells[idx + 4],
666            ),
667            _ => Color::Default,
668        };
669        let bg = match bg_type {
670            1 => Color::Indexed(self.cells[idx + 5]),
671            2 => Color::Rgb(
672                self.cells[idx + 5],
673                self.cells[idx + 6],
674                self.cells[idx + 7],
675            ),
676            _ => Color::Default,
677        };
678        CellStyle {
679            fg,
680            bg,
681            bold: (f0 >> 4) & 1 != 0,
682            dim: (f0 >> 5) & 1 != 0,
683            italic: (f0 >> 6) & 1 != 0,
684            underline: (f0 >> 7) & 1 != 0,
685            inverse: f1 & 1 != 0,
686        }
687    }
688
689    pub fn get_ansi_text(&self) -> String {
690        if self.rows == 0 || self.cols == 0 {
691            return String::new();
692        }
693        let mut result = String::new();
694        let mut cur_style = CellStyle::default();
695        for row in 0..self.rows {
696            let mut line = String::new();
697            let mut col = 0u16;
698            while col < self.cols {
699                let style = self.cell_style(row, col);
700                if style != cur_style {
701                    push_sgr(&mut line, &style);
702                    cur_style = style;
703                }
704                line.push_str(self.cell_content(row, col));
705                col += 1;
706            }
707            let trimmed = line.trim_end();
708            result.push_str(trimmed);
709            if cur_style != CellStyle::default() {
710                result.push_str("\x1b[0m");
711                cur_style = CellStyle::default();
712            }
713            if row < self.rows - 1 {
714                result.push('\n');
715            }
716        }
717        result
718    }
719
720    pub fn get_cell(&self, row: u16, col: u16) -> Vec<u8> {
721        if row >= self.rows || col >= self.cols {
722            return Vec::new();
723        }
724        let idx = self.cell_offset(row, col);
725        self.cells[idx..idx + CELL_SIZE].to_vec()
726    }
727
728    fn cell_offset(&self, row: u16, col: u16) -> usize {
729        (row as usize * self.cols as usize + col as usize) * CELL_SIZE
730    }
731
732    fn set_cell(&mut self, row: u16, col: u16, ch: char, style: CellStyle) -> u16 {
733        if row >= self.rows || col >= self.cols {
734            return 0;
735        }
736        let raw_width = UnicodeWidthChar::width(ch).unwrap_or(0);
737        if raw_width == 0 {
738            return 0;
739        }
740        let width = if raw_width > 1 && col + 1 < self.cols {
741            2
742        } else {
743            1
744        };
745        let idx = self.cell_offset(row, col);
746        encode_cell(
747            &mut self.cells[idx..idx + CELL_SIZE],
748            Some(ch),
749            style,
750            width == 2,
751            false,
752        );
753        if width == 2 {
754            let cont_idx = self.cell_offset(row, col + 1);
755            encode_cell(
756                &mut self.cells[cont_idx..cont_idx + CELL_SIZE],
757                None,
758                style,
759                false,
760                true,
761            );
762        }
763        width
764    }
765
766    fn set_blank_cell(&mut self, row: u16, col: u16, style: CellStyle) {
767        if row >= self.rows || col >= self.cols {
768            return;
769        }
770        let idx = self.cell_offset(row, col);
771        encode_cell(
772            &mut self.cells[idx..idx + CELL_SIZE],
773            None,
774            style,
775            false,
776            false,
777        );
778    }
779}
780
781#[derive(Clone, Debug)]
782pub struct TerminalState {
783    frame: FrameState,
784}
785
786impl TerminalState {
787    pub fn new(rows: u16, cols: u16) -> Self {
788        let frame = FrameState::new(rows, cols);
789        Self { frame }
790    }
791
792    pub fn frame(&self) -> &FrameState {
793        &self.frame
794    }
795
796    pub fn frame_mut(&mut self) -> &mut FrameState {
797        &mut self.frame
798    }
799
800    pub fn title(&self) -> &str {
801        self.frame.title()
802    }
803
804    pub fn rows(&self) -> u16 {
805        self.frame.rows()
806    }
807
808    pub fn cols(&self) -> u16 {
809        self.frame.cols()
810    }
811
812    pub fn is_wrapped(&self, row: u16) -> bool {
813        self.frame.is_wrapped(row)
814    }
815
816    pub fn cursor_row(&self) -> u16 {
817        self.frame.cursor_row()
818    }
819
820    pub fn cursor_col(&self) -> u16 {
821        self.frame.cursor_col()
822    }
823
824    pub fn mode(&self) -> u16 {
825        self.frame.mode()
826    }
827
828    pub fn cells(&self) -> &[u8] {
829        self.frame.cells()
830    }
831
832    pub fn set_title(&mut self, title: &str) -> bool {
833        self.frame.set_title(title.to_owned())
834    }
835
836    pub fn get_text(&self, start_row: u16, start_col: u16, end_row: u16, end_col: u16) -> String {
837        self.frame.get_text(start_row, start_col, end_row, end_col)
838    }
839
840    pub fn get_all_text(&self) -> String {
841        self.frame.get_all_text()
842    }
843
844    pub fn get_ansi_text(&self) -> String {
845        self.frame.get_ansi_text()
846    }
847
848    pub fn get_cell(&self, row: u16, col: u16) -> Vec<u8> {
849        self.frame.get_cell(row, col)
850    }
851
852    /// Maximum decompressed frame size (50 MiB). Prevents LZ4 decompression
853    /// bombs where a tiny compressed payload claims a multi-GiB output size.
854    const MAX_DECOMPRESSED_SIZE: usize = 50 * 1024 * 1024;
855
856    /// Read the LZ4 prepended uncompressed size without allocating, and reject
857    /// payloads that claim to decompress beyond `MAX_DECOMPRESSED_SIZE`.
858    fn safe_decompress(data: &[u8]) -> Result<Vec<u8>, ()> {
859        if data.len() < 4 {
860            return Err(());
861        }
862        let claimed = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
863        if claimed > Self::MAX_DECOMPRESSED_SIZE {
864            return Err(());
865        }
866        decompress_size_prepended(data).map_err(|_| ())
867    }
868
869    pub fn feed_compressed(&mut self, data: &[u8]) -> bool {
870        let payload = match Self::safe_decompress(data) {
871            Ok(d) => d,
872            Err(_) => return false,
873        };
874        self.apply_payload(&payload)
875    }
876
877    pub fn feed_compressed_batch(&mut self, batch: &[u8]) -> bool {
878        let mut changed = false;
879        let mut off = 0usize;
880        while off + 4 <= batch.len() {
881            let len =
882                u32::from_le_bytes([batch[off], batch[off + 1], batch[off + 2], batch[off + 3]])
883                    as usize;
884            off += 4;
885            if len == 0 {
886                break;
887            }
888            if off + len > batch.len() {
889                break;
890            }
891            if let Ok(payload) = Self::safe_decompress(&batch[off..off + len]) {
892                changed |= self.apply_payload(&payload);
893            }
894            off += len;
895        }
896        changed
897    }
898
899    /// Maximum total cell count allowed in a single frame (rows * cols).
900    /// 500 rows x 1000 cols = 500,000 cells x 12 bytes = 6 MB — generous for
901    /// any real terminal while preventing 48 GiB allocations from malicious
902    /// frames claiming rows=65535, cols=65535.
903    const MAX_CELL_COUNT: usize = 500_000;
904
905    fn apply_payload(&mut self, payload: &[u8]) -> bool {
906        if payload.len() < 12 {
907            return false;
908        }
909
910        let new_rows = u16::from_le_bytes([payload[0], payload[1]]);
911        let new_cols = u16::from_le_bytes([payload[2], payload[3]]);
912
913        // Reject absurd dimensions that would cause multi-GiB allocations.
914        if (new_rows as usize) * (new_cols as usize) > Self::MAX_CELL_COUNT {
915            return false;
916        }
917        let new_cursor_row = u16::from_le_bytes([payload[4], payload[5]]);
918        let new_cursor_col = u16::from_le_bytes([payload[6], payload[7]]);
919        let new_mode = u16::from_le_bytes([payload[8], payload[9]]);
920        let title_field = u16::from_le_bytes([payload[10], payload[11]]);
921        let title_present = title_field & TITLE_PRESENT != 0;
922        let ops_present = title_field & OPS_PRESENT != 0;
923        let strings_present = title_field & STRINGS_PRESENT != 0;
924        let line_flags_present = title_field & LINE_FLAGS_PRESENT != 0;
925        let title_len = (title_field & TITLE_LEN_MASK) as usize;
926
927        let title_start = 12usize;
928        let title_end = title_start.saturating_add(title_len);
929        if payload.len() < title_end {
930            return false;
931        }
932        let title_changed = if title_present {
933            let title = String::from_utf8_lossy(&payload[title_start..title_end]).into_owned();
934            self.frame.set_title(title)
935        } else {
936            false
937        };
938
939        let resized = new_rows != self.frame.rows || new_cols != self.frame.cols;
940        if resized {
941            self.frame.resize(new_rows, new_cols);
942        }
943
944        let old_cursor_row = self.frame.cursor_row;
945        let old_cursor_col = self.frame.cursor_col;
946        let old_mode = self.frame.mode;
947
948        let (content_changed, ops_end) = if ops_present {
949            let ops_start = title_end;
950            if payload.len() < ops_start + 2 {
951                return false;
952            }
953            let (changed, consumed) = self
954                .apply_ops_payload(&payload[ops_start..])
955                .unwrap_or((false, 0));
956            (changed, ops_start + consumed)
957        } else {
958            let (changed, consumed) = self
959                .apply_legacy_patch_payload(&payload[title_end..])
960                .unwrap_or((false, 0));
961            (changed, title_end + consumed)
962        };
963
964        let mut after_strings = ops_end;
965        if strings_present {
966            after_strings = self.apply_overflow_strings(&payload[ops_end..]);
967            after_strings += ops_end;
968        }
969
970        let (line_flags_changed, after_line_flags) = if line_flags_present {
971            let lf_start = after_strings;
972            let lf_end = lf_start + new_rows as usize;
973            if payload.len() >= lf_end {
974                let new_flags = &payload[lf_start..lf_end];
975                let changed = self.frame.line_flags != new_flags;
976                self.frame.line_flags.clear();
977                self.frame.line_flags.extend_from_slice(new_flags);
978                (changed, lf_end)
979            } else {
980                (false, after_strings)
981            }
982        } else {
983            (false, after_strings)
984        };
985
986        // Trailing scrollback count (backward-compatible extension).
987        if payload.len() >= after_line_flags + 4 {
988            self.frame.scrollback_lines = u32::from_le_bytes([
989                payload[after_line_flags],
990                payload[after_line_flags + 1],
991                payload[after_line_flags + 2],
992                payload[after_line_flags + 3],
993            ]);
994        }
995
996        self.frame.cursor_row = new_cursor_row.min(self.frame.rows.saturating_sub(1));
997        self.frame.cursor_col = new_cursor_col.min(self.frame.cols.saturating_sub(1));
998        self.frame.mode = new_mode;
999        resized
1000            || title_changed
1001            || content_changed
1002            || line_flags_changed
1003            || new_cursor_row != old_cursor_row
1004            || new_cursor_col != old_cursor_col
1005            || new_mode != old_mode
1006    }
1007
1008    fn apply_legacy_patch_payload(&mut self, payload: &[u8]) -> Option<(bool, usize)> {
1009        let total_cells = self.frame.rows as usize * self.frame.cols as usize;
1010        let bitmask_len = total_cells.div_ceil(8);
1011        if payload.len() < bitmask_len {
1012            return None;
1013        }
1014        let bitmask = &payload[..bitmask_len];
1015        let dirty_count = (0..total_cells)
1016            .filter(|&i| bitmask[i / 8] & (1 << (i % 8)) != 0)
1017            .count();
1018        let data = &payload[bitmask_len..];
1019        if data.len() < dirty_count * CELL_SIZE {
1020            return None;
1021        }
1022        self.apply_patch_cells(bitmask, &data[..dirty_count * CELL_SIZE], dirty_count);
1023        Some((dirty_count > 0, bitmask_len + dirty_count * CELL_SIZE))
1024    }
1025
1026    fn apply_ops_payload(&mut self, payload: &[u8]) -> Option<(bool, usize)> {
1027        if payload.len() < 2 {
1028            return None;
1029        }
1030        let op_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
1031        let total_cells = self.frame.rows as usize * self.frame.cols as usize;
1032        let bitmask_len = total_cells.div_ceil(8);
1033        let mut off = 2usize;
1034        let mut changed = false;
1035
1036        for _ in 0..op_count {
1037            if off >= payload.len() {
1038                return None;
1039            }
1040            let op = payload[off];
1041            off += 1;
1042            match op {
1043                OP_COPY_RECT => {
1044                    if payload.len() < off + 12 {
1045                        return None;
1046                    }
1047                    let src_row = u16::from_le_bytes([payload[off], payload[off + 1]]);
1048                    let src_col = u16::from_le_bytes([payload[off + 2], payload[off + 3]]);
1049                    let dst_row = u16::from_le_bytes([payload[off + 4], payload[off + 5]]);
1050                    let dst_col = u16::from_le_bytes([payload[off + 6], payload[off + 7]]);
1051                    let rows = u16::from_le_bytes([payload[off + 8], payload[off + 9]]);
1052                    let cols = u16::from_le_bytes([payload[off + 10], payload[off + 11]]);
1053                    off += 12;
1054                    changed |= self.apply_copy_rect(src_row, src_col, dst_row, dst_col, rows, cols);
1055                }
1056                OP_FILL_RECT => {
1057                    if payload.len() < off + 8 + CELL_SIZE {
1058                        return None;
1059                    }
1060                    let row = u16::from_le_bytes([payload[off], payload[off + 1]]);
1061                    let col = u16::from_le_bytes([payload[off + 2], payload[off + 3]]);
1062                    let rows = u16::from_le_bytes([payload[off + 4], payload[off + 5]]);
1063                    let cols = u16::from_le_bytes([payload[off + 6], payload[off + 7]]);
1064                    off += 8;
1065                    let mut cell = [0u8; CELL_SIZE];
1066                    cell.copy_from_slice(&payload[off..off + CELL_SIZE]);
1067                    off += CELL_SIZE;
1068                    changed |= self.apply_fill_rect(row, col, rows, cols, &cell);
1069                }
1070                OP_PATCH_CELLS => {
1071                    if payload.len() < off + bitmask_len {
1072                        return None;
1073                    }
1074                    let bitmask = &payload[off..off + bitmask_len];
1075                    off += bitmask_len;
1076                    let dirty_count = (0..total_cells)
1077                        .filter(|&i| bitmask[i / 8] & (1 << (i % 8)) != 0)
1078                        .count();
1079                    if payload.len() < off + dirty_count * CELL_SIZE {
1080                        return None;
1081                    }
1082                    self.apply_patch_cells(
1083                        bitmask,
1084                        &payload[off..off + dirty_count * CELL_SIZE],
1085                        dirty_count,
1086                    );
1087                    off += dirty_count * CELL_SIZE;
1088                    changed |= dirty_count > 0;
1089                }
1090                _ => return None,
1091            }
1092        }
1093
1094        Some((changed, off))
1095    }
1096
1097    fn apply_patch_cells(&mut self, bitmask: &[u8], data: &[u8], dirty_count: usize) {
1098        let total_cells = self.frame.rows as usize * self.frame.cols as usize;
1099        let mut dirty_idx = 0usize;
1100        for i in 0..total_cells {
1101            if bitmask[i / 8] & (1 << (i % 8)) == 0 {
1102                continue;
1103            }
1104            let cell_idx = i * CELL_SIZE;
1105            for byte_pos in 0..CELL_SIZE {
1106                self.frame.cells[cell_idx + byte_pos] = data[byte_pos * dirty_count + dirty_idx];
1107            }
1108            // Remove stale overflow entry when a cell is updated — it may
1109            // have transitioned from overflow (content_len=7) to inline.
1110            let new_content_len = (self.frame.cells[cell_idx + 1] >> 3) & 7;
1111            if new_content_len != CONTENT_OVERFLOW {
1112                self.frame.overflow.remove(&i);
1113            }
1114            dirty_idx += 1;
1115        }
1116    }
1117
1118    fn apply_copy_rect(
1119        &mut self,
1120        src_row: u16,
1121        src_col: u16,
1122        dst_row: u16,
1123        dst_col: u16,
1124        rows: u16,
1125        cols: u16,
1126    ) -> bool {
1127        let rows = rows
1128            .min(self.frame.rows.saturating_sub(src_row))
1129            .min(self.frame.rows.saturating_sub(dst_row));
1130        let cols = cols
1131            .min(self.frame.cols.saturating_sub(src_col))
1132            .min(self.frame.cols.saturating_sub(dst_col));
1133        if rows == 0 || cols == 0 {
1134            return false;
1135        }
1136
1137        let frame_cols = self.frame.cols as usize;
1138
1139        // Copy overflow strings for the source region.
1140        let mut overflow_temp: Vec<(usize, String)> = Vec::new();
1141        for r in 0..rows as usize {
1142            for c in 0..cols as usize {
1143                let src_flat = (src_row as usize + r) * frame_cols + src_col as usize + c;
1144                if let Some(s) = self.frame.overflow.get(&src_flat) {
1145                    let dst_flat = (dst_row as usize + r) * frame_cols + dst_col as usize + c;
1146                    overflow_temp.push((dst_flat, s.clone()));
1147                }
1148            }
1149        }
1150
1151        let mut temp = vec![0u8; rows as usize * cols as usize * CELL_SIZE];
1152        for r in 0..rows as usize {
1153            let src_off = self.frame.cell_offset(src_row + r as u16, src_col);
1154            let src_end = src_off + cols as usize * CELL_SIZE;
1155            let dst_off = r * cols as usize * CELL_SIZE;
1156            temp[dst_off..dst_off + cols as usize * CELL_SIZE]
1157                .copy_from_slice(&self.frame.cells[src_off..src_end]);
1158        }
1159        for r in 0..rows as usize {
1160            let dst_off = self.frame.cell_offset(dst_row + r as u16, dst_col);
1161            let dst_end = dst_off + cols as usize * CELL_SIZE;
1162            let src_off = r * cols as usize * CELL_SIZE;
1163            self.frame.cells[dst_off..dst_end]
1164                .copy_from_slice(&temp[src_off..src_off + cols as usize * CELL_SIZE]);
1165        }
1166
1167        for r in 0..rows as usize {
1168            for c in 0..cols as usize {
1169                let dst_flat = (dst_row as usize + r) * frame_cols + dst_col as usize + c;
1170                self.frame.overflow.remove(&dst_flat);
1171            }
1172        }
1173        for (idx, s) in overflow_temp {
1174            self.frame.overflow.insert(idx, s);
1175        }
1176
1177        true
1178    }
1179
1180    fn apply_fill_rect(
1181        &mut self,
1182        row: u16,
1183        col: u16,
1184        rows: u16,
1185        cols: u16,
1186        cell: &[u8; CELL_SIZE],
1187    ) -> bool {
1188        let row_end = row.saturating_add(rows).min(self.frame.rows);
1189        let col_end = col.saturating_add(cols).min(self.frame.cols);
1190        // Fill cells never have overflow content — clear stale entries.
1191        let frame_cols = self.frame.cols as usize;
1192        for r in row..row_end {
1193            for c in col..col_end {
1194                self.frame
1195                    .overflow
1196                    .remove(&(r as usize * frame_cols + c as usize));
1197            }
1198        }
1199        if row >= row_end || col >= col_end {
1200            return false;
1201        }
1202        for r in row..row_end {
1203            for c in col..col_end {
1204                let off = self.frame.cell_offset(r, c);
1205                self.frame.cells[off..off + CELL_SIZE].copy_from_slice(cell);
1206            }
1207        }
1208        true
1209    }
1210
1211    fn apply_overflow_strings(&mut self, data: &[u8]) -> usize {
1212        if data.len() < 2 {
1213            return 0;
1214        }
1215        let count = u16::from_le_bytes([data[0], data[1]]) as usize;
1216        let mut off = 2usize;
1217        for _ in 0..count {
1218            if off + 6 > data.len() {
1219                break;
1220            }
1221            let cell_idx =
1222                u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1223                    as usize;
1224            let len = u16::from_le_bytes([data[off + 4], data[off + 5]]) as usize;
1225            off += 6;
1226            if off + len > data.len() {
1227                break;
1228            }
1229            if let Ok(s) = std::str::from_utf8(&data[off..off + len]) {
1230                // Only accept indices within the current grid to prevent
1231                // unbounded BTreeMap growth from malicious wire data.
1232                let max_idx = self.frame.rows as usize * self.frame.cols as usize;
1233                if cell_idx < max_idx {
1234                    self.frame.overflow.insert(cell_idx, s.to_owned());
1235                }
1236            }
1237            off += len;
1238        }
1239        off
1240    }
1241}
1242
1243#[derive(Clone, Debug)]
1244pub enum Node {
1245    Fill {
1246        rect: Rect,
1247        ch: char,
1248        style: CellStyle,
1249    },
1250    Text {
1251        row: u16,
1252        col: u16,
1253        text: String,
1254        style: CellStyle,
1255    },
1256    WrappedText {
1257        rect: Rect,
1258        text: String,
1259        style: CellStyle,
1260    },
1261    ScrollingText {
1262        rect: Rect,
1263        lines: Vec<String>,
1264        offset_from_bottom: usize,
1265        style: CellStyle,
1266    },
1267}
1268
1269#[derive(Clone, Debug, Default)]
1270pub struct Dom {
1271    background: CellStyle,
1272    title: Option<String>,
1273    nodes: Vec<Node>,
1274}
1275
1276impl Dom {
1277    pub fn new() -> Self {
1278        Self::default()
1279    }
1280
1281    pub fn clear(&mut self) {
1282        self.title = None;
1283        self.nodes.clear();
1284    }
1285
1286    pub fn set_background(&mut self, style: CellStyle) {
1287        self.background = style;
1288    }
1289
1290    pub fn set_title(&mut self, title: impl Into<String>) {
1291        self.title = Some(title.into());
1292    }
1293
1294    pub fn fill(&mut self, rect: Rect, ch: char, style: CellStyle) {
1295        self.nodes.push(Node::Fill { rect, ch, style });
1296    }
1297
1298    pub fn text(&mut self, row: u16, col: u16, text: impl Into<String>, style: CellStyle) {
1299        self.nodes.push(Node::Text {
1300            row,
1301            col,
1302            text: text.into(),
1303            style,
1304        });
1305    }
1306
1307    pub fn wrapped_text(&mut self, rect: Rect, text: impl Into<String>, style: CellStyle) {
1308        self.nodes.push(Node::WrappedText {
1309            rect,
1310            text: text.into(),
1311            style,
1312        });
1313    }
1314
1315    pub fn scrolling_text<S, I>(
1316        &mut self,
1317        rect: Rect,
1318        lines: I,
1319        offset_from_bottom: usize,
1320        style: CellStyle,
1321    ) where
1322        S: Into<String>,
1323        I: IntoIterator<Item = S>,
1324    {
1325        self.nodes.push(Node::ScrollingText {
1326            rect,
1327            lines: lines.into_iter().map(Into::into).collect(),
1328            offset_from_bottom,
1329            style,
1330        });
1331    }
1332
1333    pub fn render_to(&self, frame: &mut FrameState) {
1334        frame.clear(self.background);
1335        frame.set_title(self.title.clone().unwrap_or_default());
1336        for node in &self.nodes {
1337            match node {
1338                Node::Fill { rect, ch, style } => frame.fill_rect(*rect, *ch, *style),
1339                Node::Text {
1340                    row,
1341                    col,
1342                    text,
1343                    style,
1344                } => {
1345                    frame.write_text(*row, *col, text, *style);
1346                }
1347                Node::WrappedText { rect, text, style } => {
1348                    frame.write_wrapped_text(*rect, text, *style);
1349                }
1350                Node::ScrollingText {
1351                    rect,
1352                    lines,
1353                    offset_from_bottom,
1354                    style,
1355                } => {
1356                    frame.write_scrolling_text(*rect, lines, *offset_from_bottom, *style);
1357                }
1358            }
1359        }
1360    }
1361}
1362
1363#[derive(Clone, Debug)]
1364pub struct CallbackRenderer {
1365    dom: Dom,
1366    frame: FrameState,
1367}
1368
1369impl CallbackRenderer {
1370    pub fn new(rows: u16, cols: u16) -> Self {
1371        Self {
1372            dom: Dom::new(),
1373            frame: FrameState::new(rows, cols),
1374        }
1375    }
1376
1377    pub fn resize(&mut self, rows: u16, cols: u16) {
1378        self.frame.resize(rows, cols);
1379    }
1380
1381    pub fn frame(&self) -> &FrameState {
1382        &self.frame
1383    }
1384
1385    pub fn render<F>(&mut self, render: F) -> &FrameState
1386    where
1387        F: FnOnce(&mut Dom),
1388    {
1389        self.dom.clear();
1390        render(&mut self.dom);
1391        self.dom.render_to(&mut self.frame);
1392        &self.frame
1393    }
1394}
1395
1396pub enum ServerMsg<'a> {
1397    Hello {
1398        version: u16,
1399        features: u32,
1400    },
1401    Update {
1402        pty_id: u16,
1403        payload: &'a [u8],
1404    },
1405    Created {
1406        pty_id: u16,
1407        tag: &'a str,
1408    },
1409    CreatedN {
1410        nonce: u16,
1411        pty_id: u16,
1412        tag: &'a str,
1413    },
1414    Closed {
1415        pty_id: u16,
1416    },
1417    Exited {
1418        pty_id: u16,
1419        exit_status: i32,
1420    },
1421    List {
1422        entries: Vec<PtyListEntry<'a>>,
1423    },
1424    Title {
1425        pty_id: u16,
1426        title: &'a [u8],
1427    },
1428    SearchResults {
1429        request_id: u16,
1430        results: Vec<SearchResultEntry<'a>>,
1431    },
1432    Ready,
1433    Text {
1434        nonce: u16,
1435        pty_id: u16,
1436        total_lines: u32,
1437        offset: u32,
1438        text: &'a str,
1439    },
1440    SurfaceCreated {
1441        surface_id: u16,
1442        parent_id: u16,
1443        width: u16,
1444        height: u16,
1445        title: &'a str,
1446        app_id: &'a str,
1447    },
1448    SurfaceDestroyed {
1449        surface_id: u16,
1450    },
1451    SurfaceFrame {
1452        surface_id: u16,
1453        timestamp: u32,
1454        flags: u8,
1455        width: u16,
1456        height: u16,
1457        data: &'a [u8],
1458    },
1459    SurfaceTitle {
1460        surface_id: u16,
1461        title: &'a str,
1462    },
1463    SurfaceAppId {
1464        surface_id: u16,
1465        app_id: &'a str,
1466    },
1467    SurfaceResized {
1468        surface_id: u16,
1469        width: u16,
1470        height: u16,
1471    },
1472    ClipboardContent {
1473        mime_type: &'a str,
1474        data: &'a [u8],
1475    },
1476    SurfaceList {
1477        entries: Vec<SurfaceListEntry>,
1478    },
1479    SurfaceCapture {
1480        surface_id: u16,
1481        width: u32,
1482        height: u32,
1483        image_data: &'a [u8],
1484    },
1485    ClipboardList {
1486        mime_types: Vec<String>,
1487    },
1488    Quit,
1489}
1490
1491#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1492pub struct PtyListEntry<'a> {
1493    pub pty_id: u16,
1494    pub tag: &'a str,
1495    pub command: &'a str,
1496}
1497
1498#[derive(Clone, Debug, PartialEq, Eq)]
1499pub struct SurfaceListEntry {
1500    pub surface_id: u16,
1501    pub parent_id: u16,
1502    pub width: u16,
1503    pub height: u16,
1504    pub title: String,
1505    pub app_id: String,
1506}
1507
1508#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1509pub struct SearchResultEntry<'a> {
1510    pub pty_id: u16,
1511    pub score: u32,
1512    pub primary_source: u8,
1513    pub matched_sources: u8,
1514    pub scroll_offset: Option<u32>,
1515    pub context: &'a [u8],
1516}
1517
1518pub fn parse_server_msg(data: &[u8]) -> Option<ServerMsg<'_>> {
1519    if data.is_empty() {
1520        return None;
1521    }
1522    match data[0] {
1523        S2C_HELLO => {
1524            if data.len() < 7 {
1525                return None;
1526            }
1527            let version = u16::from_le_bytes([data[1], data[2]]);
1528            let features = u32::from_le_bytes([data[3], data[4], data[5], data[6]]);
1529            Some(ServerMsg::Hello { version, features })
1530        }
1531        S2C_UPDATE => {
1532            if data.len() < 3 {
1533                return None;
1534            }
1535            Some(ServerMsg::Update {
1536                pty_id: u16::from_le_bytes([data[1], data[2]]),
1537                payload: &data[3..],
1538            })
1539        }
1540        S2C_CREATED => {
1541            if data.len() < 3 {
1542                return None;
1543            }
1544            let tag = std::str::from_utf8(data.get(3..).unwrap_or_default()).unwrap_or_default();
1545            Some(ServerMsg::Created {
1546                pty_id: u16::from_le_bytes([data[1], data[2]]),
1547                tag,
1548            })
1549        }
1550        S2C_CREATED_N => {
1551            if data.len() < 5 {
1552                return None;
1553            }
1554            let nonce = u16::from_le_bytes([data[1], data[2]]);
1555            let pty_id = u16::from_le_bytes([data[3], data[4]]);
1556            let tag = std::str::from_utf8(data.get(5..).unwrap_or_default()).unwrap_or_default();
1557            Some(ServerMsg::CreatedN { nonce, pty_id, tag })
1558        }
1559        S2C_CLOSED => {
1560            if data.len() < 3 {
1561                return None;
1562            }
1563            Some(ServerMsg::Closed {
1564                pty_id: u16::from_le_bytes([data[1], data[2]]),
1565            })
1566        }
1567        S2C_EXITED => {
1568            if data.len() < 7 {
1569                return None;
1570            }
1571            Some(ServerMsg::Exited {
1572                pty_id: u16::from_le_bytes([data[1], data[2]]),
1573                exit_status: i32::from_le_bytes([data[3], data[4], data[5], data[6]]),
1574            })
1575        }
1576        S2C_LIST => {
1577            if data.len() < 3 {
1578                return None;
1579            }
1580            let count = u16::from_le_bytes([data[1], data[2]]) as usize;
1581            let mut entries = Vec::with_capacity(count);
1582            let mut offset = 3;
1583            for _ in 0..count {
1584                if offset + 4 > data.len() {
1585                    break;
1586                }
1587                let pty_id = u16::from_le_bytes([data[offset], data[offset + 1]]);
1588                let tag_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
1589                offset += 4;
1590                if offset + tag_len > data.len() {
1591                    break;
1592                }
1593                let tag = std::str::from_utf8(&data[offset..offset + tag_len]).unwrap_or_default();
1594                offset += tag_len;
1595                let command = if offset + 2 <= data.len() {
1596                    let cmd_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
1597                    offset += 2;
1598                    if offset + cmd_len <= data.len() {
1599                        let cmd = std::str::from_utf8(&data[offset..offset + cmd_len])
1600                            .unwrap_or_default();
1601                        offset += cmd_len;
1602                        cmd
1603                    } else {
1604                        // Truncated command — don't advance offset past
1605                        // available data; stop parsing this entry.
1606                        offset = data.len();
1607                        ""
1608                    }
1609                } else {
1610                    ""
1611                };
1612                entries.push(PtyListEntry {
1613                    pty_id,
1614                    tag,
1615                    command,
1616                });
1617            }
1618            Some(ServerMsg::List { entries })
1619        }
1620        S2C_TITLE => {
1621            if data.len() < 3 {
1622                return None;
1623            }
1624            Some(ServerMsg::Title {
1625                pty_id: u16::from_le_bytes([data[1], data[2]]),
1626                title: &data[3..],
1627            })
1628        }
1629        S2C_SEARCH_RESULTS => {
1630            if data.len() < 5 {
1631                return None;
1632            }
1633            let request_id = u16::from_le_bytes([data[1], data[2]]);
1634            let count = u16::from_le_bytes([data[3], data[4]]) as usize;
1635            let mut results = Vec::with_capacity(count);
1636            let mut offset = 5usize;
1637            for _ in 0..count {
1638                if offset + 14 > data.len() {
1639                    return None;
1640                }
1641                let pty_id = u16::from_le_bytes([data[offset], data[offset + 1]]);
1642                let score = u32::from_le_bytes([
1643                    data[offset + 2],
1644                    data[offset + 3],
1645                    data[offset + 4],
1646                    data[offset + 5],
1647                ]);
1648                let primary_source = data[offset + 6];
1649                let matched_sources = data[offset + 7];
1650                let scroll_offset = u32::from_le_bytes([
1651                    data[offset + 8],
1652                    data[offset + 9],
1653                    data[offset + 10],
1654                    data[offset + 11],
1655                ]);
1656                let context_len =
1657                    u16::from_le_bytes([data[offset + 12], data[offset + 13]]) as usize;
1658                offset += 14;
1659                if offset + context_len > data.len() {
1660                    return None;
1661                }
1662                results.push(SearchResultEntry {
1663                    pty_id,
1664                    score,
1665                    primary_source,
1666                    matched_sources,
1667                    scroll_offset: if scroll_offset == u32::MAX {
1668                        None
1669                    } else {
1670                        Some(scroll_offset)
1671                    },
1672                    context: &data[offset..offset + context_len],
1673                });
1674                offset += context_len;
1675            }
1676            Some(ServerMsg::SearchResults {
1677                request_id,
1678                results,
1679            })
1680        }
1681        S2C_READY => Some(ServerMsg::Ready),
1682        S2C_TEXT => {
1683            if data.len() < 13 {
1684                return None;
1685            }
1686            let nonce = u16::from_le_bytes([data[1], data[2]]);
1687            let pty_id = u16::from_le_bytes([data[3], data[4]]);
1688            let total_lines = u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
1689            let offset = u32::from_le_bytes([data[9], data[10], data[11], data[12]]);
1690            let text = std::str::from_utf8(data.get(13..).unwrap_or_default()).unwrap_or_default();
1691            Some(ServerMsg::Text {
1692                nonce,
1693                pty_id,
1694                total_lines,
1695                offset,
1696                text,
1697            })
1698        }
1699        S2C_SURFACE_CREATED => {
1700            if data.len() < 13 {
1701                return None;
1702            }
1703            let surface_id = u16::from_le_bytes([data[1], data[2]]);
1704            let parent_id = u16::from_le_bytes([data[3], data[4]]);
1705            let width = u16::from_le_bytes([data[5], data[6]]);
1706            let height = u16::from_le_bytes([data[7], data[8]]);
1707            let title_len = u16::from_le_bytes([data[9], data[10]]) as usize;
1708            let mut off = 11;
1709            if off + title_len + 2 > data.len() {
1710                return None;
1711            }
1712            let title = std::str::from_utf8(&data[off..off + title_len]).unwrap_or_default();
1713            off += title_len;
1714            let app_id_len = u16::from_le_bytes([data[off], data[off + 1]]) as usize;
1715            off += 2;
1716            if off + app_id_len > data.len() {
1717                return None;
1718            }
1719            let app_id = std::str::from_utf8(&data[off..off + app_id_len]).unwrap_or_default();
1720            Some(ServerMsg::SurfaceCreated {
1721                surface_id,
1722                parent_id,
1723                width,
1724                height,
1725                title,
1726                app_id,
1727            })
1728        }
1729        S2C_SURFACE_DESTROYED => {
1730            if data.len() < 3 {
1731                return None;
1732            }
1733            Some(ServerMsg::SurfaceDestroyed {
1734                surface_id: u16::from_le_bytes([data[1], data[2]]),
1735            })
1736        }
1737        S2C_SURFACE_FRAME => {
1738            if data.len() < 12 {
1739                return None;
1740            }
1741            Some(ServerMsg::SurfaceFrame {
1742                surface_id: u16::from_le_bytes([data[1], data[2]]),
1743                timestamp: u32::from_le_bytes([data[3], data[4], data[5], data[6]]),
1744                flags: data[7],
1745                width: u16::from_le_bytes([data[8], data[9]]),
1746                height: u16::from_le_bytes([data[10], data[11]]),
1747                data: data.get(12..).unwrap_or_default(),
1748            })
1749        }
1750        S2C_SURFACE_TITLE => {
1751            if data.len() < 3 {
1752                return None;
1753            }
1754            let title = std::str::from_utf8(data.get(3..).unwrap_or_default()).unwrap_or_default();
1755            Some(ServerMsg::SurfaceTitle {
1756                surface_id: u16::from_le_bytes([data[1], data[2]]),
1757                title,
1758            })
1759        }
1760        S2C_SURFACE_APP_ID => {
1761            if data.len() < 3 {
1762                return None;
1763            }
1764            let app_id = std::str::from_utf8(data.get(3..).unwrap_or_default()).unwrap_or_default();
1765            Some(ServerMsg::SurfaceAppId {
1766                surface_id: u16::from_le_bytes([data[1], data[2]]),
1767                app_id,
1768            })
1769        }
1770        S2C_SURFACE_RESIZED => {
1771            if data.len() < 7 {
1772                return None;
1773            }
1774            Some(ServerMsg::SurfaceResized {
1775                surface_id: u16::from_le_bytes([data[1], data[2]]),
1776                width: u16::from_le_bytes([data[3], data[4]]),
1777                height: u16::from_le_bytes([data[5], data[6]]),
1778            })
1779        }
1780        S2C_CLIPBOARD_CONTENT => {
1781            if data.len() < 7 {
1782                return None;
1783            }
1784            let mime_len = u16::from_le_bytes([data[1], data[2]]) as usize;
1785            let mut off = 3;
1786            if off + mime_len + 4 > data.len() {
1787                return None;
1788            }
1789            let mime_type = std::str::from_utf8(&data[off..off + mime_len]).unwrap_or_default();
1790            off += mime_len;
1791            let data_len =
1792                u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1793                    as usize;
1794            off += 4;
1795            if off + data_len > data.len() {
1796                return None;
1797            }
1798            Some(ServerMsg::ClipboardContent {
1799                mime_type,
1800                data: &data[off..off + data_len],
1801            })
1802        }
1803        S2C_SURFACE_LIST => {
1804            if data.len() < 3 {
1805                return None;
1806            }
1807            let count = u16::from_le_bytes([data[1], data[2]]) as usize;
1808            let mut entries = Vec::with_capacity(count);
1809            let mut offset = 3;
1810            for _ in 0..count {
1811                if offset + 8 > data.len() {
1812                    break;
1813                }
1814                let surface_id = u16::from_le_bytes([data[offset], data[offset + 1]]);
1815                let parent_id = u16::from_le_bytes([data[offset + 2], data[offset + 3]]);
1816                let width = u16::from_le_bytes([data[offset + 4], data[offset + 5]]);
1817                let height = u16::from_le_bytes([data[offset + 6], data[offset + 7]]);
1818                offset += 8;
1819                if offset + 2 > data.len() {
1820                    break;
1821                }
1822                let title_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
1823                offset += 2;
1824                if offset + title_len > data.len() {
1825                    break;
1826                }
1827                let title =
1828                    std::str::from_utf8(&data[offset..offset + title_len]).unwrap_or_default();
1829                offset += title_len;
1830                if offset + 2 > data.len() {
1831                    break;
1832                }
1833                let app_id_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
1834                offset += 2;
1835                if offset + app_id_len > data.len() {
1836                    break;
1837                }
1838                let app_id =
1839                    std::str::from_utf8(&data[offset..offset + app_id_len]).unwrap_or_default();
1840                offset += app_id_len;
1841                entries.push(SurfaceListEntry {
1842                    surface_id,
1843                    parent_id,
1844                    width,
1845                    height,
1846                    title: title.to_string(),
1847                    app_id: app_id.to_string(),
1848                });
1849            }
1850            Some(ServerMsg::SurfaceList { entries })
1851        }
1852        S2C_SURFACE_CAPTURE => {
1853            if data.len() < 11 {
1854                return None;
1855            }
1856            let surface_id = u16::from_le_bytes([data[1], data[2]]);
1857            let width = u32::from_le_bytes([data[3], data[4], data[5], data[6]]);
1858            let height = u32::from_le_bytes([data[7], data[8], data[9], data[10]]);
1859            let image_data = data.get(11..).unwrap_or_default();
1860            Some(ServerMsg::SurfaceCapture {
1861                surface_id,
1862                width,
1863                height,
1864                image_data,
1865            })
1866        }
1867        S2C_CLIPBOARD_LIST => {
1868            if data.len() < 3 {
1869                return None;
1870            }
1871            let count = u16::from_le_bytes([data[1], data[2]]) as usize;
1872            let mut mime_types = Vec::with_capacity(count);
1873            let mut offset = 3;
1874            for _ in 0..count {
1875                if offset + 2 > data.len() {
1876                    break;
1877                }
1878                let mime_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
1879                offset += 2;
1880                if offset + mime_len > data.len() {
1881                    break;
1882                }
1883                let mime =
1884                    std::str::from_utf8(&data[offset..offset + mime_len]).unwrap_or_default();
1885                mime_types.push(mime.to_string());
1886                offset += mime_len;
1887            }
1888            Some(ServerMsg::ClipboardList { mime_types })
1889        }
1890        S2C_QUIT => Some(ServerMsg::Quit),
1891        _ => None,
1892    }
1893}
1894
1895pub fn msg_hello(version: u16, features: u32) -> Vec<u8> {
1896    let mut msg = Vec::with_capacity(7);
1897    msg.push(S2C_HELLO);
1898    msg.extend_from_slice(&version.to_le_bytes());
1899    msg.extend_from_slice(&features.to_le_bytes());
1900    msg
1901}
1902
1903pub fn msg_create(rows: u16, cols: u16) -> Vec<u8> {
1904    msg_create_tagged(rows, cols, "")
1905}
1906
1907pub fn msg_create_tagged(rows: u16, cols: u16, tag: &str) -> Vec<u8> {
1908    let tag_bytes = tag.as_bytes();
1909    let tag_len = tag_bytes.len().min(u16::MAX as usize);
1910    let mut msg = Vec::with_capacity(7 + tag_len);
1911    msg.push(C2S_CREATE);
1912    msg.extend_from_slice(&rows.to_le_bytes());
1913    msg.extend_from_slice(&cols.to_le_bytes());
1914    msg.extend_from_slice(&(tag_len as u16).to_le_bytes());
1915    msg.extend_from_slice(&tag_bytes[..tag_len]);
1916    msg
1917}
1918
1919/// Spawn a new PTY in the same working directory as `src_pty_id`.
1920pub fn msg_create_at(rows: u16, cols: u16, tag: &str, src_pty_id: u16) -> Vec<u8> {
1921    let tag_bytes = tag.as_bytes();
1922    let tag_len = tag_bytes.len().min(u16::MAX as usize);
1923    let mut msg = Vec::with_capacity(9 + tag_len);
1924    msg.push(C2S_CREATE_AT);
1925    msg.extend_from_slice(&rows.to_le_bytes());
1926    msg.extend_from_slice(&cols.to_le_bytes());
1927    msg.extend_from_slice(&(tag_len as u16).to_le_bytes());
1928    msg.extend_from_slice(&tag_bytes[..tag_len]);
1929    msg.extend_from_slice(&src_pty_id.to_le_bytes());
1930    msg
1931}
1932
1933pub fn msg_create_n(nonce: u16, rows: u16, cols: u16, tag: &str) -> Vec<u8> {
1934    let tag_bytes = tag.as_bytes();
1935    let tag_len = tag_bytes.len().min(u16::MAX as usize);
1936    let mut msg = Vec::with_capacity(9 + tag_len);
1937    msg.push(C2S_CREATE_N);
1938    msg.extend_from_slice(&nonce.to_le_bytes());
1939    msg.extend_from_slice(&rows.to_le_bytes());
1940    msg.extend_from_slice(&cols.to_le_bytes());
1941    msg.extend_from_slice(&(tag_len as u16).to_le_bytes());
1942    msg.extend_from_slice(&tag_bytes[..tag_len]);
1943    msg
1944}
1945
1946pub fn msg_create_n_command(nonce: u16, rows: u16, cols: u16, tag: &str, command: &str) -> Vec<u8> {
1947    let mut msg = msg_create_n(nonce, rows, cols, tag);
1948    msg.extend_from_slice(command.as_bytes());
1949    msg
1950}
1951
1952pub fn msg_create2(
1953    nonce: u16,
1954    rows: u16,
1955    cols: u16,
1956    tag: &str,
1957    command: &str,
1958    features: u8,
1959) -> Vec<u8> {
1960    let tag_bytes = tag.as_bytes();
1961    let cmd_bytes = command.as_bytes();
1962    let has_cmd = !command.is_empty();
1963    let feat = features | if has_cmd { CREATE2_HAS_COMMAND } else { 0 };
1964    let mut msg = Vec::with_capacity(10 + tag_bytes.len() + cmd_bytes.len());
1965    msg.push(C2S_CREATE2);
1966    msg.extend_from_slice(&nonce.to_le_bytes());
1967    msg.extend_from_slice(&rows.to_le_bytes());
1968    msg.extend_from_slice(&cols.to_le_bytes());
1969    msg.push(feat);
1970    msg.extend_from_slice(&(tag_bytes.len() as u16).to_le_bytes());
1971    msg.extend_from_slice(tag_bytes);
1972    if has_cmd {
1973        msg.extend_from_slice(cmd_bytes);
1974    }
1975    msg
1976}
1977
1978pub fn msg_create_command(rows: u16, cols: u16, command: &str) -> Vec<u8> {
1979    msg_create_tagged_command(rows, cols, "", command)
1980}
1981
1982pub fn msg_create_tagged_command(rows: u16, cols: u16, tag: &str, command: &str) -> Vec<u8> {
1983    let mut msg = msg_create_tagged(rows, cols, tag);
1984    msg.extend_from_slice(command.as_bytes());
1985    msg
1986}
1987
1988pub fn msg_input(pty_id: u16, data: &[u8]) -> Vec<u8> {
1989    let mut msg = Vec::with_capacity(3 + data.len());
1990    msg.push(C2S_INPUT);
1991    msg.extend_from_slice(&pty_id.to_le_bytes());
1992    msg.extend_from_slice(data);
1993    msg
1994}
1995
1996pub fn msg_mouse(pty_id: u16, type_: u8, button: u8, col: u16, row: u16) -> Vec<u8> {
1997    let mut msg = Vec::with_capacity(9);
1998    msg.push(C2S_MOUSE);
1999    msg.extend_from_slice(&pty_id.to_le_bytes());
2000    msg.push(type_);
2001    msg.push(button);
2002    msg.extend_from_slice(&col.to_le_bytes());
2003    msg.extend_from_slice(&row.to_le_bytes());
2004    msg
2005}
2006
2007pub fn msg_resize(pty_id: u16, rows: u16, cols: u16) -> Vec<u8> {
2008    let mut msg = Vec::with_capacity(7);
2009    msg.push(C2S_RESIZE);
2010    msg.extend_from_slice(&pty_id.to_le_bytes());
2011    msg.extend_from_slice(&rows.to_le_bytes());
2012    msg.extend_from_slice(&cols.to_le_bytes());
2013    msg
2014}
2015
2016pub fn msg_resize_batch(entries: &[(u16, u16, u16)]) -> Vec<u8> {
2017    let mut msg = Vec::with_capacity(1 + entries.len() * 6);
2018    msg.push(C2S_RESIZE);
2019    for &(pty_id, rows, cols) in entries {
2020        msg.extend_from_slice(&pty_id.to_le_bytes());
2021        msg.extend_from_slice(&rows.to_le_bytes());
2022        msg.extend_from_slice(&cols.to_le_bytes());
2023    }
2024    msg
2025}
2026
2027pub fn msg_focus(pty_id: u16) -> Vec<u8> {
2028    let mut msg = Vec::with_capacity(3);
2029    msg.push(C2S_FOCUS);
2030    msg.extend_from_slice(&pty_id.to_le_bytes());
2031    msg
2032}
2033
2034pub fn msg_close(pty_id: u16) -> Vec<u8> {
2035    let mut msg = Vec::with_capacity(3);
2036    msg.push(C2S_CLOSE);
2037    msg.extend_from_slice(&pty_id.to_le_bytes());
2038    msg
2039}
2040
2041pub fn msg_kill(pty_id: u16, signal: i32) -> Vec<u8> {
2042    let mut msg = Vec::with_capacity(7);
2043    msg.push(C2S_KILL);
2044    msg.extend_from_slice(&pty_id.to_le_bytes());
2045    msg.extend_from_slice(&signal.to_le_bytes());
2046    msg
2047}
2048
2049pub fn msg_restart(pty_id: u16) -> Vec<u8> {
2050    let mut msg = Vec::with_capacity(3);
2051    msg.push(C2S_RESTART);
2052    msg.extend_from_slice(&pty_id.to_le_bytes());
2053    msg
2054}
2055
2056pub fn msg_subscribe(pty_id: u16) -> Vec<u8> {
2057    let mut msg = Vec::with_capacity(3);
2058    msg.push(C2S_SUBSCRIBE);
2059    msg.extend_from_slice(&pty_id.to_le_bytes());
2060    msg
2061}
2062
2063pub fn msg_unsubscribe(pty_id: u16) -> Vec<u8> {
2064    let mut msg = Vec::with_capacity(3);
2065    msg.push(C2S_UNSUBSCRIBE);
2066    msg.extend_from_slice(&pty_id.to_le_bytes());
2067    msg
2068}
2069
2070pub fn msg_search(request_id: u16, query: &str) -> Vec<u8> {
2071    let query = query.as_bytes();
2072    let mut msg = Vec::with_capacity(3 + query.len());
2073    msg.push(C2S_SEARCH);
2074    msg.extend_from_slice(&request_id.to_le_bytes());
2075    msg.extend_from_slice(query);
2076    msg
2077}
2078
2079pub fn msg_ack() -> Vec<u8> {
2080    vec![C2S_ACK]
2081}
2082
2083pub fn msg_scroll(pty_id: u16, offset: u32) -> Vec<u8> {
2084    let mut msg = Vec::with_capacity(7);
2085    msg.push(C2S_SCROLL);
2086    msg.extend_from_slice(&pty_id.to_le_bytes());
2087    msg.extend_from_slice(&offset.to_le_bytes());
2088    msg
2089}
2090
2091pub fn msg_display_rate(fps: u16) -> Vec<u8> {
2092    let mut msg = Vec::with_capacity(3);
2093    msg.push(C2S_DISPLAY_RATE);
2094    msg.extend_from_slice(&fps.to_le_bytes());
2095    msg
2096}
2097
2098pub fn msg_client_metrics(backlog: u16, ack_ahead: u16, apply_ms_x10: u16) -> Vec<u8> {
2099    let mut msg = Vec::with_capacity(7);
2100    msg.push(C2S_CLIENT_METRICS);
2101    msg.extend_from_slice(&backlog.to_le_bytes());
2102    msg.extend_from_slice(&ack_ahead.to_le_bytes());
2103    msg.extend_from_slice(&apply_ms_x10.to_le_bytes());
2104    msg
2105}
2106
2107pub fn msg_read(nonce: u16, pty_id: u16, offset: u32, limit: u32, flags: u8) -> Vec<u8> {
2108    let mut msg = Vec::with_capacity(14);
2109    msg.push(C2S_READ);
2110    msg.extend_from_slice(&nonce.to_le_bytes());
2111    msg.extend_from_slice(&pty_id.to_le_bytes());
2112    msg.extend_from_slice(&offset.to_le_bytes());
2113    msg.extend_from_slice(&limit.to_le_bytes());
2114    msg.push(flags);
2115    msg
2116}
2117
2118pub fn msg_copy_range(
2119    nonce: u16,
2120    pty_id: u16,
2121    start_tail: u32,
2122    start_col: u16,
2123    end_tail: u32,
2124    end_col: u16,
2125    flags: u8,
2126) -> Vec<u8> {
2127    let mut msg = Vec::with_capacity(18);
2128    msg.push(C2S_COPY_RANGE);
2129    msg.extend_from_slice(&nonce.to_le_bytes());
2130    msg.extend_from_slice(&pty_id.to_le_bytes());
2131    msg.extend_from_slice(&start_tail.to_le_bytes());
2132    msg.extend_from_slice(&start_col.to_le_bytes());
2133    msg.extend_from_slice(&end_tail.to_le_bytes());
2134    msg.extend_from_slice(&end_col.to_le_bytes());
2135    msg.push(flags);
2136    msg
2137}
2138
2139pub fn msg_exited(pty_id: u16, exit_status: i32) -> Vec<u8> {
2140    let mut msg = Vec::with_capacity(7);
2141    msg.push(S2C_EXITED);
2142    msg.extend_from_slice(&pty_id.to_le_bytes());
2143    msg.extend_from_slice(&exit_status.to_le_bytes());
2144    msg
2145}
2146
2147/// Build a C2S_QUIT message (client requests server shutdown).
2148pub fn msg_quit() -> Vec<u8> {
2149    vec![C2S_QUIT]
2150}
2151
2152/// Build an S2C_QUIT message (server notifies clients of shutdown).
2153pub fn msg_s2c_quit() -> Vec<u8> {
2154    vec![S2C_QUIT]
2155}
2156
2157pub fn msg_surface_created(
2158    surface_id: u16,
2159    parent_id: u16,
2160    width: u16,
2161    height: u16,
2162    title: &str,
2163    app_id: &str,
2164) -> Vec<u8> {
2165    let title_bytes = title.as_bytes();
2166    let app_id_bytes = app_id.as_bytes();
2167    let mut msg = Vec::with_capacity(13 + title_bytes.len() + app_id_bytes.len());
2168    msg.push(S2C_SURFACE_CREATED);
2169    msg.extend_from_slice(&surface_id.to_le_bytes());
2170    msg.extend_from_slice(&parent_id.to_le_bytes());
2171    msg.extend_from_slice(&width.to_le_bytes());
2172    msg.extend_from_slice(&height.to_le_bytes());
2173    msg.extend_from_slice(&(title_bytes.len() as u16).to_le_bytes());
2174    msg.extend_from_slice(title_bytes);
2175    msg.extend_from_slice(&(app_id_bytes.len() as u16).to_le_bytes());
2176    msg.extend_from_slice(app_id_bytes);
2177    msg
2178}
2179
2180pub fn msg_surface_destroyed(surface_id: u16) -> Vec<u8> {
2181    let mut msg = Vec::with_capacity(3);
2182    msg.push(S2C_SURFACE_DESTROYED);
2183    msg.extend_from_slice(&surface_id.to_le_bytes());
2184    msg
2185}
2186
2187pub fn msg_surface_frame(
2188    surface_id: u16,
2189    timestamp: u32,
2190    flags: u8,
2191    width: u16,
2192    height: u16,
2193    data: &[u8],
2194) -> Vec<u8> {
2195    let mut msg = Vec::with_capacity(12 + data.len());
2196    msg.push(S2C_SURFACE_FRAME);
2197    msg.extend_from_slice(&surface_id.to_le_bytes());
2198    msg.extend_from_slice(&timestamp.to_le_bytes());
2199    msg.push(flags);
2200    msg.extend_from_slice(&width.to_le_bytes());
2201    msg.extend_from_slice(&height.to_le_bytes());
2202    msg.extend_from_slice(data);
2203    msg
2204}
2205
2206pub fn msg_surface_title(surface_id: u16, title: &str) -> Vec<u8> {
2207    let title_bytes = title.as_bytes();
2208    let mut msg = Vec::with_capacity(3 + title_bytes.len());
2209    msg.push(S2C_SURFACE_TITLE);
2210    msg.extend_from_slice(&surface_id.to_le_bytes());
2211    msg.extend_from_slice(title_bytes);
2212    msg
2213}
2214
2215pub fn msg_surface_app_id(surface_id: u16, app_id: &str) -> Vec<u8> {
2216    let app_id_bytes = app_id.as_bytes();
2217    let mut msg = Vec::with_capacity(3 + app_id_bytes.len());
2218    msg.push(S2C_SURFACE_APP_ID);
2219    msg.extend_from_slice(&surface_id.to_le_bytes());
2220    msg.extend_from_slice(app_id_bytes);
2221    msg
2222}
2223
2224/// Build S2C_SURFACE_ENCODER: `[0x2A][surface_id:2][name\0codec_string]`.
2225/// The codec_string is the WebCodecs codec string (e.g. "av01.2.05M.08")
2226/// appended after a NUL separator.  Old clients that don't split on NUL
2227/// will just display the full string as the encoder name, which is fine.
2228pub fn msg_surface_encoder(surface_id: u16, encoder_name: &str, codec_string: &str) -> Vec<u8> {
2229    let name_bytes = encoder_name.as_bytes();
2230    let codec_bytes = codec_string.as_bytes();
2231    let mut msg = Vec::with_capacity(3 + name_bytes.len() + 1 + codec_bytes.len());
2232    msg.push(S2C_SURFACE_ENCODER);
2233    msg.extend_from_slice(&surface_id.to_le_bytes());
2234    msg.extend_from_slice(name_bytes);
2235    msg.push(0); // NUL separator
2236    msg.extend_from_slice(codec_bytes);
2237    msg
2238}
2239
2240pub fn msg_surface_resized(surface_id: u16, width: u16, height: u16) -> Vec<u8> {
2241    let mut msg = Vec::with_capacity(7);
2242    msg.push(S2C_SURFACE_RESIZED);
2243    msg.extend_from_slice(&surface_id.to_le_bytes());
2244    msg.extend_from_slice(&width.to_le_bytes());
2245    msg.extend_from_slice(&height.to_le_bytes());
2246    msg
2247}
2248
2249pub fn msg_s2c_clipboard_content(mime_type: &str, data: &[u8]) -> Vec<u8> {
2250    let mime_bytes = mime_type.as_bytes();
2251    let mut msg = Vec::with_capacity(7 + mime_bytes.len() + data.len());
2252    msg.push(S2C_CLIPBOARD_CONTENT);
2253    msg.extend_from_slice(&(mime_bytes.len() as u16).to_le_bytes());
2254    msg.extend_from_slice(mime_bytes);
2255    msg.extend_from_slice(&(data.len() as u32).to_le_bytes());
2256    msg.extend_from_slice(data);
2257    msg
2258}
2259
2260pub fn msg_surface_input(surface_id: u16, data: &[u8]) -> Vec<u8> {
2261    let mut msg = Vec::with_capacity(3 + data.len());
2262    msg.push(C2S_SURFACE_INPUT);
2263    msg.extend_from_slice(&surface_id.to_le_bytes());
2264    msg.extend_from_slice(data);
2265    msg
2266}
2267
2268pub fn msg_surface_pointer(surface_id: u16, event_type: u8, button: u8, x: u16, y: u16) -> Vec<u8> {
2269    let mut msg = Vec::with_capacity(8);
2270    msg.push(C2S_SURFACE_POINTER);
2271    msg.extend_from_slice(&surface_id.to_le_bytes());
2272    msg.push(event_type);
2273    msg.push(button);
2274    msg.extend_from_slice(&x.to_le_bytes());
2275    msg.extend_from_slice(&y.to_le_bytes());
2276    msg
2277}
2278
2279pub fn msg_surface_pointer_axis(surface_id: u16, axis: u8, value_x100: i32) -> Vec<u8> {
2280    let mut msg = Vec::with_capacity(8);
2281    msg.push(C2S_SURFACE_POINTER_AXIS);
2282    msg.extend_from_slice(&surface_id.to_le_bytes());
2283    msg.push(axis);
2284    msg.extend_from_slice(&value_x100.to_le_bytes());
2285    msg
2286}
2287
2288/// `scale_120` is the device-pixel-ratio in 1/120th units, matching
2289/// Wayland's `fractional_scale_v1` convention: 120 = 1×, 180 = 1.5×,
2290/// 240 = 2×.  A value of 0 means "unspecified" (server defaults to 1×).
2291pub fn msg_surface_resize(surface_id: u16, width: u16, height: u16, scale_120: u16) -> Vec<u8> {
2292    let mut msg = Vec::with_capacity(9);
2293    msg.push(C2S_SURFACE_RESIZE);
2294    msg.extend_from_slice(&surface_id.to_le_bytes());
2295    msg.extend_from_slice(&width.to_le_bytes());
2296    msg.extend_from_slice(&height.to_le_bytes());
2297    msg.extend_from_slice(&scale_120.to_le_bytes());
2298    msg
2299}
2300
2301pub fn msg_surface_focus(surface_id: u16) -> Vec<u8> {
2302    let mut msg = Vec::with_capacity(3);
2303    msg.push(C2S_SURFACE_FOCUS);
2304    msg.extend_from_slice(&surface_id.to_le_bytes());
2305    msg
2306}
2307
2308pub fn msg_surface_subscribe(surface_id: u16) -> Vec<u8> {
2309    let mut msg = Vec::with_capacity(3);
2310    msg.push(C2S_SURFACE_SUBSCRIBE);
2311    msg.extend_from_slice(&surface_id.to_le_bytes());
2312    msg
2313}
2314
2315/// Extended surface subscribe with per-surface codec and quality overrides.
2316///
2317/// `codec_support`: CODEC_SUPPORT_* bitmask (0 = use connection default).
2318/// `quality`: SURFACE_QUALITY_* constant (0 = use server default).
2319pub fn msg_surface_subscribe_ext(surface_id: u16, codec_support: u8, quality: u8) -> Vec<u8> {
2320    let mut msg = Vec::with_capacity(5);
2321    msg.push(C2S_SURFACE_SUBSCRIBE);
2322    msg.extend_from_slice(&surface_id.to_le_bytes());
2323    msg.push(codec_support);
2324    msg.push(quality);
2325    msg
2326}
2327
2328/// Scaled surface subscribe: ask the server to encode this surface at
2329/// exactly `width × height` for this client, bypassing surface-size
2330/// mediation.  Intended for side-panel thumbnails and any viewer that
2331/// wants a fixed-size stream independent of the compositor's native
2332/// surface size and of other clients' view sizes.
2333///
2334/// `width` / `height` in pixels.  Passing `0, 0` is equivalent to
2335/// `msg_surface_subscribe_ext` (mediated subscription).
2336pub fn msg_surface_subscribe_scaled(
2337    surface_id: u16,
2338    codec_support: u8,
2339    quality: u8,
2340    width: u16,
2341    height: u16,
2342) -> Vec<u8> {
2343    let mut msg = Vec::with_capacity(9);
2344    msg.push(C2S_SURFACE_SUBSCRIBE);
2345    msg.extend_from_slice(&surface_id.to_le_bytes());
2346    msg.push(codec_support);
2347    msg.push(quality);
2348    msg.extend_from_slice(&width.to_le_bytes());
2349    msg.extend_from_slice(&height.to_le_bytes());
2350    msg
2351}
2352
2353pub fn msg_surface_unsubscribe(surface_id: u16) -> Vec<u8> {
2354    let mut msg = Vec::with_capacity(3);
2355    msg.push(C2S_SURFACE_UNSUBSCRIBE);
2356    msg.extend_from_slice(&surface_id.to_le_bytes());
2357    msg
2358}
2359
2360pub fn msg_surface_close(surface_id: u16) -> Vec<u8> {
2361    let mut msg = Vec::with_capacity(3);
2362    msg.push(C2S_SURFACE_CLOSE);
2363    msg.extend_from_slice(&surface_id.to_le_bytes());
2364    msg
2365}
2366
2367/// Build a C2S_CLIPBOARD_LIST message (request available MIME types).
2368pub fn msg_c2s_clipboard_list() -> Vec<u8> {
2369    vec![C2S_CLIPBOARD_LIST]
2370}
2371
2372/// Build a C2S_CLIPBOARD_GET message (request clipboard content for a specific MIME type).
2373pub fn msg_c2s_clipboard_get(mime_type: &str) -> Vec<u8> {
2374    let mime_bytes = mime_type.as_bytes();
2375    let mut msg = Vec::with_capacity(3 + mime_bytes.len());
2376    msg.push(C2S_CLIPBOARD_GET);
2377    msg.extend_from_slice(&(mime_bytes.len() as u16).to_le_bytes());
2378    msg.extend_from_slice(mime_bytes);
2379    msg
2380}
2381
2382/// Build an S2C_CLIPBOARD_LIST message (response with available MIME types).
2383pub fn msg_s2c_clipboard_list(mime_types: &[String]) -> Vec<u8> {
2384    let count = mime_types.len().min(u16::MAX as usize);
2385    let mut msg = Vec::with_capacity(3 + count * 20);
2386    msg.push(S2C_CLIPBOARD_LIST);
2387    msg.extend_from_slice(&(count as u16).to_le_bytes());
2388    for mime in mime_types.iter().take(count) {
2389        let bytes = mime.as_bytes();
2390        msg.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
2391        msg.extend_from_slice(bytes);
2392    }
2393    msg
2394}
2395
2396pub fn msg_c2s_clipboard_set(mime_type: &str, data: &[u8]) -> Vec<u8> {
2397    let mime_bytes = mime_type.as_bytes();
2398    let mut msg = Vec::with_capacity(7 + mime_bytes.len() + data.len());
2399    msg.push(C2S_CLIPBOARD_SET);
2400    msg.extend_from_slice(&(mime_bytes.len() as u16).to_le_bytes());
2401    msg.extend_from_slice(mime_bytes);
2402    msg.extend_from_slice(&(data.len() as u32).to_le_bytes());
2403    msg.extend_from_slice(data);
2404    msg
2405}
2406
2407fn push_sgr(out: &mut String, style: &CellStyle) {
2408    use std::fmt::Write;
2409    out.push_str("\x1b[0");
2410    if style.bold {
2411        out.push_str(";1");
2412    }
2413    if style.dim {
2414        out.push_str(";2");
2415    }
2416    if style.italic {
2417        out.push_str(";3");
2418    }
2419    if style.underline {
2420        out.push_str(";4");
2421    }
2422    if style.inverse {
2423        out.push_str(";7");
2424    }
2425    match style.fg {
2426        Color::Indexed(n) => {
2427            let _ = write!(out, ";38;5;{n}");
2428        }
2429        Color::Rgb(r, g, b) => {
2430            let _ = write!(out, ";38;2;{r};{g};{b}");
2431        }
2432        Color::Default => {}
2433    }
2434    match style.bg {
2435        Color::Indexed(n) => {
2436            let _ = write!(out, ";48;5;{n}");
2437        }
2438        Color::Rgb(r, g, b) => {
2439            let _ = write!(out, ";48;2;{r};{g};{b}");
2440        }
2441        Color::Default => {}
2442    }
2443    out.push('m');
2444}
2445
2446const MODE_ALT_SCREEN: u16 = 1 << 11;
2447
2448fn mode_is_cooked(mode: u16) -> bool {
2449    mode & MODE_ECHO != 0 && mode & MODE_ICANON != 0 && mode & MODE_ALT_SCREEN == 0
2450}
2451
2452pub fn build_update_msg(
2453    pty_id: u16,
2454    current: &FrameState,
2455    previous: &FrameState,
2456) -> Option<Vec<u8>> {
2457    let title_changed = current.title != previous.title;
2458    let same_size = previous.rows == current.rows
2459        && previous.cols == current.cols
2460        && previous.cells.len() == current.cells.len();
2461
2462    // Try scroll-aware ops when dimensions match and content differs.
2463    let mut ops = Vec::new();
2464    let mut op_count = 0u16;
2465
2466    // Scroll-aware ops apply when content is "cooked" (shell output) or when
2467    // either frame has mode 0 (scrollback frames use mode=0, and their content
2468    // is always static text that benefits from COPY_RECT).
2469    let scroll_eligible = (mode_is_cooked(current.mode) && mode_is_cooked(previous.mode))
2470        || current.mode == 0
2471        || previous.mode == 0;
2472    if ENABLE_SCROLL_OPS
2473        && same_size
2474        && previous.cells != current.cells
2475        && scroll_eligible
2476        && let Some(delta_rows) = detect_vertical_scroll(current, previous)
2477    {
2478        let mut basis = previous.clone();
2479        encode_copy_rect_op(&mut ops, current, delta_rows);
2480        apply_vertical_scroll_copy(&mut basis, delta_rows);
2481        op_count += 1;
2482        append_full_width_fill_ops(current, &mut basis, &mut ops, &mut op_count);
2483        if let Some(patch_op) = build_patch_op(current, &basis) {
2484            ops.extend_from_slice(&patch_op);
2485            op_count += 1;
2486        }
2487    }
2488
2489    // Fallback: bare PATCH_CELLS against previous (or a blank frame on resize).
2490    if op_count == 0 {
2491        let basis = if same_size {
2492            previous
2493        } else {
2494            &FrameState::new(current.rows, current.cols)
2495        };
2496        if let Some(patch_op) = build_patch_op(current, basis) {
2497            ops = patch_op;
2498            op_count = 1;
2499        }
2500    }
2501
2502    if op_count == 0 {
2503        // No cell changes — still emit a frame if cursor/mode/title changed.
2504        if !title_changed
2505            && current.cursor_row == previous.cursor_row
2506            && current.cursor_col == previous.cursor_col
2507            && current.mode == previous.mode
2508        {
2509            return None;
2510        }
2511    }
2512
2513    // Collect overflow strings that need to be transmitted.
2514    // We send all overflow entries from the current frame that correspond
2515    // to cells that changed (are in the dirty set).  For a resize (not
2516    // same_size), all cells are "dirty", so we send all overflow entries.
2517    let has_overflow = !current.overflow.is_empty();
2518    let overflow_section = if has_overflow {
2519        serialize_overflow_strings(current)
2520    } else {
2521        Vec::new()
2522    };
2523
2524    let line_flags_changed =
2525        current.line_flags != previous.line_flags || current.rows != previous.rows;
2526    let has_line_flags = line_flags_changed && !current.line_flags.iter().all(|&f| f == 0);
2527
2528    let title_bytes = if title_changed {
2529        current.title.as_bytes()
2530    } else {
2531        &[]
2532    };
2533    let title_len = title_bytes.len().min(TITLE_LEN_MASK as usize);
2534    let title_field = OPS_PRESENT
2535        | if has_overflow { STRINGS_PRESENT } else { 0 }
2536        | if has_line_flags {
2537            LINE_FLAGS_PRESENT
2538        } else {
2539            0
2540        }
2541        | if title_changed {
2542            TITLE_PRESENT | title_len as u16
2543        } else {
2544            0
2545        };
2546
2547    let mut payload = Vec::with_capacity(
2548        12 + title_len
2549            + 2
2550            + ops.len()
2551            + overflow_section.len()
2552            + if has_line_flags {
2553                current.rows as usize
2554            } else {
2555                0
2556            }
2557            + 4,
2558    );
2559    payload.extend_from_slice(&current.rows.to_le_bytes());
2560    payload.extend_from_slice(&current.cols.to_le_bytes());
2561    payload.extend_from_slice(&current.cursor_row.to_le_bytes());
2562    payload.extend_from_slice(&current.cursor_col.to_le_bytes());
2563    payload.extend_from_slice(&current.mode.to_le_bytes());
2564    payload.extend_from_slice(&title_field.to_le_bytes());
2565    if title_changed {
2566        payload.extend_from_slice(&title_bytes[..title_len]);
2567    }
2568    payload.extend_from_slice(&op_count.to_le_bytes());
2569    payload.extend_from_slice(&ops);
2570    payload.extend_from_slice(&overflow_section);
2571    if has_line_flags {
2572        payload.extend_from_slice(&current.line_flags);
2573    }
2574    // Trailing scrollback count — old clients ignore extra bytes.
2575    payload.extend_from_slice(&current.scrollback_lines.to_le_bytes());
2576
2577    let compressed = compress_prepend_size(&payload);
2578    let mut msg = Vec::with_capacity(3 + compressed.len());
2579    msg.push(S2C_UPDATE);
2580    msg.extend_from_slice(&pty_id.to_le_bytes());
2581    msg.extend_from_slice(&compressed);
2582    Some(msg)
2583}
2584
2585/// Serialize overflow strings: [u16 count] [for each: u32 cell_index, u16 len, utf8 bytes]
2586fn serialize_overflow_strings(frame: &FrameState) -> Vec<u8> {
2587    let count = frame.overflow.len().min(u16::MAX as usize);
2588    let mut out = Vec::with_capacity(2 + count * 8);
2589    out.extend_from_slice(&(count as u16).to_le_bytes());
2590    for (&cell_idx, s) in frame.overflow.iter().take(count) {
2591        let bytes = s.as_bytes();
2592        let len = bytes.len().min(u16::MAX as usize);
2593        out.extend_from_slice(&(cell_idx as u32).to_le_bytes());
2594        out.extend_from_slice(&(len as u16).to_le_bytes());
2595        out.extend_from_slice(&bytes[..len]);
2596    }
2597    out
2598}
2599
2600fn build_patch_op(current: &FrameState, previous: &FrameState) -> Option<Vec<u8>> {
2601    let total_cells = current.rows as usize * current.cols as usize;
2602    let total_bytes = total_cells * CELL_SIZE;
2603    // Fast path: a single bulk memcmp short-circuits the common idle case
2604    // where nothing has changed, avoiding both the per-cell loop and the
2605    // bitmask allocation.
2606    if current.cells.len() >= total_bytes
2607        && previous.cells.len() >= total_bytes
2608        && current.cells[..total_bytes] == previous.cells[..total_bytes]
2609    {
2610        return None;
2611    }
2612    let bitmask_len = total_cells.div_ceil(8);
2613    let mut bitmask = vec![0u8; bitmask_len];
2614    let mut dirty_count = 0usize;
2615    for i in 0..total_cells {
2616        let off = i * CELL_SIZE;
2617        if current.cells[off..off + CELL_SIZE] != previous.cells[off..off + CELL_SIZE] {
2618            bitmask[i / 8] |= 1 << (i % 8);
2619            dirty_count += 1;
2620        }
2621    }
2622    if dirty_count == 0 {
2623        return None;
2624    }
2625
2626    let mut op = Vec::with_capacity(1 + bitmask_len + dirty_count * CELL_SIZE);
2627    op.push(OP_PATCH_CELLS);
2628    op.extend_from_slice(&bitmask);
2629    for byte_pos in 0..CELL_SIZE {
2630        for i in 0..total_cells {
2631            if bitmask[i / 8] & (1 << (i % 8)) != 0 {
2632                op.push(current.cells[i * CELL_SIZE + byte_pos]);
2633            }
2634        }
2635    }
2636    Some(op)
2637}
2638
2639fn detect_vertical_scroll(current: &FrameState, previous: &FrameState) -> Option<i16> {
2640    let rows = current.rows as usize;
2641    let cols = current.cols as usize;
2642    if rows < 4 || cols == 0 {
2643        return None;
2644    }
2645    let row_bytes = cols * CELL_SIZE;
2646    let max_delta = rows.saturating_sub(1).min(8);
2647    let mut best: Option<(usize, i16)> = None;
2648
2649    for delta in 1..=max_delta {
2650        let overlap = rows - delta;
2651        if overlap < 3 {
2652            continue;
2653        }
2654        for signed_delta in [-(delta as i16), delta as i16] {
2655            let mut matched = 0usize;
2656            for row in 0..rows {
2657                let src_row = row as i32 - signed_delta as i32;
2658                if src_row < 0 || src_row >= rows as i32 {
2659                    continue;
2660                }
2661                let cur_off = row * row_bytes;
2662                let prev_off = src_row as usize * row_bytes;
2663                if current.cells[cur_off..cur_off + row_bytes]
2664                    == previous.cells[prev_off..prev_off + row_bytes]
2665                {
2666                    matched += 1;
2667                }
2668            }
2669            if matched * 5 < overlap * 4 {
2670                continue;
2671            }
2672            let replace = match best {
2673                None => true,
2674                Some((best_matched, best_delta)) => {
2675                    matched > best_matched
2676                        || (matched == best_matched
2677                            && signed_delta.unsigned_abs() < best_delta.unsigned_abs())
2678                }
2679            };
2680            if replace {
2681                best = Some((matched, signed_delta));
2682            }
2683        }
2684    }
2685
2686    best.map(|(_, delta)| delta)
2687}
2688
2689fn encode_copy_rect_op(out: &mut Vec<u8>, current: &FrameState, delta_rows: i16) {
2690    let rows = current.rows;
2691    let cols = current.cols;
2692    let delta = delta_rows.unsigned_abs();
2693    let (src_row, dst_row, copy_rows) = if delta_rows > 0 {
2694        (0, delta, rows.saturating_sub(delta))
2695    } else {
2696        (delta, 0, rows.saturating_sub(delta))
2697    };
2698    out.push(OP_COPY_RECT);
2699    out.extend_from_slice(&src_row.to_le_bytes());
2700    out.extend_from_slice(&0u16.to_le_bytes());
2701    out.extend_from_slice(&dst_row.to_le_bytes());
2702    out.extend_from_slice(&0u16.to_le_bytes());
2703    out.extend_from_slice(&copy_rows.to_le_bytes());
2704    out.extend_from_slice(&cols.to_le_bytes());
2705}
2706
2707fn apply_vertical_scroll_copy(frame: &mut FrameState, delta_rows: i16) {
2708    let delta = delta_rows.unsigned_abs();
2709    if delta == 0 || delta >= frame.rows {
2710        return;
2711    }
2712    let (src_row, dst_row, rows) = if delta_rows > 0 {
2713        (0, delta, frame.rows - delta)
2714    } else {
2715        (delta, 0, frame.rows - delta)
2716    };
2717    apply_copy_rect_frame(frame, src_row, 0, dst_row, 0, rows, frame.cols);
2718}
2719
2720fn apply_copy_rect_frame(
2721    frame: &mut FrameState,
2722    src_row: u16,
2723    src_col: u16,
2724    dst_row: u16,
2725    dst_col: u16,
2726    rows: u16,
2727    cols: u16,
2728) {
2729    let rows = rows
2730        .min(frame.rows.saturating_sub(src_row))
2731        .min(frame.rows.saturating_sub(dst_row));
2732    let cols = cols
2733        .min(frame.cols.saturating_sub(src_col))
2734        .min(frame.cols.saturating_sub(dst_col));
2735    if rows == 0 || cols == 0 {
2736        return;
2737    }
2738    let mut temp = vec![0u8; rows as usize * cols as usize * CELL_SIZE];
2739    for r in 0..rows as usize {
2740        let src_off = frame.cell_offset(src_row + r as u16, src_col);
2741        let src_end = src_off + cols as usize * CELL_SIZE;
2742        let dst_off = r * cols as usize * CELL_SIZE;
2743        temp[dst_off..dst_off + cols as usize * CELL_SIZE]
2744            .copy_from_slice(&frame.cells[src_off..src_end]);
2745    }
2746    for r in 0..rows as usize {
2747        let dst_off = frame.cell_offset(dst_row + r as u16, dst_col);
2748        let dst_end = dst_off + cols as usize * CELL_SIZE;
2749        let src_off = r * cols as usize * CELL_SIZE;
2750        frame.cells[dst_off..dst_end]
2751            .copy_from_slice(&temp[src_off..src_off + cols as usize * CELL_SIZE]);
2752    }
2753}
2754
2755fn append_full_width_fill_ops(
2756    current: &FrameState,
2757    basis: &mut FrameState,
2758    out: &mut Vec<u8>,
2759    op_count: &mut u16,
2760) {
2761    let rows = current.rows as usize;
2762    let cols = current.cols as usize;
2763    if rows == 0 || cols == 0 {
2764        return;
2765    }
2766
2767    let row_bytes = cols * CELL_SIZE;
2768    let mut row = 0usize;
2769    while row < rows {
2770        let row_off = row * row_bytes;
2771        if current.cells[row_off..row_off + row_bytes] == basis.cells[row_off..row_off + row_bytes]
2772        {
2773            row += 1;
2774            continue;
2775        }
2776        let Some(cell) = uniform_row_cell(current, row) else {
2777            row += 1;
2778            continue;
2779        };
2780        let mut end = row + 1;
2781        while end < rows {
2782            if uniform_row_cell(current, end).as_ref() != Some(&cell) {
2783                break;
2784            }
2785            end += 1;
2786        }
2787
2788        if *op_count == u16::MAX {
2789            break;
2790        }
2791        out.push(OP_FILL_RECT);
2792        out.extend_from_slice(&(row as u16).to_le_bytes());
2793        out.extend_from_slice(&0u16.to_le_bytes());
2794        out.extend_from_slice(&((end - row) as u16).to_le_bytes());
2795        out.extend_from_slice(&current.cols.to_le_bytes());
2796        out.extend_from_slice(&cell);
2797        *op_count = op_count.saturating_add(1);
2798
2799        for r in row..end {
2800            let row_off = basis.cell_offset(r as u16, 0);
2801            for c in 0..cols {
2802                let off = row_off + c * CELL_SIZE;
2803                basis.cells[off..off + CELL_SIZE].copy_from_slice(&cell);
2804            }
2805        }
2806
2807        row = end;
2808    }
2809}
2810
2811fn uniform_row_cell(frame: &FrameState, row: usize) -> Option<[u8; CELL_SIZE]> {
2812    let cols = frame.cols as usize;
2813    if row >= frame.rows as usize || cols == 0 {
2814        return None;
2815    }
2816    let start = row * cols * CELL_SIZE;
2817    let mut first = [0u8; CELL_SIZE];
2818    first.copy_from_slice(&frame.cells[start..start + CELL_SIZE]);
2819    if first[1] & 0b110 != 0 {
2820        return None;
2821    }
2822    for col in 1..cols {
2823        let off = start + col * CELL_SIZE;
2824        if frame.cells[off..off + CELL_SIZE] != first {
2825            return None;
2826        }
2827    }
2828    Some(first)
2829}
2830
2831fn encode_cell(dst: &mut [u8], ch: Option<char>, style: CellStyle, wide: bool, wide_cont: bool) {
2832    dst.fill(0);
2833
2834    let mut f0 = 0u8;
2835    encode_color(style.fg, &mut f0, &mut dst[2..5], false);
2836    encode_color(style.bg, &mut f0, &mut dst[5..8], true);
2837    if style.bold {
2838        f0 |= 1 << 4;
2839    }
2840    if style.dim {
2841        f0 |= 1 << 5;
2842    }
2843    if style.italic {
2844        f0 |= 1 << 6;
2845    }
2846    if style.underline {
2847        f0 |= 1 << 7;
2848    }
2849    dst[0] = f0;
2850
2851    let mut f1 = 0u8;
2852    if style.inverse {
2853        f1 |= 1;
2854    }
2855    if wide {
2856        f1 |= 1 << 1;
2857    }
2858    if wide_cont {
2859        f1 |= 1 << 2;
2860    }
2861    if let Some(ch) = ch {
2862        let mut buf = [0u8; 4];
2863        let encoded = ch.encode_utf8(&mut buf).as_bytes();
2864        let len = encoded.len().min(4);
2865        dst[8..8 + len].copy_from_slice(&encoded[..len]);
2866        f1 |= (len as u8) << 3;
2867    }
2868    dst[1] = f1;
2869}
2870
2871fn encode_color(color: Color, flags: &mut u8, dst: &mut [u8], is_bg: bool) {
2872    let shift = if is_bg { 2 } else { 0 };
2873    match color {
2874        Color::Default => {}
2875        Color::Indexed(idx) => {
2876            *flags |= 1 << shift;
2877            dst[0] = idx;
2878        }
2879        Color::Rgb(r, g, b) => {
2880            *flags |= 2 << shift;
2881            dst[0] = r;
2882            dst[1] = g;
2883            dst[2] = b;
2884        }
2885    }
2886}
2887
2888fn wrap_text_lines(text: &str, width: usize) -> Vec<String> {
2889    if width == 0 {
2890        return Vec::new();
2891    }
2892    let mut out = Vec::new();
2893    for paragraph in text.split('\n') {
2894        if paragraph.is_empty() {
2895            out.push(String::new());
2896            continue;
2897        }
2898        let mut line = String::new();
2899        let mut line_width = 0usize;
2900        for word in paragraph.split_whitespace() {
2901            push_wrapped_word(word, width, &mut out, &mut line, &mut line_width);
2902        }
2903        if !line.is_empty() {
2904            out.push(line);
2905        }
2906    }
2907    if out.is_empty() {
2908        out.push(String::new());
2909    }
2910    out
2911}
2912
2913fn push_wrapped_word(
2914    word: &str,
2915    width: usize,
2916    out: &mut Vec<String>,
2917    line: &mut String,
2918    line_width: &mut usize,
2919) {
2920    let word_width = UnicodeWidthStr::width(word);
2921    if line.is_empty() {
2922        if word_width <= width {
2923            line.push_str(word);
2924            *line_width = word_width;
2925            return;
2926        }
2927    } else if *line_width + 1 + word_width <= width {
2928        line.push(' ');
2929        line.push_str(word);
2930        *line_width += 1 + word_width;
2931        return;
2932    } else {
2933        out.push(std::mem::take(line));
2934        *line_width = 0;
2935        if word_width <= width {
2936            line.push_str(word);
2937            *line_width = word_width;
2938            return;
2939        }
2940    }
2941
2942    for ch in word.chars() {
2943        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(1).max(1);
2944        if *line_width + ch_width > width && !line.is_empty() {
2945            out.push(std::mem::take(line));
2946            *line_width = 0;
2947        }
2948        line.push(ch);
2949        *line_width += ch_width;
2950    }
2951}
2952
2953#[cfg(test)]
2954mod tests {
2955    use super::*;
2956
2957    #[test]
2958    fn update_round_trip_preserves_title_and_cells() {
2959        let style = CellStyle::default();
2960        let mut prev = FrameState::new(2, 8);
2961        prev.set_title("one");
2962        prev.write_text(0, 0, "hello", style);
2963
2964        let mut next = prev.clone();
2965        next.set_title("two");
2966        next.write_text(1, 0, "world", style);
2967
2968        let baseline = build_update_msg(7, &prev, &FrameState::default()).unwrap();
2969        let delta = build_update_msg(7, &next, &prev).unwrap();
2970
2971        let mut term = TerminalState::new(2, 8);
2972        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
2973            panic!("expected update");
2974        };
2975        assert!(term.feed_compressed(payload));
2976        assert_eq!(term.title(), "one");
2977
2978        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2979            panic!("expected update");
2980        };
2981        assert!(term.feed_compressed(payload));
2982        assert_eq!(term.title(), "two");
2983        assert_eq!(term.get_all_text(), "hello\nworld");
2984    }
2985
2986    #[test]
2987    fn title_can_be_cleared_via_update() {
2988        let style = CellStyle::default();
2989        let mut prev = FrameState::new(1, 4);
2990        prev.set_title("busy");
2991        prev.write_text(0, 0, "ping", style);
2992
2993        let mut next = prev.clone();
2994        next.set_title("");
2995
2996        let baseline = build_update_msg(1, &prev, &FrameState::default()).unwrap();
2997        let delta = build_update_msg(1, &next, &prev).unwrap();
2998
2999        let mut term = TerminalState::new(1, 4);
3000        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
3001            panic!("expected update");
3002        };
3003        term.feed_compressed(payload);
3004        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
3005            panic!("expected update");
3006        };
3007        term.feed_compressed(payload);
3008        assert_eq!(term.title(), "");
3009    }
3010
3011    #[test]
3012    fn scroll_heavy_update_can_use_ops_payload() {
3013        let style = CellStyle::default();
3014        let mut prev = FrameState::new(5, 6);
3015        prev.write_text(0, 0, "one", style);
3016        prev.write_text(1, 0, "two", style);
3017        prev.write_text(2, 0, "three", style);
3018        prev.write_text(3, 0, "four", style);
3019        prev.write_text(4, 0, "five", style);
3020
3021        let mut next = FrameState::new(5, 6);
3022        next.write_text(0, 0, "two", style);
3023        next.write_text(1, 0, "three", style);
3024        next.write_text(2, 0, "four", style);
3025        next.write_text(3, 0, "five", style);
3026
3027        let delta = build_update_msg(9, &next, &prev).unwrap();
3028        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
3029            panic!("expected update");
3030        };
3031        let decoded = decompress_size_prepended(payload).unwrap();
3032        let title_field = u16::from_le_bytes([decoded[10], decoded[11]]);
3033        assert_ne!(title_field & OPS_PRESENT, 0);
3034
3035        let mut term = TerminalState::new(5, 6);
3036        let baseline = build_update_msg(9, &prev, &FrameState::default()).unwrap();
3037        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
3038            panic!("expected update");
3039        };
3040        assert!(term.feed_compressed(payload));
3041        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
3042            panic!("expected update");
3043        };
3044        assert!(term.feed_compressed(payload));
3045        assert_eq!(term.get_all_text(), "two\nthree\nfour\nfive\n");
3046    }
3047
3048    #[test]
3049    fn cooked_scroll_heavy_update_uses_copy_rect_op() {
3050        let style = CellStyle::default();
3051        let mut prev = FrameState::new(5, 6);
3052        prev.set_mode(MODE_ECHO | MODE_ICANON);
3053        prev.write_text(0, 0, "one", style);
3054        prev.write_text(1, 0, "two", style);
3055        prev.write_text(2, 0, "three", style);
3056        prev.write_text(3, 0, "four", style);
3057        prev.write_text(4, 0, "five", style);
3058
3059        let mut next = FrameState::new(5, 6);
3060        next.set_mode(MODE_ECHO | MODE_ICANON);
3061        next.write_text(0, 0, "two", style);
3062        next.write_text(1, 0, "three", style);
3063        next.write_text(2, 0, "four", style);
3064        next.write_text(3, 0, "five", style);
3065
3066        let delta = build_update_msg(9, &next, &prev).unwrap();
3067        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
3068            panic!("expected update");
3069        };
3070        let decoded = decompress_size_prepended(payload).unwrap();
3071        let op_count = u16::from_le_bytes([decoded[12], decoded[13]]);
3072        assert!(op_count >= 1);
3073        assert_eq!(decoded[14], OP_COPY_RECT);
3074    }
3075
3076    #[test]
3077    fn mode_zero_scroll_uses_copy_rect() {
3078        let style = CellStyle::default();
3079        let mut prev = FrameState::new(5, 6);
3080        prev.write_text(0, 0, "one", style);
3081        prev.write_text(1, 0, "two", style);
3082        prev.write_text(2, 0, "three", style);
3083        prev.write_text(3, 0, "four", style);
3084        prev.write_text(4, 0, "five", style);
3085
3086        let mut next = FrameState::new(5, 6);
3087        next.write_text(0, 0, "two", style);
3088        next.write_text(1, 0, "three", style);
3089        next.write_text(2, 0, "four", style);
3090        next.write_text(3, 0, "five", style);
3091
3092        let delta = build_update_msg(9, &next, &prev).unwrap();
3093        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
3094            panic!("expected update");
3095        };
3096        let decoded = decompress_size_prepended(payload).unwrap();
3097        let op_count = u16::from_le_bytes([decoded[12], decoded[13]]);
3098        assert!(op_count >= 1);
3099        // mode=0 frames (scrollback) now use COPY_RECT for efficient scrolling
3100        assert_eq!(decoded[14], OP_COPY_RECT);
3101
3102        // Verify round-trip correctness
3103        let baseline = build_update_msg(9, &prev, &FrameState::new(5, 6)).unwrap();
3104        let mut state = TerminalState::new(5, 6);
3105        let ServerMsg::Update { payload: bp, .. } = parse_server_msg(&baseline).unwrap() else {
3106            panic!("expected update");
3107        };
3108        state.feed_compressed(bp);
3109        state.feed_compressed(payload);
3110        assert_eq!(state.frame().cells(), next.cells());
3111    }
3112
3113    #[test]
3114    fn callback_renderer_wraps_text() {
3115        let mut renderer = CallbackRenderer::new(2, 8);
3116        renderer.render(|dom| {
3117            dom.wrapped_text(
3118                Rect::new(0, 0, 2, 8),
3119                "alpha beta gamma",
3120                CellStyle::default(),
3121            );
3122        });
3123        assert_eq!(renderer.frame().get_all_text(), "alpha\nbeta");
3124    }
3125
3126    #[test]
3127    fn scrolling_text_shows_tail() {
3128        let mut frame = FrameState::new(3, 8);
3129        frame.write_scrolling_text(
3130            Rect::new(0, 0, 3, 8),
3131            &["one", "two", "three", "four"],
3132            0,
3133            CellStyle::default(),
3134        );
3135        assert_eq!(frame.get_all_text(), "two\nthree\nfour");
3136    }
3137
3138    #[test]
3139    fn search_results_round_trip_with_context() {
3140        let msg = [
3141            vec![S2C_SEARCH_RESULTS],
3142            7u16.to_le_bytes().to_vec(),
3143            1u16.to_le_bytes().to_vec(),
3144            42u16.to_le_bytes().to_vec(),
3145            1234u32.to_le_bytes().to_vec(),
3146            vec![1, 0b111],
3147            9u32.to_le_bytes().to_vec(),
3148            5u16.to_le_bytes().to_vec(),
3149            b"hello".to_vec(),
3150        ]
3151        .concat();
3152
3153        let ServerMsg::SearchResults {
3154            request_id,
3155            results,
3156        } = parse_server_msg(&msg).unwrap()
3157        else {
3158            panic!("expected search results");
3159        };
3160        assert_eq!(request_id, 7);
3161        assert_eq!(results.len(), 1);
3162        assert_eq!(results[0].pty_id, 42);
3163        assert_eq!(results[0].score, 1234);
3164        assert_eq!(results[0].primary_source, 1);
3165        assert_eq!(results[0].matched_sources, 0b111);
3166        assert_eq!(results[0].scroll_offset, Some(9));
3167        assert_eq!(results[0].context, b"hello");
3168    }
3169
3170    // --- Tag tests ---
3171
3172    #[test]
3173    fn msg_create_no_tag_has_zero_tag_len() {
3174        let msg = msg_create(24, 80);
3175        assert_eq!(msg.len(), 7);
3176        assert_eq!(msg[0], C2S_CREATE);
3177        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 24);
3178        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 80);
3179        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 0);
3180    }
3181
3182    #[test]
3183    fn msg_create_tagged_encodes_tag() {
3184        let msg = msg_create_tagged(24, 80, "my-pty");
3185        assert_eq!(msg[0], C2S_CREATE);
3186        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
3187        assert_eq!(tag_len, 6);
3188        assert_eq!(&msg[7..7 + tag_len], b"my-pty");
3189        assert_eq!(msg.len(), 7 + tag_len);
3190    }
3191
3192    #[test]
3193    fn msg_create_tagged_command_encodes_both() {
3194        let msg = msg_create_tagged_command(30, 120, "editor", "vim");
3195        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
3196        assert_eq!(tag_len, 6);
3197        assert_eq!(&msg[7..13], b"editor");
3198        assert_eq!(&msg[13..], b"vim");
3199    }
3200
3201    #[test]
3202    fn msg_create_command_has_empty_tag() {
3203        let msg = msg_create_command(24, 80, "ls");
3204        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
3205        assert_eq!(tag_len, 0);
3206        assert_eq!(&msg[7..], b"ls");
3207    }
3208
3209    #[test]
3210    fn msg_create_tagged_empty_tag() {
3211        let msg = msg_create_tagged(24, 80, "");
3212        assert_eq!(msg.len(), 7);
3213        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 0);
3214    }
3215
3216    #[test]
3217    fn msg_create_tagged_unicode_tag() {
3218        let msg = msg_create_tagged(24, 80, "日本語");
3219        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
3220        assert_eq!(tag_len, "日本語".len());
3221        assert_eq!(std::str::from_utf8(&msg[7..7 + tag_len]).unwrap(), "日本語");
3222    }
3223
3224    #[test]
3225    fn parse_created_with_tag() {
3226        let mut wire = vec![S2C_CREATED, 0x05, 0x00];
3227        wire.extend_from_slice(b"hello");
3228        let msg = parse_server_msg(&wire).unwrap();
3229        match msg {
3230            ServerMsg::Created { pty_id, tag } => {
3231                assert_eq!(pty_id, 5);
3232                assert_eq!(tag, "hello");
3233            }
3234            _ => panic!("expected Created"),
3235        }
3236    }
3237
3238    #[test]
3239    fn parse_created_without_tag() {
3240        let wire = vec![S2C_CREATED, 0x03, 0x00];
3241        let msg = parse_server_msg(&wire).unwrap();
3242        match msg {
3243            ServerMsg::Created { pty_id, tag } => {
3244                assert_eq!(pty_id, 3);
3245                assert_eq!(tag, "");
3246            }
3247            _ => panic!("expected Created"),
3248        }
3249    }
3250
3251    #[test]
3252    fn parse_created_n_with_tag() {
3253        let mut wire = vec![S2C_CREATED_N, 0x2a, 0x00, 0x05, 0x00];
3254        wire.extend_from_slice(b"hello");
3255        let msg = parse_server_msg(&wire).unwrap();
3256        match msg {
3257            ServerMsg::CreatedN { nonce, pty_id, tag } => {
3258                assert_eq!(nonce, 42);
3259                assert_eq!(pty_id, 5);
3260                assert_eq!(tag, "hello");
3261            }
3262            _ => panic!("expected CreatedN"),
3263        }
3264    }
3265
3266    #[test]
3267    fn msg_create_n_format() {
3268        let msg = msg_create_n(42, 24, 80, "test");
3269        assert_eq!(msg[0], C2S_CREATE_N);
3270        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
3271        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
3272        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
3273        assert_eq!(u16::from_le_bytes([msg[7], msg[8]]), 4);
3274        assert_eq!(&msg[9..], b"test");
3275    }
3276
3277    #[test]
3278    fn msg_create_n_command_format() {
3279        let msg = msg_create_n_command(7, 30, 120, "bg", "make build");
3280        assert_eq!(msg[0], C2S_CREATE_N);
3281        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 7);
3282        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 30);
3283        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 120);
3284        let tag_len = u16::from_le_bytes([msg[7], msg[8]]) as usize;
3285        assert_eq!(tag_len, 2);
3286        assert_eq!(&msg[9..9 + tag_len], b"bg");
3287        assert_eq!(&msg[9 + tag_len..], b"make build");
3288    }
3289
3290    #[test]
3291    fn parse_list_with_tags() {
3292        // 2 entries: id=1 tag="ab", id=2 tag=""
3293        let mut wire = vec![S2C_LIST, 0x02, 0x00];
3294        // entry 1: id=1, tag_len=2, tag="ab", cmd_len=0
3295        wire.extend_from_slice(&1u16.to_le_bytes());
3296        wire.extend_from_slice(&2u16.to_le_bytes());
3297        wire.extend_from_slice(b"ab");
3298        wire.extend_from_slice(&0u16.to_le_bytes());
3299        // entry 2: id=2, tag_len=0, cmd_len=0
3300        wire.extend_from_slice(&2u16.to_le_bytes());
3301        wire.extend_from_slice(&0u16.to_le_bytes());
3302        wire.extend_from_slice(&0u16.to_le_bytes());
3303
3304        let msg = parse_server_msg(&wire).unwrap();
3305        match msg {
3306            ServerMsg::List { entries } => {
3307                assert_eq!(entries.len(), 2);
3308                assert_eq!(entries[0].pty_id, 1);
3309                assert_eq!(entries[0].tag, "ab");
3310                assert_eq!(entries[1].pty_id, 2);
3311                assert_eq!(entries[1].tag, "");
3312            }
3313            _ => panic!("expected List"),
3314        }
3315    }
3316
3317    #[test]
3318    fn parse_list_empty() {
3319        let wire = vec![S2C_LIST, 0x00, 0x00];
3320        let msg = parse_server_msg(&wire).unwrap();
3321        match msg {
3322            ServerMsg::List { entries } => assert_eq!(entries.len(), 0),
3323            _ => panic!("expected List"),
3324        }
3325    }
3326
3327    #[test]
3328    fn parse_list_truncated_gracefully() {
3329        // count=2 but only 1 complete entry
3330        let mut wire = vec![S2C_LIST, 0x02, 0x00];
3331        wire.extend_from_slice(&1u16.to_le_bytes());
3332        wire.extend_from_slice(&0u16.to_le_bytes());
3333        // missing second entry
3334        let msg = parse_server_msg(&wire).unwrap();
3335        match msg {
3336            ServerMsg::List { entries } => assert_eq!(entries.len(), 1),
3337            _ => panic!("expected List"),
3338        }
3339    }
3340
3341    #[test]
3342    fn parse_list_with_long_tags() {
3343        let long_tag = "a".repeat(300);
3344        let mut wire = vec![S2C_LIST, 0x01, 0x00];
3345        wire.extend_from_slice(&42u16.to_le_bytes());
3346        wire.extend_from_slice(&(long_tag.len() as u16).to_le_bytes());
3347        wire.extend_from_slice(long_tag.as_bytes());
3348
3349        let msg = parse_server_msg(&wire).unwrap();
3350        match msg {
3351            ServerMsg::List { entries } => {
3352                assert_eq!(entries.len(), 1);
3353                assert_eq!(entries[0].pty_id, 42);
3354                assert_eq!(entries[0].tag, long_tag);
3355            }
3356            _ => panic!("expected List"),
3357        }
3358    }
3359
3360    #[test]
3361    fn create_and_created_tag_round_trip() {
3362        // Simulate: client sends create with tag, server echoes tag in created
3363        let create_msg = msg_create_tagged(24, 80, "my-session");
3364        let tag_len = u16::from_le_bytes([create_msg[5], create_msg[6]]) as usize;
3365        let tag = std::str::from_utf8(&create_msg[7..7 + tag_len]).unwrap();
3366
3367        // Server builds S2C_CREATED with the tag
3368        let mut created_wire = vec![S2C_CREATED, 0x07, 0x00]; // pty_id = 7
3369        created_wire.extend_from_slice(tag.as_bytes());
3370
3371        let msg = parse_server_msg(&created_wire).unwrap();
3372        match msg {
3373            ServerMsg::Created {
3374                pty_id,
3375                tag: parsed_tag,
3376            } => {
3377                assert_eq!(pty_id, 7);
3378                assert_eq!(parsed_tag, "my-session");
3379            }
3380            _ => panic!("expected Created"),
3381        }
3382    }
3383
3384    // --- FrameState tests ---
3385
3386    #[test]
3387    fn frame_state_accessors() {
3388        let mut f = FrameState::new(4, 10);
3389        assert_eq!(f.rows(), 4);
3390        assert_eq!(f.cols(), 10);
3391        assert_eq!(f.cursor_row(), 0);
3392        assert_eq!(f.cursor_col(), 0);
3393        assert_eq!(f.mode(), 0);
3394        assert_eq!(f.title(), "");
3395        assert_eq!(f.cells().len(), 4 * 10 * CELL_SIZE);
3396        assert_eq!(f.cells_mut().len(), 4 * 10 * CELL_SIZE);
3397        assert!(f.overflow().is_empty());
3398        assert!(f.overflow_mut().is_empty());
3399    }
3400
3401    #[test]
3402    fn frame_state_from_parts() {
3403        let cells = vec![0u8; 2 * 4 * CELL_SIZE];
3404        let f = FrameState::from_parts(2, 4, 1, 3, 0x0F, "hello", cells.clone());
3405        assert_eq!(f.rows(), 2);
3406        assert_eq!(f.cols(), 4);
3407        assert_eq!(f.cursor_row(), 1);
3408        assert_eq!(f.cursor_col(), 3);
3409        assert_eq!(f.mode(), 0x0F);
3410        assert_eq!(f.title(), "hello");
3411        assert_eq!(f.cells(), &cells[..]);
3412    }
3413
3414    #[test]
3415    fn frame_state_from_parts_wrong_size() {
3416        // cells with wrong size should be ignored (stays zeroed)
3417        let cells = vec![0u8; 10]; // wrong size
3418        let f = FrameState::from_parts(2, 4, 0, 0, 0, "", cells);
3419        assert_eq!(f.cells().len(), 2 * 4 * CELL_SIZE);
3420    }
3421
3422    #[test]
3423    fn frame_state_resize() {
3424        let mut f = FrameState::new(4, 10);
3425        f.set_cursor(3, 9);
3426        f.resize(2, 5);
3427        assert_eq!(f.rows(), 2);
3428        assert_eq!(f.cols(), 5);
3429        assert_eq!(f.cursor_row(), 1); // clamped
3430        assert_eq!(f.cursor_col(), 4); // clamped
3431        assert_eq!(f.cells().len(), 2 * 5 * CELL_SIZE);
3432    }
3433
3434    #[test]
3435    fn frame_state_resize_noop() {
3436        let mut f = FrameState::new(4, 10);
3437        let ptr_before = f.cells().as_ptr();
3438        f.resize(4, 10); // same size
3439        let ptr_after = f.cells().as_ptr();
3440        assert_eq!(ptr_before, ptr_after); // no realloc
3441    }
3442
3443    #[test]
3444    fn frame_state_set_cursor_clamps() {
3445        let mut f = FrameState::new(4, 10);
3446        f.set_cursor(100, 200);
3447        assert_eq!(f.cursor_row(), 3);
3448        assert_eq!(f.cursor_col(), 9);
3449    }
3450
3451    #[test]
3452    fn frame_state_set_title() {
3453        let mut f = FrameState::new(2, 2);
3454        assert!(f.set_title("new title"));
3455        assert_eq!(f.title(), "new title");
3456        assert!(!f.set_title("new title")); // same title returns false
3457        assert!(f.set_title("other"));
3458    }
3459
3460    #[test]
3461    fn frame_state_get_text_and_write_text() {
3462        let mut f = FrameState::new(2, 10);
3463        f.write_text(0, 0, "Hello", CellStyle::default());
3464        f.write_text(1, 0, "World", CellStyle::default());
3465        let text = f.get_text(0, 0, 1, 9);
3466        assert!(text.contains("Hello"));
3467        assert!(text.contains("World"));
3468        let all = f.get_all_text();
3469        assert!(all.contains("Hello"));
3470    }
3471
3472    #[test]
3473    fn frame_state_get_text_empty() {
3474        let f = FrameState::new(0, 0);
3475        assert_eq!(f.get_text(0, 0, 0, 0), "");
3476        assert_eq!(f.get_all_text(), "");
3477    }
3478
3479    #[test]
3480    fn frame_state_get_cell() {
3481        let f = FrameState::new(2, 4);
3482        let cell = f.get_cell(0, 0);
3483        assert_eq!(cell.len(), CELL_SIZE);
3484        // Out of bounds
3485        assert!(f.get_cell(100, 100).is_empty());
3486    }
3487
3488    #[test]
3489    fn frame_state_cell_content_blank() {
3490        let f = FrameState::new(2, 4);
3491        assert_eq!(f.cell_content(0, 0), " "); // blank cell
3492        assert_eq!(f.cell_content(100, 0), ""); // out of bounds
3493    }
3494
3495    #[test]
3496    fn frame_state_cell_content_with_text() {
3497        let mut f = FrameState::new(2, 10);
3498        f.write_text(0, 0, "A", CellStyle::default());
3499        assert_eq!(f.cell_content(0, 0), "A");
3500    }
3501
3502    #[test]
3503    fn frame_state_fill_rect() {
3504        let mut f = FrameState::new(4, 10);
3505        f.fill_rect(Rect::new(0, 0, 2, 5), 'X', CellStyle::default());
3506        assert_eq!(f.cell_content(0, 0), "X");
3507        assert_eq!(f.cell_content(1, 4), "X");
3508        assert_eq!(f.cell_content(2, 0), " "); // outside rect
3509    }
3510
3511    #[test]
3512    fn frame_state_wrapped_text() {
3513        let mut f = FrameState::new(4, 10);
3514        let lines =
3515            f.write_wrapped_text(Rect::new(0, 0, 4, 5), "hello world", CellStyle::default());
3516        assert!(lines >= 2); // "hello world" wraps at width 5
3517    }
3518
3519    #[test]
3520    fn frame_state_wrapped_text_empty_rect() {
3521        let mut f = FrameState::new(4, 10);
3522        assert_eq!(
3523            f.write_wrapped_text(Rect::new(0, 0, 0, 0), "hi", CellStyle::default()),
3524            0
3525        );
3526    }
3527
3528    #[test]
3529    fn frame_state_scrolling_text() {
3530        let mut f = FrameState::new(4, 10);
3531        f.write_scrolling_text(
3532            Rect::new(0, 0, 3, 10),
3533            &["line1", "line2", "line3", "line4"],
3534            0,
3535            CellStyle::default(),
3536        );
3537        // Last 3 lines visible with offset_from_bottom=0
3538        assert_eq!(f.cell_content(0, 0), "l"); // "line2"
3539    }
3540
3541    #[test]
3542    fn frame_state_scrolling_text_empty_rect() {
3543        let mut f = FrameState::new(4, 10);
3544        f.write_scrolling_text(Rect::new(0, 0, 0, 0), &["hi"], 0, CellStyle::default());
3545        // Should not panic
3546    }
3547
3548    #[test]
3549    fn frame_state_clear() {
3550        let mut f = FrameState::new(2, 4);
3551        f.write_text(0, 0, "AB", CellStyle::default());
3552        f.clear(CellStyle::default());
3553        assert_eq!(f.cell_content(0, 0), " ");
3554    }
3555
3556    // --- TerminalState tests ---
3557
3558    #[test]
3559    fn terminal_state_accessors() {
3560        let t = TerminalState::new(24, 80);
3561        assert_eq!(t.rows(), 24);
3562        assert_eq!(t.cols(), 80);
3563        assert_eq!(t.cursor_row(), 0);
3564        assert_eq!(t.cursor_col(), 0);
3565        assert_eq!(t.mode(), 0);
3566        assert_eq!(t.title(), "");
3567        assert_eq!(t.cells().len(), 24 * 80 * CELL_SIZE);
3568        assert_eq!(t.frame().rows(), 24);
3569    }
3570
3571    #[test]
3572    fn terminal_state_mutators() {
3573        let mut t = TerminalState::new(4, 10);
3574        t.frame_mut().set_title("test");
3575        assert_eq!(t.title(), "test");
3576    }
3577
3578    #[test]
3579    fn terminal_state_set_title() {
3580        let mut t = TerminalState::new(4, 10);
3581        assert!(t.frame_mut().set_title("hello"));
3582        assert_eq!(t.title(), "hello");
3583        assert!(!t.frame_mut().set_title("hello")); // same
3584    }
3585
3586    #[test]
3587    fn terminal_state_get_text() {
3588        let t = TerminalState::new(2, 10);
3589        let text = t.get_text(0, 0, 0, 9);
3590        assert!(text.is_empty() || text.chars().all(|c| c == ' ' || c == '\n'));
3591        assert!(t.get_cell(0, 0).len() == CELL_SIZE);
3592        assert!(t.get_cell(100, 100).is_empty());
3593    }
3594
3595    #[test]
3596    fn terminal_state_resize() {
3597        let mut t = TerminalState::new(4, 10);
3598        t.frame_mut().resize(2, 5);
3599        // Note: TerminalState.dirty isn't updated by frame_mut().resize()
3600        // directly — that happens through feed_compressed. So just check frame.
3601        assert_eq!(t.rows(), 2);
3602        assert_eq!(t.cols(), 5);
3603    }
3604
3605    #[test]
3606    fn terminal_state_feed_compressed_invalid() {
3607        let mut t = TerminalState::new(4, 10);
3608        assert!(!t.feed_compressed(b"garbage"));
3609        assert!(!t.feed_compressed(&[]));
3610    }
3611
3612    #[test]
3613    fn terminal_state_feed_compressed_batch_empty() {
3614        let mut t = TerminalState::new(4, 10);
3615        assert!(!t.feed_compressed_batch(&[]));
3616    }
3617
3618    #[test]
3619    fn terminal_state_feed_compressed_batch_truncated() {
3620        let mut t = TerminalState::new(4, 10);
3621        // Length header says 100 bytes but only 4 bytes present
3622        let batch = &[100, 0, 0, 0];
3623        assert!(!t.feed_compressed_batch(batch));
3624    }
3625
3626    // --- Client message builder tests ---
3627
3628    #[test]
3629    fn msg_input_format() {
3630        let msg = msg_input(5, b"hello");
3631        assert_eq!(msg[0], C2S_INPUT);
3632        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 5);
3633        assert_eq!(&msg[3..], b"hello");
3634    }
3635
3636    #[test]
3637    fn msg_resize_format() {
3638        let msg = msg_resize(3, 24, 80);
3639        assert_eq!(msg[0], C2S_RESIZE);
3640        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
3641        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
3642        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
3643    }
3644
3645    #[test]
3646    fn msg_resize_batch_format() {
3647        let msg = msg_resize_batch(&[(3, 24, 80), (5, 40, 120)]);
3648        assert_eq!(msg[0], C2S_RESIZE);
3649        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
3650        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
3651        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
3652        assert_eq!(u16::from_le_bytes([msg[7], msg[8]]), 5);
3653        assert_eq!(u16::from_le_bytes([msg[9], msg[10]]), 40);
3654        assert_eq!(u16::from_le_bytes([msg[11], msg[12]]), 120);
3655    }
3656
3657    #[test]
3658    fn msg_focus_format() {
3659        let msg = msg_focus(7);
3660        assert_eq!(msg[0], C2S_FOCUS);
3661        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 7);
3662        assert_eq!(msg.len(), 3);
3663    }
3664
3665    #[test]
3666    fn msg_close_format() {
3667        let msg = msg_close(9);
3668        assert_eq!(msg[0], C2S_CLOSE);
3669        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 9);
3670    }
3671
3672    #[test]
3673    fn msg_subscribe_unsubscribe_format() {
3674        let sub = msg_subscribe(1);
3675        assert_eq!(sub[0], C2S_SUBSCRIBE);
3676        assert_eq!(u16::from_le_bytes([sub[1], sub[2]]), 1);
3677
3678        let unsub = msg_unsubscribe(2);
3679        assert_eq!(unsub[0], C2S_UNSUBSCRIBE);
3680        assert_eq!(u16::from_le_bytes([unsub[1], unsub[2]]), 2);
3681    }
3682
3683    #[test]
3684    fn msg_search_format() {
3685        let msg = msg_search(42, "test query");
3686        assert_eq!(msg[0], C2S_SEARCH);
3687        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
3688        assert_eq!(&msg[3..], b"test query");
3689    }
3690
3691    #[test]
3692    fn msg_ack_format() {
3693        let msg = msg_ack();
3694        assert_eq!(msg, vec![C2S_ACK]);
3695    }
3696
3697    #[test]
3698    fn msg_scroll_format() {
3699        let msg = msg_scroll(5, 1000);
3700        assert_eq!(msg[0], C2S_SCROLL);
3701        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 5);
3702        assert_eq!(u32::from_le_bytes([msg[3], msg[4], msg[5], msg[6]]), 1000);
3703    }
3704
3705    #[test]
3706    fn msg_display_rate_format() {
3707        let msg = msg_display_rate(120);
3708        assert_eq!(msg[0], C2S_DISPLAY_RATE);
3709        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 120);
3710    }
3711
3712    #[test]
3713    fn msg_client_metrics_format() {
3714        let msg = msg_client_metrics(3, 5, 100);
3715        assert_eq!(msg[0], C2S_CLIENT_METRICS);
3716        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
3717        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 5);
3718        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 100);
3719    }
3720
3721    // --- CallbackRenderer tests ---
3722
3723    #[test]
3724    fn callback_renderer_resize() {
3725        let mut r = CallbackRenderer::new(2, 8);
3726        assert_eq!(r.frame().rows(), 2);
3727        r.resize(4, 16);
3728        assert_eq!(r.frame().rows(), 4);
3729        assert_eq!(r.frame().cols(), 16);
3730    }
3731
3732    #[test]
3733    fn callback_renderer_fill() {
3734        let mut r = CallbackRenderer::new(4, 10);
3735        r.render(|dom| {
3736            dom.fill(Rect::new(0, 0, 2, 5), '#', CellStyle::default());
3737        });
3738        assert_eq!(r.frame().cell_content(0, 0), "#");
3739        assert_eq!(r.frame().cell_content(1, 4), "#");
3740    }
3741
3742    #[test]
3743    fn callback_renderer_text() {
3744        let mut r = CallbackRenderer::new(4, 20);
3745        r.render(|dom| {
3746            dom.text(0, 0, "Hello", CellStyle::default());
3747        });
3748        assert_eq!(r.frame().cell_content(0, 0), "H");
3749        assert_eq!(r.frame().cell_content(0, 4), "o");
3750    }
3751
3752    #[test]
3753    fn callback_renderer_set_title() {
3754        let mut r = CallbackRenderer::new(2, 8);
3755        r.render(|dom| {
3756            dom.set_title("my title");
3757        });
3758        assert_eq!(r.frame().title(), "my title");
3759    }
3760
3761    #[test]
3762    fn callback_renderer_set_background() {
3763        let mut r = CallbackRenderer::new(2, 4);
3764        let style = CellStyle {
3765            bg: Color::Rgb(255, 0, 0),
3766            ..CellStyle::default()
3767        };
3768        r.render(|dom| {
3769            dom.set_background(style);
3770        });
3771        // Background fill should have been applied to all cells
3772        assert_eq!(r.frame().cells().len(), 2 * 4 * CELL_SIZE);
3773    }
3774
3775    #[test]
3776    fn callback_renderer_scrolling_text() {
3777        let mut r = CallbackRenderer::new(4, 20);
3778        r.render(|dom| {
3779            dom.scrolling_text(
3780                Rect::new(0, 0, 3, 20),
3781                ["a", "b", "c", "d", "e"].map(String::from),
3782                0,
3783                CellStyle::default(),
3784            );
3785        });
3786        // Should show the last 3 lines
3787        assert_eq!(r.frame().cell_content(0, 0), "c");
3788    }
3789
3790    // --- parse_server_msg edge cases ---
3791
3792    #[test]
3793    fn parse_empty_returns_none() {
3794        assert!(parse_server_msg(&[]).is_none());
3795    }
3796
3797    #[test]
3798    fn parse_unknown_type_returns_none() {
3799        assert!(parse_server_msg(&[0xFF, 0x00, 0x00]).is_none());
3800    }
3801
3802    #[test]
3803    fn parse_update_too_short() {
3804        assert!(parse_server_msg(&[S2C_UPDATE, 0x00]).is_none());
3805    }
3806
3807    #[test]
3808    fn parse_closed() {
3809        let msg = parse_server_msg(&[S2C_CLOSED, 0x05, 0x00]).unwrap();
3810        match msg {
3811            ServerMsg::Closed { pty_id } => assert_eq!(pty_id, 5),
3812            _ => panic!("expected Closed"),
3813        }
3814    }
3815
3816    #[test]
3817    fn parse_title() {
3818        let mut wire = vec![S2C_TITLE, 0x01, 0x00];
3819        wire.extend_from_slice(b"mytitle");
3820        let msg = parse_server_msg(&wire).unwrap();
3821        match msg {
3822            ServerMsg::Title { pty_id, title } => {
3823                assert_eq!(pty_id, 1);
3824                assert_eq!(title, b"mytitle");
3825            }
3826            _ => panic!("expected Title"),
3827        }
3828    }
3829
3830    // --- build_update_msg round-trip ---
3831
3832    #[test]
3833    fn build_update_msg_round_trip_with_resize() {
3834        let style = CellStyle::default();
3835        let mut prev = FrameState::new(2, 4);
3836        prev.write_text(0, 0, "AB", style);
3837
3838        let mut next = FrameState::new(3, 5); // different size
3839        next.write_text(0, 0, "XY", style);
3840        next.set_title("resized");
3841
3842        let msg = build_update_msg(1, &next, &prev).unwrap();
3843        assert!(!msg.is_empty());
3844
3845        // Apply to a terminal
3846        let mut t = TerminalState::new(2, 4);
3847        assert!(t.feed_compressed(&msg[3..])); // skip pty_id header
3848        assert_eq!(t.rows(), 3);
3849        assert_eq!(t.cols(), 5);
3850        assert_eq!(t.title(), "resized");
3851    }
3852
3853    #[test]
3854    fn build_update_msg_cursor_change() {
3855        let mut prev = FrameState::new(4, 10);
3856        prev.set_cursor(0, 0);
3857
3858        let mut next = prev.clone();
3859        next.set_cursor(2, 5);
3860
3861        let msg = build_update_msg(0, &next, &prev).unwrap();
3862
3863        let mut t = TerminalState::new(4, 10);
3864        assert!(t.feed_compressed(&msg[3..]));
3865        assert_eq!(t.cursor_row(), 2);
3866        assert_eq!(t.cursor_col(), 5);
3867    }
3868
3869    #[test]
3870    fn build_update_msg_mode_change() {
3871        let prev = FrameState::new(2, 4);
3872        let mut next = prev.clone();
3873        next.set_mode(0x0F);
3874
3875        let msg = build_update_msg(0, &next, &prev).unwrap();
3876        let mut t = TerminalState::new(2, 4);
3877        assert!(t.feed_compressed(&msg[3..]));
3878        assert_eq!(t.mode(), 0x0F);
3879    }
3880
3881    #[test]
3882    fn feed_compressed_batch_multiple_frames() {
3883        let style = CellStyle::default();
3884        let prev = FrameState::new(2, 4);
3885
3886        let mut mid = prev.clone();
3887        mid.write_text(0, 0, "AB", style);
3888        let msg1 = build_update_msg(0, &mid, &prev).unwrap();
3889
3890        let mut next = mid.clone();
3891        next.write_text(1, 0, "CD", style);
3892        let msg2 = build_update_msg(0, &next, &mid).unwrap();
3893
3894        // Build batch: [len1:4][compressed1][len2:4][compressed2]
3895        let payload1 = &msg1[3..];
3896        let payload2 = &msg2[3..];
3897        let mut batch = Vec::new();
3898        batch.extend_from_slice(&(payload1.len() as u32).to_le_bytes());
3899        batch.extend_from_slice(payload1);
3900        batch.extend_from_slice(&(payload2.len() as u32).to_le_bytes());
3901        batch.extend_from_slice(payload2);
3902
3903        let mut t = TerminalState::new(2, 4);
3904        assert!(t.feed_compressed_batch(&batch));
3905        let text = t.get_all_text();
3906        assert!(text.contains("AB"));
3907        assert!(text.contains("CD"));
3908    }
3909}