Skip to main content

ratatui_toolkit/vt100_term/
mod.rs

1//! VT100 Terminal implementation inspired by mprocs
2//!
3//! This module provides a VT100 terminal emulator with:
4//! - VecDeque-based infinite scrollback
5//! - Copy mode with frozen screen snapshots
6//! - Mouse and keyboard text selection
7//! - OSC 52 clipboard integration
8//!
9//! Architecture:
10//! - termwiz: Parse VT100 escape sequences
11//! - Custom Grid/Screen: State management
12//! - ratatui: Rendering
13
14mod cell;
15mod copy_mode;
16mod grid;
17mod parser;
18mod screen;
19
20pub use cell::{Attrs, Cell};
21pub use copy_mode::{CopyMode, Pos};
22pub use grid::Grid;
23pub use parser::Parser;
24pub use screen::Screen;
25
26use anyhow::Result;
27use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
28use ratatui::layout::Rect;
29use ratatui::style::{Color, Style};
30use ratatui::widgets::{Block, BorderType, Borders};
31use ratatui::Frame;
32use std::io::{Read, Write};
33use std::sync::{Arc, Mutex};
34
35/// VT100 Terminal Widget
36///
37/// This terminal uses termwiz for parsing and a custom VT100 implementation
38/// for state management, giving us full control over scrollback and selection.
39pub struct VT100Term {
40    /// Terminal parser and screen state
41    parser: Arc<Mutex<Parser>>,
42
43    /// Title for the terminal
44    pub title: String,
45
46    /// Whether the terminal has focus
47    pub focused: bool,
48
49    /// Copy mode state
50    pub copy_mode: CopyMode,
51
52    /// Process management
53    _master: Option<Arc<Mutex<Box<dyn MasterPty + Send>>>>,
54    _child: Option<Box<dyn Child + Send + Sync>>,
55    writer: Option<Arc<Mutex<Box<dyn Write + Send>>>>,
56
57    // Styling
58    pub border_style: Style,
59    pub focused_border_style: Style,
60}
61
62impl VT100Term {
63    /// Create a new VT100 terminal
64    pub fn new(title: impl Into<String>) -> Self {
65        let parser = Parser::new(24, 80, 1000);
66
67        Self {
68            parser: Arc::new(Mutex::new(parser)),
69            title: title.into(),
70            focused: false,
71            copy_mode: CopyMode::None,
72            _master: None,
73            _child: None,
74            writer: None,
75            border_style: Style::default().fg(Color::White),
76            focused_border_style: Style::default().fg(Color::Cyan),
77        }
78    }
79
80    /// Handle keyboard input
81    pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
82        use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
83
84        eprintln!(
85            "[VT100] handle_key called: focused={} key={:?}",
86            self.focused, key
87        );
88
89        // Only handle Press events, ignore Release and Repeat
90        if key.kind != KeyEventKind::Press {
91            eprintln!("[VT100] Ignoring non-Press event: {:?}", key.kind);
92            return false;
93        }
94
95        // Check if in copy mode
96        if self.copy_mode.is_active() {
97            return self.handle_copy_mode_key(key);
98        }
99
100        // Ctrl+Shift+C: Copy selection
101        if key.code == KeyCode::Char('c')
102            && key.modifiers.contains(KeyModifiers::CONTROL)
103            && key.modifiers.contains(KeyModifiers::SHIFT)
104        {
105            if let Some(text) = self.copy_mode.get_selected_text() {
106                if let Ok(mut clipboard) = arboard::Clipboard::new() {
107                    let _ = clipboard.set_text(text);
108                }
109                return true;
110            }
111        }
112
113        // Esc: Clear selection or exit copy mode
114        if key.code == KeyCode::Esc && self.copy_mode.is_active() {
115            self.copy_mode = CopyMode::None;
116            return true;
117        }
118
119        // Enter copy mode with Ctrl+B (like tmux)
120        if key.code == KeyCode::Char('b') && key.modifiers.contains(KeyModifiers::CONTROL) {
121            self.enter_copy_mode();
122            return true;
123        }
124
125        // Convert key to terminal input and send
126        let text = self.key_to_terminal_input(key);
127        eprintln!(
128            "[VT100] key_to_terminal_input returned: {:?} (len={})",
129            text,
130            text.len()
131        );
132        if !text.is_empty() {
133            self.send_input(&text);
134            true
135        } else {
136            eprintln!("[VT100] ERROR: empty text from key_to_terminal_input!");
137            false
138        }
139    }
140
141    /// Handle keyboard in copy mode
142    fn handle_copy_mode_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
143        use copy_mode::CopyMoveDir;
144        use crossterm::event::KeyCode;
145
146        match key.code {
147            KeyCode::Esc | KeyCode::Char('q') => {
148                self.copy_mode = CopyMode::None;
149                true
150            }
151            KeyCode::Up | KeyCode::Char('k') => {
152                let (dx, dy) = CopyMoveDir::Up.delta();
153                self.copy_mode.move_cursor(dx, dy);
154                true
155            }
156            KeyCode::Down | KeyCode::Char('j') => {
157                let (dx, dy) = CopyMoveDir::Down.delta();
158                self.copy_mode.move_cursor(dx, dy);
159                true
160            }
161            KeyCode::Left | KeyCode::Char('h') => {
162                let (dx, dy) = CopyMoveDir::Left.delta();
163                self.copy_mode.move_cursor(dx, dy);
164                true
165            }
166            KeyCode::Right | KeyCode::Char('l') => {
167                let (dx, dy) = CopyMoveDir::Right.delta();
168                self.copy_mode.move_cursor(dx, dy);
169                true
170            }
171            KeyCode::Char(' ') | KeyCode::Enter => {
172                // Set end position to start range selection
173                self.copy_mode.set_end();
174                true
175            }
176            KeyCode::Char('c') | KeyCode::Char('y') => {
177                // Copy selected text
178                if let Some(text) = self.copy_mode.get_selected_text() {
179                    if let Ok(mut clipboard) = arboard::Clipboard::new() {
180                        let _ = clipboard.set_text(text);
181                    }
182                }
183                self.copy_mode = CopyMode::None;
184                true
185            }
186            _ => false,
187        }
188    }
189
190    /// Convert key event to terminal input sequence
191    fn key_to_terminal_input(&self, key: crossterm::event::KeyEvent) -> String {
192        use crossterm::event::{KeyCode, KeyModifiers};
193
194        match key.code {
195            KeyCode::Char(c) => {
196                if key.modifiers.contains(KeyModifiers::CONTROL) {
197                    match c.to_ascii_lowercase() {
198                        'a'..='z' => {
199                            let code = (c.to_ascii_lowercase() as u8 - b'a' + 1) as char;
200                            code.to_string()
201                        }
202                        '@' => "\x00".to_string(),
203                        '[' => "\x1b".to_string(),
204                        '\\' => "\x1c".to_string(),
205                        ']' => "\x1d".to_string(),
206                        '^' => "\x1e".to_string(),
207                        '_' => "\x1f".to_string(),
208                        _ => c.to_string(),
209                    }
210                } else if key.modifiers.contains(KeyModifiers::ALT) {
211                    format!("\x1b{}", c)
212                } else {
213                    c.to_string()
214                }
215            }
216            KeyCode::Enter => "\r".to_string(),
217            KeyCode::Backspace => "\x7f".to_string(),
218            KeyCode::Tab => "\t".to_string(),
219            KeyCode::Esc => "\x1b".to_string(),
220            KeyCode::Up => "\x1b[A".to_string(),
221            KeyCode::Down => "\x1b[B".to_string(),
222            KeyCode::Right => "\x1b[C".to_string(),
223            KeyCode::Left => "\x1b[D".to_string(),
224            KeyCode::Home => "\x1b[H".to_string(),
225            KeyCode::End => "\x1b[F".to_string(),
226            KeyCode::PageUp => "\x1b[5~".to_string(),
227            KeyCode::PageDown => "\x1b[6~".to_string(),
228            KeyCode::Delete => "\x1b[3~".to_string(),
229            _ => String::new(),
230        }
231    }
232
233    /// Enter copy mode with frozen screen
234    fn enter_copy_mode(&mut self) {
235        let parser = self.parser.lock().unwrap();
236        let screen = parser.screen().clone();
237        let size = screen.size();
238
239        // Start at bottom-right of visible area
240        let start = Pos::new(size.cols as i32 - 1, size.rows as i32 - 1);
241        self.copy_mode = CopyMode::enter(screen, start);
242    }
243
244    /// Handle mouse selection
245    pub fn handle_mouse_down(&mut self, x: u16, y: u16) {
246        // Adjust for border (subtract 1)
247        let content_x = x.saturating_sub(1) as i32;
248        let content_y = y.saturating_sub(1) as i32;
249
250        // Enter copy mode with mouse
251        let parser = self.parser.lock().unwrap();
252        let screen = parser.screen().clone();
253
254        let start = Pos::new(content_x, content_y);
255        self.copy_mode = CopyMode::enter(screen, start);
256    }
257
258    /// Handle mouse drag
259    pub fn handle_mouse_drag(&mut self, x: u16, y: u16) {
260        if self.copy_mode.is_active() {
261            let content_x = x.saturating_sub(1) as i32;
262            let content_y = y.saturating_sub(1) as i32;
263
264            // Update end position
265            if let CopyMode::Active { end, .. } = &mut self.copy_mode {
266                *end = Some(Pos::new(content_x, content_y));
267            }
268        }
269    }
270
271    /// Handle mouse up
272    pub fn handle_mouse_up(&mut self) {
273        // Keep selection active, user can press Esc or 'c' to copy/exit
274    }
275
276    /// Scroll up
277    pub fn scroll_up(&mut self, lines: usize) {
278        let mut parser = self.parser.lock().unwrap();
279        parser.screen_mut().scroll_screen_up(lines);
280    }
281
282    /// Scroll down
283    pub fn scroll_down(&mut self, lines: usize) {
284        let mut parser = self.parser.lock().unwrap();
285        parser.screen_mut().scroll_screen_down(lines);
286    }
287
288    /// Clear selection
289    pub fn clear_selection(&mut self) {
290        self.copy_mode = CopyMode::None;
291    }
292
293    /// Check if has selection
294    pub fn has_selection(&self) -> bool {
295        self.copy_mode.is_active()
296    }
297
298    /// Get selected text
299    pub fn get_selected_text(&self) -> Option<String> {
300        self.copy_mode.get_selected_text()
301    }
302
303    /// Spawn a terminal with a command
304    pub fn spawn_with_command(
305        title: impl Into<String>,
306        command: &str,
307        args: &[&str],
308    ) -> Result<Self> {
309        let rows = 24;
310        let cols = 80;
311
312        let pty_system = native_pty_system();
313        let pty_size = PtySize {
314            rows,
315            cols,
316            pixel_width: 0,
317            pixel_height: 0,
318        };
319
320        let pair = pty_system.openpty(pty_size)?;
321
322        let mut cmd = CommandBuilder::new(command);
323        for arg in args {
324            cmd.arg(arg);
325        }
326
327        // Set terminal type to prevent fish from timing out on capability queries
328        // xterm-256color is widely supported and doesn't require DA queries
329        cmd.env("TERM", "xterm-256color");
330
331        // Disable fish greeting and fast startup
332        cmd.env("fish_greeting", "");
333
334        let current_dir = std::env::current_dir()?;
335        cmd.cwd(current_dir);
336
337        let child = pair.slave.spawn_command(cmd)?;
338
339        #[cfg(unix)]
340        {
341            if let Some(fd) = pair.master.as_raw_fd() {
342                unsafe {
343                    let flags = libc::fcntl(fd, libc::F_GETFL, 0);
344                    libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
345                }
346            }
347        }
348
349        // Clone reader FIRST, then take writer (which consumes master)
350        let reader = pair.master.try_clone_reader()?;
351        let writer = pair.master.take_writer()?;
352
353        // Wrap writer in Arc/Mutex for thread-safe access
354        let writer = Arc::new(Mutex::new(writer));
355
356        let parser = Parser::new(rows as usize, cols as usize, 1000);
357        let parser = Arc::new(Mutex::new(parser));
358        let parser_clone = Arc::clone(&parser);
359
360        // Spawn read thread
361        tokio::spawn(async move {
362            let mut buf = [0u8; 8192];
363            let mut reader = reader;
364            loop {
365                match reader.read(&mut buf) {
366                    Ok(0) => {
367                        eprintln!("[VT100] PTY closed");
368                        break;
369                    }
370                    Ok(n) => {
371                        if let Ok(mut parser) = parser_clone.lock() {
372                            parser.process(&buf[..n]);
373                        }
374                        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
375                    }
376                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
377                        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
378                    }
379                    Err(e) => {
380                        eprintln!("[VT100] Read error: {}", e);
381                        break;
382                    }
383                }
384            }
385        });
386
387        Ok(Self {
388            parser,
389            title: title.into(),
390            focused: false,
391            copy_mode: CopyMode::None,
392            _master: None, // Can't store master after taking writer
393            _child: Some(child),
394            writer: Some(writer),
395            border_style: Style::default().fg(Color::White),
396            focused_border_style: Style::default().fg(Color::Cyan),
397        })
398    }
399
400    /// Send input to the terminal
401    pub fn send_input(&self, text: &str) {
402        if let Some(ref writer) = self.writer {
403            let mut writer = writer.lock().unwrap();
404            eprintln!("[VT100] Sending {} bytes: {:?}", text.len(), text);
405            match writer.write_all(text.as_bytes()) {
406                Ok(_) => match writer.flush() {
407                    Ok(_) => eprintln!("[VT100] Flushed successfully"),
408                    Err(e) => eprintln!("[VT100] Flush error: {}", e),
409                },
410                Err(e) => eprintln!("[VT100] Write error: {}", e),
411            }
412        } else {
413            eprintln!("[VT100] ERROR: No writer available!");
414        }
415    }
416
417    /// Render the terminal
418    pub fn render_content(&mut self, frame: &mut Frame, area: Rect) {
419        // Get screen state
420        let parser = self.parser.lock().unwrap();
421        let screen = parser.screen();
422
423        // Render cells
424        for row in 0..area.height.min(screen.size().rows as u16) {
425            for col in 0..area.width.min(screen.size().cols as u16) {
426                if let Some(cell) = screen.cell(row as usize, col as usize) {
427                    let ratatui_cell = cell.to_ratatui();
428                    let buf_cell = frame.buffer_mut().cell_mut((area.x + col, area.y + row));
429                    if let Some(buf_cell) = buf_cell {
430                        *buf_cell = ratatui_cell;
431                    }
432                }
433            }
434        }
435
436        // Render selection if in copy mode
437        if let Some((start, end)) = self.copy_mode.get_selection() {
438            self.render_selection(frame, area, start, end, screen.scrollback());
439        }
440
441        // Show scrollback indicator
442        let scrollback = screen.scrollback();
443        if scrollback > 0 {
444            let _indicator = format!(" -{} ", scrollback);
445            // Render at top-right
446            // TODO: Implement indicator rendering
447        }
448    }
449
450    /// Render selection highlight
451    fn render_selection(
452        &self,
453        frame: &mut Frame,
454        area: Rect,
455        start: Pos,
456        end: Pos,
457        scrollback: usize,
458    ) {
459        let (low, high) = Pos::to_low_high(&start, &end);
460
461        let selection_style = Style::default()
462            .bg(Color::Rgb(70, 130, 180))
463            .fg(Color::White);
464
465        for row in low.y..=high.y {
466            let visible_row = (row + scrollback as i32) as u16;
467            if visible_row >= area.height {
468                continue;
469            }
470
471            let y = area.y + visible_row;
472            let start_col = if row == low.y { low.x as u16 } else { 0 };
473            let end_col = if row == high.y {
474                (high.x as u16).min(area.width)
475            } else {
476                area.width
477            };
478
479            for x in start_col..end_col {
480                if let Some(cell) = frame.buffer_mut().cell_mut((area.x + x, y)) {
481                    cell.set_style(selection_style);
482                }
483            }
484        }
485    }
486
487    /// Render with borders
488    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
489        let border_style = if self.focused {
490            self.focused_border_style
491        } else {
492            self.border_style
493        };
494
495        let block = Block::default()
496            .borders(Borders::ALL)
497            .border_type(BorderType::Rounded)
498            .border_style(border_style)
499            .title(&*self.title);
500
501        let inner = block.inner(area);
502        frame.render_widget(block, area);
503        self.render_content(frame, inner);
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
511
512    #[test]
513    fn test_vt100_term_creation() {
514        let term = VT100Term::new("Test Terminal");
515        assert_eq!(term.title, "Test Terminal");
516        assert!(!term.focused);
517        assert!(!term.copy_mode.is_active());
518    }
519
520    #[test]
521    fn test_vt100_term_focus_state() {
522        let mut term = VT100Term::new("Test");
523
524        // Initially not focused
525        assert!(!term.focused);
526
527        // Set focused
528        term.focused = true;
529        assert!(term.focused);
530
531        // Unset focused
532        term.focused = false;
533        assert!(!term.focused);
534    }
535
536    #[test]
537    fn test_vt100_term_handle_key_when_not_focused() {
538        let mut term = VT100Term::new("Test");
539        term.focused = false;
540
541        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
542
543        // Should still handle key even when not focused
544        // (focus is for UI indication, not functionality in this implementation)
545        let handled = term.handle_key(key);
546        assert!(handled);
547    }
548
549    #[test]
550    fn test_vt100_term_handle_key_when_focused() {
551        let mut term = VT100Term::new("Test");
552        term.focused = true;
553
554        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
555        let handled = term.handle_key(key);
556        assert!(handled);
557    }
558
559    #[test]
560    fn test_vt100_term_copy_mode_enter() {
561        let mut term = VT100Term::new("Test");
562
563        // Not in copy mode initially
564        assert!(!term.copy_mode.is_active());
565
566        // Enter copy mode with Ctrl+B
567        let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
568        term.handle_key(key);
569
570        assert!(term.copy_mode.is_active());
571    }
572
573    #[test]
574    fn test_vt100_term_copy_mode_exit() {
575        let mut term = VT100Term::new("Test");
576
577        // Enter copy mode
578        let enter_key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
579        term.handle_key(enter_key);
580        assert!(term.copy_mode.is_active());
581
582        // Exit copy mode with ESC
583        let esc_key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
584        term.handle_key(esc_key);
585
586        assert!(!term.copy_mode.is_active());
587    }
588
589    #[test]
590    fn test_vt100_term_key_to_terminal_input_char() {
591        let term = VT100Term::new("Test");
592
593        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
594        let input = term.key_to_terminal_input(key);
595        assert_eq!(input, "a");
596    }
597
598    #[test]
599    fn test_vt100_term_key_to_terminal_input_enter() {
600        let term = VT100Term::new("Test");
601
602        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
603        let input = term.key_to_terminal_input(key);
604        assert_eq!(input, "\r");
605    }
606
607    #[test]
608    fn test_vt100_term_key_to_terminal_input_ctrl() {
609        let term = VT100Term::new("Test");
610
611        // Ctrl+C should produce control character
612        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
613        let input = term.key_to_terminal_input(key);
614        assert_eq!(input, "\u{0003}"); // Ctrl+C = 0x03
615    }
616
617    #[test]
618    fn test_vt100_term_key_to_terminal_input_arrow_up() {
619        let term = VT100Term::new("Test");
620
621        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
622        let input = term.key_to_terminal_input(key);
623        assert_eq!(input, "\x1b[A");
624    }
625
626    #[test]
627    fn test_vt100_term_key_to_terminal_input_arrow_down() {
628        let term = VT100Term::new("Test");
629
630        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
631        let input = term.key_to_terminal_input(key);
632        assert_eq!(input, "\x1b[B");
633    }
634
635    #[test]
636    fn test_vt100_term_selection_mouse_handling() {
637        let mut term = VT100Term::new("Test");
638
639        // Start selection
640        term.handle_mouse_down(5, 5);
641
642        // Update selection
643        term.handle_mouse_drag(10, 5);
644
645        // End selection
646        term.handle_mouse_up();
647
648        // Clear selection
649        term.clear_selection();
650        assert!(!term.has_selection());
651    }
652
653    #[test]
654    fn test_vt100_term_scroll_up() {
655        let mut term = VT100Term::new("Test");
656
657        // Should not panic
658        term.scroll_up(3);
659    }
660
661    #[test]
662    fn test_vt100_term_scroll_down() {
663        let mut term = VT100Term::new("Test");
664
665        // Should not panic
666        term.scroll_down(3);
667    }
668
669    #[test]
670    fn test_spawn_command_sets_term_env() {
671        // This test verifies that TERM environment variable is set
672        // which prevents fish shell from timing out on capability queries
673
674        // We can't easily test the actual spawn without a full PTY setup,
675        // but we document the expected behavior:
676        // 1. TERM should be set to "xterm-256color"
677        // 2. fish_greeting should be set to ""
678
679        // The actual implementation is in spawn_with_command which sets:
680        // cmd.env("TERM", "xterm-256color");
681        // cmd.env("fish_greeting", "");
682    }
683}