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