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