Skip to main content

blit_remote/
lib.rs

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