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