Skip to main content

aimux_server/
screen.rs

1//! VT state machine for maintaining per-pane screen state.
2//!
3//! Uses the `vte` crate to parse VT sequences and maintain a grid of cells.
4//! The screen is split into `ScreenState` (mutable state, implements `vte::Perform`)
5//! and `Screen` (owns both parser and state) to satisfy borrow checker requirements.
6
7use std::collections::VecDeque;
8
9pub(crate) const DEFAULT_SCROLLBACK_LIMIT: usize = 2000;
10
11// ---------------------------------------------------------------------------
12// Types
13// ---------------------------------------------------------------------------
14
15#[derive(Clone, Default)]
16pub struct Cell {
17    pub ch: char,
18    pub attrs: CellAttrs,
19}
20
21#[derive(Clone, Default)]
22pub struct CellAttrs {
23    pub bold: bool,
24    pub dim: bool,
25    pub italic: bool,
26    pub underline: bool,
27    pub fg: Color,
28    pub bg: Color,
29}
30
31#[derive(Clone, Default, PartialEq, Debug)]
32pub enum Color {
33    #[default]
34    Default,
35    Indexed(u8),
36    Rgb(u8, u8, u8),
37}
38
39#[derive(Clone, Copy, Debug, Default)]
40pub struct CursorPos {
41    pub row: u16,
42    pub col: u16,
43}
44
45// ---------------------------------------------------------------------------
46// ScreenState - the mutable state that implements vte::Perform
47// ---------------------------------------------------------------------------
48
49pub struct ScreenState {
50    cols: u16,
51    rows: u16,
52    grid: Vec<Vec<Cell>>,
53    cursor: CursorPos,
54    saved_cursor: Option<CursorPos>,
55    attrs: CellAttrs,
56    alternate_screen: bool,
57    alternate_grid: Option<Vec<Vec<Cell>>>,
58    alternate_cursor: Option<CursorPos>,
59    title: String,
60    cursor_visible: bool,
61    scroll_top: u16,
62    scroll_bottom: u16,
63    scrollback: VecDeque<String>,
64    scrollback_limit: usize,
65}
66
67fn make_grid(cols: u16, rows: u16) -> Vec<Vec<Cell>> {
68    vec![vec![Cell { ch: ' ', attrs: CellAttrs::default() }; cols as usize]; rows as usize]
69}
70
71fn blank_row(cols: u16) -> Vec<Cell> {
72    vec![Cell { ch: ' ', attrs: CellAttrs::default() }; cols as usize]
73}
74
75impl ScreenState {
76    fn new(cols: u16, rows: u16, scrollback_limit: usize) -> Self {
77        Self {
78            cols,
79            rows,
80            grid: make_grid(cols, rows),
81            cursor: CursorPos::default(),
82            saved_cursor: None,
83            attrs: CellAttrs::default(),
84            alternate_screen: false,
85            alternate_grid: None,
86            alternate_cursor: None,
87            title: String::new(),
88            cursor_visible: true,
89            scroll_top: 0,
90            scroll_bottom: rows.saturating_sub(1),
91            scrollback: VecDeque::new(),
92            scrollback_limit,
93        }
94    }
95
96    /// Scroll the region [scroll_top..=scroll_bottom] up by one line.
97    fn scroll_up(&mut self) {
98        let top = self.scroll_top as usize;
99        let bottom = self.scroll_bottom as usize;
100        if top < self.grid.len() && bottom < self.grid.len() && top <= bottom {
101            // Capture evicted line to scrollback — only from main screen, full-screen scroll
102            if !self.alternate_screen && self.scroll_top == 0 {
103                let line: String = self.grid[top]
104                    .iter()
105                    .map(|c| c.ch)
106                    .collect::<String>()
107                    .trim_end()
108                    .to_string();
109                self.scrollback.push_back(line);
110                if self.scrollback.len() > self.scrollback_limit {
111                    self.scrollback.pop_front();
112                }
113            }
114            self.grid.remove(top);
115            self.grid.insert(bottom, blank_row(self.cols));
116        }
117    }
118
119    /// Scroll the region [scroll_top..=scroll_bottom] down by one line.
120    fn scroll_down(&mut self) {
121        let top = self.scroll_top as usize;
122        let bottom = self.scroll_bottom as usize;
123        if top < self.grid.len() && bottom < self.grid.len() && top <= bottom {
124            self.grid.remove(bottom);
125            self.grid.insert(top, blank_row(self.cols));
126        }
127    }
128
129    fn clamp_cursor(&mut self) {
130        if self.cursor.row >= self.rows {
131            self.cursor.row = self.rows.saturating_sub(1);
132        }
133        if self.cursor.col >= self.cols {
134            self.cursor.col = self.cols.saturating_sub(1);
135        }
136    }
137
138    /// Parse SGR (Select Graphic Rendition) parameters.
139    fn handle_sgr(&mut self, params: &vte::Params) {
140        let raw: Vec<Vec<u16>> = params.iter().map(|p| p.to_vec()).collect();
141        let mut i = 0;
142        while i < raw.len() {
143            let p = raw[i].first().copied().unwrap_or(0);
144            match p {
145                0 => self.attrs = CellAttrs::default(),
146                1 => self.attrs.bold = true,
147                2 => self.attrs.dim = true,
148                3 => self.attrs.italic = true,
149                4 => self.attrs.underline = true,
150                22 => {
151                    self.attrs.bold = false;
152                    self.attrs.dim = false;
153                }
154                23 => self.attrs.italic = false,
155                24 => self.attrs.underline = false,
156                30..=37 => self.attrs.fg = Color::Indexed((p - 30) as u8),
157                38 => {
158                    i += 1;
159                    if i < raw.len() {
160                        let mode = raw[i].first().copied().unwrap_or(0);
161                        if mode == 5 {
162                            i += 1;
163                            if i < raw.len() {
164                                let n = raw[i].first().copied().unwrap_or(0);
165                                self.attrs.fg = Color::Indexed(n as u8);
166                            }
167                        } else if mode == 2 && i + 3 < raw.len() {
168                            let r = raw.get(i + 1).and_then(|v| v.first()).copied().unwrap_or(0) as u8;
169                            let g = raw.get(i + 2).and_then(|v| v.first()).copied().unwrap_or(0) as u8;
170                            let b = raw.get(i + 3).and_then(|v| v.first()).copied().unwrap_or(0) as u8;
171                            self.attrs.fg = Color::Rgb(r, g, b);
172                            i += 3;
173                        }
174                    }
175                }
176                39 => self.attrs.fg = Color::Default,
177                40..=47 => self.attrs.bg = Color::Indexed((p - 40) as u8),
178                48 => {
179                    i += 1;
180                    if i < raw.len() {
181                        let mode = raw[i].first().copied().unwrap_or(0);
182                        if mode == 5 {
183                            i += 1;
184                            if i < raw.len() {
185                                let n = raw[i].first().copied().unwrap_or(0);
186                                self.attrs.bg = Color::Indexed(n as u8);
187                            }
188                        } else if mode == 2 && i + 3 < raw.len() {
189                            let r = raw.get(i + 1).and_then(|v| v.first()).copied().unwrap_or(0) as u8;
190                            let g = raw.get(i + 2).and_then(|v| v.first()).copied().unwrap_or(0) as u8;
191                            let b = raw.get(i + 3).and_then(|v| v.first()).copied().unwrap_or(0) as u8;
192                            self.attrs.bg = Color::Rgb(r, g, b);
193                            i += 3;
194                        }
195                    }
196                }
197                49 => self.attrs.bg = Color::Default,
198                90..=97 => self.attrs.fg = Color::Indexed((p - 90 + 8) as u8),
199                100..=107 => self.attrs.bg = Color::Indexed((p - 100 + 8) as u8),
200                _ => {}
201            }
202            i += 1;
203        }
204    }
205
206    fn get_param(params: &vte::Params, idx: usize, default: u16) -> u16 {
207        params
208            .iter()
209            .nth(idx)
210            .and_then(|p| p.first().copied())
211            .map(|v| if v == 0 { default } else { v })
212            .unwrap_or(default)
213    }
214
215    fn get_param_raw(params: &vte::Params, idx: usize) -> u16 {
216        params
217            .iter()
218            .nth(idx)
219            .and_then(|p| p.first().copied())
220            .unwrap_or(0)
221    }
222
223    fn reset(&mut self) {
224        self.grid = make_grid(self.cols, self.rows);
225        self.cursor = CursorPos::default();
226        self.saved_cursor = None;
227        self.attrs = CellAttrs::default();
228        self.alternate_screen = false;
229        self.alternate_grid = None;
230        self.alternate_cursor = None;
231        self.cursor_visible = true;
232        self.scroll_top = 0;
233        self.scroll_bottom = self.rows.saturating_sub(1);
234        self.scrollback.clear();
235    }
236
237    fn capture_scrollback_last(&self, n: usize) -> Vec<String> {
238        let len = self.scrollback.len();
239        let start = len.saturating_sub(n);
240        self.scrollback.range(start..).cloned().collect()
241    }
242
243    fn capture_scrollback_all(&self) -> Vec<String> {
244        self.scrollback.iter().cloned().collect()
245    }
246
247    fn scrollback_len(&self) -> usize {
248        self.scrollback.len()
249    }
250
251    fn enter_alternate_screen(&mut self) {
252        if !self.alternate_screen {
253            let main_grid = std::mem::replace(
254                &mut self.grid,
255                make_grid(self.cols, self.rows),
256            );
257            self.alternate_grid = Some(main_grid);
258            self.alternate_cursor = Some(self.cursor);
259            self.cursor = CursorPos::default();
260            self.alternate_screen = true;
261        }
262    }
263
264    fn exit_alternate_screen(&mut self) {
265        if self.alternate_screen {
266            if let Some(main_grid) = self.alternate_grid.take() {
267                self.grid = main_grid;
268            }
269            if let Some(saved) = self.alternate_cursor.take() {
270                self.cursor = saved;
271            }
272            self.alternate_screen = false;
273        }
274    }
275}
276
277impl vte::Perform for ScreenState {
278    fn print(&mut self, c: char) {
279        // Wrap to next line if past last column.
280        if self.cursor.col >= self.cols {
281            self.cursor.col = 0;
282            self.cursor.row += 1;
283            if self.cursor.row > self.scroll_bottom {
284                self.cursor.row = self.scroll_bottom;
285                self.scroll_up();
286            }
287        }
288
289        let row = self.cursor.row as usize;
290        let col = self.cursor.col as usize;
291        if row < self.grid.len() && col < self.grid[row].len() {
292            self.grid[row][col] = Cell {
293                ch: c,
294                attrs: self.attrs.clone(),
295            };
296        }
297        self.cursor.col += 1;
298    }
299
300    fn execute(&mut self, byte: u8) {
301        match byte {
302            0x08 => {
303                // BS - move cursor left
304                self.cursor.col = self.cursor.col.saturating_sub(1);
305            }
306            0x09 => {
307                // TAB - move to next tab stop (every 8 columns)
308                let next = ((self.cursor.col / 8) + 1) * 8;
309                self.cursor.col = next.min(self.cols.saturating_sub(1));
310            }
311            0x0A => {
312                // LF - move down, scroll if at bottom of scroll region
313                if self.cursor.row >= self.scroll_bottom {
314                    self.scroll_up();
315                } else {
316                    self.cursor.row += 1;
317                }
318            }
319            0x0D => {
320                // CR - move to column 0
321                self.cursor.col = 0;
322            }
323            0x07 => {
324                // BEL - ignore in headless mode
325            }
326            _ => {}
327        }
328    }
329
330    fn csi_dispatch(&mut self, params: &vte::Params, intermediates: &[u8], _ignore: bool, action: char) {
331        let is_private = intermediates.first() == Some(&b'?');
332
333        match action {
334            // Cursor movement
335            'A' => {
336                let n = Self::get_param(params, 0, 1);
337                self.cursor.row = self.cursor.row.saturating_sub(n);
338            }
339            'B' => {
340                let n = Self::get_param(params, 0, 1);
341                self.cursor.row = (self.cursor.row + n).min(self.rows.saturating_sub(1));
342            }
343            'C' => {
344                let n = Self::get_param(params, 0, 1);
345                self.cursor.col = (self.cursor.col + n).min(self.cols.saturating_sub(1));
346            }
347            'D' if !is_private => {
348                let n = Self::get_param(params, 0, 1);
349                self.cursor.col = self.cursor.col.saturating_sub(n);
350            }
351            'G' => {
352                let n = Self::get_param(params, 0, 1);
353                self.cursor.col = (n - 1).min(self.cols.saturating_sub(1));
354            }
355            'H' | 'f' => {
356                let row = Self::get_param(params, 0, 1);
357                let col = Self::get_param(params, 1, 1);
358                self.cursor.row = (row - 1).min(self.rows.saturating_sub(1));
359                self.cursor.col = (col - 1).min(self.cols.saturating_sub(1));
360            }
361            'd' => {
362                let n = Self::get_param(params, 0, 1);
363                self.cursor.row = (n - 1).min(self.rows.saturating_sub(1));
364            }
365
366            // Erase
367            'J' => {
368                let mode = Self::get_param_raw(params, 0);
369                let cols = self.cols;
370                match mode {
371                    0 => {
372                        let row = self.cursor.row as usize;
373                        let col = self.cursor.col as usize;
374                        if row < self.grid.len() {
375                            for c in col..self.grid[row].len() {
376                                self.grid[row][c] = Cell { ch: ' ', attrs: CellAttrs::default() };
377                            }
378                            for r in (row + 1)..self.grid.len() {
379                                self.grid[r] = blank_row(cols);
380                            }
381                        }
382                    }
383                    1 => {
384                        let row = self.cursor.row as usize;
385                        let col = self.cursor.col as usize;
386                        for r in 0..row {
387                            self.grid[r] = blank_row(cols);
388                        }
389                        if row < self.grid.len() {
390                            for c in 0..=col.min(self.grid[row].len().saturating_sub(1)) {
391                                self.grid[row][c] = Cell { ch: ' ', attrs: CellAttrs::default() };
392                            }
393                        }
394                    }
395                    2 | 3 => {
396                        for row in self.grid.iter_mut() {
397                            *row = blank_row(cols);
398                        }
399                    }
400                    _ => {}
401                }
402            }
403            'K' => {
404                let mode = Self::get_param_raw(params, 0);
405                let row = self.cursor.row as usize;
406                let col = self.cursor.col as usize;
407                let cols = self.cols;
408                if row < self.grid.len() {
409                    match mode {
410                        0 => {
411                            for c in col..self.grid[row].len() {
412                                self.grid[row][c] = Cell { ch: ' ', attrs: CellAttrs::default() };
413                            }
414                        }
415                        1 => {
416                            for c in 0..=col.min(self.grid[row].len().saturating_sub(1)) {
417                                self.grid[row][c] = Cell { ch: ' ', attrs: CellAttrs::default() };
418                            }
419                        }
420                        2 => {
421                            self.grid[row] = blank_row(cols);
422                        }
423                        _ => {}
424                    }
425                }
426            }
427            'X' => {
428                let n = Self::get_param(params, 0, 1) as usize;
429                let row = self.cursor.row as usize;
430                let col = self.cursor.col as usize;
431                if row < self.grid.len() {
432                    for c in col..(col + n).min(self.grid[row].len()) {
433                        self.grid[row][c] = Cell { ch: ' ', attrs: CellAttrs::default() };
434                    }
435                }
436            }
437            'P' => {
438                let n = Self::get_param(params, 0, 1) as usize;
439                let row = self.cursor.row as usize;
440                let col = self.cursor.col as usize;
441                let cols = self.cols as usize;
442                if row < self.grid.len() {
443                    let end = (col + n).min(self.grid[row].len());
444                    for _ in col..end {
445                        if col < self.grid[row].len() {
446                            self.grid[row].remove(col);
447                        }
448                    }
449                    while self.grid[row].len() < cols {
450                        self.grid[row].push(Cell { ch: ' ', attrs: CellAttrs::default() });
451                    }
452                }
453            }
454
455            // Line operations
456            'L' => {
457                let n = Self::get_param(params, 0, 1);
458                let row = self.cursor.row as usize;
459                let bottom = self.scroll_bottom as usize;
460                let cols = self.cols;
461                for _ in 0..n {
462                    if bottom < self.grid.len() {
463                        self.grid.remove(bottom);
464                    }
465                    if row <= self.grid.len() {
466                        self.grid.insert(row, blank_row(cols));
467                    }
468                }
469            }
470            'M' => {
471                let n = Self::get_param(params, 0, 1);
472                let row = self.cursor.row as usize;
473                let bottom = self.scroll_bottom as usize;
474                let cols = self.cols;
475                for _ in 0..n {
476                    if row < self.grid.len() {
477                        self.grid.remove(row);
478                    }
479                    if bottom <= self.grid.len() {
480                        self.grid.insert(bottom, blank_row(cols));
481                    }
482                }
483            }
484
485            // Scroll
486            'S' if !is_private => {
487                let n = Self::get_param(params, 0, 1);
488                for _ in 0..n {
489                    self.scroll_up();
490                }
491            }
492            'T' if !is_private => {
493                let n = Self::get_param(params, 0, 1);
494                for _ in 0..n {
495                    self.scroll_down();
496                }
497            }
498
499            // SGR
500            'm' => {
501                self.handle_sgr(params);
502            }
503
504            // Scroll region
505            'r' if !is_private => {
506                let top = Self::get_param(params, 0, 1);
507                let bottom = Self::get_param(params, 1, self.rows);
508                self.scroll_top = (top - 1).min(self.rows.saturating_sub(1));
509                self.scroll_bottom = (bottom - 1).min(self.rows.saturating_sub(1));
510                self.cursor = CursorPos::default();
511            }
512
513            // DEC private modes
514            'h' if is_private => {
515                let mode = Self::get_param_raw(params, 0);
516                match mode {
517                    1049 => self.enter_alternate_screen(),
518                    25 => self.cursor_visible = true,
519                    _ => {}
520                }
521            }
522            'l' if is_private => {
523                let mode = Self::get_param_raw(params, 0);
524                match mode {
525                    1049 => self.exit_alternate_screen(),
526                    25 => self.cursor_visible = false,
527                    _ => {}
528                }
529            }
530
531            _ => {}
532        }
533    }
534
535    fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
536        if !intermediates.is_empty() {
537            return;
538        }
539        match byte {
540            b'7' => {
541                self.saved_cursor = Some(self.cursor);
542            }
543            b'8' => {
544                if let Some(saved) = self.saved_cursor {
545                    self.cursor = saved;
546                }
547            }
548            b'D' => {
549                if self.cursor.row >= self.scroll_bottom {
550                    self.scroll_up();
551                } else {
552                    self.cursor.row += 1;
553                }
554            }
555            b'M' => {
556                if self.cursor.row <= self.scroll_top {
557                    self.scroll_down();
558                } else {
559                    self.cursor.row -= 1;
560                }
561            }
562            b'c' => {
563                self.reset();
564            }
565            _ => {}
566        }
567    }
568
569    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
570        if params.is_empty() {
571            return;
572        }
573        let code = params[0];
574        if (code == b"0" || code == b"2") && params.len() > 1 {
575            self.title = String::from_utf8_lossy(params[1]).to_string();
576        }
577    }
578
579    fn hook(&mut self, _params: &vte::Params, _intermediates: &[u8], _ignore: bool, _action: char) {}
580    fn put(&mut self, _byte: u8) {}
581    fn unhook(&mut self) {}
582}
583
584// ---------------------------------------------------------------------------
585// Screen - public API wrapping parser + state
586// ---------------------------------------------------------------------------
587
588pub struct Screen {
589    state: ScreenState,
590    parser: vte::Parser,
591}
592
593impl Screen {
594    pub fn new(cols: u16, rows: u16, scrollback_limit: usize) -> Self {
595        Self {
596            state: ScreenState::new(cols, rows, scrollback_limit),
597            parser: vte::Parser::new(),
598        }
599    }
600
601    /// Feed raw bytes from ConPTY through the VT parser.
602    pub fn feed(&mut self, data: &[u8]) {
603        self.parser.advance(&mut self.state, data);
604    }
605
606    /// Capture current screen as text lines (trailing whitespace trimmed).
607    pub fn capture_text(&self) -> Vec<String> {
608        self.state
609            .grid
610            .iter()
611            .map(|row| {
612                let line: String = row.iter().map(|c| c.ch).collect();
613                line.trim_end().to_string()
614            })
615            .collect()
616    }
617
618    /// Get cursor position.
619    pub fn cursor(&self) -> CursorPos {
620        self.state.cursor
621    }
622
623    /// Get screen dimensions.
624    pub fn size(&self) -> (u16, u16) {
625        (self.state.cols, self.state.rows)
626    }
627
628    /// Get window title.
629    pub fn title(&self) -> &str {
630        &self.state.title
631    }
632
633    pub fn capture_scrollback_last(&self, n: usize) -> Vec<String> {
634        self.state.capture_scrollback_last(n)
635    }
636
637    pub fn capture_scrollback_all(&self) -> Vec<String> {
638        self.state.capture_scrollback_all()
639    }
640
641    pub fn scrollback_len(&self) -> usize {
642        self.state.scrollback_len()
643    }
644
645    pub fn set_scrollback_limit(&mut self, limit: usize) {
646        self.state.scrollback_limit = limit;
647        while self.state.scrollback.len() > limit {
648            self.state.scrollback.pop_front();
649        }
650    }
651
652    pub fn scrollback_limit(&self) -> usize {
653        self.state.scrollback_limit
654    }
655
656    pub fn cursor_visible(&self) -> bool {
657        self.state.cursor_visible
658    }
659
660    pub fn grid(&self) -> &[Vec<Cell>] {
661        &self.state.grid
662    }
663
664    /// Resize the screen. Truncates or extends rows/cols as needed.
665    pub fn resize(&mut self, cols: u16, rows: u16) {
666        let old_cols = self.state.cols as usize;
667        let new_cols = cols as usize;
668        let new_rows = rows as usize;
669
670        for row in self.state.grid.iter_mut() {
671            if new_cols > old_cols {
672                row.resize(new_cols, Cell { ch: ' ', attrs: CellAttrs::default() });
673            } else {
674                row.truncate(new_cols);
675            }
676        }
677
678        let current_rows = self.state.grid.len();
679        if new_rows > current_rows {
680            for _ in 0..(new_rows - current_rows) {
681                self.state.grid.push(vec![Cell { ch: ' ', attrs: CellAttrs::default() }; new_cols]);
682            }
683        } else {
684            self.state.grid.truncate(new_rows);
685        }
686
687        self.state.cols = cols;
688        self.state.rows = rows;
689        self.state.scroll_bottom = rows.saturating_sub(1);
690        if self.state.scroll_top >= rows {
691            self.state.scroll_top = 0;
692        }
693        self.state.clamp_cursor();
694    }
695}
696
697// ---------------------------------------------------------------------------
698// Tests
699// ---------------------------------------------------------------------------
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn basic_text() {
707        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
708        screen.feed(b"hello\r\nworld");
709        let lines = screen.capture_text();
710        assert_eq!(lines[0], "hello");
711        assert_eq!(lines[1], "world");
712    }
713
714    #[test]
715    fn cursor_movement() {
716        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
717        // Move cursor to row 3, col 5 (1-based: 4;6) then write 'X'
718        screen.feed(b"\x1b[4;6HX");
719        let lines = screen.capture_text();
720        assert_eq!(lines[3].chars().nth(5), Some('X'));
721        assert_eq!(screen.cursor().row, 3);
722        assert_eq!(screen.cursor().col, 6);
723    }
724
725    #[test]
726    fn erase_display() {
727        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
728        screen.feed(b"hello");
729        screen.feed(b"\x1b[2J");
730        let lines = screen.capture_text();
731        assert!(lines.iter().all(|l| l.is_empty()));
732    }
733
734    #[test]
735    fn erase_line() {
736        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
737        screen.feed(b"hello world");
738        // CR to go to col 0, CHA to col 6, then EL(0)
739        screen.feed(b"\r\x1b[6G\x1b[K");
740        let lines = screen.capture_text();
741        assert_eq!(lines[0], "hello");
742    }
743
744    #[test]
745    fn sgr_attributes() {
746        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
747        screen.feed(b"\x1b[1mA\x1b[0m");
748        assert!(screen.state.grid[0][0].attrs.bold);
749        assert_eq!(screen.state.grid[0][0].ch, 'A');
750        assert!(!screen.state.attrs.bold);
751    }
752
753    #[test]
754    fn scrolling() {
755        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
756        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
757        screen.feed(b"\r\nline5");
758        let lines = screen.capture_text();
759        assert_eq!(lines[0], "line2");
760        assert_eq!(lines[1], "line3");
761        assert_eq!(lines[2], "line4");
762        assert_eq!(lines[3], "line5");
763    }
764
765    #[test]
766    fn alternate_screen() {
767        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
768        screen.feed(b"original");
769        screen.feed(b"\x1b[?1049h");
770        assert!(screen.state.alternate_screen);
771        screen.feed(b"alternate");
772        let lines = screen.capture_text();
773        assert_eq!(lines[0], "alternate");
774        screen.feed(b"\x1b[?1049l");
775        assert!(!screen.state.alternate_screen);
776        let lines = screen.capture_text();
777        assert_eq!(lines[0], "original");
778    }
779
780    #[test]
781    fn tab_stops() {
782        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
783        screen.feed(b"A\tB");
784        let lines = screen.capture_text();
785        assert_eq!(lines[0].chars().nth(0), Some('A'));
786        assert_eq!(lines[0].chars().nth(8), Some('B'));
787    }
788
789    #[test]
790    fn line_wrapping() {
791        let mut screen = Screen::new(10, 4, DEFAULT_SCROLLBACK_LIMIT);
792        screen.feed(b"0123456789X");
793        let lines = screen.capture_text();
794        assert_eq!(lines[0], "0123456789");
795        assert_eq!(lines[1], "X");
796    }
797
798    #[test]
799    fn window_title() {
800        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
801        screen.feed(b"\x1b]0;my title\x07");
802        assert_eq!(screen.title(), "my title");
803        screen.feed(b"\x1b]2;new title\x1b\\");
804        assert_eq!(screen.title(), "new title");
805    }
806
807    #[test]
808    fn resize() {
809        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
810        screen.feed(b"hello");
811        screen.resize(40, 12);
812        assert_eq!(screen.size(), (40, 12));
813        let lines = screen.capture_text();
814        assert_eq!(lines.len(), 12);
815        assert_eq!(lines[0], "hello");
816    }
817
818    #[test]
819    fn cursor_save_restore() {
820        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
821        screen.feed(b"\x1b[5;10H");
822        screen.feed(b"\x1b7");
823        screen.feed(b"\x1b[1;1H");
824        assert_eq!(screen.cursor().row, 0);
825        screen.feed(b"\x1b8");
826        assert_eq!(screen.cursor().row, 4);
827        assert_eq!(screen.cursor().col, 9);
828    }
829
830    #[test]
831    fn scroll_region() {
832        let mut screen = Screen::new(80, 5, DEFAULT_SCROLLBACK_LIMIT);
833        screen.feed(b"line0\r\nline1\r\nline2\r\nline3\r\nline4");
834        screen.feed(b"\x1b[2;4r");
835        assert_eq!(screen.cursor().row, 0);
836        assert_eq!(screen.cursor().col, 0);
837        screen.feed(b"\x1b[4;1H");
838        screen.feed(b"\n");
839        let lines = screen.capture_text();
840        assert_eq!(lines[0], "line0");
841        assert_eq!(lines[1], "line2");
842        assert_eq!(lines[2], "line3");
843        assert_eq!(lines[3], "");
844        assert_eq!(lines[4], "line4");
845    }
846
847    #[test]
848    fn insert_lines() {
849        let mut screen = Screen::new(80, 5, DEFAULT_SCROLLBACK_LIMIT);
850        screen.feed(b"aaa\r\nbbb\r\nccc\r\nddd\r\neee");
851        screen.feed(b"\x1b[2;1H\x1b[L");
852        let lines = screen.capture_text();
853        assert_eq!(lines[0], "aaa");
854        assert_eq!(lines[1], "");
855        assert_eq!(lines[2], "bbb");
856        assert_eq!(lines[3], "ccc");
857        assert_eq!(lines[4], "ddd");
858    }
859
860    #[test]
861    fn delete_lines() {
862        let mut screen = Screen::new(80, 5, DEFAULT_SCROLLBACK_LIMIT);
863        screen.feed(b"aaa\r\nbbb\r\nccc\r\nddd\r\neee");
864        screen.feed(b"\x1b[2;1H\x1b[M");
865        let lines = screen.capture_text();
866        assert_eq!(lines[0], "aaa");
867        assert_eq!(lines[1], "ccc");
868        assert_eq!(lines[2], "ddd");
869        assert_eq!(lines[3], "eee");
870        assert_eq!(lines[4], "");
871    }
872
873    #[test]
874    fn sgr_256_color() {
875        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
876        screen.feed(b"\x1b[38;5;196mR\x1b[0m");
877        assert_eq!(screen.state.grid[0][0].attrs.fg, Color::Indexed(196));
878    }
879
880    #[test]
881    fn sgr_rgb_color() {
882        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
883        screen.feed(b"\x1b[38;2;255;128;0mO\x1b[0m");
884        assert_eq!(screen.state.grid[0][0].attrs.fg, Color::Rgb(255, 128, 0));
885    }
886
887    #[test]
888    fn erase_characters() {
889        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
890        screen.feed(b"ABCDEF");
891        screen.feed(b"\x1b[1;3H\x1b[2X");
892        let lines = screen.capture_text();
893        assert_eq!(&lines[0][..6], "AB  EF");
894    }
895
896    #[test]
897    fn delete_characters() {
898        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
899        screen.feed(b"ABCDEF");
900        screen.feed(b"\x1b[1;3H\x1b[2P");
901        let lines = screen.capture_text();
902        assert_eq!(&lines[0][..4], "ABEF");
903    }
904
905    #[test]
906    fn full_reset() {
907        let mut screen = Screen::new(80, 24, DEFAULT_SCROLLBACK_LIMIT);
908        screen.feed(b"hello");
909        screen.feed(b"\x1bc");
910        let lines = screen.capture_text();
911        assert!(lines.iter().all(|l| l.is_empty()));
912        assert_eq!(screen.cursor().row, 0);
913        assert_eq!(screen.cursor().col, 0);
914    }
915
916    // -----------------------------------------------------------------------
917    // Scrollback buffer tests
918    // -----------------------------------------------------------------------
919
920    #[test]
921    fn scrollback_fills_on_scroll() {
922        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
923        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
924        screen.feed(b"\r\nline5");
925        assert_eq!(screen.scrollback_len(), 1);
926        assert_eq!(screen.capture_scrollback_all(), vec!["line1"]);
927    }
928
929    #[test]
930    fn scrollback_respects_limit() {
931        let mut screen = Screen::new(80, 4, 3);
932        // Fill 4 rows then push 5 more lines to scroll 5 lines off top
933        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
934        screen.feed(b"\r\nline5\r\nline6\r\nline7\r\nline8\r\nline9");
935        assert_eq!(screen.scrollback_len(), 3);
936        assert_eq!(screen.capture_scrollback_all(), vec!["line3", "line4", "line5"]);
937    }
938
939    #[test]
940    fn capture_scrollback_last_returns_subset() {
941        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
942        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
943        screen.feed(b"\r\nline5\r\nline6\r\nline7");
944        // 3 lines scrolled off: line1, line2, line3
945        assert_eq!(screen.scrollback_len(), 3);
946        assert_eq!(screen.capture_scrollback_last(2), vec!["line2", "line3"]);
947        assert_eq!(screen.capture_scrollback_last(1), vec!["line3"]);
948        // Requesting more than available returns all
949        assert_eq!(screen.capture_scrollback_last(100), vec!["line1", "line2", "line3"]);
950    }
951
952    #[test]
953    fn capture_scrollback_all_returns_everything() {
954        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
955        screen.feed(b"aaa\r\nbbb\r\nccc\r\nddd");
956        screen.feed(b"\r\neee\r\nfff");
957        assert_eq!(screen.capture_scrollback_all(), vec!["aaa", "bbb"]);
958    }
959
960    #[test]
961    fn alternate_screen_does_not_populate_scrollback() {
962        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
963        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
964        // Enter alternate screen
965        screen.feed(b"\x1b[?1049h");
966        // Scroll in alternate screen
967        screen.feed(b"alt1\r\nalt2\r\nalt3\r\nalt4\r\nalt5");
968        assert_eq!(screen.scrollback_len(), 0);
969        // Exit alternate screen
970        screen.feed(b"\x1b[?1049l");
971        assert_eq!(screen.scrollback_len(), 0);
972    }
973
974    #[test]
975    fn scroll_region_does_not_populate_scrollback() {
976        let mut screen = Screen::new(80, 5, DEFAULT_SCROLLBACK_LIMIT);
977        screen.feed(b"line0\r\nline1\r\nline2\r\nline3\r\nline4");
978        // Set scroll region to rows 2-4 (1-based)
979        screen.feed(b"\x1b[2;4r");
980        // Move to bottom of region and scroll
981        screen.feed(b"\x1b[4;1H");
982        screen.feed(b"\n");
983        // Sub-region scroll should NOT add to scrollback
984        assert_eq!(screen.scrollback_len(), 0);
985    }
986
987    #[test]
988    fn full_reset_clears_scrollback() {
989        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
990        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
991        screen.feed(b"\r\nline5");
992        assert_eq!(screen.scrollback_len(), 1);
993        // ESC c = full reset
994        screen.feed(b"\x1bc");
995        assert_eq!(screen.scrollback_len(), 0);
996    }
997
998    // -----------------------------------------------------------------------
999    // Scrollback limit option tests
1000    // -----------------------------------------------------------------------
1001
1002    #[test]
1003    fn set_scrollback_limit_trims_excess() {
1004        let mut screen = Screen::new(80, 4, DEFAULT_SCROLLBACK_LIMIT);
1005        // Scroll 5 lines into scrollback
1006        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
1007        screen.feed(b"\r\nline5\r\nline6\r\nline7\r\nline8\r\nline9");
1008        assert_eq!(screen.scrollback_len(), 5);
1009
1010        // Reduce limit — should trim oldest lines
1011        screen.set_scrollback_limit(2);
1012        assert_eq!(screen.scrollback_len(), 2);
1013        assert_eq!(screen.capture_scrollback_all(), vec!["line4", "line5"]);
1014        assert_eq!(screen.scrollback_limit(), 2);
1015    }
1016
1017    #[test]
1018    fn set_scrollback_limit_raise_keeps_all() {
1019        let mut screen = Screen::new(80, 4, 3);
1020        screen.feed(b"line1\r\nline2\r\nline3\r\nline4");
1021        screen.feed(b"\r\nline5\r\nline6\r\nline7\r\nline8\r\nline9");
1022        assert_eq!(screen.scrollback_len(), 3);
1023
1024        // Raise limit — no lines discarded
1025        screen.set_scrollback_limit(10);
1026        assert_eq!(screen.scrollback_len(), 3);
1027        assert_eq!(screen.scrollback_limit(), 10);
1028    }
1029
1030    #[test]
1031    fn screen_new_respects_scrollback_limit() {
1032        let screen = Screen::new(80, 24, 500);
1033        assert_eq!(screen.scrollback_limit(), 500);
1034    }
1035}