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
63pub const S2C_UPDATE: u8 = 0x00;
64pub const S2C_CREATED: u8 = 0x01;
65pub const S2C_CLOSED: u8 = 0x02;
66pub const S2C_LIST: u8 = 0x03;
67pub const S2C_TITLE: u8 = 0x04;
68pub const S2C_SEARCH_RESULTS: u8 = 0x05;
69pub const S2C_CREATED_N: u8 = 0x06;
70pub const S2C_HELLO: u8 = 0x07;
71/// The PTY's subprocess has exited but the terminal state is retained.
72/// Clients can still read/scroll the last frame. Send C2S_CLOSE to dismiss.
73pub const S2C_EXITED: u8 = 0x08;
74
75pub const FEATURE_CREATE_NONCE: u32 = 1 << 0;
76pub const FEATURE_RESTART: u32 = 1 << 1;
77pub const FEATURE_RESIZE_BATCH: u32 = 1 << 2;
78
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum Color {
81    #[default]
82    Default,
83    Indexed(u8),
84    Rgb(u8, u8, u8),
85}
86
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub struct CellStyle {
89    pub fg: Color,
90    pub bg: Color,
91    pub bold: bool,
92    pub dim: bool,
93    pub italic: bool,
94    pub underline: bool,
95    pub inverse: bool,
96}
97
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
99pub struct Rect {
100    pub row: u16,
101    pub col: u16,
102    pub rows: u16,
103    pub cols: u16,
104}
105
106impl Rect {
107    pub const fn new(row: u16, col: u16, rows: u16, cols: u16) -> Self {
108        Self {
109            row,
110            col,
111            rows,
112            cols,
113        }
114    }
115}
116
117#[derive(Clone, Debug, Default, PartialEq, Eq)]
118pub struct FrameState {
119    rows: u16,
120    cols: u16,
121    cells: Vec<u8>,
122    cursor_row: u16,
123    cursor_col: u16,
124    mode: u16,
125    title: String,
126    /// Overflow strings for cells whose content exceeds 4 bytes.
127    /// Keyed by flat cell index (row * cols + col).
128    overflow: BTreeMap<usize, String>,
129    /// Per-row flags. `ROW_FLAG_WRAPPED` means the row continues on the next.
130    line_flags: Vec<u8>,
131    /// Total scrollback lines available for this PTY.
132    scrollback_lines: u32,
133}
134
135impl FrameState {
136    pub fn new(rows: u16, cols: u16) -> Self {
137        let total = rows as usize * cols as usize;
138        Self {
139            rows,
140            cols,
141            cells: vec![0; total * CELL_SIZE],
142            cursor_row: 0,
143            cursor_col: 0,
144            mode: 0,
145            title: String::new(),
146            overflow: BTreeMap::new(),
147            line_flags: vec![0; rows as usize],
148            scrollback_lines: 0,
149        }
150    }
151
152    pub fn from_parts(
153        rows: u16,
154        cols: u16,
155        cursor_row: u16,
156        cursor_col: u16,
157        mode: u16,
158        title: impl Into<String>,
159        cells: Vec<u8>,
160    ) -> Self {
161        let mut state = Self::new(rows, cols);
162        if cells.len() == state.cells.len() {
163            state.cells = cells;
164        }
165        state.cursor_row = cursor_row;
166        state.cursor_col = cursor_col;
167        state.mode = mode;
168        state.title = title.into();
169        state
170    }
171
172    pub fn rows(&self) -> u16 {
173        self.rows
174    }
175
176    pub fn cols(&self) -> u16 {
177        self.cols
178    }
179
180    pub fn cursor_row(&self) -> u16 {
181        self.cursor_row
182    }
183
184    pub fn cursor_col(&self) -> u16 {
185        self.cursor_col
186    }
187
188    pub fn mode(&self) -> u16 {
189        self.mode
190    }
191
192    pub fn title(&self) -> &str {
193        &self.title
194    }
195
196    pub fn cells(&self) -> &[u8] {
197        &self.cells
198    }
199
200    pub fn cells_mut(&mut self) -> &mut [u8] {
201        &mut self.cells
202    }
203
204    pub fn overflow(&self) -> &BTreeMap<usize, String> {
205        &self.overflow
206    }
207
208    pub fn overflow_mut(&mut self) -> &mut BTreeMap<usize, String> {
209        &mut self.overflow
210    }
211
212    pub fn line_flags(&self) -> &[u8] {
213        &self.line_flags
214    }
215
216    pub fn line_flags_mut(&mut self) -> &mut Vec<u8> {
217        &mut self.line_flags
218    }
219
220    pub fn scrollback_lines(&self) -> u32 {
221        self.scrollback_lines
222    }
223
224    pub fn set_scrollback_lines(&mut self, lines: u32) {
225        self.scrollback_lines = lines;
226    }
227
228    pub fn is_wrapped(&self, row: u16) -> bool {
229        self.line_flags.get(row as usize).copied().unwrap_or(0) & ROW_FLAG_WRAPPED != 0
230    }
231
232    pub fn set_wrapped(&mut self, row: u16, wrapped: bool) {
233        if let Some(flags) = self.line_flags.get_mut(row as usize) {
234            if wrapped {
235                *flags |= ROW_FLAG_WRAPPED;
236            } else {
237                *flags &= !ROW_FLAG_WRAPPED;
238            }
239        }
240    }
241
242    /// Returns the text content of a cell, resolving overflow if needed.
243    pub fn cell_content(&self, row: u16, col: u16) -> &str {
244        if row >= self.rows || col >= self.cols {
245            return "";
246        }
247        let flat = row as usize * self.cols as usize + col as usize;
248        let idx = flat * CELL_SIZE;
249        let f1 = self.cells[idx + 1];
250        if f1 & 4 != 0 {
251            return ""; // wide continuation
252        }
253        let content_len = ((f1 >> 3) & 7) as usize;
254        if content_len == CONTENT_OVERFLOW as usize {
255            if let Some(s) = self.overflow.get(&flat) {
256                return s.as_str();
257            }
258            return "";
259        }
260        if content_len == 0 {
261            return " ";
262        }
263        std::str::from_utf8(&self.cells[idx + 8..idx + 8 + content_len]).unwrap_or(" ")
264    }
265
266    pub fn resize(&mut self, rows: u16, cols: u16) {
267        if rows == self.rows && cols == self.cols {
268            return;
269        }
270        self.rows = rows;
271        self.cols = cols;
272        self.cells = vec![0; rows as usize * cols as usize * CELL_SIZE];
273        self.overflow.clear();
274        self.line_flags = vec![0; rows as usize];
275        self.cursor_row = self.cursor_row.min(rows.saturating_sub(1));
276        self.cursor_col = self.cursor_col.min(cols.saturating_sub(1));
277    }
278
279    pub fn set_cursor(&mut self, row: u16, col: u16) {
280        self.cursor_row = row.min(self.rows.saturating_sub(1));
281        self.cursor_col = col.min(self.cols.saturating_sub(1));
282    }
283
284    pub fn set_mode(&mut self, mode: u16) {
285        self.mode = mode;
286    }
287
288    pub fn set_title(&mut self, title: impl Into<String>) -> bool {
289        let title = title.into();
290        if self.title == title {
291            return false;
292        }
293        self.title = title;
294        true
295    }
296
297    pub fn clear(&mut self, style: CellStyle) {
298        for row in 0..self.rows {
299            for col in 0..self.cols {
300                self.set_blank_cell(row, col, style);
301            }
302        }
303    }
304
305    pub fn fill_rect(&mut self, rect: Rect, ch: char, style: CellStyle) {
306        let row_end = rect.row.saturating_add(rect.rows).min(self.rows);
307        let col_end = rect.col.saturating_add(rect.cols).min(self.cols);
308        for row in rect.row..row_end {
309            let mut col = rect.col;
310            while col < col_end {
311                let width = self.set_cell(row, col, ch, style);
312                if width == 0 {
313                    break;
314                }
315                col = col.saturating_add(width);
316            }
317        }
318    }
319
320    pub fn write_text(&mut self, row: u16, col: u16, text: &str, style: CellStyle) -> u16 {
321        if row >= self.rows || col >= self.cols {
322            return col;
323        }
324        let mut cur_col = col;
325        for ch in text.chars() {
326            if cur_col >= self.cols {
327                break;
328            }
329            let width = self.set_cell(row, cur_col, ch, style);
330            if width == 0 {
331                continue;
332            }
333            cur_col = cur_col.saturating_add(width);
334        }
335        cur_col
336    }
337
338    pub fn write_wrapped_text(&mut self, rect: Rect, text: &str, style: CellStyle) -> usize {
339        if rect.rows == 0 || rect.cols == 0 {
340            return 0;
341        }
342        let lines = wrap_text_lines(text, rect.cols as usize);
343        let max_rows = rect.rows.min(self.rows.saturating_sub(rect.row));
344        for (idx, line) in lines.iter().take(max_rows as usize).enumerate() {
345            let row = rect.row + idx as u16;
346            self.write_text(row, rect.col, line, style);
347        }
348        lines.len()
349    }
350
351    pub fn write_scrolling_text<S: AsRef<str>>(
352        &mut self,
353        rect: Rect,
354        lines: &[S],
355        offset_from_bottom: usize,
356        style: CellStyle,
357    ) {
358        if rect.rows == 0 || rect.cols == 0 {
359            return;
360        }
361        let mut wrapped = Vec::new();
362        for line in lines {
363            let line = line.as_ref();
364            let out = wrap_text_lines(line, rect.cols as usize);
365            if out.is_empty() {
366                wrapped.push(String::new());
367            } else {
368                wrapped.extend(out);
369            }
370        }
371        let visible = rect.rows as usize;
372        let end = wrapped.len().saturating_sub(offset_from_bottom);
373        let start = end.saturating_sub(visible);
374        for row in 0..rect.rows {
375            self.fill_rect(
376                Rect::new(rect.row + row, rect.col, 1, rect.cols),
377                ' ',
378                style,
379            );
380        }
381        for (idx, line) in wrapped[start..end].iter().enumerate() {
382            self.write_text(rect.row + idx as u16, rect.col, line, style);
383        }
384    }
385
386    pub fn get_text(&self, start_row: u16, start_col: u16, end_row: u16, end_col: u16) -> String {
387        let mut result = String::new();
388        if self.rows == 0 || self.cols == 0 {
389            return result;
390        }
391        for row in start_row..=end_row.min(self.rows.saturating_sub(1)) {
392            let c0 = if row == start_row { start_col } else { 0 };
393            let c1 = if row == end_row {
394                end_col
395            } else {
396                self.cols - 1
397            };
398            let mut line = String::new();
399            let mut col = c0;
400            while col <= c1.min(self.cols - 1) {
401                line.push_str(self.cell_content(row, col));
402                col += 1;
403            }
404            result.push_str(line.trim_end());
405            if row < end_row.min(self.rows.saturating_sub(1)) {
406                result.push('\n');
407            }
408        }
409        result
410    }
411
412    pub fn get_all_text(&self) -> String {
413        if self.rows == 0 || self.cols == 0 {
414            return String::new();
415        }
416        self.get_text(0, 0, self.rows - 1, self.cols - 1)
417    }
418
419    pub fn get_cell(&self, row: u16, col: u16) -> Vec<u8> {
420        if row >= self.rows || col >= self.cols {
421            return Vec::new();
422        }
423        let idx = self.cell_offset(row, col);
424        self.cells[idx..idx + CELL_SIZE].to_vec()
425    }
426
427    fn cell_offset(&self, row: u16, col: u16) -> usize {
428        (row as usize * self.cols as usize + col as usize) * CELL_SIZE
429    }
430
431    fn set_cell(&mut self, row: u16, col: u16, ch: char, style: CellStyle) -> u16 {
432        if row >= self.rows || col >= self.cols {
433            return 0;
434        }
435        let raw_width = UnicodeWidthChar::width(ch).unwrap_or(0);
436        if raw_width == 0 {
437            return 0;
438        }
439        let width = if raw_width > 1 && col + 1 < self.cols {
440            2
441        } else {
442            1
443        };
444        let idx = self.cell_offset(row, col);
445        encode_cell(
446            &mut self.cells[idx..idx + CELL_SIZE],
447            Some(ch),
448            style,
449            width == 2,
450            false,
451        );
452        if width == 2 {
453            let cont_idx = self.cell_offset(row, col + 1);
454            encode_cell(
455                &mut self.cells[cont_idx..cont_idx + CELL_SIZE],
456                None,
457                style,
458                false,
459                true,
460            );
461        }
462        width
463    }
464
465    fn set_blank_cell(&mut self, row: u16, col: u16, style: CellStyle) {
466        if row >= self.rows || col >= self.cols {
467            return;
468        }
469        let idx = self.cell_offset(row, col);
470        encode_cell(
471            &mut self.cells[idx..idx + CELL_SIZE],
472            None,
473            style,
474            false,
475            false,
476        );
477    }
478}
479
480#[derive(Clone, Debug)]
481pub struct TerminalState {
482    frame: FrameState,
483}
484
485impl TerminalState {
486    pub fn new(rows: u16, cols: u16) -> Self {
487        let frame = FrameState::new(rows, cols);
488        Self { frame }
489    }
490
491    pub fn frame(&self) -> &FrameState {
492        &self.frame
493    }
494
495    pub fn frame_mut(&mut self) -> &mut FrameState {
496        &mut self.frame
497    }
498
499    pub fn title(&self) -> &str {
500        self.frame.title()
501    }
502
503    pub fn rows(&self) -> u16 {
504        self.frame.rows()
505    }
506
507    pub fn cols(&self) -> u16 {
508        self.frame.cols()
509    }
510
511    pub fn is_wrapped(&self, row: u16) -> bool {
512        self.frame.is_wrapped(row)
513    }
514
515    pub fn cursor_row(&self) -> u16 {
516        self.frame.cursor_row()
517    }
518
519    pub fn cursor_col(&self) -> u16 {
520        self.frame.cursor_col()
521    }
522
523    pub fn mode(&self) -> u16 {
524        self.frame.mode()
525    }
526
527    pub fn cells(&self) -> &[u8] {
528        self.frame.cells()
529    }
530
531    pub fn set_title(&mut self, title: &str) -> bool {
532        self.frame.set_title(title.to_owned())
533    }
534
535    pub fn get_text(&self, start_row: u16, start_col: u16, end_row: u16, end_col: u16) -> String {
536        self.frame.get_text(start_row, start_col, end_row, end_col)
537    }
538
539    pub fn get_all_text(&self) -> String {
540        self.frame.get_all_text()
541    }
542
543    pub fn get_cell(&self, row: u16, col: u16) -> Vec<u8> {
544        self.frame.get_cell(row, col)
545    }
546
547    pub fn feed_compressed(&mut self, data: &[u8]) -> bool {
548        let payload = match decompress_size_prepended(data) {
549            Ok(d) => d,
550            Err(_) => return false,
551        };
552        self.apply_payload(&payload)
553    }
554
555    pub fn feed_compressed_batch(&mut self, batch: &[u8]) -> bool {
556        let mut changed = false;
557        let mut off = 0usize;
558        while off + 4 <= batch.len() {
559            let len =
560                u32::from_le_bytes([batch[off], batch[off + 1], batch[off + 2], batch[off + 3]])
561                    as usize;
562            off += 4;
563            if off + len > batch.len() {
564                break;
565            }
566            if let Ok(payload) = decompress_size_prepended(&batch[off..off + len]) {
567                changed |= self.apply_payload(&payload);
568            }
569            off += len;
570        }
571        changed
572    }
573
574    fn apply_payload(&mut self, payload: &[u8]) -> bool {
575        if payload.len() < 12 {
576            return false;
577        }
578
579        let new_rows = u16::from_le_bytes([payload[0], payload[1]]);
580        let new_cols = u16::from_le_bytes([payload[2], payload[3]]);
581        let new_cursor_row = u16::from_le_bytes([payload[4], payload[5]]);
582        let new_cursor_col = u16::from_le_bytes([payload[6], payload[7]]);
583        let new_mode = u16::from_le_bytes([payload[8], payload[9]]);
584        let title_field = u16::from_le_bytes([payload[10], payload[11]]);
585        let title_present = title_field & TITLE_PRESENT != 0;
586        let ops_present = title_field & OPS_PRESENT != 0;
587        let strings_present = title_field & STRINGS_PRESENT != 0;
588        let line_flags_present = title_field & LINE_FLAGS_PRESENT != 0;
589        let title_len = (title_field & TITLE_LEN_MASK) as usize;
590
591        let title_start = 12usize;
592        let title_end = title_start.saturating_add(title_len);
593        if payload.len() < title_end {
594            return false;
595        }
596        let title_changed = if title_present {
597            let title = String::from_utf8_lossy(&payload[title_start..title_end]).into_owned();
598            self.frame.set_title(title)
599        } else {
600            false
601        };
602
603        let resized = new_rows != self.frame.rows || new_cols != self.frame.cols;
604        if resized {
605            self.frame.resize(new_rows, new_cols);
606        }
607
608        let old_cursor_row = self.frame.cursor_row;
609        let old_cursor_col = self.frame.cursor_col;
610        let old_mode = self.frame.mode;
611
612        let (content_changed, ops_end) = if ops_present {
613            let ops_start = title_end;
614            if payload.len() < ops_start + 2 {
615                return false;
616            }
617            let (changed, consumed) = self
618                .apply_ops_payload(&payload[ops_start..])
619                .unwrap_or((false, 0));
620            (changed, ops_start + consumed)
621        } else {
622            let (changed, consumed) = self
623                .apply_legacy_patch_payload(&payload[title_end..])
624                .unwrap_or((false, 0));
625            (changed, title_end + consumed)
626        };
627
628        let mut after_strings = ops_end;
629        if strings_present {
630            after_strings = self.apply_overflow_strings(&payload[ops_end..]);
631            after_strings += ops_end;
632        }
633
634        let (line_flags_changed, after_line_flags) = if line_flags_present {
635            let lf_start = after_strings;
636            let lf_end = lf_start + new_rows as usize;
637            if payload.len() >= lf_end {
638                let new_flags = &payload[lf_start..lf_end];
639                let changed = self.frame.line_flags != new_flags;
640                self.frame.line_flags.clear();
641                self.frame.line_flags.extend_from_slice(new_flags);
642                (changed, lf_end)
643            } else {
644                (false, after_strings)
645            }
646        } else {
647            (false, after_strings)
648        };
649
650        // Trailing scrollback count (backward-compatible extension).
651        if payload.len() >= after_line_flags + 4 {
652            self.frame.scrollback_lines = u32::from_le_bytes([
653                payload[after_line_flags],
654                payload[after_line_flags + 1],
655                payload[after_line_flags + 2],
656                payload[after_line_flags + 3],
657            ]);
658        }
659
660        self.frame.cursor_row = new_cursor_row.min(self.frame.rows.saturating_sub(1));
661        self.frame.cursor_col = new_cursor_col.min(self.frame.cols.saturating_sub(1));
662        self.frame.mode = new_mode;
663        resized
664            || title_changed
665            || content_changed
666            || line_flags_changed
667            || new_cursor_row != old_cursor_row
668            || new_cursor_col != old_cursor_col
669            || new_mode != old_mode
670    }
671
672    fn apply_legacy_patch_payload(&mut self, payload: &[u8]) -> Option<(bool, usize)> {
673        let total_cells = self.frame.rows as usize * self.frame.cols as usize;
674        let bitmask_len = total_cells.div_ceil(8);
675        if payload.len() < bitmask_len {
676            return None;
677        }
678        let bitmask = &payload[..bitmask_len];
679        let dirty_count = (0..total_cells)
680            .filter(|&i| bitmask[i / 8] & (1 << (i % 8)) != 0)
681            .count();
682        let data = &payload[bitmask_len..];
683        if data.len() < dirty_count * CELL_SIZE {
684            return None;
685        }
686        self.apply_patch_cells(bitmask, &data[..dirty_count * CELL_SIZE], dirty_count);
687        Some((dirty_count > 0, bitmask_len + dirty_count * CELL_SIZE))
688    }
689
690    fn apply_ops_payload(&mut self, payload: &[u8]) -> Option<(bool, usize)> {
691        if payload.len() < 2 {
692            return None;
693        }
694        let op_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
695        let total_cells = self.frame.rows as usize * self.frame.cols as usize;
696        let bitmask_len = total_cells.div_ceil(8);
697        let mut off = 2usize;
698        let mut changed = false;
699
700        for _ in 0..op_count {
701            if off >= payload.len() {
702                return None;
703            }
704            let op = payload[off];
705            off += 1;
706            match op {
707                OP_COPY_RECT => {
708                    if payload.len() < off + 12 {
709                        return None;
710                    }
711                    let src_row = u16::from_le_bytes([payload[off], payload[off + 1]]);
712                    let src_col = u16::from_le_bytes([payload[off + 2], payload[off + 3]]);
713                    let dst_row = u16::from_le_bytes([payload[off + 4], payload[off + 5]]);
714                    let dst_col = u16::from_le_bytes([payload[off + 6], payload[off + 7]]);
715                    let rows = u16::from_le_bytes([payload[off + 8], payload[off + 9]]);
716                    let cols = u16::from_le_bytes([payload[off + 10], payload[off + 11]]);
717                    off += 12;
718                    changed |= self.apply_copy_rect(src_row, src_col, dst_row, dst_col, rows, cols);
719                }
720                OP_FILL_RECT => {
721                    if payload.len() < off + 8 + CELL_SIZE {
722                        return None;
723                    }
724                    let row = u16::from_le_bytes([payload[off], payload[off + 1]]);
725                    let col = u16::from_le_bytes([payload[off + 2], payload[off + 3]]);
726                    let rows = u16::from_le_bytes([payload[off + 4], payload[off + 5]]);
727                    let cols = u16::from_le_bytes([payload[off + 6], payload[off + 7]]);
728                    off += 8;
729                    let mut cell = [0u8; CELL_SIZE];
730                    cell.copy_from_slice(&payload[off..off + CELL_SIZE]);
731                    off += CELL_SIZE;
732                    changed |= self.apply_fill_rect(row, col, rows, cols, &cell);
733                }
734                OP_PATCH_CELLS => {
735                    if payload.len() < off + bitmask_len {
736                        return None;
737                    }
738                    let bitmask = &payload[off..off + bitmask_len];
739                    off += bitmask_len;
740                    let dirty_count = (0..total_cells)
741                        .filter(|&i| bitmask[i / 8] & (1 << (i % 8)) != 0)
742                        .count();
743                    if payload.len() < off + dirty_count * CELL_SIZE {
744                        return None;
745                    }
746                    self.apply_patch_cells(
747                        bitmask,
748                        &payload[off..off + dirty_count * CELL_SIZE],
749                        dirty_count,
750                    );
751                    off += dirty_count * CELL_SIZE;
752                    changed |= dirty_count > 0;
753                }
754                _ => return None,
755            }
756        }
757
758        Some((changed, off))
759    }
760
761    fn apply_patch_cells(&mut self, bitmask: &[u8], data: &[u8], dirty_count: usize) {
762        let total_cells = self.frame.rows as usize * self.frame.cols as usize;
763        let mut dirty_idx = 0usize;
764        for i in 0..total_cells {
765            if bitmask[i / 8] & (1 << (i % 8)) == 0 {
766                continue;
767            }
768            let cell_idx = i * CELL_SIZE;
769            for byte_pos in 0..CELL_SIZE {
770                self.frame.cells[cell_idx + byte_pos] = data[byte_pos * dirty_count + dirty_idx];
771            }
772            // Remove stale overflow entry when a cell is updated — it may
773            // have transitioned from overflow (content_len=7) to inline.
774            let new_content_len = (self.frame.cells[cell_idx + 1] >> 3) & 7;
775            if new_content_len != CONTENT_OVERFLOW {
776                self.frame.overflow.remove(&i);
777            }
778            dirty_idx += 1;
779        }
780    }
781
782    fn apply_copy_rect(
783        &mut self,
784        src_row: u16,
785        src_col: u16,
786        dst_row: u16,
787        dst_col: u16,
788        rows: u16,
789        cols: u16,
790    ) -> bool {
791        let rows = rows
792            .min(self.frame.rows.saturating_sub(src_row))
793            .min(self.frame.rows.saturating_sub(dst_row));
794        let cols = cols
795            .min(self.frame.cols.saturating_sub(src_col))
796            .min(self.frame.cols.saturating_sub(dst_col));
797        if rows == 0 || cols == 0 {
798            return false;
799        }
800
801        let frame_cols = self.frame.cols as usize;
802
803        // Copy overflow strings for the source region.
804        let mut overflow_temp: Vec<(usize, String)> = Vec::new();
805        for r in 0..rows as usize {
806            for c in 0..cols as usize {
807                let src_flat = (src_row as usize + r) * frame_cols + src_col as usize + c;
808                if let Some(s) = self.frame.overflow.get(&src_flat) {
809                    let dst_flat = (dst_row as usize + r) * frame_cols + dst_col as usize + c;
810                    overflow_temp.push((dst_flat, s.clone()));
811                }
812            }
813        }
814
815        let mut temp = vec![0u8; rows as usize * cols as usize * CELL_SIZE];
816        for r in 0..rows as usize {
817            let src_off = self.frame.cell_offset(src_row + r as u16, src_col);
818            let src_end = src_off + cols as usize * CELL_SIZE;
819            let dst_off = r * cols as usize * CELL_SIZE;
820            temp[dst_off..dst_off + cols as usize * CELL_SIZE]
821                .copy_from_slice(&self.frame.cells[src_off..src_end]);
822        }
823        for r in 0..rows as usize {
824            let dst_off = self.frame.cell_offset(dst_row + r as u16, dst_col);
825            let dst_end = dst_off + cols as usize * CELL_SIZE;
826            let src_off = r * cols as usize * CELL_SIZE;
827            self.frame.cells[dst_off..dst_end]
828                .copy_from_slice(&temp[src_off..src_off + cols as usize * CELL_SIZE]);
829        }
830
831        for r in 0..rows as usize {
832            for c in 0..cols as usize {
833                let dst_flat = (dst_row as usize + r) * frame_cols + dst_col as usize + c;
834                self.frame.overflow.remove(&dst_flat);
835            }
836        }
837        for (idx, s) in overflow_temp {
838            self.frame.overflow.insert(idx, s);
839        }
840
841        true
842    }
843
844    fn apply_fill_rect(
845        &mut self,
846        row: u16,
847        col: u16,
848        rows: u16,
849        cols: u16,
850        cell: &[u8; CELL_SIZE],
851    ) -> bool {
852        let row_end = row.saturating_add(rows).min(self.frame.rows);
853        let col_end = col.saturating_add(cols).min(self.frame.cols);
854        // Fill cells never have overflow content — clear stale entries.
855        let frame_cols = self.frame.cols as usize;
856        for r in row..row_end {
857            for c in col..col_end {
858                self.frame
859                    .overflow
860                    .remove(&(r as usize * frame_cols + c as usize));
861            }
862        }
863        if row >= row_end || col >= col_end {
864            return false;
865        }
866        for r in row..row_end {
867            for c in col..col_end {
868                let off = self.frame.cell_offset(r, c);
869                self.frame.cells[off..off + CELL_SIZE].copy_from_slice(cell);
870            }
871        }
872        true
873    }
874
875    fn apply_overflow_strings(&mut self, data: &[u8]) -> usize {
876        if data.len() < 2 {
877            return 0;
878        }
879        let count = u16::from_le_bytes([data[0], data[1]]) as usize;
880        let mut off = 2usize;
881        for _ in 0..count {
882            if off + 6 > data.len() {
883                break;
884            }
885            let cell_idx =
886                u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
887                    as usize;
888            let len = u16::from_le_bytes([data[off + 4], data[off + 5]]) as usize;
889            off += 6;
890            if off + len > data.len() {
891                break;
892            }
893            if let Ok(s) = std::str::from_utf8(&data[off..off + len]) {
894                self.frame.overflow.insert(cell_idx, s.to_owned());
895            }
896            off += len;
897        }
898        off
899    }
900}
901
902#[derive(Clone, Debug)]
903pub enum Node {
904    Fill {
905        rect: Rect,
906        ch: char,
907        style: CellStyle,
908    },
909    Text {
910        row: u16,
911        col: u16,
912        text: String,
913        style: CellStyle,
914    },
915    WrappedText {
916        rect: Rect,
917        text: String,
918        style: CellStyle,
919    },
920    ScrollingText {
921        rect: Rect,
922        lines: Vec<String>,
923        offset_from_bottom: usize,
924        style: CellStyle,
925    },
926}
927
928#[derive(Clone, Debug, Default)]
929pub struct Dom {
930    background: CellStyle,
931    title: Option<String>,
932    nodes: Vec<Node>,
933}
934
935impl Dom {
936    pub fn new() -> Self {
937        Self::default()
938    }
939
940    pub fn clear(&mut self) {
941        self.title = None;
942        self.nodes.clear();
943    }
944
945    pub fn set_background(&mut self, style: CellStyle) {
946        self.background = style;
947    }
948
949    pub fn set_title(&mut self, title: impl Into<String>) {
950        self.title = Some(title.into());
951    }
952
953    pub fn fill(&mut self, rect: Rect, ch: char, style: CellStyle) {
954        self.nodes.push(Node::Fill { rect, ch, style });
955    }
956
957    pub fn text(&mut self, row: u16, col: u16, text: impl Into<String>, style: CellStyle) {
958        self.nodes.push(Node::Text {
959            row,
960            col,
961            text: text.into(),
962            style,
963        });
964    }
965
966    pub fn wrapped_text(&mut self, rect: Rect, text: impl Into<String>, style: CellStyle) {
967        self.nodes.push(Node::WrappedText {
968            rect,
969            text: text.into(),
970            style,
971        });
972    }
973
974    pub fn scrolling_text<S, I>(
975        &mut self,
976        rect: Rect,
977        lines: I,
978        offset_from_bottom: usize,
979        style: CellStyle,
980    ) where
981        S: Into<String>,
982        I: IntoIterator<Item = S>,
983    {
984        self.nodes.push(Node::ScrollingText {
985            rect,
986            lines: lines.into_iter().map(Into::into).collect(),
987            offset_from_bottom,
988            style,
989        });
990    }
991
992    pub fn render_to(&self, frame: &mut FrameState) {
993        frame.clear(self.background);
994        frame.set_title(self.title.clone().unwrap_or_default());
995        for node in &self.nodes {
996            match node {
997                Node::Fill { rect, ch, style } => frame.fill_rect(*rect, *ch, *style),
998                Node::Text {
999                    row,
1000                    col,
1001                    text,
1002                    style,
1003                } => {
1004                    frame.write_text(*row, *col, text, *style);
1005                }
1006                Node::WrappedText { rect, text, style } => {
1007                    frame.write_wrapped_text(*rect, text, *style);
1008                }
1009                Node::ScrollingText {
1010                    rect,
1011                    lines,
1012                    offset_from_bottom,
1013                    style,
1014                } => {
1015                    frame.write_scrolling_text(*rect, lines, *offset_from_bottom, *style);
1016                }
1017            }
1018        }
1019    }
1020}
1021
1022#[derive(Clone, Debug)]
1023pub struct CallbackRenderer {
1024    dom: Dom,
1025    frame: FrameState,
1026}
1027
1028impl CallbackRenderer {
1029    pub fn new(rows: u16, cols: u16) -> Self {
1030        Self {
1031            dom: Dom::new(),
1032            frame: FrameState::new(rows, cols),
1033        }
1034    }
1035
1036    pub fn resize(&mut self, rows: u16, cols: u16) {
1037        self.frame.resize(rows, cols);
1038    }
1039
1040    pub fn frame(&self) -> &FrameState {
1041        &self.frame
1042    }
1043
1044    pub fn render<F>(&mut self, render: F) -> &FrameState
1045    where
1046        F: FnOnce(&mut Dom),
1047    {
1048        self.dom.clear();
1049        render(&mut self.dom);
1050        self.dom.render_to(&mut self.frame);
1051        &self.frame
1052    }
1053}
1054
1055pub enum ServerMsg<'a> {
1056    Hello {
1057        version: u16,
1058        features: u32,
1059    },
1060    Update {
1061        pty_id: u16,
1062        payload: &'a [u8],
1063    },
1064    Created {
1065        pty_id: u16,
1066        tag: &'a str,
1067    },
1068    CreatedN {
1069        nonce: u16,
1070        pty_id: u16,
1071        tag: &'a str,
1072    },
1073    Closed {
1074        pty_id: u16,
1075    },
1076    Exited {
1077        pty_id: u16,
1078    },
1079    List {
1080        entries: Vec<PtyListEntry<'a>>,
1081    },
1082    Title {
1083        pty_id: u16,
1084        title: &'a [u8],
1085    },
1086    SearchResults {
1087        request_id: u16,
1088        results: Vec<SearchResultEntry<'a>>,
1089    },
1090}
1091
1092#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1093pub struct PtyListEntry<'a> {
1094    pub pty_id: u16,
1095    pub tag: &'a str,
1096}
1097
1098#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1099pub struct SearchResultEntry<'a> {
1100    pub pty_id: u16,
1101    pub score: u32,
1102    pub primary_source: u8,
1103    pub matched_sources: u8,
1104    pub scroll_offset: Option<u32>,
1105    pub context: &'a [u8],
1106}
1107
1108pub fn parse_server_msg(data: &[u8]) -> Option<ServerMsg<'_>> {
1109    if data.is_empty() {
1110        return None;
1111    }
1112    match data[0] {
1113        S2C_HELLO => {
1114            if data.len() < 7 {
1115                return None;
1116            }
1117            let version = u16::from_le_bytes([data[1], data[2]]);
1118            let features = u32::from_le_bytes([data[3], data[4], data[5], data[6]]);
1119            Some(ServerMsg::Hello { version, features })
1120        }
1121        S2C_UPDATE => {
1122            if data.len() < 3 {
1123                return None;
1124            }
1125            Some(ServerMsg::Update {
1126                pty_id: u16::from_le_bytes([data[1], data[2]]),
1127                payload: &data[3..],
1128            })
1129        }
1130        S2C_CREATED => {
1131            if data.len() < 3 {
1132                return None;
1133            }
1134            let tag = std::str::from_utf8(data.get(3..).unwrap_or_default()).unwrap_or_default();
1135            Some(ServerMsg::Created {
1136                pty_id: u16::from_le_bytes([data[1], data[2]]),
1137                tag,
1138            })
1139        }
1140        S2C_CREATED_N => {
1141            if data.len() < 5 {
1142                return None;
1143            }
1144            let nonce = u16::from_le_bytes([data[1], data[2]]);
1145            let pty_id = u16::from_le_bytes([data[3], data[4]]);
1146            let tag = std::str::from_utf8(data.get(5..).unwrap_or_default()).unwrap_or_default();
1147            Some(ServerMsg::CreatedN { nonce, pty_id, tag })
1148        }
1149        S2C_CLOSED => {
1150            if data.len() < 3 {
1151                return None;
1152            }
1153            Some(ServerMsg::Closed {
1154                pty_id: u16::from_le_bytes([data[1], data[2]]),
1155            })
1156        }
1157        S2C_EXITED => {
1158            if data.len() < 3 {
1159                return None;
1160            }
1161            Some(ServerMsg::Exited {
1162                pty_id: u16::from_le_bytes([data[1], data[2]]),
1163            })
1164        }
1165        S2C_LIST => {
1166            if data.len() < 3 {
1167                return None;
1168            }
1169            let count = u16::from_le_bytes([data[1], data[2]]) as usize;
1170            let mut entries = Vec::with_capacity(count);
1171            let mut offset = 3;
1172            for _ in 0..count {
1173                if offset + 4 > data.len() {
1174                    break;
1175                }
1176                let pty_id = u16::from_le_bytes([data[offset], data[offset + 1]]);
1177                let tag_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
1178                offset += 4;
1179                if offset + tag_len > data.len() {
1180                    break;
1181                }
1182                let tag = std::str::from_utf8(&data[offset..offset + tag_len]).unwrap_or_default();
1183                offset += tag_len;
1184                entries.push(PtyListEntry { pty_id, tag });
1185            }
1186            Some(ServerMsg::List { entries })
1187        }
1188        S2C_TITLE => {
1189            if data.len() < 3 {
1190                return None;
1191            }
1192            Some(ServerMsg::Title {
1193                pty_id: u16::from_le_bytes([data[1], data[2]]),
1194                title: &data[3..],
1195            })
1196        }
1197        S2C_SEARCH_RESULTS => {
1198            if data.len() < 5 {
1199                return None;
1200            }
1201            let request_id = u16::from_le_bytes([data[1], data[2]]);
1202            let count = u16::from_le_bytes([data[3], data[4]]) as usize;
1203            let mut results = Vec::with_capacity(count);
1204            let mut offset = 5usize;
1205            for _ in 0..count {
1206                if offset + 14 > data.len() {
1207                    return None;
1208                }
1209                let pty_id = u16::from_le_bytes([data[offset], data[offset + 1]]);
1210                let score = u32::from_le_bytes([
1211                    data[offset + 2],
1212                    data[offset + 3],
1213                    data[offset + 4],
1214                    data[offset + 5],
1215                ]);
1216                let primary_source = data[offset + 6];
1217                let matched_sources = data[offset + 7];
1218                let scroll_offset = u32::from_le_bytes([
1219                    data[offset + 8],
1220                    data[offset + 9],
1221                    data[offset + 10],
1222                    data[offset + 11],
1223                ]);
1224                let context_len =
1225                    u16::from_le_bytes([data[offset + 12], data[offset + 13]]) as usize;
1226                offset += 14;
1227                if offset + context_len > data.len() {
1228                    return None;
1229                }
1230                results.push(SearchResultEntry {
1231                    pty_id,
1232                    score,
1233                    primary_source,
1234                    matched_sources,
1235                    scroll_offset: if scroll_offset == u32::MAX {
1236                        None
1237                    } else {
1238                        Some(scroll_offset)
1239                    },
1240                    context: &data[offset..offset + context_len],
1241                });
1242                offset += context_len;
1243            }
1244            Some(ServerMsg::SearchResults {
1245                request_id,
1246                results,
1247            })
1248        }
1249        _ => None,
1250    }
1251}
1252
1253pub fn msg_hello(version: u16, features: u32) -> Vec<u8> {
1254    let mut msg = Vec::with_capacity(7);
1255    msg.push(S2C_HELLO);
1256    msg.extend_from_slice(&version.to_le_bytes());
1257    msg.extend_from_slice(&features.to_le_bytes());
1258    msg
1259}
1260
1261pub fn msg_create(rows: u16, cols: u16) -> Vec<u8> {
1262    msg_create_tagged(rows, cols, "")
1263}
1264
1265pub fn msg_create_tagged(rows: u16, cols: u16, tag: &str) -> Vec<u8> {
1266    let tag_bytes = tag.as_bytes();
1267    let tag_len = tag_bytes.len().min(u16::MAX as usize);
1268    let mut msg = Vec::with_capacity(7 + tag_len);
1269    msg.push(C2S_CREATE);
1270    msg.extend_from_slice(&rows.to_le_bytes());
1271    msg.extend_from_slice(&cols.to_le_bytes());
1272    msg.extend_from_slice(&(tag_len as u16).to_le_bytes());
1273    msg.extend_from_slice(&tag_bytes[..tag_len]);
1274    msg
1275}
1276
1277/// Spawn a new PTY in the same working directory as `src_pty_id`.
1278pub fn msg_create_at(rows: u16, cols: u16, tag: &str, src_pty_id: u16) -> Vec<u8> {
1279    let tag_bytes = tag.as_bytes();
1280    let tag_len = tag_bytes.len().min(u16::MAX as usize);
1281    let mut msg = Vec::with_capacity(9 + tag_len);
1282    msg.push(C2S_CREATE_AT);
1283    msg.extend_from_slice(&rows.to_le_bytes());
1284    msg.extend_from_slice(&cols.to_le_bytes());
1285    msg.extend_from_slice(&(tag_len as u16).to_le_bytes());
1286    msg.extend_from_slice(&tag_bytes[..tag_len]);
1287    msg.extend_from_slice(&src_pty_id.to_le_bytes());
1288    msg
1289}
1290
1291pub fn msg_create_n(nonce: u16, rows: u16, cols: u16, tag: &str) -> Vec<u8> {
1292    let tag_bytes = tag.as_bytes();
1293    let tag_len = tag_bytes.len().min(u16::MAX as usize);
1294    let mut msg = Vec::with_capacity(9 + tag_len);
1295    msg.push(C2S_CREATE_N);
1296    msg.extend_from_slice(&nonce.to_le_bytes());
1297    msg.extend_from_slice(&rows.to_le_bytes());
1298    msg.extend_from_slice(&cols.to_le_bytes());
1299    msg.extend_from_slice(&(tag_len as u16).to_le_bytes());
1300    msg.extend_from_slice(&tag_bytes[..tag_len]);
1301    msg
1302}
1303
1304pub fn msg_create_n_command(nonce: u16, rows: u16, cols: u16, tag: &str, command: &str) -> Vec<u8> {
1305    let mut msg = msg_create_n(nonce, rows, cols, tag);
1306    msg.extend_from_slice(command.as_bytes());
1307    msg
1308}
1309
1310pub fn msg_create_command(rows: u16, cols: u16, command: &str) -> Vec<u8> {
1311    msg_create_tagged_command(rows, cols, "", command)
1312}
1313
1314pub fn msg_create_tagged_command(rows: u16, cols: u16, tag: &str, command: &str) -> Vec<u8> {
1315    let mut msg = msg_create_tagged(rows, cols, tag);
1316    msg.extend_from_slice(command.as_bytes());
1317    msg
1318}
1319
1320pub fn msg_input(pty_id: u16, data: &[u8]) -> Vec<u8> {
1321    let mut msg = Vec::with_capacity(3 + data.len());
1322    msg.push(C2S_INPUT);
1323    msg.extend_from_slice(&pty_id.to_le_bytes());
1324    msg.extend_from_slice(data);
1325    msg
1326}
1327
1328pub fn msg_resize(pty_id: u16, rows: u16, cols: u16) -> Vec<u8> {
1329    let mut msg = Vec::with_capacity(7);
1330    msg.push(C2S_RESIZE);
1331    msg.extend_from_slice(&pty_id.to_le_bytes());
1332    msg.extend_from_slice(&rows.to_le_bytes());
1333    msg.extend_from_slice(&cols.to_le_bytes());
1334    msg
1335}
1336
1337pub fn msg_resize_batch(entries: &[(u16, u16, u16)]) -> Vec<u8> {
1338    let mut msg = Vec::with_capacity(1 + entries.len() * 6);
1339    msg.push(C2S_RESIZE);
1340    for &(pty_id, rows, cols) in entries {
1341        msg.extend_from_slice(&pty_id.to_le_bytes());
1342        msg.extend_from_slice(&rows.to_le_bytes());
1343        msg.extend_from_slice(&cols.to_le_bytes());
1344    }
1345    msg
1346}
1347
1348pub fn msg_focus(pty_id: u16) -> Vec<u8> {
1349    let mut msg = Vec::with_capacity(3);
1350    msg.push(C2S_FOCUS);
1351    msg.extend_from_slice(&pty_id.to_le_bytes());
1352    msg
1353}
1354
1355pub fn msg_close(pty_id: u16) -> Vec<u8> {
1356    let mut msg = Vec::with_capacity(3);
1357    msg.push(C2S_CLOSE);
1358    msg.extend_from_slice(&pty_id.to_le_bytes());
1359    msg
1360}
1361
1362pub fn msg_subscribe(pty_id: u16) -> Vec<u8> {
1363    let mut msg = Vec::with_capacity(3);
1364    msg.push(C2S_SUBSCRIBE);
1365    msg.extend_from_slice(&pty_id.to_le_bytes());
1366    msg
1367}
1368
1369pub fn msg_unsubscribe(pty_id: u16) -> Vec<u8> {
1370    let mut msg = Vec::with_capacity(3);
1371    msg.push(C2S_UNSUBSCRIBE);
1372    msg.extend_from_slice(&pty_id.to_le_bytes());
1373    msg
1374}
1375
1376pub fn msg_search(request_id: u16, query: &str) -> Vec<u8> {
1377    let query = query.as_bytes();
1378    let mut msg = Vec::with_capacity(3 + query.len());
1379    msg.push(C2S_SEARCH);
1380    msg.extend_from_slice(&request_id.to_le_bytes());
1381    msg.extend_from_slice(query);
1382    msg
1383}
1384
1385pub fn msg_ack() -> Vec<u8> {
1386    vec![C2S_ACK]
1387}
1388
1389pub fn msg_scroll(pty_id: u16, offset: u32) -> Vec<u8> {
1390    let mut msg = Vec::with_capacity(7);
1391    msg.push(C2S_SCROLL);
1392    msg.extend_from_slice(&pty_id.to_le_bytes());
1393    msg.extend_from_slice(&offset.to_le_bytes());
1394    msg
1395}
1396
1397pub fn msg_display_rate(fps: u16) -> Vec<u8> {
1398    let mut msg = Vec::with_capacity(3);
1399    msg.push(C2S_DISPLAY_RATE);
1400    msg.extend_from_slice(&fps.to_le_bytes());
1401    msg
1402}
1403
1404pub fn msg_client_metrics(backlog: u16, ack_ahead: u16, apply_ms_x10: u16) -> Vec<u8> {
1405    let mut msg = Vec::with_capacity(7);
1406    msg.push(C2S_CLIENT_METRICS);
1407    msg.extend_from_slice(&backlog.to_le_bytes());
1408    msg.extend_from_slice(&ack_ahead.to_le_bytes());
1409    msg.extend_from_slice(&apply_ms_x10.to_le_bytes());
1410    msg
1411}
1412
1413const MODE_ALT_SCREEN: u16 = 1 << 11;
1414
1415fn mode_is_cooked(mode: u16) -> bool {
1416    mode & MODE_ECHO != 0 && mode & MODE_ICANON != 0 && mode & MODE_ALT_SCREEN == 0
1417}
1418
1419pub fn build_update_msg(
1420    pty_id: u16,
1421    current: &FrameState,
1422    previous: &FrameState,
1423) -> Option<Vec<u8>> {
1424    let title_changed = current.title != previous.title;
1425    let same_size = previous.rows == current.rows
1426        && previous.cols == current.cols
1427        && previous.cells.len() == current.cells.len();
1428
1429    // Try scroll-aware ops when dimensions match and content differs.
1430    let mut ops = Vec::new();
1431    let mut op_count = 0u16;
1432
1433    // Scroll-aware ops apply when content is "cooked" (shell output) or when
1434    // either frame has mode 0 (scrollback frames use mode=0, and their content
1435    // is always static text that benefits from COPY_RECT).
1436    let scroll_eligible = (mode_is_cooked(current.mode) && mode_is_cooked(previous.mode))
1437        || current.mode == 0
1438        || previous.mode == 0;
1439    if ENABLE_SCROLL_OPS && same_size && previous.cells != current.cells && scroll_eligible {
1440        if let Some(delta_rows) = detect_vertical_scroll(current, previous) {
1441            let mut basis = previous.clone();
1442            encode_copy_rect_op(&mut ops, current, delta_rows);
1443            apply_vertical_scroll_copy(&mut basis, delta_rows);
1444            op_count += 1;
1445            append_full_width_fill_ops(current, &mut basis, &mut ops, &mut op_count);
1446            if let Some(patch_op) = build_patch_op(current, &basis) {
1447                ops.extend_from_slice(&patch_op);
1448                op_count += 1;
1449            }
1450        }
1451    }
1452
1453    // Fallback: bare PATCH_CELLS against previous (or a blank frame on resize).
1454    if op_count == 0 {
1455        let basis = if same_size {
1456            previous
1457        } else {
1458            &FrameState::new(current.rows, current.cols)
1459        };
1460        if let Some(patch_op) = build_patch_op(current, basis) {
1461            ops = patch_op;
1462            op_count = 1;
1463        }
1464    }
1465
1466    if op_count == 0 {
1467        // No cell changes — still emit a frame if cursor/mode/title changed.
1468        if !title_changed
1469            && current.cursor_row == previous.cursor_row
1470            && current.cursor_col == previous.cursor_col
1471            && current.mode == previous.mode
1472        {
1473            return None;
1474        }
1475    }
1476
1477    // Collect overflow strings that need to be transmitted.
1478    // We send all overflow entries from the current frame that correspond
1479    // to cells that changed (are in the dirty set).  For a resize (not
1480    // same_size), all cells are "dirty", so we send all overflow entries.
1481    let has_overflow = !current.overflow.is_empty();
1482    let overflow_section = if has_overflow {
1483        serialize_overflow_strings(current)
1484    } else {
1485        Vec::new()
1486    };
1487
1488    let line_flags_changed =
1489        current.line_flags != previous.line_flags || current.rows != previous.rows;
1490    let has_line_flags = line_flags_changed && !current.line_flags.iter().all(|&f| f == 0);
1491
1492    let title_bytes = if title_changed {
1493        current.title.as_bytes()
1494    } else {
1495        &[]
1496    };
1497    let title_len = title_bytes.len().min(TITLE_LEN_MASK as usize);
1498    let title_field = OPS_PRESENT
1499        | if has_overflow { STRINGS_PRESENT } else { 0 }
1500        | if has_line_flags {
1501            LINE_FLAGS_PRESENT
1502        } else {
1503            0
1504        }
1505        | if title_changed {
1506            TITLE_PRESENT | title_len as u16
1507        } else {
1508            0
1509        };
1510
1511    let mut payload = Vec::with_capacity(
1512        12 + title_len
1513            + 2
1514            + ops.len()
1515            + overflow_section.len()
1516            + if has_line_flags {
1517                current.rows as usize
1518            } else {
1519                0
1520            }
1521            + 4,
1522    );
1523    payload.extend_from_slice(&current.rows.to_le_bytes());
1524    payload.extend_from_slice(&current.cols.to_le_bytes());
1525    payload.extend_from_slice(&current.cursor_row.to_le_bytes());
1526    payload.extend_from_slice(&current.cursor_col.to_le_bytes());
1527    payload.extend_from_slice(&current.mode.to_le_bytes());
1528    payload.extend_from_slice(&title_field.to_le_bytes());
1529    if title_changed {
1530        payload.extend_from_slice(&title_bytes[..title_len]);
1531    }
1532    payload.extend_from_slice(&op_count.to_le_bytes());
1533    payload.extend_from_slice(&ops);
1534    payload.extend_from_slice(&overflow_section);
1535    if has_line_flags {
1536        payload.extend_from_slice(&current.line_flags);
1537    }
1538    // Trailing scrollback count — old clients ignore extra bytes.
1539    payload.extend_from_slice(&current.scrollback_lines.to_le_bytes());
1540
1541    let compressed = compress_prepend_size(&payload);
1542    let mut msg = Vec::with_capacity(3 + compressed.len());
1543    msg.push(S2C_UPDATE);
1544    msg.extend_from_slice(&pty_id.to_le_bytes());
1545    msg.extend_from_slice(&compressed);
1546    Some(msg)
1547}
1548
1549/// Serialize overflow strings: [u16 count] [for each: u32 cell_index, u16 len, utf8 bytes]
1550fn serialize_overflow_strings(frame: &FrameState) -> Vec<u8> {
1551    let count = frame.overflow.len().min(u16::MAX as usize);
1552    let mut out = Vec::new();
1553    out.extend_from_slice(&(count as u16).to_le_bytes());
1554    for (&cell_idx, s) in frame.overflow.iter().take(count) {
1555        let bytes = s.as_bytes();
1556        let len = bytes.len().min(u16::MAX as usize);
1557        out.extend_from_slice(&(cell_idx as u32).to_le_bytes());
1558        out.extend_from_slice(&(len as u16).to_le_bytes());
1559        out.extend_from_slice(&bytes[..len]);
1560    }
1561    out
1562}
1563
1564fn build_patch_op(current: &FrameState, previous: &FrameState) -> Option<Vec<u8>> {
1565    let total_cells = current.rows as usize * current.cols as usize;
1566    let bitmask_len = total_cells.div_ceil(8);
1567    let mut bitmask = vec![0u8; bitmask_len];
1568    let mut dirty_count = 0usize;
1569    for i in 0..total_cells {
1570        let off = i * CELL_SIZE;
1571        if current.cells[off..off + CELL_SIZE] != previous.cells[off..off + CELL_SIZE] {
1572            bitmask[i / 8] |= 1 << (i % 8);
1573            dirty_count += 1;
1574        }
1575    }
1576    if dirty_count == 0 {
1577        return None;
1578    }
1579
1580    let mut op = Vec::with_capacity(1 + bitmask_len + dirty_count * CELL_SIZE);
1581    op.push(OP_PATCH_CELLS);
1582    op.extend_from_slice(&bitmask);
1583    for byte_pos in 0..CELL_SIZE {
1584        for i in 0..total_cells {
1585            if bitmask[i / 8] & (1 << (i % 8)) != 0 {
1586                op.push(current.cells[i * CELL_SIZE + byte_pos]);
1587            }
1588        }
1589    }
1590    Some(op)
1591}
1592
1593fn detect_vertical_scroll(current: &FrameState, previous: &FrameState) -> Option<i16> {
1594    let rows = current.rows as usize;
1595    let cols = current.cols as usize;
1596    if rows < 4 || cols == 0 {
1597        return None;
1598    }
1599    let row_bytes = cols * CELL_SIZE;
1600    let max_delta = rows.saturating_sub(1).min(8);
1601    let mut best: Option<(usize, i16)> = None;
1602
1603    for delta in 1..=max_delta {
1604        let overlap = rows - delta;
1605        if overlap < 3 {
1606            continue;
1607        }
1608        for signed_delta in [-(delta as i16), delta as i16] {
1609            let mut matched = 0usize;
1610            for row in 0..rows {
1611                let src_row = row as i32 - signed_delta as i32;
1612                if src_row < 0 || src_row >= rows as i32 {
1613                    continue;
1614                }
1615                let cur_off = row * row_bytes;
1616                let prev_off = src_row as usize * row_bytes;
1617                if current.cells[cur_off..cur_off + row_bytes]
1618                    == previous.cells[prev_off..prev_off + row_bytes]
1619                {
1620                    matched += 1;
1621                }
1622            }
1623            if matched * 5 < overlap * 4 {
1624                continue;
1625            }
1626            let replace = match best {
1627                None => true,
1628                Some((best_matched, best_delta)) => {
1629                    matched > best_matched
1630                        || (matched == best_matched
1631                            && signed_delta.unsigned_abs() < best_delta.unsigned_abs())
1632                }
1633            };
1634            if replace {
1635                best = Some((matched, signed_delta));
1636            }
1637        }
1638    }
1639
1640    best.map(|(_, delta)| delta)
1641}
1642
1643fn encode_copy_rect_op(out: &mut Vec<u8>, current: &FrameState, delta_rows: i16) {
1644    let rows = current.rows;
1645    let cols = current.cols;
1646    let delta = delta_rows.unsigned_abs();
1647    let (src_row, dst_row, copy_rows) = if delta_rows > 0 {
1648        (0, delta, rows.saturating_sub(delta))
1649    } else {
1650        (delta, 0, rows.saturating_sub(delta))
1651    };
1652    out.push(OP_COPY_RECT);
1653    out.extend_from_slice(&src_row.to_le_bytes());
1654    out.extend_from_slice(&0u16.to_le_bytes());
1655    out.extend_from_slice(&dst_row.to_le_bytes());
1656    out.extend_from_slice(&0u16.to_le_bytes());
1657    out.extend_from_slice(&copy_rows.to_le_bytes());
1658    out.extend_from_slice(&cols.to_le_bytes());
1659}
1660
1661fn apply_vertical_scroll_copy(frame: &mut FrameState, delta_rows: i16) {
1662    let delta = delta_rows.unsigned_abs();
1663    if delta == 0 || delta >= frame.rows {
1664        return;
1665    }
1666    let (src_row, dst_row, rows) = if delta_rows > 0 {
1667        (0, delta, frame.rows - delta)
1668    } else {
1669        (delta, 0, frame.rows - delta)
1670    };
1671    apply_copy_rect_frame(frame, src_row, 0, dst_row, 0, rows, frame.cols);
1672}
1673
1674fn apply_copy_rect_frame(
1675    frame: &mut FrameState,
1676    src_row: u16,
1677    src_col: u16,
1678    dst_row: u16,
1679    dst_col: u16,
1680    rows: u16,
1681    cols: u16,
1682) {
1683    let rows = rows
1684        .min(frame.rows.saturating_sub(src_row))
1685        .min(frame.rows.saturating_sub(dst_row));
1686    let cols = cols
1687        .min(frame.cols.saturating_sub(src_col))
1688        .min(frame.cols.saturating_sub(dst_col));
1689    if rows == 0 || cols == 0 {
1690        return;
1691    }
1692    let mut temp = vec![0u8; rows as usize * cols as usize * CELL_SIZE];
1693    for r in 0..rows as usize {
1694        let src_off = frame.cell_offset(src_row + r as u16, src_col);
1695        let src_end = src_off + cols as usize * CELL_SIZE;
1696        let dst_off = r * cols as usize * CELL_SIZE;
1697        temp[dst_off..dst_off + cols as usize * CELL_SIZE]
1698            .copy_from_slice(&frame.cells[src_off..src_end]);
1699    }
1700    for r in 0..rows as usize {
1701        let dst_off = frame.cell_offset(dst_row + r as u16, dst_col);
1702        let dst_end = dst_off + cols as usize * CELL_SIZE;
1703        let src_off = r * cols as usize * CELL_SIZE;
1704        frame.cells[dst_off..dst_end]
1705            .copy_from_slice(&temp[src_off..src_off + cols as usize * CELL_SIZE]);
1706    }
1707}
1708
1709fn append_full_width_fill_ops(
1710    current: &FrameState,
1711    basis: &mut FrameState,
1712    out: &mut Vec<u8>,
1713    op_count: &mut u16,
1714) {
1715    let rows = current.rows as usize;
1716    let cols = current.cols as usize;
1717    if rows == 0 || cols == 0 {
1718        return;
1719    }
1720
1721    let row_bytes = cols * CELL_SIZE;
1722    let mut row = 0usize;
1723    while row < rows {
1724        let row_off = row * row_bytes;
1725        if current.cells[row_off..row_off + row_bytes] == basis.cells[row_off..row_off + row_bytes]
1726        {
1727            row += 1;
1728            continue;
1729        }
1730        let Some(cell) = uniform_row_cell(current, row) else {
1731            row += 1;
1732            continue;
1733        };
1734        let mut end = row + 1;
1735        while end < rows {
1736            if uniform_row_cell(current, end).as_ref() != Some(&cell) {
1737                break;
1738            }
1739            end += 1;
1740        }
1741
1742        if *op_count == u16::MAX {
1743            break;
1744        }
1745        out.push(OP_FILL_RECT);
1746        out.extend_from_slice(&(row as u16).to_le_bytes());
1747        out.extend_from_slice(&0u16.to_le_bytes());
1748        out.extend_from_slice(&((end - row) as u16).to_le_bytes());
1749        out.extend_from_slice(&current.cols.to_le_bytes());
1750        out.extend_from_slice(&cell);
1751        *op_count = op_count.saturating_add(1);
1752
1753        for r in row..end {
1754            let row_off = basis.cell_offset(r as u16, 0);
1755            for c in 0..cols {
1756                let off = row_off + c * CELL_SIZE;
1757                basis.cells[off..off + CELL_SIZE].copy_from_slice(&cell);
1758            }
1759        }
1760
1761        row = end;
1762    }
1763}
1764
1765fn uniform_row_cell(frame: &FrameState, row: usize) -> Option<[u8; CELL_SIZE]> {
1766    let cols = frame.cols as usize;
1767    if row >= frame.rows as usize || cols == 0 {
1768        return None;
1769    }
1770    let start = row * cols * CELL_SIZE;
1771    let mut first = [0u8; CELL_SIZE];
1772    first.copy_from_slice(&frame.cells[start..start + CELL_SIZE]);
1773    if first[1] & 0b110 != 0 {
1774        return None;
1775    }
1776    for col in 1..cols {
1777        let off = start + col * CELL_SIZE;
1778        if frame.cells[off..off + CELL_SIZE] != first {
1779            return None;
1780        }
1781    }
1782    Some(first)
1783}
1784
1785fn encode_cell(dst: &mut [u8], ch: Option<char>, style: CellStyle, wide: bool, wide_cont: bool) {
1786    dst.fill(0);
1787
1788    let mut f0 = 0u8;
1789    encode_color(style.fg, &mut f0, &mut dst[2..5], false);
1790    encode_color(style.bg, &mut f0, &mut dst[5..8], true);
1791    if style.bold {
1792        f0 |= 1 << 4;
1793    }
1794    if style.dim {
1795        f0 |= 1 << 5;
1796    }
1797    if style.italic {
1798        f0 |= 1 << 6;
1799    }
1800    if style.underline {
1801        f0 |= 1 << 7;
1802    }
1803    dst[0] = f0;
1804
1805    let mut f1 = 0u8;
1806    if style.inverse {
1807        f1 |= 1;
1808    }
1809    if wide {
1810        f1 |= 1 << 1;
1811    }
1812    if wide_cont {
1813        f1 |= 1 << 2;
1814    }
1815    if let Some(ch) = ch {
1816        let mut buf = [0u8; 4];
1817        let encoded = ch.encode_utf8(&mut buf).as_bytes();
1818        let len = encoded.len().min(4);
1819        dst[8..8 + len].copy_from_slice(&encoded[..len]);
1820        f1 |= (len as u8) << 3;
1821    }
1822    dst[1] = f1;
1823}
1824
1825fn encode_color(color: Color, flags: &mut u8, dst: &mut [u8], is_bg: bool) {
1826    let shift = if is_bg { 2 } else { 0 };
1827    match color {
1828        Color::Default => {}
1829        Color::Indexed(idx) => {
1830            *flags |= 1 << shift;
1831            dst[0] = idx;
1832        }
1833        Color::Rgb(r, g, b) => {
1834            *flags |= 2 << shift;
1835            dst[0] = r;
1836            dst[1] = g;
1837            dst[2] = b;
1838        }
1839    }
1840}
1841
1842fn wrap_text_lines(text: &str, width: usize) -> Vec<String> {
1843    if width == 0 {
1844        return Vec::new();
1845    }
1846    let mut out = Vec::new();
1847    for paragraph in text.split('\n') {
1848        if paragraph.is_empty() {
1849            out.push(String::new());
1850            continue;
1851        }
1852        let mut line = String::new();
1853        let mut line_width = 0usize;
1854        for word in paragraph.split_whitespace() {
1855            push_wrapped_word(word, width, &mut out, &mut line, &mut line_width);
1856        }
1857        if !line.is_empty() {
1858            out.push(line);
1859        }
1860    }
1861    if out.is_empty() {
1862        out.push(String::new());
1863    }
1864    out
1865}
1866
1867fn push_wrapped_word(
1868    word: &str,
1869    width: usize,
1870    out: &mut Vec<String>,
1871    line: &mut String,
1872    line_width: &mut usize,
1873) {
1874    let word_width = UnicodeWidthStr::width(word);
1875    if line.is_empty() {
1876        if word_width <= width {
1877            line.push_str(word);
1878            *line_width = word_width;
1879            return;
1880        }
1881    } else if *line_width + 1 + word_width <= width {
1882        line.push(' ');
1883        line.push_str(word);
1884        *line_width += 1 + word_width;
1885        return;
1886    } else {
1887        out.push(std::mem::take(line));
1888        *line_width = 0;
1889        if word_width <= width {
1890            line.push_str(word);
1891            *line_width = word_width;
1892            return;
1893        }
1894    }
1895
1896    for ch in word.chars() {
1897        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(1).max(1);
1898        if *line_width + ch_width > width && !line.is_empty() {
1899            out.push(std::mem::take(line));
1900            *line_width = 0;
1901        }
1902        line.push(ch);
1903        *line_width += ch_width;
1904    }
1905}
1906
1907#[cfg(test)]
1908mod tests {
1909    use super::*;
1910
1911    #[test]
1912    fn update_round_trip_preserves_title_and_cells() {
1913        let style = CellStyle::default();
1914        let mut prev = FrameState::new(2, 8);
1915        prev.set_title("one");
1916        prev.write_text(0, 0, "hello", style);
1917
1918        let mut next = prev.clone();
1919        next.set_title("two");
1920        next.write_text(1, 0, "world", style);
1921
1922        let baseline = build_update_msg(7, &prev, &FrameState::default()).unwrap();
1923        let delta = build_update_msg(7, &next, &prev).unwrap();
1924
1925        let mut term = TerminalState::new(2, 8);
1926        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
1927            panic!("expected update");
1928        };
1929        assert!(term.feed_compressed(payload));
1930        assert_eq!(term.title(), "one");
1931
1932        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
1933            panic!("expected update");
1934        };
1935        assert!(term.feed_compressed(payload));
1936        assert_eq!(term.title(), "two");
1937        assert_eq!(term.get_all_text(), "hello\nworld");
1938    }
1939
1940    #[test]
1941    fn title_can_be_cleared_via_update() {
1942        let style = CellStyle::default();
1943        let mut prev = FrameState::new(1, 4);
1944        prev.set_title("busy");
1945        prev.write_text(0, 0, "ping", style);
1946
1947        let mut next = prev.clone();
1948        next.set_title("");
1949
1950        let baseline = build_update_msg(1, &prev, &FrameState::default()).unwrap();
1951        let delta = build_update_msg(1, &next, &prev).unwrap();
1952
1953        let mut term = TerminalState::new(1, 4);
1954        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
1955            panic!("expected update");
1956        };
1957        term.feed_compressed(payload);
1958        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
1959            panic!("expected update");
1960        };
1961        term.feed_compressed(payload);
1962        assert_eq!(term.title(), "");
1963    }
1964
1965    #[test]
1966    fn scroll_heavy_update_can_use_ops_payload() {
1967        let style = CellStyle::default();
1968        let mut prev = FrameState::new(5, 6);
1969        prev.write_text(0, 0, "one", style);
1970        prev.write_text(1, 0, "two", style);
1971        prev.write_text(2, 0, "three", style);
1972        prev.write_text(3, 0, "four", style);
1973        prev.write_text(4, 0, "five", style);
1974
1975        let mut next = FrameState::new(5, 6);
1976        next.write_text(0, 0, "two", style);
1977        next.write_text(1, 0, "three", style);
1978        next.write_text(2, 0, "four", style);
1979        next.write_text(3, 0, "five", style);
1980
1981        let delta = build_update_msg(9, &next, &prev).unwrap();
1982        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
1983            panic!("expected update");
1984        };
1985        let decoded = decompress_size_prepended(payload).unwrap();
1986        let title_field = u16::from_le_bytes([decoded[10], decoded[11]]);
1987        assert_ne!(title_field & OPS_PRESENT, 0);
1988
1989        let mut term = TerminalState::new(5, 6);
1990        let baseline = build_update_msg(9, &prev, &FrameState::default()).unwrap();
1991        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
1992            panic!("expected update");
1993        };
1994        assert!(term.feed_compressed(payload));
1995        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
1996            panic!("expected update");
1997        };
1998        assert!(term.feed_compressed(payload));
1999        assert_eq!(term.get_all_text(), "two\nthree\nfour\nfive\n");
2000    }
2001
2002    #[test]
2003    fn cooked_scroll_heavy_update_uses_copy_rect_op() {
2004        let style = CellStyle::default();
2005        let mut prev = FrameState::new(5, 6);
2006        prev.set_mode(MODE_ECHO | MODE_ICANON);
2007        prev.write_text(0, 0, "one", style);
2008        prev.write_text(1, 0, "two", style);
2009        prev.write_text(2, 0, "three", style);
2010        prev.write_text(3, 0, "four", style);
2011        prev.write_text(4, 0, "five", style);
2012
2013        let mut next = FrameState::new(5, 6);
2014        next.set_mode(MODE_ECHO | MODE_ICANON);
2015        next.write_text(0, 0, "two", style);
2016        next.write_text(1, 0, "three", style);
2017        next.write_text(2, 0, "four", style);
2018        next.write_text(3, 0, "five", style);
2019
2020        let delta = build_update_msg(9, &next, &prev).unwrap();
2021        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2022            panic!("expected update");
2023        };
2024        let decoded = decompress_size_prepended(payload).unwrap();
2025        let op_count = u16::from_le_bytes([decoded[12], decoded[13]]);
2026        assert!(op_count >= 1);
2027        assert_eq!(decoded[14], OP_COPY_RECT);
2028    }
2029
2030    #[test]
2031    fn mode_zero_scroll_uses_copy_rect() {
2032        let style = CellStyle::default();
2033        let mut prev = FrameState::new(5, 6);
2034        prev.write_text(0, 0, "one", style);
2035        prev.write_text(1, 0, "two", style);
2036        prev.write_text(2, 0, "three", style);
2037        prev.write_text(3, 0, "four", style);
2038        prev.write_text(4, 0, "five", style);
2039
2040        let mut next = FrameState::new(5, 6);
2041        next.write_text(0, 0, "two", style);
2042        next.write_text(1, 0, "three", style);
2043        next.write_text(2, 0, "four", style);
2044        next.write_text(3, 0, "five", style);
2045
2046        let delta = build_update_msg(9, &next, &prev).unwrap();
2047        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2048            panic!("expected update");
2049        };
2050        let decoded = decompress_size_prepended(payload).unwrap();
2051        let op_count = u16::from_le_bytes([decoded[12], decoded[13]]);
2052        assert!(op_count >= 1);
2053        // mode=0 frames (scrollback) now use COPY_RECT for efficient scrolling
2054        assert_eq!(decoded[14], OP_COPY_RECT);
2055
2056        // Verify round-trip correctness
2057        let baseline = build_update_msg(9, &prev, &FrameState::new(5, 6)).unwrap();
2058        let mut state = TerminalState::new(5, 6);
2059        let ServerMsg::Update { payload: bp, .. } = parse_server_msg(&baseline).unwrap() else {
2060            panic!("expected update");
2061        };
2062        state.feed_compressed(bp);
2063        state.feed_compressed(payload);
2064        assert_eq!(state.frame().cells(), next.cells());
2065    }
2066
2067    #[test]
2068    fn callback_renderer_wraps_text() {
2069        let mut renderer = CallbackRenderer::new(2, 8);
2070        renderer.render(|dom| {
2071            dom.wrapped_text(
2072                Rect::new(0, 0, 2, 8),
2073                "alpha beta gamma",
2074                CellStyle::default(),
2075            );
2076        });
2077        assert_eq!(renderer.frame().get_all_text(), "alpha\nbeta");
2078    }
2079
2080    #[test]
2081    fn scrolling_text_shows_tail() {
2082        let mut frame = FrameState::new(3, 8);
2083        frame.write_scrolling_text(
2084            Rect::new(0, 0, 3, 8),
2085            &["one", "two", "three", "four"],
2086            0,
2087            CellStyle::default(),
2088        );
2089        assert_eq!(frame.get_all_text(), "two\nthree\nfour");
2090    }
2091
2092    #[test]
2093    fn search_results_round_trip_with_context() {
2094        let msg = [
2095            vec![S2C_SEARCH_RESULTS],
2096            7u16.to_le_bytes().to_vec(),
2097            1u16.to_le_bytes().to_vec(),
2098            42u16.to_le_bytes().to_vec(),
2099            1234u32.to_le_bytes().to_vec(),
2100            vec![1, 0b111],
2101            9u32.to_le_bytes().to_vec(),
2102            5u16.to_le_bytes().to_vec(),
2103            b"hello".to_vec(),
2104        ]
2105        .concat();
2106
2107        let ServerMsg::SearchResults {
2108            request_id,
2109            results,
2110        } = parse_server_msg(&msg).unwrap()
2111        else {
2112            panic!("expected search results");
2113        };
2114        assert_eq!(request_id, 7);
2115        assert_eq!(results.len(), 1);
2116        assert_eq!(results[0].pty_id, 42);
2117        assert_eq!(results[0].score, 1234);
2118        assert_eq!(results[0].primary_source, 1);
2119        assert_eq!(results[0].matched_sources, 0b111);
2120        assert_eq!(results[0].scroll_offset, Some(9));
2121        assert_eq!(results[0].context, b"hello");
2122    }
2123
2124    // --- Tag tests ---
2125
2126    #[test]
2127    fn msg_create_no_tag_has_zero_tag_len() {
2128        let msg = msg_create(24, 80);
2129        assert_eq!(msg.len(), 7);
2130        assert_eq!(msg[0], C2S_CREATE);
2131        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 24);
2132        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 80);
2133        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 0);
2134    }
2135
2136    #[test]
2137    fn msg_create_tagged_encodes_tag() {
2138        let msg = msg_create_tagged(24, 80, "my-pty");
2139        assert_eq!(msg[0], C2S_CREATE);
2140        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2141        assert_eq!(tag_len, 6);
2142        assert_eq!(&msg[7..7 + tag_len], b"my-pty");
2143        assert_eq!(msg.len(), 7 + tag_len);
2144    }
2145
2146    #[test]
2147    fn msg_create_tagged_command_encodes_both() {
2148        let msg = msg_create_tagged_command(30, 120, "editor", "vim");
2149        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2150        assert_eq!(tag_len, 6);
2151        assert_eq!(&msg[7..13], b"editor");
2152        assert_eq!(&msg[13..], b"vim");
2153    }
2154
2155    #[test]
2156    fn msg_create_command_has_empty_tag() {
2157        let msg = msg_create_command(24, 80, "ls");
2158        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2159        assert_eq!(tag_len, 0);
2160        assert_eq!(&msg[7..], b"ls");
2161    }
2162
2163    #[test]
2164    fn msg_create_tagged_empty_tag() {
2165        let msg = msg_create_tagged(24, 80, "");
2166        assert_eq!(msg.len(), 7);
2167        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 0);
2168    }
2169
2170    #[test]
2171    fn msg_create_tagged_unicode_tag() {
2172        let msg = msg_create_tagged(24, 80, "日本語");
2173        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2174        assert_eq!(tag_len, "日本語".len());
2175        assert_eq!(std::str::from_utf8(&msg[7..7 + tag_len]).unwrap(), "日本語");
2176    }
2177
2178    #[test]
2179    fn parse_created_with_tag() {
2180        let mut wire = vec![S2C_CREATED, 0x05, 0x00];
2181        wire.extend_from_slice(b"hello");
2182        let msg = parse_server_msg(&wire).unwrap();
2183        match msg {
2184            ServerMsg::Created { pty_id, tag } => {
2185                assert_eq!(pty_id, 5);
2186                assert_eq!(tag, "hello");
2187            }
2188            _ => panic!("expected Created"),
2189        }
2190    }
2191
2192    #[test]
2193    fn parse_created_without_tag() {
2194        let wire = vec![S2C_CREATED, 0x03, 0x00];
2195        let msg = parse_server_msg(&wire).unwrap();
2196        match msg {
2197            ServerMsg::Created { pty_id, tag } => {
2198                assert_eq!(pty_id, 3);
2199                assert_eq!(tag, "");
2200            }
2201            _ => panic!("expected Created"),
2202        }
2203    }
2204
2205    #[test]
2206    fn parse_created_n_with_tag() {
2207        let mut wire = vec![S2C_CREATED_N, 0x2a, 0x00, 0x05, 0x00];
2208        wire.extend_from_slice(b"hello");
2209        let msg = parse_server_msg(&wire).unwrap();
2210        match msg {
2211            ServerMsg::CreatedN { nonce, pty_id, tag } => {
2212                assert_eq!(nonce, 42);
2213                assert_eq!(pty_id, 5);
2214                assert_eq!(tag, "hello");
2215            }
2216            _ => panic!("expected CreatedN"),
2217        }
2218    }
2219
2220    #[test]
2221    fn msg_create_n_format() {
2222        let msg = msg_create_n(42, 24, 80, "test");
2223        assert_eq!(msg[0], C2S_CREATE_N);
2224        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
2225        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
2226        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
2227        assert_eq!(u16::from_le_bytes([msg[7], msg[8]]), 4);
2228        assert_eq!(&msg[9..], b"test");
2229    }
2230
2231    #[test]
2232    fn msg_create_n_command_format() {
2233        let msg = msg_create_n_command(7, 30, 120, "bg", "make build");
2234        assert_eq!(msg[0], C2S_CREATE_N);
2235        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 7);
2236        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 30);
2237        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 120);
2238        let tag_len = u16::from_le_bytes([msg[7], msg[8]]) as usize;
2239        assert_eq!(tag_len, 2);
2240        assert_eq!(&msg[9..9 + tag_len], b"bg");
2241        assert_eq!(&msg[9 + tag_len..], b"make build");
2242    }
2243
2244    #[test]
2245    fn parse_list_with_tags() {
2246        // 2 entries: id=1 tag="ab", id=2 tag=""
2247        let mut wire = vec![S2C_LIST, 0x02, 0x00];
2248        // entry 1: id=1, tag_len=2, tag="ab"
2249        wire.extend_from_slice(&1u16.to_le_bytes());
2250        wire.extend_from_slice(&2u16.to_le_bytes());
2251        wire.extend_from_slice(b"ab");
2252        // entry 2: id=2, tag_len=0
2253        wire.extend_from_slice(&2u16.to_le_bytes());
2254        wire.extend_from_slice(&0u16.to_le_bytes());
2255
2256        let msg = parse_server_msg(&wire).unwrap();
2257        match msg {
2258            ServerMsg::List { entries } => {
2259                assert_eq!(entries.len(), 2);
2260                assert_eq!(entries[0].pty_id, 1);
2261                assert_eq!(entries[0].tag, "ab");
2262                assert_eq!(entries[1].pty_id, 2);
2263                assert_eq!(entries[1].tag, "");
2264            }
2265            _ => panic!("expected List"),
2266        }
2267    }
2268
2269    #[test]
2270    fn parse_list_empty() {
2271        let wire = vec![S2C_LIST, 0x00, 0x00];
2272        let msg = parse_server_msg(&wire).unwrap();
2273        match msg {
2274            ServerMsg::List { entries } => assert_eq!(entries.len(), 0),
2275            _ => panic!("expected List"),
2276        }
2277    }
2278
2279    #[test]
2280    fn parse_list_truncated_gracefully() {
2281        // count=2 but only 1 complete entry
2282        let mut wire = vec![S2C_LIST, 0x02, 0x00];
2283        wire.extend_from_slice(&1u16.to_le_bytes());
2284        wire.extend_from_slice(&0u16.to_le_bytes());
2285        // missing second entry
2286        let msg = parse_server_msg(&wire).unwrap();
2287        match msg {
2288            ServerMsg::List { entries } => assert_eq!(entries.len(), 1),
2289            _ => panic!("expected List"),
2290        }
2291    }
2292
2293    #[test]
2294    fn parse_list_with_long_tags() {
2295        let long_tag = "a".repeat(300);
2296        let mut wire = vec![S2C_LIST, 0x01, 0x00];
2297        wire.extend_from_slice(&42u16.to_le_bytes());
2298        wire.extend_from_slice(&(long_tag.len() as u16).to_le_bytes());
2299        wire.extend_from_slice(long_tag.as_bytes());
2300
2301        let msg = parse_server_msg(&wire).unwrap();
2302        match msg {
2303            ServerMsg::List { entries } => {
2304                assert_eq!(entries.len(), 1);
2305                assert_eq!(entries[0].pty_id, 42);
2306                assert_eq!(entries[0].tag, long_tag);
2307            }
2308            _ => panic!("expected List"),
2309        }
2310    }
2311
2312    #[test]
2313    fn create_and_created_tag_round_trip() {
2314        // Simulate: client sends create with tag, server echoes tag in created
2315        let create_msg = msg_create_tagged(24, 80, "my-session");
2316        let tag_len = u16::from_le_bytes([create_msg[5], create_msg[6]]) as usize;
2317        let tag = std::str::from_utf8(&create_msg[7..7 + tag_len]).unwrap();
2318
2319        // Server builds S2C_CREATED with the tag
2320        let mut created_wire = vec![S2C_CREATED, 0x07, 0x00]; // pty_id = 7
2321        created_wire.extend_from_slice(tag.as_bytes());
2322
2323        let msg = parse_server_msg(&created_wire).unwrap();
2324        match msg {
2325            ServerMsg::Created {
2326                pty_id,
2327                tag: parsed_tag,
2328            } => {
2329                assert_eq!(pty_id, 7);
2330                assert_eq!(parsed_tag, "my-session");
2331            }
2332            _ => panic!("expected Created"),
2333        }
2334    }
2335
2336    // --- FrameState tests ---
2337
2338    #[test]
2339    fn frame_state_accessors() {
2340        let mut f = FrameState::new(4, 10);
2341        assert_eq!(f.rows(), 4);
2342        assert_eq!(f.cols(), 10);
2343        assert_eq!(f.cursor_row(), 0);
2344        assert_eq!(f.cursor_col(), 0);
2345        assert_eq!(f.mode(), 0);
2346        assert_eq!(f.title(), "");
2347        assert_eq!(f.cells().len(), 4 * 10 * CELL_SIZE);
2348        assert_eq!(f.cells_mut().len(), 4 * 10 * CELL_SIZE);
2349        assert!(f.overflow().is_empty());
2350        assert!(f.overflow_mut().is_empty());
2351    }
2352
2353    #[test]
2354    fn frame_state_from_parts() {
2355        let cells = vec![0u8; 2 * 4 * CELL_SIZE];
2356        let f = FrameState::from_parts(2, 4, 1, 3, 0x0F, "hello", cells.clone());
2357        assert_eq!(f.rows(), 2);
2358        assert_eq!(f.cols(), 4);
2359        assert_eq!(f.cursor_row(), 1);
2360        assert_eq!(f.cursor_col(), 3);
2361        assert_eq!(f.mode(), 0x0F);
2362        assert_eq!(f.title(), "hello");
2363        assert_eq!(f.cells(), &cells[..]);
2364    }
2365
2366    #[test]
2367    fn frame_state_from_parts_wrong_size() {
2368        // cells with wrong size should be ignored (stays zeroed)
2369        let cells = vec![0u8; 10]; // wrong size
2370        let f = FrameState::from_parts(2, 4, 0, 0, 0, "", cells);
2371        assert_eq!(f.cells().len(), 2 * 4 * CELL_SIZE);
2372    }
2373
2374    #[test]
2375    fn frame_state_resize() {
2376        let mut f = FrameState::new(4, 10);
2377        f.set_cursor(3, 9);
2378        f.resize(2, 5);
2379        assert_eq!(f.rows(), 2);
2380        assert_eq!(f.cols(), 5);
2381        assert_eq!(f.cursor_row(), 1); // clamped
2382        assert_eq!(f.cursor_col(), 4); // clamped
2383        assert_eq!(f.cells().len(), 2 * 5 * CELL_SIZE);
2384    }
2385
2386    #[test]
2387    fn frame_state_resize_noop() {
2388        let mut f = FrameState::new(4, 10);
2389        let ptr_before = f.cells().as_ptr();
2390        f.resize(4, 10); // same size
2391        let ptr_after = f.cells().as_ptr();
2392        assert_eq!(ptr_before, ptr_after); // no realloc
2393    }
2394
2395    #[test]
2396    fn frame_state_set_cursor_clamps() {
2397        let mut f = FrameState::new(4, 10);
2398        f.set_cursor(100, 200);
2399        assert_eq!(f.cursor_row(), 3);
2400        assert_eq!(f.cursor_col(), 9);
2401    }
2402
2403    #[test]
2404    fn frame_state_set_title() {
2405        let mut f = FrameState::new(2, 2);
2406        assert!(f.set_title("new title"));
2407        assert_eq!(f.title(), "new title");
2408        assert!(!f.set_title("new title")); // same title returns false
2409        assert!(f.set_title("other"));
2410    }
2411
2412    #[test]
2413    fn frame_state_get_text_and_write_text() {
2414        let mut f = FrameState::new(2, 10);
2415        f.write_text(0, 0, "Hello", CellStyle::default());
2416        f.write_text(1, 0, "World", CellStyle::default());
2417        let text = f.get_text(0, 0, 1, 9);
2418        assert!(text.contains("Hello"));
2419        assert!(text.contains("World"));
2420        let all = f.get_all_text();
2421        assert!(all.contains("Hello"));
2422    }
2423
2424    #[test]
2425    fn frame_state_get_text_empty() {
2426        let f = FrameState::new(0, 0);
2427        assert_eq!(f.get_text(0, 0, 0, 0), "");
2428        assert_eq!(f.get_all_text(), "");
2429    }
2430
2431    #[test]
2432    fn frame_state_get_cell() {
2433        let f = FrameState::new(2, 4);
2434        let cell = f.get_cell(0, 0);
2435        assert_eq!(cell.len(), CELL_SIZE);
2436        // Out of bounds
2437        assert!(f.get_cell(100, 100).is_empty());
2438    }
2439
2440    #[test]
2441    fn frame_state_cell_content_blank() {
2442        let f = FrameState::new(2, 4);
2443        assert_eq!(f.cell_content(0, 0), " "); // blank cell
2444        assert_eq!(f.cell_content(100, 0), ""); // out of bounds
2445    }
2446
2447    #[test]
2448    fn frame_state_cell_content_with_text() {
2449        let mut f = FrameState::new(2, 10);
2450        f.write_text(0, 0, "A", CellStyle::default());
2451        assert_eq!(f.cell_content(0, 0), "A");
2452    }
2453
2454    #[test]
2455    fn frame_state_fill_rect() {
2456        let mut f = FrameState::new(4, 10);
2457        f.fill_rect(Rect::new(0, 0, 2, 5), 'X', CellStyle::default());
2458        assert_eq!(f.cell_content(0, 0), "X");
2459        assert_eq!(f.cell_content(1, 4), "X");
2460        assert_eq!(f.cell_content(2, 0), " "); // outside rect
2461    }
2462
2463    #[test]
2464    fn frame_state_wrapped_text() {
2465        let mut f = FrameState::new(4, 10);
2466        let lines =
2467            f.write_wrapped_text(Rect::new(0, 0, 4, 5), "hello world", CellStyle::default());
2468        assert!(lines >= 2); // "hello world" wraps at width 5
2469    }
2470
2471    #[test]
2472    fn frame_state_wrapped_text_empty_rect() {
2473        let mut f = FrameState::new(4, 10);
2474        assert_eq!(
2475            f.write_wrapped_text(Rect::new(0, 0, 0, 0), "hi", CellStyle::default()),
2476            0
2477        );
2478    }
2479
2480    #[test]
2481    fn frame_state_scrolling_text() {
2482        let mut f = FrameState::new(4, 10);
2483        f.write_scrolling_text(
2484            Rect::new(0, 0, 3, 10),
2485            &["line1", "line2", "line3", "line4"],
2486            0,
2487            CellStyle::default(),
2488        );
2489        // Last 3 lines visible with offset_from_bottom=0
2490        assert_eq!(f.cell_content(0, 0), "l"); // "line2"
2491    }
2492
2493    #[test]
2494    fn frame_state_scrolling_text_empty_rect() {
2495        let mut f = FrameState::new(4, 10);
2496        f.write_scrolling_text(Rect::new(0, 0, 0, 0), &["hi"], 0, CellStyle::default());
2497        // Should not panic
2498    }
2499
2500    #[test]
2501    fn frame_state_clear() {
2502        let mut f = FrameState::new(2, 4);
2503        f.write_text(0, 0, "AB", CellStyle::default());
2504        f.clear(CellStyle::default());
2505        assert_eq!(f.cell_content(0, 0), " ");
2506    }
2507
2508    // --- TerminalState tests ---
2509
2510    #[test]
2511    fn terminal_state_accessors() {
2512        let t = TerminalState::new(24, 80);
2513        assert_eq!(t.rows(), 24);
2514        assert_eq!(t.cols(), 80);
2515        assert_eq!(t.cursor_row(), 0);
2516        assert_eq!(t.cursor_col(), 0);
2517        assert_eq!(t.mode(), 0);
2518        assert_eq!(t.title(), "");
2519        assert_eq!(t.cells().len(), 24 * 80 * CELL_SIZE);
2520        assert_eq!(t.frame().rows(), 24);
2521    }
2522
2523    #[test]
2524    fn terminal_state_mutators() {
2525        let mut t = TerminalState::new(4, 10);
2526        t.frame_mut().set_title("test");
2527        assert_eq!(t.title(), "test");
2528    }
2529
2530    #[test]
2531    fn terminal_state_set_title() {
2532        let mut t = TerminalState::new(4, 10);
2533        assert!(t.frame_mut().set_title("hello"));
2534        assert_eq!(t.title(), "hello");
2535        assert!(!t.frame_mut().set_title("hello")); // same
2536    }
2537
2538    #[test]
2539    fn terminal_state_get_text() {
2540        let t = TerminalState::new(2, 10);
2541        let text = t.get_text(0, 0, 0, 9);
2542        assert!(text.is_empty() || text.chars().all(|c| c == ' ' || c == '\n'));
2543        assert!(t.get_cell(0, 0).len() == CELL_SIZE);
2544        assert!(t.get_cell(100, 100).is_empty());
2545    }
2546
2547    #[test]
2548    fn terminal_state_resize() {
2549        let mut t = TerminalState::new(4, 10);
2550        t.frame_mut().resize(2, 5);
2551        // Note: TerminalState.dirty isn't updated by frame_mut().resize()
2552        // directly — that happens through feed_compressed. So just check frame.
2553        assert_eq!(t.rows(), 2);
2554        assert_eq!(t.cols(), 5);
2555    }
2556
2557    #[test]
2558    fn terminal_state_feed_compressed_invalid() {
2559        let mut t = TerminalState::new(4, 10);
2560        assert!(!t.feed_compressed(b"garbage"));
2561        assert!(!t.feed_compressed(&[]));
2562    }
2563
2564    #[test]
2565    fn terminal_state_feed_compressed_batch_empty() {
2566        let mut t = TerminalState::new(4, 10);
2567        assert!(!t.feed_compressed_batch(&[]));
2568    }
2569
2570    #[test]
2571    fn terminal_state_feed_compressed_batch_truncated() {
2572        let mut t = TerminalState::new(4, 10);
2573        // Length header says 100 bytes but only 4 bytes present
2574        let batch = &[100, 0, 0, 0];
2575        assert!(!t.feed_compressed_batch(batch));
2576    }
2577
2578    // --- Client message builder tests ---
2579
2580    #[test]
2581    fn msg_input_format() {
2582        let msg = msg_input(5, b"hello");
2583        assert_eq!(msg[0], C2S_INPUT);
2584        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 5);
2585        assert_eq!(&msg[3..], b"hello");
2586    }
2587
2588    #[test]
2589    fn msg_resize_format() {
2590        let msg = msg_resize(3, 24, 80);
2591        assert_eq!(msg[0], C2S_RESIZE);
2592        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
2593        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
2594        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
2595    }
2596
2597    #[test]
2598    fn msg_resize_batch_format() {
2599        let msg = msg_resize_batch(&[(3, 24, 80), (5, 40, 120)]);
2600        assert_eq!(msg[0], C2S_RESIZE);
2601        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
2602        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
2603        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
2604        assert_eq!(u16::from_le_bytes([msg[7], msg[8]]), 5);
2605        assert_eq!(u16::from_le_bytes([msg[9], msg[10]]), 40);
2606        assert_eq!(u16::from_le_bytes([msg[11], msg[12]]), 120);
2607    }
2608
2609    #[test]
2610    fn msg_focus_format() {
2611        let msg = msg_focus(7);
2612        assert_eq!(msg[0], C2S_FOCUS);
2613        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 7);
2614        assert_eq!(msg.len(), 3);
2615    }
2616
2617    #[test]
2618    fn msg_close_format() {
2619        let msg = msg_close(9);
2620        assert_eq!(msg[0], C2S_CLOSE);
2621        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 9);
2622    }
2623
2624    #[test]
2625    fn msg_subscribe_unsubscribe_format() {
2626        let sub = msg_subscribe(1);
2627        assert_eq!(sub[0], C2S_SUBSCRIBE);
2628        assert_eq!(u16::from_le_bytes([sub[1], sub[2]]), 1);
2629
2630        let unsub = msg_unsubscribe(2);
2631        assert_eq!(unsub[0], C2S_UNSUBSCRIBE);
2632        assert_eq!(u16::from_le_bytes([unsub[1], unsub[2]]), 2);
2633    }
2634
2635    #[test]
2636    fn msg_search_format() {
2637        let msg = msg_search(42, "test query");
2638        assert_eq!(msg[0], C2S_SEARCH);
2639        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
2640        assert_eq!(&msg[3..], b"test query");
2641    }
2642
2643    #[test]
2644    fn msg_ack_format() {
2645        let msg = msg_ack();
2646        assert_eq!(msg, vec![C2S_ACK]);
2647    }
2648
2649    #[test]
2650    fn msg_scroll_format() {
2651        let msg = msg_scroll(5, 1000);
2652        assert_eq!(msg[0], C2S_SCROLL);
2653        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 5);
2654        assert_eq!(u32::from_le_bytes([msg[3], msg[4], msg[5], msg[6]]), 1000);
2655    }
2656
2657    #[test]
2658    fn msg_display_rate_format() {
2659        let msg = msg_display_rate(120);
2660        assert_eq!(msg[0], C2S_DISPLAY_RATE);
2661        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 120);
2662    }
2663
2664    #[test]
2665    fn msg_client_metrics_format() {
2666        let msg = msg_client_metrics(3, 5, 100);
2667        assert_eq!(msg[0], C2S_CLIENT_METRICS);
2668        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
2669        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 5);
2670        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 100);
2671    }
2672
2673    // --- CallbackRenderer tests ---
2674
2675    #[test]
2676    fn callback_renderer_resize() {
2677        let mut r = CallbackRenderer::new(2, 8);
2678        assert_eq!(r.frame().rows(), 2);
2679        r.resize(4, 16);
2680        assert_eq!(r.frame().rows(), 4);
2681        assert_eq!(r.frame().cols(), 16);
2682    }
2683
2684    #[test]
2685    fn callback_renderer_fill() {
2686        let mut r = CallbackRenderer::new(4, 10);
2687        r.render(|dom| {
2688            dom.fill(Rect::new(0, 0, 2, 5), '#', CellStyle::default());
2689        });
2690        assert_eq!(r.frame().cell_content(0, 0), "#");
2691        assert_eq!(r.frame().cell_content(1, 4), "#");
2692    }
2693
2694    #[test]
2695    fn callback_renderer_text() {
2696        let mut r = CallbackRenderer::new(4, 20);
2697        r.render(|dom| {
2698            dom.text(0, 0, "Hello", CellStyle::default());
2699        });
2700        assert_eq!(r.frame().cell_content(0, 0), "H");
2701        assert_eq!(r.frame().cell_content(0, 4), "o");
2702    }
2703
2704    #[test]
2705    fn callback_renderer_set_title() {
2706        let mut r = CallbackRenderer::new(2, 8);
2707        r.render(|dom| {
2708            dom.set_title("my title");
2709        });
2710        assert_eq!(r.frame().title(), "my title");
2711    }
2712
2713    #[test]
2714    fn callback_renderer_set_background() {
2715        let mut r = CallbackRenderer::new(2, 4);
2716        let style = CellStyle {
2717            bg: Color::Rgb(255, 0, 0),
2718            ..CellStyle::default()
2719        };
2720        r.render(|dom| {
2721            dom.set_background(style);
2722        });
2723        // Background fill should have been applied to all cells
2724        assert_eq!(r.frame().cells().len(), 2 * 4 * CELL_SIZE);
2725    }
2726
2727    #[test]
2728    fn callback_renderer_scrolling_text() {
2729        let mut r = CallbackRenderer::new(4, 20);
2730        r.render(|dom| {
2731            dom.scrolling_text(
2732                Rect::new(0, 0, 3, 20),
2733                ["a", "b", "c", "d", "e"].map(String::from),
2734                0,
2735                CellStyle::default(),
2736            );
2737        });
2738        // Should show the last 3 lines
2739        assert_eq!(r.frame().cell_content(0, 0), "c");
2740    }
2741
2742    // --- parse_server_msg edge cases ---
2743
2744    #[test]
2745    fn parse_empty_returns_none() {
2746        assert!(parse_server_msg(&[]).is_none());
2747    }
2748
2749    #[test]
2750    fn parse_unknown_type_returns_none() {
2751        assert!(parse_server_msg(&[0xFF, 0x00, 0x00]).is_none());
2752    }
2753
2754    #[test]
2755    fn parse_update_too_short() {
2756        assert!(parse_server_msg(&[S2C_UPDATE, 0x00]).is_none());
2757    }
2758
2759    #[test]
2760    fn parse_closed() {
2761        let msg = parse_server_msg(&[S2C_CLOSED, 0x05, 0x00]).unwrap();
2762        match msg {
2763            ServerMsg::Closed { pty_id } => assert_eq!(pty_id, 5),
2764            _ => panic!("expected Closed"),
2765        }
2766    }
2767
2768    #[test]
2769    fn parse_title() {
2770        let mut wire = vec![S2C_TITLE, 0x01, 0x00];
2771        wire.extend_from_slice(b"mytitle");
2772        let msg = parse_server_msg(&wire).unwrap();
2773        match msg {
2774            ServerMsg::Title { pty_id, title } => {
2775                assert_eq!(pty_id, 1);
2776                assert_eq!(title, b"mytitle");
2777            }
2778            _ => panic!("expected Title"),
2779        }
2780    }
2781
2782    // --- build_update_msg round-trip ---
2783
2784    #[test]
2785    fn build_update_msg_round_trip_with_resize() {
2786        let style = CellStyle::default();
2787        let mut prev = FrameState::new(2, 4);
2788        prev.write_text(0, 0, "AB", style);
2789
2790        let mut next = FrameState::new(3, 5); // different size
2791        next.write_text(0, 0, "XY", style);
2792        next.set_title("resized");
2793
2794        let msg = build_update_msg(1, &next, &prev).unwrap();
2795        assert!(!msg.is_empty());
2796
2797        // Apply to a terminal
2798        let mut t = TerminalState::new(2, 4);
2799        assert!(t.feed_compressed(&msg[3..])); // skip pty_id header
2800        assert_eq!(t.rows(), 3);
2801        assert_eq!(t.cols(), 5);
2802        assert_eq!(t.title(), "resized");
2803    }
2804
2805    #[test]
2806    fn build_update_msg_cursor_change() {
2807        let mut prev = FrameState::new(4, 10);
2808        prev.set_cursor(0, 0);
2809
2810        let mut next = prev.clone();
2811        next.set_cursor(2, 5);
2812
2813        let msg = build_update_msg(0, &next, &prev).unwrap();
2814
2815        let mut t = TerminalState::new(4, 10);
2816        assert!(t.feed_compressed(&msg[3..]));
2817        assert_eq!(t.cursor_row(), 2);
2818        assert_eq!(t.cursor_col(), 5);
2819    }
2820
2821    #[test]
2822    fn build_update_msg_mode_change() {
2823        let prev = FrameState::new(2, 4);
2824        let mut next = prev.clone();
2825        next.set_mode(0x0F);
2826
2827        let msg = build_update_msg(0, &next, &prev).unwrap();
2828        let mut t = TerminalState::new(2, 4);
2829        assert!(t.feed_compressed(&msg[3..]));
2830        assert_eq!(t.mode(), 0x0F);
2831    }
2832
2833    #[test]
2834    fn feed_compressed_batch_multiple_frames() {
2835        let style = CellStyle::default();
2836        let prev = FrameState::new(2, 4);
2837
2838        let mut mid = prev.clone();
2839        mid.write_text(0, 0, "AB", style);
2840        let msg1 = build_update_msg(0, &mid, &prev).unwrap();
2841
2842        let mut next = mid.clone();
2843        next.write_text(1, 0, "CD", style);
2844        let msg2 = build_update_msg(0, &next, &mid).unwrap();
2845
2846        // Build batch: [len1:4][compressed1][len2:4][compressed2]
2847        let payload1 = &msg1[3..];
2848        let payload2 = &msg2[3..];
2849        let mut batch = Vec::new();
2850        batch.extend_from_slice(&(payload1.len() as u32).to_le_bytes());
2851        batch.extend_from_slice(payload1);
2852        batch.extend_from_slice(&(payload2.len() as u32).to_le_bytes());
2853        batch.extend_from_slice(payload2);
2854
2855        let mut t = TerminalState::new(2, 4);
2856        assert!(t.feed_compressed_batch(&batch));
2857        let text = t.get_all_text();
2858        assert!(text.contains("AB"));
2859        assert!(text.contains("CD"));
2860    }
2861}