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