Skip to main content

blit_remote/
lib.rs

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