Skip to main content

blit_remote/
lib.rs

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