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