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