Skip to main content

blit_remote/
lib.rs

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