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_subscribe(pty_id: u16) -> Vec<u8> {
1485    let mut msg = Vec::with_capacity(3);
1486    msg.push(C2S_SUBSCRIBE);
1487    msg.extend_from_slice(&pty_id.to_le_bytes());
1488    msg
1489}
1490
1491pub fn msg_unsubscribe(pty_id: u16) -> Vec<u8> {
1492    let mut msg = Vec::with_capacity(3);
1493    msg.push(C2S_UNSUBSCRIBE);
1494    msg.extend_from_slice(&pty_id.to_le_bytes());
1495    msg
1496}
1497
1498pub fn msg_search(request_id: u16, query: &str) -> Vec<u8> {
1499    let query = query.as_bytes();
1500    let mut msg = Vec::with_capacity(3 + query.len());
1501    msg.push(C2S_SEARCH);
1502    msg.extend_from_slice(&request_id.to_le_bytes());
1503    msg.extend_from_slice(query);
1504    msg
1505}
1506
1507pub fn msg_ack() -> Vec<u8> {
1508    vec![C2S_ACK]
1509}
1510
1511pub fn msg_scroll(pty_id: u16, offset: u32) -> Vec<u8> {
1512    let mut msg = Vec::with_capacity(7);
1513    msg.push(C2S_SCROLL);
1514    msg.extend_from_slice(&pty_id.to_le_bytes());
1515    msg.extend_from_slice(&offset.to_le_bytes());
1516    msg
1517}
1518
1519pub fn msg_display_rate(fps: u16) -> Vec<u8> {
1520    let mut msg = Vec::with_capacity(3);
1521    msg.push(C2S_DISPLAY_RATE);
1522    msg.extend_from_slice(&fps.to_le_bytes());
1523    msg
1524}
1525
1526pub fn msg_client_metrics(backlog: u16, ack_ahead: u16, apply_ms_x10: u16) -> Vec<u8> {
1527    let mut msg = Vec::with_capacity(7);
1528    msg.push(C2S_CLIENT_METRICS);
1529    msg.extend_from_slice(&backlog.to_le_bytes());
1530    msg.extend_from_slice(&ack_ahead.to_le_bytes());
1531    msg.extend_from_slice(&apply_ms_x10.to_le_bytes());
1532    msg
1533}
1534
1535pub fn msg_read(nonce: u16, pty_id: u16, offset: u32, limit: u32, flags: u8) -> Vec<u8> {
1536    let mut msg = Vec::with_capacity(14);
1537    msg.push(C2S_READ);
1538    msg.extend_from_slice(&nonce.to_le_bytes());
1539    msg.extend_from_slice(&pty_id.to_le_bytes());
1540    msg.extend_from_slice(&offset.to_le_bytes());
1541    msg.extend_from_slice(&limit.to_le_bytes());
1542    msg.push(flags);
1543    msg
1544}
1545
1546pub fn msg_exited(pty_id: u16, exit_status: i32) -> Vec<u8> {
1547    let mut msg = Vec::with_capacity(7);
1548    msg.push(S2C_EXITED);
1549    msg.extend_from_slice(&pty_id.to_le_bytes());
1550    msg.extend_from_slice(&exit_status.to_le_bytes());
1551    msg
1552}
1553
1554fn push_sgr(out: &mut String, style: &CellStyle) {
1555    use std::fmt::Write;
1556    out.push_str("\x1b[0");
1557    if style.bold {
1558        out.push_str(";1");
1559    }
1560    if style.dim {
1561        out.push_str(";2");
1562    }
1563    if style.italic {
1564        out.push_str(";3");
1565    }
1566    if style.underline {
1567        out.push_str(";4");
1568    }
1569    if style.inverse {
1570        out.push_str(";7");
1571    }
1572    match style.fg {
1573        Color::Indexed(n) => {
1574            let _ = write!(out, ";38;5;{n}");
1575        }
1576        Color::Rgb(r, g, b) => {
1577            let _ = write!(out, ";38;2;{r};{g};{b}");
1578        }
1579        Color::Default => {}
1580    }
1581    match style.bg {
1582        Color::Indexed(n) => {
1583            let _ = write!(out, ";48;5;{n}");
1584        }
1585        Color::Rgb(r, g, b) => {
1586            let _ = write!(out, ";48;2;{r};{g};{b}");
1587        }
1588        Color::Default => {}
1589    }
1590    out.push('m');
1591}
1592
1593const MODE_ALT_SCREEN: u16 = 1 << 11;
1594
1595fn mode_is_cooked(mode: u16) -> bool {
1596    mode & MODE_ECHO != 0 && mode & MODE_ICANON != 0 && mode & MODE_ALT_SCREEN == 0
1597}
1598
1599pub fn build_update_msg(
1600    pty_id: u16,
1601    current: &FrameState,
1602    previous: &FrameState,
1603) -> Option<Vec<u8>> {
1604    let title_changed = current.title != previous.title;
1605    let same_size = previous.rows == current.rows
1606        && previous.cols == current.cols
1607        && previous.cells.len() == current.cells.len();
1608
1609    // Try scroll-aware ops when dimensions match and content differs.
1610    let mut ops = Vec::new();
1611    let mut op_count = 0u16;
1612
1613    // Scroll-aware ops apply when content is "cooked" (shell output) or when
1614    // either frame has mode 0 (scrollback frames use mode=0, and their content
1615    // is always static text that benefits from COPY_RECT).
1616    let scroll_eligible = (mode_is_cooked(current.mode) && mode_is_cooked(previous.mode))
1617        || current.mode == 0
1618        || previous.mode == 0;
1619    if ENABLE_SCROLL_OPS && same_size && previous.cells != current.cells && scroll_eligible {
1620        if let Some(delta_rows) = detect_vertical_scroll(current, previous) {
1621            let mut basis = previous.clone();
1622            encode_copy_rect_op(&mut ops, current, delta_rows);
1623            apply_vertical_scroll_copy(&mut basis, delta_rows);
1624            op_count += 1;
1625            append_full_width_fill_ops(current, &mut basis, &mut ops, &mut op_count);
1626            if let Some(patch_op) = build_patch_op(current, &basis) {
1627                ops.extend_from_slice(&patch_op);
1628                op_count += 1;
1629            }
1630        }
1631    }
1632
1633    // Fallback: bare PATCH_CELLS against previous (or a blank frame on resize).
1634    if op_count == 0 {
1635        let basis = if same_size {
1636            previous
1637        } else {
1638            &FrameState::new(current.rows, current.cols)
1639        };
1640        if let Some(patch_op) = build_patch_op(current, basis) {
1641            ops = patch_op;
1642            op_count = 1;
1643        }
1644    }
1645
1646    if op_count == 0 {
1647        // No cell changes — still emit a frame if cursor/mode/title changed.
1648        if !title_changed
1649            && current.cursor_row == previous.cursor_row
1650            && current.cursor_col == previous.cursor_col
1651            && current.mode == previous.mode
1652        {
1653            return None;
1654        }
1655    }
1656
1657    // Collect overflow strings that need to be transmitted.
1658    // We send all overflow entries from the current frame that correspond
1659    // to cells that changed (are in the dirty set).  For a resize (not
1660    // same_size), all cells are "dirty", so we send all overflow entries.
1661    let has_overflow = !current.overflow.is_empty();
1662    let overflow_section = if has_overflow {
1663        serialize_overflow_strings(current)
1664    } else {
1665        Vec::new()
1666    };
1667
1668    let line_flags_changed =
1669        current.line_flags != previous.line_flags || current.rows != previous.rows;
1670    let has_line_flags = line_flags_changed && !current.line_flags.iter().all(|&f| f == 0);
1671
1672    let title_bytes = if title_changed {
1673        current.title.as_bytes()
1674    } else {
1675        &[]
1676    };
1677    let title_len = title_bytes.len().min(TITLE_LEN_MASK as usize);
1678    let title_field = OPS_PRESENT
1679        | if has_overflow { STRINGS_PRESENT } else { 0 }
1680        | if has_line_flags {
1681            LINE_FLAGS_PRESENT
1682        } else {
1683            0
1684        }
1685        | if title_changed {
1686            TITLE_PRESENT | title_len as u16
1687        } else {
1688            0
1689        };
1690
1691    let mut payload = Vec::with_capacity(
1692        12 + title_len
1693            + 2
1694            + ops.len()
1695            + overflow_section.len()
1696            + if has_line_flags {
1697                current.rows as usize
1698            } else {
1699                0
1700            }
1701            + 4,
1702    );
1703    payload.extend_from_slice(&current.rows.to_le_bytes());
1704    payload.extend_from_slice(&current.cols.to_le_bytes());
1705    payload.extend_from_slice(&current.cursor_row.to_le_bytes());
1706    payload.extend_from_slice(&current.cursor_col.to_le_bytes());
1707    payload.extend_from_slice(&current.mode.to_le_bytes());
1708    payload.extend_from_slice(&title_field.to_le_bytes());
1709    if title_changed {
1710        payload.extend_from_slice(&title_bytes[..title_len]);
1711    }
1712    payload.extend_from_slice(&op_count.to_le_bytes());
1713    payload.extend_from_slice(&ops);
1714    payload.extend_from_slice(&overflow_section);
1715    if has_line_flags {
1716        payload.extend_from_slice(&current.line_flags);
1717    }
1718    // Trailing scrollback count — old clients ignore extra bytes.
1719    payload.extend_from_slice(&current.scrollback_lines.to_le_bytes());
1720
1721    let compressed = compress_prepend_size(&payload);
1722    let mut msg = Vec::with_capacity(3 + compressed.len());
1723    msg.push(S2C_UPDATE);
1724    msg.extend_from_slice(&pty_id.to_le_bytes());
1725    msg.extend_from_slice(&compressed);
1726    Some(msg)
1727}
1728
1729/// Serialize overflow strings: [u16 count] [for each: u32 cell_index, u16 len, utf8 bytes]
1730fn serialize_overflow_strings(frame: &FrameState) -> Vec<u8> {
1731    let count = frame.overflow.len().min(u16::MAX as usize);
1732    let mut out = Vec::new();
1733    out.extend_from_slice(&(count as u16).to_le_bytes());
1734    for (&cell_idx, s) in frame.overflow.iter().take(count) {
1735        let bytes = s.as_bytes();
1736        let len = bytes.len().min(u16::MAX as usize);
1737        out.extend_from_slice(&(cell_idx as u32).to_le_bytes());
1738        out.extend_from_slice(&(len as u16).to_le_bytes());
1739        out.extend_from_slice(&bytes[..len]);
1740    }
1741    out
1742}
1743
1744fn build_patch_op(current: &FrameState, previous: &FrameState) -> Option<Vec<u8>> {
1745    let total_cells = current.rows as usize * current.cols as usize;
1746    let bitmask_len = total_cells.div_ceil(8);
1747    let mut bitmask = vec![0u8; bitmask_len];
1748    let mut dirty_count = 0usize;
1749    for i in 0..total_cells {
1750        let off = i * CELL_SIZE;
1751        if current.cells[off..off + CELL_SIZE] != previous.cells[off..off + CELL_SIZE] {
1752            bitmask[i / 8] |= 1 << (i % 8);
1753            dirty_count += 1;
1754        }
1755    }
1756    if dirty_count == 0 {
1757        return None;
1758    }
1759
1760    let mut op = Vec::with_capacity(1 + bitmask_len + dirty_count * CELL_SIZE);
1761    op.push(OP_PATCH_CELLS);
1762    op.extend_from_slice(&bitmask);
1763    for byte_pos in 0..CELL_SIZE {
1764        for i in 0..total_cells {
1765            if bitmask[i / 8] & (1 << (i % 8)) != 0 {
1766                op.push(current.cells[i * CELL_SIZE + byte_pos]);
1767            }
1768        }
1769    }
1770    Some(op)
1771}
1772
1773fn detect_vertical_scroll(current: &FrameState, previous: &FrameState) -> Option<i16> {
1774    let rows = current.rows as usize;
1775    let cols = current.cols as usize;
1776    if rows < 4 || cols == 0 {
1777        return None;
1778    }
1779    let row_bytes = cols * CELL_SIZE;
1780    let max_delta = rows.saturating_sub(1).min(8);
1781    let mut best: Option<(usize, i16)> = None;
1782
1783    for delta in 1..=max_delta {
1784        let overlap = rows - delta;
1785        if overlap < 3 {
1786            continue;
1787        }
1788        for signed_delta in [-(delta as i16), delta as i16] {
1789            let mut matched = 0usize;
1790            for row in 0..rows {
1791                let src_row = row as i32 - signed_delta as i32;
1792                if src_row < 0 || src_row >= rows as i32 {
1793                    continue;
1794                }
1795                let cur_off = row * row_bytes;
1796                let prev_off = src_row as usize * row_bytes;
1797                if current.cells[cur_off..cur_off + row_bytes]
1798                    == previous.cells[prev_off..prev_off + row_bytes]
1799                {
1800                    matched += 1;
1801                }
1802            }
1803            if matched * 5 < overlap * 4 {
1804                continue;
1805            }
1806            let replace = match best {
1807                None => true,
1808                Some((best_matched, best_delta)) => {
1809                    matched > best_matched
1810                        || (matched == best_matched
1811                            && signed_delta.unsigned_abs() < best_delta.unsigned_abs())
1812                }
1813            };
1814            if replace {
1815                best = Some((matched, signed_delta));
1816            }
1817        }
1818    }
1819
1820    best.map(|(_, delta)| delta)
1821}
1822
1823fn encode_copy_rect_op(out: &mut Vec<u8>, current: &FrameState, delta_rows: i16) {
1824    let rows = current.rows;
1825    let cols = current.cols;
1826    let delta = delta_rows.unsigned_abs();
1827    let (src_row, dst_row, copy_rows) = if delta_rows > 0 {
1828        (0, delta, rows.saturating_sub(delta))
1829    } else {
1830        (delta, 0, rows.saturating_sub(delta))
1831    };
1832    out.push(OP_COPY_RECT);
1833    out.extend_from_slice(&src_row.to_le_bytes());
1834    out.extend_from_slice(&0u16.to_le_bytes());
1835    out.extend_from_slice(&dst_row.to_le_bytes());
1836    out.extend_from_slice(&0u16.to_le_bytes());
1837    out.extend_from_slice(&copy_rows.to_le_bytes());
1838    out.extend_from_slice(&cols.to_le_bytes());
1839}
1840
1841fn apply_vertical_scroll_copy(frame: &mut FrameState, delta_rows: i16) {
1842    let delta = delta_rows.unsigned_abs();
1843    if delta == 0 || delta >= frame.rows {
1844        return;
1845    }
1846    let (src_row, dst_row, rows) = if delta_rows > 0 {
1847        (0, delta, frame.rows - delta)
1848    } else {
1849        (delta, 0, frame.rows - delta)
1850    };
1851    apply_copy_rect_frame(frame, src_row, 0, dst_row, 0, rows, frame.cols);
1852}
1853
1854fn apply_copy_rect_frame(
1855    frame: &mut FrameState,
1856    src_row: u16,
1857    src_col: u16,
1858    dst_row: u16,
1859    dst_col: u16,
1860    rows: u16,
1861    cols: u16,
1862) {
1863    let rows = rows
1864        .min(frame.rows.saturating_sub(src_row))
1865        .min(frame.rows.saturating_sub(dst_row));
1866    let cols = cols
1867        .min(frame.cols.saturating_sub(src_col))
1868        .min(frame.cols.saturating_sub(dst_col));
1869    if rows == 0 || cols == 0 {
1870        return;
1871    }
1872    let mut temp = vec![0u8; rows as usize * cols as usize * CELL_SIZE];
1873    for r in 0..rows as usize {
1874        let src_off = frame.cell_offset(src_row + r as u16, src_col);
1875        let src_end = src_off + cols as usize * CELL_SIZE;
1876        let dst_off = r * cols as usize * CELL_SIZE;
1877        temp[dst_off..dst_off + cols as usize * CELL_SIZE]
1878            .copy_from_slice(&frame.cells[src_off..src_end]);
1879    }
1880    for r in 0..rows as usize {
1881        let dst_off = frame.cell_offset(dst_row + r as u16, dst_col);
1882        let dst_end = dst_off + cols as usize * CELL_SIZE;
1883        let src_off = r * cols as usize * CELL_SIZE;
1884        frame.cells[dst_off..dst_end]
1885            .copy_from_slice(&temp[src_off..src_off + cols as usize * CELL_SIZE]);
1886    }
1887}
1888
1889fn append_full_width_fill_ops(
1890    current: &FrameState,
1891    basis: &mut FrameState,
1892    out: &mut Vec<u8>,
1893    op_count: &mut u16,
1894) {
1895    let rows = current.rows as usize;
1896    let cols = current.cols as usize;
1897    if rows == 0 || cols == 0 {
1898        return;
1899    }
1900
1901    let row_bytes = cols * CELL_SIZE;
1902    let mut row = 0usize;
1903    while row < rows {
1904        let row_off = row * row_bytes;
1905        if current.cells[row_off..row_off + row_bytes] == basis.cells[row_off..row_off + row_bytes]
1906        {
1907            row += 1;
1908            continue;
1909        }
1910        let Some(cell) = uniform_row_cell(current, row) else {
1911            row += 1;
1912            continue;
1913        };
1914        let mut end = row + 1;
1915        while end < rows {
1916            if uniform_row_cell(current, end).as_ref() != Some(&cell) {
1917                break;
1918            }
1919            end += 1;
1920        }
1921
1922        if *op_count == u16::MAX {
1923            break;
1924        }
1925        out.push(OP_FILL_RECT);
1926        out.extend_from_slice(&(row as u16).to_le_bytes());
1927        out.extend_from_slice(&0u16.to_le_bytes());
1928        out.extend_from_slice(&((end - row) as u16).to_le_bytes());
1929        out.extend_from_slice(&current.cols.to_le_bytes());
1930        out.extend_from_slice(&cell);
1931        *op_count = op_count.saturating_add(1);
1932
1933        for r in row..end {
1934            let row_off = basis.cell_offset(r as u16, 0);
1935            for c in 0..cols {
1936                let off = row_off + c * CELL_SIZE;
1937                basis.cells[off..off + CELL_SIZE].copy_from_slice(&cell);
1938            }
1939        }
1940
1941        row = end;
1942    }
1943}
1944
1945fn uniform_row_cell(frame: &FrameState, row: usize) -> Option<[u8; CELL_SIZE]> {
1946    let cols = frame.cols as usize;
1947    if row >= frame.rows as usize || cols == 0 {
1948        return None;
1949    }
1950    let start = row * cols * CELL_SIZE;
1951    let mut first = [0u8; CELL_SIZE];
1952    first.copy_from_slice(&frame.cells[start..start + CELL_SIZE]);
1953    if first[1] & 0b110 != 0 {
1954        return None;
1955    }
1956    for col in 1..cols {
1957        let off = start + col * CELL_SIZE;
1958        if frame.cells[off..off + CELL_SIZE] != first {
1959            return None;
1960        }
1961    }
1962    Some(first)
1963}
1964
1965fn encode_cell(dst: &mut [u8], ch: Option<char>, style: CellStyle, wide: bool, wide_cont: bool) {
1966    dst.fill(0);
1967
1968    let mut f0 = 0u8;
1969    encode_color(style.fg, &mut f0, &mut dst[2..5], false);
1970    encode_color(style.bg, &mut f0, &mut dst[5..8], true);
1971    if style.bold {
1972        f0 |= 1 << 4;
1973    }
1974    if style.dim {
1975        f0 |= 1 << 5;
1976    }
1977    if style.italic {
1978        f0 |= 1 << 6;
1979    }
1980    if style.underline {
1981        f0 |= 1 << 7;
1982    }
1983    dst[0] = f0;
1984
1985    let mut f1 = 0u8;
1986    if style.inverse {
1987        f1 |= 1;
1988    }
1989    if wide {
1990        f1 |= 1 << 1;
1991    }
1992    if wide_cont {
1993        f1 |= 1 << 2;
1994    }
1995    if let Some(ch) = ch {
1996        let mut buf = [0u8; 4];
1997        let encoded = ch.encode_utf8(&mut buf).as_bytes();
1998        let len = encoded.len().min(4);
1999        dst[8..8 + len].copy_from_slice(&encoded[..len]);
2000        f1 |= (len as u8) << 3;
2001    }
2002    dst[1] = f1;
2003}
2004
2005fn encode_color(color: Color, flags: &mut u8, dst: &mut [u8], is_bg: bool) {
2006    let shift = if is_bg { 2 } else { 0 };
2007    match color {
2008        Color::Default => {}
2009        Color::Indexed(idx) => {
2010            *flags |= 1 << shift;
2011            dst[0] = idx;
2012        }
2013        Color::Rgb(r, g, b) => {
2014            *flags |= 2 << shift;
2015            dst[0] = r;
2016            dst[1] = g;
2017            dst[2] = b;
2018        }
2019    }
2020}
2021
2022fn wrap_text_lines(text: &str, width: usize) -> Vec<String> {
2023    if width == 0 {
2024        return Vec::new();
2025    }
2026    let mut out = Vec::new();
2027    for paragraph in text.split('\n') {
2028        if paragraph.is_empty() {
2029            out.push(String::new());
2030            continue;
2031        }
2032        let mut line = String::new();
2033        let mut line_width = 0usize;
2034        for word in paragraph.split_whitespace() {
2035            push_wrapped_word(word, width, &mut out, &mut line, &mut line_width);
2036        }
2037        if !line.is_empty() {
2038            out.push(line);
2039        }
2040    }
2041    if out.is_empty() {
2042        out.push(String::new());
2043    }
2044    out
2045}
2046
2047fn push_wrapped_word(
2048    word: &str,
2049    width: usize,
2050    out: &mut Vec<String>,
2051    line: &mut String,
2052    line_width: &mut usize,
2053) {
2054    let word_width = UnicodeWidthStr::width(word);
2055    if line.is_empty() {
2056        if word_width <= width {
2057            line.push_str(word);
2058            *line_width = word_width;
2059            return;
2060        }
2061    } else if *line_width + 1 + word_width <= width {
2062        line.push(' ');
2063        line.push_str(word);
2064        *line_width += 1 + word_width;
2065        return;
2066    } else {
2067        out.push(std::mem::take(line));
2068        *line_width = 0;
2069        if word_width <= width {
2070            line.push_str(word);
2071            *line_width = word_width;
2072            return;
2073        }
2074    }
2075
2076    for ch in word.chars() {
2077        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(1).max(1);
2078        if *line_width + ch_width > width && !line.is_empty() {
2079            out.push(std::mem::take(line));
2080            *line_width = 0;
2081        }
2082        line.push(ch);
2083        *line_width += ch_width;
2084    }
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089    use super::*;
2090
2091    #[test]
2092    fn update_round_trip_preserves_title_and_cells() {
2093        let style = CellStyle::default();
2094        let mut prev = FrameState::new(2, 8);
2095        prev.set_title("one");
2096        prev.write_text(0, 0, "hello", style);
2097
2098        let mut next = prev.clone();
2099        next.set_title("two");
2100        next.write_text(1, 0, "world", style);
2101
2102        let baseline = build_update_msg(7, &prev, &FrameState::default()).unwrap();
2103        let delta = build_update_msg(7, &next, &prev).unwrap();
2104
2105        let mut term = TerminalState::new(2, 8);
2106        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
2107            panic!("expected update");
2108        };
2109        assert!(term.feed_compressed(payload));
2110        assert_eq!(term.title(), "one");
2111
2112        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2113            panic!("expected update");
2114        };
2115        assert!(term.feed_compressed(payload));
2116        assert_eq!(term.title(), "two");
2117        assert_eq!(term.get_all_text(), "hello\nworld");
2118    }
2119
2120    #[test]
2121    fn title_can_be_cleared_via_update() {
2122        let style = CellStyle::default();
2123        let mut prev = FrameState::new(1, 4);
2124        prev.set_title("busy");
2125        prev.write_text(0, 0, "ping", style);
2126
2127        let mut next = prev.clone();
2128        next.set_title("");
2129
2130        let baseline = build_update_msg(1, &prev, &FrameState::default()).unwrap();
2131        let delta = build_update_msg(1, &next, &prev).unwrap();
2132
2133        let mut term = TerminalState::new(1, 4);
2134        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
2135            panic!("expected update");
2136        };
2137        term.feed_compressed(payload);
2138        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2139            panic!("expected update");
2140        };
2141        term.feed_compressed(payload);
2142        assert_eq!(term.title(), "");
2143    }
2144
2145    #[test]
2146    fn scroll_heavy_update_can_use_ops_payload() {
2147        let style = CellStyle::default();
2148        let mut prev = FrameState::new(5, 6);
2149        prev.write_text(0, 0, "one", style);
2150        prev.write_text(1, 0, "two", style);
2151        prev.write_text(2, 0, "three", style);
2152        prev.write_text(3, 0, "four", style);
2153        prev.write_text(4, 0, "five", style);
2154
2155        let mut next = FrameState::new(5, 6);
2156        next.write_text(0, 0, "two", style);
2157        next.write_text(1, 0, "three", style);
2158        next.write_text(2, 0, "four", style);
2159        next.write_text(3, 0, "five", style);
2160
2161        let delta = build_update_msg(9, &next, &prev).unwrap();
2162        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2163            panic!("expected update");
2164        };
2165        let decoded = decompress_size_prepended(payload).unwrap();
2166        let title_field = u16::from_le_bytes([decoded[10], decoded[11]]);
2167        assert_ne!(title_field & OPS_PRESENT, 0);
2168
2169        let mut term = TerminalState::new(5, 6);
2170        let baseline = build_update_msg(9, &prev, &FrameState::default()).unwrap();
2171        let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
2172            panic!("expected update");
2173        };
2174        assert!(term.feed_compressed(payload));
2175        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2176            panic!("expected update");
2177        };
2178        assert!(term.feed_compressed(payload));
2179        assert_eq!(term.get_all_text(), "two\nthree\nfour\nfive\n");
2180    }
2181
2182    #[test]
2183    fn cooked_scroll_heavy_update_uses_copy_rect_op() {
2184        let style = CellStyle::default();
2185        let mut prev = FrameState::new(5, 6);
2186        prev.set_mode(MODE_ECHO | MODE_ICANON);
2187        prev.write_text(0, 0, "one", style);
2188        prev.write_text(1, 0, "two", style);
2189        prev.write_text(2, 0, "three", style);
2190        prev.write_text(3, 0, "four", style);
2191        prev.write_text(4, 0, "five", style);
2192
2193        let mut next = FrameState::new(5, 6);
2194        next.set_mode(MODE_ECHO | MODE_ICANON);
2195        next.write_text(0, 0, "two", style);
2196        next.write_text(1, 0, "three", style);
2197        next.write_text(2, 0, "four", style);
2198        next.write_text(3, 0, "five", style);
2199
2200        let delta = build_update_msg(9, &next, &prev).unwrap();
2201        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2202            panic!("expected update");
2203        };
2204        let decoded = decompress_size_prepended(payload).unwrap();
2205        let op_count = u16::from_le_bytes([decoded[12], decoded[13]]);
2206        assert!(op_count >= 1);
2207        assert_eq!(decoded[14], OP_COPY_RECT);
2208    }
2209
2210    #[test]
2211    fn mode_zero_scroll_uses_copy_rect() {
2212        let style = CellStyle::default();
2213        let mut prev = FrameState::new(5, 6);
2214        prev.write_text(0, 0, "one", style);
2215        prev.write_text(1, 0, "two", style);
2216        prev.write_text(2, 0, "three", style);
2217        prev.write_text(3, 0, "four", style);
2218        prev.write_text(4, 0, "five", style);
2219
2220        let mut next = FrameState::new(5, 6);
2221        next.write_text(0, 0, "two", style);
2222        next.write_text(1, 0, "three", style);
2223        next.write_text(2, 0, "four", style);
2224        next.write_text(3, 0, "five", style);
2225
2226        let delta = build_update_msg(9, &next, &prev).unwrap();
2227        let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
2228            panic!("expected update");
2229        };
2230        let decoded = decompress_size_prepended(payload).unwrap();
2231        let op_count = u16::from_le_bytes([decoded[12], decoded[13]]);
2232        assert!(op_count >= 1);
2233        // mode=0 frames (scrollback) now use COPY_RECT for efficient scrolling
2234        assert_eq!(decoded[14], OP_COPY_RECT);
2235
2236        // Verify round-trip correctness
2237        let baseline = build_update_msg(9, &prev, &FrameState::new(5, 6)).unwrap();
2238        let mut state = TerminalState::new(5, 6);
2239        let ServerMsg::Update { payload: bp, .. } = parse_server_msg(&baseline).unwrap() else {
2240            panic!("expected update");
2241        };
2242        state.feed_compressed(bp);
2243        state.feed_compressed(payload);
2244        assert_eq!(state.frame().cells(), next.cells());
2245    }
2246
2247    #[test]
2248    fn callback_renderer_wraps_text() {
2249        let mut renderer = CallbackRenderer::new(2, 8);
2250        renderer.render(|dom| {
2251            dom.wrapped_text(
2252                Rect::new(0, 0, 2, 8),
2253                "alpha beta gamma",
2254                CellStyle::default(),
2255            );
2256        });
2257        assert_eq!(renderer.frame().get_all_text(), "alpha\nbeta");
2258    }
2259
2260    #[test]
2261    fn scrolling_text_shows_tail() {
2262        let mut frame = FrameState::new(3, 8);
2263        frame.write_scrolling_text(
2264            Rect::new(0, 0, 3, 8),
2265            &["one", "two", "three", "four"],
2266            0,
2267            CellStyle::default(),
2268        );
2269        assert_eq!(frame.get_all_text(), "two\nthree\nfour");
2270    }
2271
2272    #[test]
2273    fn search_results_round_trip_with_context() {
2274        let msg = [
2275            vec![S2C_SEARCH_RESULTS],
2276            7u16.to_le_bytes().to_vec(),
2277            1u16.to_le_bytes().to_vec(),
2278            42u16.to_le_bytes().to_vec(),
2279            1234u32.to_le_bytes().to_vec(),
2280            vec![1, 0b111],
2281            9u32.to_le_bytes().to_vec(),
2282            5u16.to_le_bytes().to_vec(),
2283            b"hello".to_vec(),
2284        ]
2285        .concat();
2286
2287        let ServerMsg::SearchResults {
2288            request_id,
2289            results,
2290        } = parse_server_msg(&msg).unwrap()
2291        else {
2292            panic!("expected search results");
2293        };
2294        assert_eq!(request_id, 7);
2295        assert_eq!(results.len(), 1);
2296        assert_eq!(results[0].pty_id, 42);
2297        assert_eq!(results[0].score, 1234);
2298        assert_eq!(results[0].primary_source, 1);
2299        assert_eq!(results[0].matched_sources, 0b111);
2300        assert_eq!(results[0].scroll_offset, Some(9));
2301        assert_eq!(results[0].context, b"hello");
2302    }
2303
2304    // --- Tag tests ---
2305
2306    #[test]
2307    fn msg_create_no_tag_has_zero_tag_len() {
2308        let msg = msg_create(24, 80);
2309        assert_eq!(msg.len(), 7);
2310        assert_eq!(msg[0], C2S_CREATE);
2311        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 24);
2312        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 80);
2313        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 0);
2314    }
2315
2316    #[test]
2317    fn msg_create_tagged_encodes_tag() {
2318        let msg = msg_create_tagged(24, 80, "my-pty");
2319        assert_eq!(msg[0], C2S_CREATE);
2320        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2321        assert_eq!(tag_len, 6);
2322        assert_eq!(&msg[7..7 + tag_len], b"my-pty");
2323        assert_eq!(msg.len(), 7 + tag_len);
2324    }
2325
2326    #[test]
2327    fn msg_create_tagged_command_encodes_both() {
2328        let msg = msg_create_tagged_command(30, 120, "editor", "vim");
2329        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2330        assert_eq!(tag_len, 6);
2331        assert_eq!(&msg[7..13], b"editor");
2332        assert_eq!(&msg[13..], b"vim");
2333    }
2334
2335    #[test]
2336    fn msg_create_command_has_empty_tag() {
2337        let msg = msg_create_command(24, 80, "ls");
2338        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2339        assert_eq!(tag_len, 0);
2340        assert_eq!(&msg[7..], b"ls");
2341    }
2342
2343    #[test]
2344    fn msg_create_tagged_empty_tag() {
2345        let msg = msg_create_tagged(24, 80, "");
2346        assert_eq!(msg.len(), 7);
2347        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 0);
2348    }
2349
2350    #[test]
2351    fn msg_create_tagged_unicode_tag() {
2352        let msg = msg_create_tagged(24, 80, "日本語");
2353        let tag_len = u16::from_le_bytes([msg[5], msg[6]]) as usize;
2354        assert_eq!(tag_len, "日本語".len());
2355        assert_eq!(std::str::from_utf8(&msg[7..7 + tag_len]).unwrap(), "日本語");
2356    }
2357
2358    #[test]
2359    fn parse_created_with_tag() {
2360        let mut wire = vec![S2C_CREATED, 0x05, 0x00];
2361        wire.extend_from_slice(b"hello");
2362        let msg = parse_server_msg(&wire).unwrap();
2363        match msg {
2364            ServerMsg::Created { pty_id, tag } => {
2365                assert_eq!(pty_id, 5);
2366                assert_eq!(tag, "hello");
2367            }
2368            _ => panic!("expected Created"),
2369        }
2370    }
2371
2372    #[test]
2373    fn parse_created_without_tag() {
2374        let wire = vec![S2C_CREATED, 0x03, 0x00];
2375        let msg = parse_server_msg(&wire).unwrap();
2376        match msg {
2377            ServerMsg::Created { pty_id, tag } => {
2378                assert_eq!(pty_id, 3);
2379                assert_eq!(tag, "");
2380            }
2381            _ => panic!("expected Created"),
2382        }
2383    }
2384
2385    #[test]
2386    fn parse_created_n_with_tag() {
2387        let mut wire = vec![S2C_CREATED_N, 0x2a, 0x00, 0x05, 0x00];
2388        wire.extend_from_slice(b"hello");
2389        let msg = parse_server_msg(&wire).unwrap();
2390        match msg {
2391            ServerMsg::CreatedN { nonce, pty_id, tag } => {
2392                assert_eq!(nonce, 42);
2393                assert_eq!(pty_id, 5);
2394                assert_eq!(tag, "hello");
2395            }
2396            _ => panic!("expected CreatedN"),
2397        }
2398    }
2399
2400    #[test]
2401    fn msg_create_n_format() {
2402        let msg = msg_create_n(42, 24, 80, "test");
2403        assert_eq!(msg[0], C2S_CREATE_N);
2404        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
2405        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
2406        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
2407        assert_eq!(u16::from_le_bytes([msg[7], msg[8]]), 4);
2408        assert_eq!(&msg[9..], b"test");
2409    }
2410
2411    #[test]
2412    fn msg_create_n_command_format() {
2413        let msg = msg_create_n_command(7, 30, 120, "bg", "make build");
2414        assert_eq!(msg[0], C2S_CREATE_N);
2415        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 7);
2416        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 30);
2417        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 120);
2418        let tag_len = u16::from_le_bytes([msg[7], msg[8]]) as usize;
2419        assert_eq!(tag_len, 2);
2420        assert_eq!(&msg[9..9 + tag_len], b"bg");
2421        assert_eq!(&msg[9 + tag_len..], b"make build");
2422    }
2423
2424    #[test]
2425    fn parse_list_with_tags() {
2426        // 2 entries: id=1 tag="ab", id=2 tag=""
2427        let mut wire = vec![S2C_LIST, 0x02, 0x00];
2428        // entry 1: id=1, tag_len=2, tag="ab"
2429        wire.extend_from_slice(&1u16.to_le_bytes());
2430        wire.extend_from_slice(&2u16.to_le_bytes());
2431        wire.extend_from_slice(b"ab");
2432        // entry 2: id=2, tag_len=0
2433        wire.extend_from_slice(&2u16.to_le_bytes());
2434        wire.extend_from_slice(&0u16.to_le_bytes());
2435
2436        let msg = parse_server_msg(&wire).unwrap();
2437        match msg {
2438            ServerMsg::List { entries } => {
2439                assert_eq!(entries.len(), 2);
2440                assert_eq!(entries[0].pty_id, 1);
2441                assert_eq!(entries[0].tag, "ab");
2442                assert_eq!(entries[1].pty_id, 2);
2443                assert_eq!(entries[1].tag, "");
2444            }
2445            _ => panic!("expected List"),
2446        }
2447    }
2448
2449    #[test]
2450    fn parse_list_empty() {
2451        let wire = vec![S2C_LIST, 0x00, 0x00];
2452        let msg = parse_server_msg(&wire).unwrap();
2453        match msg {
2454            ServerMsg::List { entries } => assert_eq!(entries.len(), 0),
2455            _ => panic!("expected List"),
2456        }
2457    }
2458
2459    #[test]
2460    fn parse_list_truncated_gracefully() {
2461        // count=2 but only 1 complete entry
2462        let mut wire = vec![S2C_LIST, 0x02, 0x00];
2463        wire.extend_from_slice(&1u16.to_le_bytes());
2464        wire.extend_from_slice(&0u16.to_le_bytes());
2465        // missing second entry
2466        let msg = parse_server_msg(&wire).unwrap();
2467        match msg {
2468            ServerMsg::List { entries } => assert_eq!(entries.len(), 1),
2469            _ => panic!("expected List"),
2470        }
2471    }
2472
2473    #[test]
2474    fn parse_list_with_long_tags() {
2475        let long_tag = "a".repeat(300);
2476        let mut wire = vec![S2C_LIST, 0x01, 0x00];
2477        wire.extend_from_slice(&42u16.to_le_bytes());
2478        wire.extend_from_slice(&(long_tag.len() as u16).to_le_bytes());
2479        wire.extend_from_slice(long_tag.as_bytes());
2480
2481        let msg = parse_server_msg(&wire).unwrap();
2482        match msg {
2483            ServerMsg::List { entries } => {
2484                assert_eq!(entries.len(), 1);
2485                assert_eq!(entries[0].pty_id, 42);
2486                assert_eq!(entries[0].tag, long_tag);
2487            }
2488            _ => panic!("expected List"),
2489        }
2490    }
2491
2492    #[test]
2493    fn create_and_created_tag_round_trip() {
2494        // Simulate: client sends create with tag, server echoes tag in created
2495        let create_msg = msg_create_tagged(24, 80, "my-session");
2496        let tag_len = u16::from_le_bytes([create_msg[5], create_msg[6]]) as usize;
2497        let tag = std::str::from_utf8(&create_msg[7..7 + tag_len]).unwrap();
2498
2499        // Server builds S2C_CREATED with the tag
2500        let mut created_wire = vec![S2C_CREATED, 0x07, 0x00]; // pty_id = 7
2501        created_wire.extend_from_slice(tag.as_bytes());
2502
2503        let msg = parse_server_msg(&created_wire).unwrap();
2504        match msg {
2505            ServerMsg::Created {
2506                pty_id,
2507                tag: parsed_tag,
2508            } => {
2509                assert_eq!(pty_id, 7);
2510                assert_eq!(parsed_tag, "my-session");
2511            }
2512            _ => panic!("expected Created"),
2513        }
2514    }
2515
2516    // --- FrameState tests ---
2517
2518    #[test]
2519    fn frame_state_accessors() {
2520        let mut f = FrameState::new(4, 10);
2521        assert_eq!(f.rows(), 4);
2522        assert_eq!(f.cols(), 10);
2523        assert_eq!(f.cursor_row(), 0);
2524        assert_eq!(f.cursor_col(), 0);
2525        assert_eq!(f.mode(), 0);
2526        assert_eq!(f.title(), "");
2527        assert_eq!(f.cells().len(), 4 * 10 * CELL_SIZE);
2528        assert_eq!(f.cells_mut().len(), 4 * 10 * CELL_SIZE);
2529        assert!(f.overflow().is_empty());
2530        assert!(f.overflow_mut().is_empty());
2531    }
2532
2533    #[test]
2534    fn frame_state_from_parts() {
2535        let cells = vec![0u8; 2 * 4 * CELL_SIZE];
2536        let f = FrameState::from_parts(2, 4, 1, 3, 0x0F, "hello", cells.clone());
2537        assert_eq!(f.rows(), 2);
2538        assert_eq!(f.cols(), 4);
2539        assert_eq!(f.cursor_row(), 1);
2540        assert_eq!(f.cursor_col(), 3);
2541        assert_eq!(f.mode(), 0x0F);
2542        assert_eq!(f.title(), "hello");
2543        assert_eq!(f.cells(), &cells[..]);
2544    }
2545
2546    #[test]
2547    fn frame_state_from_parts_wrong_size() {
2548        // cells with wrong size should be ignored (stays zeroed)
2549        let cells = vec![0u8; 10]; // wrong size
2550        let f = FrameState::from_parts(2, 4, 0, 0, 0, "", cells);
2551        assert_eq!(f.cells().len(), 2 * 4 * CELL_SIZE);
2552    }
2553
2554    #[test]
2555    fn frame_state_resize() {
2556        let mut f = FrameState::new(4, 10);
2557        f.set_cursor(3, 9);
2558        f.resize(2, 5);
2559        assert_eq!(f.rows(), 2);
2560        assert_eq!(f.cols(), 5);
2561        assert_eq!(f.cursor_row(), 1); // clamped
2562        assert_eq!(f.cursor_col(), 4); // clamped
2563        assert_eq!(f.cells().len(), 2 * 5 * CELL_SIZE);
2564    }
2565
2566    #[test]
2567    fn frame_state_resize_noop() {
2568        let mut f = FrameState::new(4, 10);
2569        let ptr_before = f.cells().as_ptr();
2570        f.resize(4, 10); // same size
2571        let ptr_after = f.cells().as_ptr();
2572        assert_eq!(ptr_before, ptr_after); // no realloc
2573    }
2574
2575    #[test]
2576    fn frame_state_set_cursor_clamps() {
2577        let mut f = FrameState::new(4, 10);
2578        f.set_cursor(100, 200);
2579        assert_eq!(f.cursor_row(), 3);
2580        assert_eq!(f.cursor_col(), 9);
2581    }
2582
2583    #[test]
2584    fn frame_state_set_title() {
2585        let mut f = FrameState::new(2, 2);
2586        assert!(f.set_title("new title"));
2587        assert_eq!(f.title(), "new title");
2588        assert!(!f.set_title("new title")); // same title returns false
2589        assert!(f.set_title("other"));
2590    }
2591
2592    #[test]
2593    fn frame_state_get_text_and_write_text() {
2594        let mut f = FrameState::new(2, 10);
2595        f.write_text(0, 0, "Hello", CellStyle::default());
2596        f.write_text(1, 0, "World", CellStyle::default());
2597        let text = f.get_text(0, 0, 1, 9);
2598        assert!(text.contains("Hello"));
2599        assert!(text.contains("World"));
2600        let all = f.get_all_text();
2601        assert!(all.contains("Hello"));
2602    }
2603
2604    #[test]
2605    fn frame_state_get_text_empty() {
2606        let f = FrameState::new(0, 0);
2607        assert_eq!(f.get_text(0, 0, 0, 0), "");
2608        assert_eq!(f.get_all_text(), "");
2609    }
2610
2611    #[test]
2612    fn frame_state_get_cell() {
2613        let f = FrameState::new(2, 4);
2614        let cell = f.get_cell(0, 0);
2615        assert_eq!(cell.len(), CELL_SIZE);
2616        // Out of bounds
2617        assert!(f.get_cell(100, 100).is_empty());
2618    }
2619
2620    #[test]
2621    fn frame_state_cell_content_blank() {
2622        let f = FrameState::new(2, 4);
2623        assert_eq!(f.cell_content(0, 0), " "); // blank cell
2624        assert_eq!(f.cell_content(100, 0), ""); // out of bounds
2625    }
2626
2627    #[test]
2628    fn frame_state_cell_content_with_text() {
2629        let mut f = FrameState::new(2, 10);
2630        f.write_text(0, 0, "A", CellStyle::default());
2631        assert_eq!(f.cell_content(0, 0), "A");
2632    }
2633
2634    #[test]
2635    fn frame_state_fill_rect() {
2636        let mut f = FrameState::new(4, 10);
2637        f.fill_rect(Rect::new(0, 0, 2, 5), 'X', CellStyle::default());
2638        assert_eq!(f.cell_content(0, 0), "X");
2639        assert_eq!(f.cell_content(1, 4), "X");
2640        assert_eq!(f.cell_content(2, 0), " "); // outside rect
2641    }
2642
2643    #[test]
2644    fn frame_state_wrapped_text() {
2645        let mut f = FrameState::new(4, 10);
2646        let lines =
2647            f.write_wrapped_text(Rect::new(0, 0, 4, 5), "hello world", CellStyle::default());
2648        assert!(lines >= 2); // "hello world" wraps at width 5
2649    }
2650
2651    #[test]
2652    fn frame_state_wrapped_text_empty_rect() {
2653        let mut f = FrameState::new(4, 10);
2654        assert_eq!(
2655            f.write_wrapped_text(Rect::new(0, 0, 0, 0), "hi", CellStyle::default()),
2656            0
2657        );
2658    }
2659
2660    #[test]
2661    fn frame_state_scrolling_text() {
2662        let mut f = FrameState::new(4, 10);
2663        f.write_scrolling_text(
2664            Rect::new(0, 0, 3, 10),
2665            &["line1", "line2", "line3", "line4"],
2666            0,
2667            CellStyle::default(),
2668        );
2669        // Last 3 lines visible with offset_from_bottom=0
2670        assert_eq!(f.cell_content(0, 0), "l"); // "line2"
2671    }
2672
2673    #[test]
2674    fn frame_state_scrolling_text_empty_rect() {
2675        let mut f = FrameState::new(4, 10);
2676        f.write_scrolling_text(Rect::new(0, 0, 0, 0), &["hi"], 0, CellStyle::default());
2677        // Should not panic
2678    }
2679
2680    #[test]
2681    fn frame_state_clear() {
2682        let mut f = FrameState::new(2, 4);
2683        f.write_text(0, 0, "AB", CellStyle::default());
2684        f.clear(CellStyle::default());
2685        assert_eq!(f.cell_content(0, 0), " ");
2686    }
2687
2688    // --- TerminalState tests ---
2689
2690    #[test]
2691    fn terminal_state_accessors() {
2692        let t = TerminalState::new(24, 80);
2693        assert_eq!(t.rows(), 24);
2694        assert_eq!(t.cols(), 80);
2695        assert_eq!(t.cursor_row(), 0);
2696        assert_eq!(t.cursor_col(), 0);
2697        assert_eq!(t.mode(), 0);
2698        assert_eq!(t.title(), "");
2699        assert_eq!(t.cells().len(), 24 * 80 * CELL_SIZE);
2700        assert_eq!(t.frame().rows(), 24);
2701    }
2702
2703    #[test]
2704    fn terminal_state_mutators() {
2705        let mut t = TerminalState::new(4, 10);
2706        t.frame_mut().set_title("test");
2707        assert_eq!(t.title(), "test");
2708    }
2709
2710    #[test]
2711    fn terminal_state_set_title() {
2712        let mut t = TerminalState::new(4, 10);
2713        assert!(t.frame_mut().set_title("hello"));
2714        assert_eq!(t.title(), "hello");
2715        assert!(!t.frame_mut().set_title("hello")); // same
2716    }
2717
2718    #[test]
2719    fn terminal_state_get_text() {
2720        let t = TerminalState::new(2, 10);
2721        let text = t.get_text(0, 0, 0, 9);
2722        assert!(text.is_empty() || text.chars().all(|c| c == ' ' || c == '\n'));
2723        assert!(t.get_cell(0, 0).len() == CELL_SIZE);
2724        assert!(t.get_cell(100, 100).is_empty());
2725    }
2726
2727    #[test]
2728    fn terminal_state_resize() {
2729        let mut t = TerminalState::new(4, 10);
2730        t.frame_mut().resize(2, 5);
2731        // Note: TerminalState.dirty isn't updated by frame_mut().resize()
2732        // directly — that happens through feed_compressed. So just check frame.
2733        assert_eq!(t.rows(), 2);
2734        assert_eq!(t.cols(), 5);
2735    }
2736
2737    #[test]
2738    fn terminal_state_feed_compressed_invalid() {
2739        let mut t = TerminalState::new(4, 10);
2740        assert!(!t.feed_compressed(b"garbage"));
2741        assert!(!t.feed_compressed(&[]));
2742    }
2743
2744    #[test]
2745    fn terminal_state_feed_compressed_batch_empty() {
2746        let mut t = TerminalState::new(4, 10);
2747        assert!(!t.feed_compressed_batch(&[]));
2748    }
2749
2750    #[test]
2751    fn terminal_state_feed_compressed_batch_truncated() {
2752        let mut t = TerminalState::new(4, 10);
2753        // Length header says 100 bytes but only 4 bytes present
2754        let batch = &[100, 0, 0, 0];
2755        assert!(!t.feed_compressed_batch(batch));
2756    }
2757
2758    // --- Client message builder tests ---
2759
2760    #[test]
2761    fn msg_input_format() {
2762        let msg = msg_input(5, b"hello");
2763        assert_eq!(msg[0], C2S_INPUT);
2764        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 5);
2765        assert_eq!(&msg[3..], b"hello");
2766    }
2767
2768    #[test]
2769    fn msg_resize_format() {
2770        let msg = msg_resize(3, 24, 80);
2771        assert_eq!(msg[0], C2S_RESIZE);
2772        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
2773        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
2774        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
2775    }
2776
2777    #[test]
2778    fn msg_resize_batch_format() {
2779        let msg = msg_resize_batch(&[(3, 24, 80), (5, 40, 120)]);
2780        assert_eq!(msg[0], C2S_RESIZE);
2781        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
2782        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 24);
2783        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 80);
2784        assert_eq!(u16::from_le_bytes([msg[7], msg[8]]), 5);
2785        assert_eq!(u16::from_le_bytes([msg[9], msg[10]]), 40);
2786        assert_eq!(u16::from_le_bytes([msg[11], msg[12]]), 120);
2787    }
2788
2789    #[test]
2790    fn msg_focus_format() {
2791        let msg = msg_focus(7);
2792        assert_eq!(msg[0], C2S_FOCUS);
2793        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 7);
2794        assert_eq!(msg.len(), 3);
2795    }
2796
2797    #[test]
2798    fn msg_close_format() {
2799        let msg = msg_close(9);
2800        assert_eq!(msg[0], C2S_CLOSE);
2801        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 9);
2802    }
2803
2804    #[test]
2805    fn msg_subscribe_unsubscribe_format() {
2806        let sub = msg_subscribe(1);
2807        assert_eq!(sub[0], C2S_SUBSCRIBE);
2808        assert_eq!(u16::from_le_bytes([sub[1], sub[2]]), 1);
2809
2810        let unsub = msg_unsubscribe(2);
2811        assert_eq!(unsub[0], C2S_UNSUBSCRIBE);
2812        assert_eq!(u16::from_le_bytes([unsub[1], unsub[2]]), 2);
2813    }
2814
2815    #[test]
2816    fn msg_search_format() {
2817        let msg = msg_search(42, "test query");
2818        assert_eq!(msg[0], C2S_SEARCH);
2819        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
2820        assert_eq!(&msg[3..], b"test query");
2821    }
2822
2823    #[test]
2824    fn msg_ack_format() {
2825        let msg = msg_ack();
2826        assert_eq!(msg, vec![C2S_ACK]);
2827    }
2828
2829    #[test]
2830    fn msg_scroll_format() {
2831        let msg = msg_scroll(5, 1000);
2832        assert_eq!(msg[0], C2S_SCROLL);
2833        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 5);
2834        assert_eq!(u32::from_le_bytes([msg[3], msg[4], msg[5], msg[6]]), 1000);
2835    }
2836
2837    #[test]
2838    fn msg_display_rate_format() {
2839        let msg = msg_display_rate(120);
2840        assert_eq!(msg[0], C2S_DISPLAY_RATE);
2841        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 120);
2842    }
2843
2844    #[test]
2845    fn msg_client_metrics_format() {
2846        let msg = msg_client_metrics(3, 5, 100);
2847        assert_eq!(msg[0], C2S_CLIENT_METRICS);
2848        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 3);
2849        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 5);
2850        assert_eq!(u16::from_le_bytes([msg[5], msg[6]]), 100);
2851    }
2852
2853    // --- CallbackRenderer tests ---
2854
2855    #[test]
2856    fn callback_renderer_resize() {
2857        let mut r = CallbackRenderer::new(2, 8);
2858        assert_eq!(r.frame().rows(), 2);
2859        r.resize(4, 16);
2860        assert_eq!(r.frame().rows(), 4);
2861        assert_eq!(r.frame().cols(), 16);
2862    }
2863
2864    #[test]
2865    fn callback_renderer_fill() {
2866        let mut r = CallbackRenderer::new(4, 10);
2867        r.render(|dom| {
2868            dom.fill(Rect::new(0, 0, 2, 5), '#', CellStyle::default());
2869        });
2870        assert_eq!(r.frame().cell_content(0, 0), "#");
2871        assert_eq!(r.frame().cell_content(1, 4), "#");
2872    }
2873
2874    #[test]
2875    fn callback_renderer_text() {
2876        let mut r = CallbackRenderer::new(4, 20);
2877        r.render(|dom| {
2878            dom.text(0, 0, "Hello", CellStyle::default());
2879        });
2880        assert_eq!(r.frame().cell_content(0, 0), "H");
2881        assert_eq!(r.frame().cell_content(0, 4), "o");
2882    }
2883
2884    #[test]
2885    fn callback_renderer_set_title() {
2886        let mut r = CallbackRenderer::new(2, 8);
2887        r.render(|dom| {
2888            dom.set_title("my title");
2889        });
2890        assert_eq!(r.frame().title(), "my title");
2891    }
2892
2893    #[test]
2894    fn callback_renderer_set_background() {
2895        let mut r = CallbackRenderer::new(2, 4);
2896        let style = CellStyle {
2897            bg: Color::Rgb(255, 0, 0),
2898            ..CellStyle::default()
2899        };
2900        r.render(|dom| {
2901            dom.set_background(style);
2902        });
2903        // Background fill should have been applied to all cells
2904        assert_eq!(r.frame().cells().len(), 2 * 4 * CELL_SIZE);
2905    }
2906
2907    #[test]
2908    fn callback_renderer_scrolling_text() {
2909        let mut r = CallbackRenderer::new(4, 20);
2910        r.render(|dom| {
2911            dom.scrolling_text(
2912                Rect::new(0, 0, 3, 20),
2913                ["a", "b", "c", "d", "e"].map(String::from),
2914                0,
2915                CellStyle::default(),
2916            );
2917        });
2918        // Should show the last 3 lines
2919        assert_eq!(r.frame().cell_content(0, 0), "c");
2920    }
2921
2922    // --- parse_server_msg edge cases ---
2923
2924    #[test]
2925    fn parse_empty_returns_none() {
2926        assert!(parse_server_msg(&[]).is_none());
2927    }
2928
2929    #[test]
2930    fn parse_unknown_type_returns_none() {
2931        assert!(parse_server_msg(&[0xFF, 0x00, 0x00]).is_none());
2932    }
2933
2934    #[test]
2935    fn parse_update_too_short() {
2936        assert!(parse_server_msg(&[S2C_UPDATE, 0x00]).is_none());
2937    }
2938
2939    #[test]
2940    fn parse_closed() {
2941        let msg = parse_server_msg(&[S2C_CLOSED, 0x05, 0x00]).unwrap();
2942        match msg {
2943            ServerMsg::Closed { pty_id } => assert_eq!(pty_id, 5),
2944            _ => panic!("expected Closed"),
2945        }
2946    }
2947
2948    #[test]
2949    fn parse_title() {
2950        let mut wire = vec![S2C_TITLE, 0x01, 0x00];
2951        wire.extend_from_slice(b"mytitle");
2952        let msg = parse_server_msg(&wire).unwrap();
2953        match msg {
2954            ServerMsg::Title { pty_id, title } => {
2955                assert_eq!(pty_id, 1);
2956                assert_eq!(title, b"mytitle");
2957            }
2958            _ => panic!("expected Title"),
2959        }
2960    }
2961
2962    // --- build_update_msg round-trip ---
2963
2964    #[test]
2965    fn build_update_msg_round_trip_with_resize() {
2966        let style = CellStyle::default();
2967        let mut prev = FrameState::new(2, 4);
2968        prev.write_text(0, 0, "AB", style);
2969
2970        let mut next = FrameState::new(3, 5); // different size
2971        next.write_text(0, 0, "XY", style);
2972        next.set_title("resized");
2973
2974        let msg = build_update_msg(1, &next, &prev).unwrap();
2975        assert!(!msg.is_empty());
2976
2977        // Apply to a terminal
2978        let mut t = TerminalState::new(2, 4);
2979        assert!(t.feed_compressed(&msg[3..])); // skip pty_id header
2980        assert_eq!(t.rows(), 3);
2981        assert_eq!(t.cols(), 5);
2982        assert_eq!(t.title(), "resized");
2983    }
2984
2985    #[test]
2986    fn build_update_msg_cursor_change() {
2987        let mut prev = FrameState::new(4, 10);
2988        prev.set_cursor(0, 0);
2989
2990        let mut next = prev.clone();
2991        next.set_cursor(2, 5);
2992
2993        let msg = build_update_msg(0, &next, &prev).unwrap();
2994
2995        let mut t = TerminalState::new(4, 10);
2996        assert!(t.feed_compressed(&msg[3..]));
2997        assert_eq!(t.cursor_row(), 2);
2998        assert_eq!(t.cursor_col(), 5);
2999    }
3000
3001    #[test]
3002    fn build_update_msg_mode_change() {
3003        let prev = FrameState::new(2, 4);
3004        let mut next = prev.clone();
3005        next.set_mode(0x0F);
3006
3007        let msg = build_update_msg(0, &next, &prev).unwrap();
3008        let mut t = TerminalState::new(2, 4);
3009        assert!(t.feed_compressed(&msg[3..]));
3010        assert_eq!(t.mode(), 0x0F);
3011    }
3012
3013    #[test]
3014    fn feed_compressed_batch_multiple_frames() {
3015        let style = CellStyle::default();
3016        let prev = FrameState::new(2, 4);
3017
3018        let mut mid = prev.clone();
3019        mid.write_text(0, 0, "AB", style);
3020        let msg1 = build_update_msg(0, &mid, &prev).unwrap();
3021
3022        let mut next = mid.clone();
3023        next.write_text(1, 0, "CD", style);
3024        let msg2 = build_update_msg(0, &next, &mid).unwrap();
3025
3026        // Build batch: [len1:4][compressed1][len2:4][compressed2]
3027        let payload1 = &msg1[3..];
3028        let payload2 = &msg2[3..];
3029        let mut batch = Vec::new();
3030        batch.extend_from_slice(&(payload1.len() as u32).to_le_bytes());
3031        batch.extend_from_slice(payload1);
3032        batch.extend_from_slice(&(payload2.len() as u32).to_le_bytes());
3033        batch.extend_from_slice(payload2);
3034
3035        let mut t = TerminalState::new(2, 4);
3036        assert!(t.feed_compressed_batch(&batch));
3037        let text = t.get_all_text();
3038        assert!(text.contains("AB"));
3039        assert!(text.contains("CD"));
3040    }
3041}