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