Skip to main content

blit_remote/
lib.rs

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