Skip to main content

ratatui_toolkit/termtui/
mod.rs

1//! TermTui - Terminal emulator using mprocs architecture
2//!
3//! This module provides a terminal emulator with:
4//! - termwiz for VT100 escape sequence parsing
5//! - VecDeque-based infinite scrollback
6//! - Copy mode with frozen screen snapshots
7//! - Mouse and keyboard text selection
8//!
9//! Architecture (matching mprocs):
10//! ```text
11//! bytes → termwiz Parser → actions → Screen.handle_action() → Grid (VecDeque<Row>)
12//! ```
13
14mod attrs;
15mod cell;
16mod copy_mode;
17mod grid;
18mod keybindings;
19mod parser;
20mod row;
21mod screen;
22mod size;
23mod widget;
24
25pub use attrs::{Attrs, Color};
26pub use cell::Cell;
27pub use copy_mode::{CopyMode, CopyMoveDir, CopyPos};
28pub use grid::{Grid, Pos};
29pub use keybindings::TermTuiKeyBindings;
30pub use parser::Parser;
31pub use row::Row;
32pub use screen::Screen;
33pub use size::Size;
34pub use widget::TermTuiWidget;
35
36use anyhow::Result;
37use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
38use ratatui::layout::Rect;
39use ratatui::style::{Color as RatatuiColor, Style};
40use ratatui::widgets::{Block, BorderType, Borders};
41use ratatui::Frame;
42use std::io::{Read, Write};
43use std::sync::{Arc, Mutex};
44
45/// TermTui - Terminal widget with mprocs-style architecture
46///
47/// Features:
48/// - termwiz-based VT100 parsing
49/// - VecDeque scrollback buffer
50/// - Copy mode with frozen screen snapshots
51/// - Mouse and keyboard selection
52pub struct TermTui {
53    /// Terminal parser and screen state
54    parser: Arc<Mutex<Parser>>,
55
56    /// Title for the terminal
57    pub title: String,
58
59    /// Whether the terminal has focus
60    pub focused: bool,
61
62    /// Copy mode state
63    pub copy_mode: CopyMode,
64
65    /// Process management
66    _master: Option<Arc<Mutex<Box<dyn MasterPty + Send>>>>,
67    _child: Option<Box<dyn Child + Send + Sync>>,
68    writer: Option<Arc<Mutex<Box<dyn Write + Send>>>>,
69
70    /// Styling
71    pub border_style: Style,
72    pub focused_border_style: Style,
73
74    /// Customizable keybindings
75    pub keybindings: TermTuiKeyBindings,
76}
77
78impl TermTui {
79    /// Create a new terminal
80    pub fn new(title: impl Into<String>) -> Self {
81        let parser = Parser::new(24, 80, 10000);
82
83        Self {
84            parser: Arc::new(Mutex::new(parser)),
85            title: title.into(),
86            focused: false,
87            copy_mode: CopyMode::None,
88            _master: None,
89            _child: None,
90            writer: None,
91            border_style: Style::default().fg(RatatuiColor::White),
92            focused_border_style: Style::default().fg(RatatuiColor::Cyan),
93            keybindings: TermTuiKeyBindings::default(),
94        }
95    }
96
97    /// Set custom keybindings (builder pattern)
98    pub fn with_keybindings(mut self, keybindings: TermTuiKeyBindings) -> Self {
99        self.keybindings = keybindings;
100        self
101    }
102
103    /// Spawn a terminal with a command
104    pub fn spawn_with_command(
105        title: impl Into<String>,
106        command: &str,
107        args: &[&str],
108    ) -> Result<Self> {
109        let rows = 24;
110        let cols = 80;
111
112        let pty_system = native_pty_system();
113        let pty_size = PtySize {
114            rows,
115            cols,
116            pixel_width: 0,
117            pixel_height: 0,
118        };
119
120        let pair = pty_system.openpty(pty_size)?;
121
122        let mut cmd = CommandBuilder::new(command);
123        for arg in args {
124            cmd.arg(arg);
125        }
126
127        // Set terminal type
128        cmd.env("TERM", "xterm-256color");
129
130        let current_dir = std::env::current_dir()?;
131        cmd.cwd(current_dir);
132
133        let child = pair.slave.spawn_command(cmd)?;
134
135        // Set non-blocking mode
136        #[cfg(unix)]
137        {
138            if let Some(fd) = pair.master.as_raw_fd() {
139                unsafe {
140                    let flags = libc::fcntl(fd, libc::F_GETFL, 0);
141                    libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
142                }
143            }
144        }
145
146        let reader = pair.master.try_clone_reader()?;
147        let writer = pair.master.take_writer()?;
148
149        let writer = Arc::new(Mutex::new(writer));
150
151        let parser = Parser::new(rows as usize, cols as usize, 10000);
152        let parser = Arc::new(Mutex::new(parser));
153        let parser_clone = Arc::clone(&parser);
154
155        // Spawn read thread (using std::thread for sync compatibility)
156        std::thread::spawn(move || {
157            let mut buf = [0u8; 8192];
158            let mut reader = reader;
159            loop {
160                match reader.read(&mut buf) {
161                    Ok(0) => break,
162                    Ok(n) => {
163                        if let Ok(mut parser) = parser_clone.lock() {
164                            parser.process(&buf[..n]);
165                        }
166                        std::thread::sleep(std::time::Duration::from_millis(10));
167                    }
168                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
169                        std::thread::sleep(std::time::Duration::from_millis(10));
170                    }
171                    Err(_) => break,
172                }
173            }
174        });
175
176        Ok(Self {
177            parser,
178            title: title.into(),
179            focused: false,
180            copy_mode: CopyMode::None,
181            _master: None,
182            _child: Some(child),
183            writer: Some(writer),
184            border_style: Style::default().fg(RatatuiColor::White),
185            focused_border_style: Style::default().fg(RatatuiColor::Cyan),
186            keybindings: TermTuiKeyBindings::default(),
187        })
188    }
189
190    /// Handle keyboard input
191    pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
192        use crossterm::event::KeyEventKind;
193
194        // Only handle Press events
195        if key.kind != KeyEventKind::Press {
196            return false;
197        }
198
199        // Handle copy mode keys
200        if self.copy_mode.is_active() {
201            return self.handle_copy_mode_key(key);
202        }
203
204        // Copy selection (configurable, default: Ctrl+Shift+C)
205        if TermTuiKeyBindings::key_matches(&key, &self.keybindings.copy_selection) {
206            if let Some(text) = self.copy_mode.get_selected_text() {
207                if let Ok(mut clipboard) = arboard::Clipboard::new() {
208                    let _ = clipboard.set_text(text);
209                }
210                return true;
211            }
212        }
213
214        // Enter copy mode (configurable, default: Ctrl+X)
215        if TermTuiKeyBindings::key_matches(&key, &self.keybindings.enter_copy_mode) {
216            self.enter_copy_mode();
217            return true;
218        }
219
220        // Convert key to terminal input and send
221        let text = self.key_to_terminal_input(key);
222        if !text.is_empty() {
223            self.send_input(&text);
224            true
225        } else {
226            false
227        }
228    }
229
230    /// Handle keyboard in copy mode
231    fn handle_copy_mode_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
232        let kb = &self.keybindings;
233
234        // Exit copy mode
235        if TermTuiKeyBindings::key_matches(&key, &kb.copy_exit)
236            || TermTuiKeyBindings::key_matches(&key, &kb.copy_exit_alt)
237        {
238            self.copy_mode = CopyMode::None;
239            return true;
240        }
241
242        // Navigation - Up
243        if TermTuiKeyBindings::key_matches(&key, &kb.copy_move_up)
244            || TermTuiKeyBindings::key_matches(&key, &kb.copy_move_up_alt)
245        {
246            self.copy_mode.move_dir(CopyMoveDir::Up);
247            return true;
248        }
249
250        // Navigation - Down
251        if TermTuiKeyBindings::key_matches(&key, &kb.copy_move_down)
252            || TermTuiKeyBindings::key_matches(&key, &kb.copy_move_down_alt)
253        {
254            self.copy_mode.move_dir(CopyMoveDir::Down);
255            return true;
256        }
257
258        // Navigation - Left
259        if TermTuiKeyBindings::key_matches(&key, &kb.copy_move_left)
260            || TermTuiKeyBindings::key_matches(&key, &kb.copy_move_left_alt)
261        {
262            self.copy_mode.move_dir(CopyMoveDir::Left);
263            return true;
264        }
265
266        // Navigation - Right
267        if TermTuiKeyBindings::key_matches(&key, &kb.copy_move_right)
268            || TermTuiKeyBindings::key_matches(&key, &kb.copy_move_right_alt)
269        {
270            self.copy_mode.move_dir(CopyMoveDir::Right);
271            return true;
272        }
273
274        // Line start
275        if TermTuiKeyBindings::key_matches(&key, &kb.copy_line_start)
276            || TermTuiKeyBindings::key_matches(&key, &kb.copy_line_start_alt)
277        {
278            self.copy_mode.move_dir(CopyMoveDir::LineStart);
279            return true;
280        }
281
282        // Line end
283        if TermTuiKeyBindings::key_matches(&key, &kb.copy_line_end)
284            || TermTuiKeyBindings::key_matches(&key, &kb.copy_line_end_alt)
285        {
286            self.copy_mode.move_dir(CopyMoveDir::LineEnd);
287            return true;
288        }
289
290        // Page up
291        if TermTuiKeyBindings::key_matches(&key, &kb.copy_page_up)
292            || TermTuiKeyBindings::key_matches(&key, &kb.copy_page_up_alt)
293        {
294            self.copy_mode.move_dir(CopyMoveDir::PageUp);
295            return true;
296        }
297
298        // Page down
299        if TermTuiKeyBindings::key_matches(&key, &kb.copy_page_down)
300            || TermTuiKeyBindings::key_matches(&key, &kb.copy_page_down_alt)
301        {
302            self.copy_mode.move_dir(CopyMoveDir::PageDown);
303            return true;
304        }
305
306        // Top
307        if TermTuiKeyBindings::key_matches(&key, &kb.copy_top) {
308            self.copy_mode.move_dir(CopyMoveDir::Top);
309            return true;
310        }
311
312        // Bottom
313        if TermTuiKeyBindings::key_matches(&key, &kb.copy_bottom) {
314            self.copy_mode.move_dir(CopyMoveDir::Bottom);
315            return true;
316        }
317
318        // Word left
319        if TermTuiKeyBindings::key_matches(&key, &kb.copy_word_left) {
320            self.copy_mode.move_dir(CopyMoveDir::WordLeft);
321            return true;
322        }
323
324        // Word right
325        if TermTuiKeyBindings::key_matches(&key, &kb.copy_word_right) {
326            self.copy_mode.move_dir(CopyMoveDir::WordRight);
327            return true;
328        }
329
330        // Start/toggle selection
331        if TermTuiKeyBindings::key_matches(&key, &kb.copy_start_selection)
332            || TermTuiKeyBindings::key_matches(&key, &kb.copy_start_selection_alt)
333        {
334            self.copy_mode.set_anchor();
335            return true;
336        }
337
338        // Copy and exit
339        if TermTuiKeyBindings::key_matches(&key, &kb.copy_and_exit)
340            || TermTuiKeyBindings::key_matches(&key, &kb.copy_and_exit_alt)
341        {
342            if let Some(text) = self.copy_mode.get_selected_text() {
343                if let Ok(mut clipboard) = arboard::Clipboard::new() {
344                    let _ = clipboard.set_text(text);
345                }
346            }
347            self.copy_mode = CopyMode::None;
348            return true;
349        }
350
351        false
352    }
353
354    /// Convert key event to terminal input sequence
355    fn key_to_terminal_input(&self, key: crossterm::event::KeyEvent) -> String {
356        use crossterm::event::{KeyCode, KeyModifiers};
357
358        match key.code {
359            KeyCode::Char(c) => {
360                if key.modifiers.contains(KeyModifiers::CONTROL) {
361                    match c.to_ascii_lowercase() {
362                        'a'..='z' => {
363                            let code = (c.to_ascii_lowercase() as u8 - b'a' + 1) as char;
364                            code.to_string()
365                        }
366                        '@' => "\x00".to_string(),
367                        '[' => "\x1b".to_string(),
368                        '\\' => "\x1c".to_string(),
369                        ']' => "\x1d".to_string(),
370                        '^' => "\x1e".to_string(),
371                        '_' => "\x1f".to_string(),
372                        _ => c.to_string(),
373                    }
374                } else if key.modifiers.contains(KeyModifiers::ALT) {
375                    format!("\x1b{}", c)
376                } else {
377                    c.to_string()
378                }
379            }
380            KeyCode::Enter => "\r".to_string(),
381            KeyCode::Backspace => "\x7f".to_string(),
382            KeyCode::Tab => "\t".to_string(),
383            KeyCode::Esc => "\x1b".to_string(),
384            KeyCode::Up => "\x1b[A".to_string(),
385            KeyCode::Down => "\x1b[B".to_string(),
386            KeyCode::Right => "\x1b[C".to_string(),
387            KeyCode::Left => "\x1b[D".to_string(),
388            KeyCode::Home => "\x1b[H".to_string(),
389            KeyCode::End => "\x1b[F".to_string(),
390            KeyCode::PageUp => "\x1b[5~".to_string(),
391            KeyCode::PageDown => "\x1b[6~".to_string(),
392            KeyCode::Delete => "\x1b[3~".to_string(),
393            _ => String::new(),
394        }
395    }
396
397    /// Enter copy mode with frozen screen
398    pub fn enter_copy_mode(&mut self) {
399        let parser = self.parser.lock().unwrap();
400        let screen = parser.screen().clone();
401        let size = screen.size();
402
403        // Start at bottom-right of visible area
404        let start = CopyPos::new(size.cols as i32 - 1, size.rows as i32 - 1);
405        self.copy_mode = CopyMode::enter(screen, start);
406    }
407
408    /// Handle mouse events
409    ///
410    /// This method handles all mouse interactions:
411    /// - Mouse drag: automatically enters copy mode and starts selection (like mprocs)
412    /// - Mouse wheel: scrolls the terminal content
413    /// - In copy mode: mouse click moves cursor, mouse drag selects
414    ///
415    /// # Arguments
416    /// * `event` - The mouse event from crossterm
417    /// * `area` - The area where the terminal is rendered (for coordinate translation)
418    ///
419    /// # Returns
420    /// `true` if the event was handled, `false` otherwise
421    pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent, area: Rect) -> bool {
422        use crossterm::event::{MouseButton, MouseEventKind};
423
424        // Translate coordinates relative to the content area (inside borders)
425        let content_x = event.column.saturating_sub(area.x + 1) as i32;
426        let content_y = event.row.saturating_sub(area.y + 1) as i32;
427
428        match event.kind {
429            MouseEventKind::Down(MouseButton::Left) => {
430                if self.copy_mode.is_active() {
431                    // In copy mode, click moves cursor
432                    if let CopyMode::Active { cursor, .. } = &mut self.copy_mode {
433                        cursor.x = content_x;
434                        cursor.y = content_y;
435                    }
436                } else {
437                    // Not in copy mode - enter copy mode and position cursor
438                    let parser = self.parser.lock().unwrap();
439                    let screen = parser.screen().clone();
440                    drop(parser);
441
442                    let start = CopyPos::new(content_x, content_y);
443                    self.copy_mode = CopyMode::enter(screen, start);
444                }
445                true
446            }
447            MouseEventKind::Drag(MouseButton::Left) => {
448                if !self.copy_mode.is_active() {
449                    // Auto-enter copy mode on drag (like mprocs)
450                    let parser = self.parser.lock().unwrap();
451                    let screen = parser.screen().clone();
452                    drop(parser);
453
454                    let start = CopyPos::new(content_x, content_y);
455                    self.copy_mode = CopyMode::enter(screen, start);
456                    // Set anchor immediately for selection
457                    self.copy_mode.set_anchor();
458                } else {
459                    // Already in copy mode - set anchor if not set, then update cursor
460                    self.copy_mode.set_end();
461
462                    // Move cursor to new position
463                    if let CopyMode::Active { cursor, .. } = &mut self.copy_mode {
464                        cursor.x = content_x;
465                        cursor.y = content_y;
466                    }
467                }
468                true
469            }
470            MouseEventKind::Up(MouseButton::Left) => {
471                // Keep selection active on mouse up
472                true
473            }
474            MouseEventKind::ScrollUp => {
475                self.scroll_up(3);
476                true
477            }
478            MouseEventKind::ScrollDown => {
479                self.scroll_down(3);
480                true
481            }
482            _ => false,
483        }
484    }
485
486    /// Handle mouse down (start selection)
487    ///
488    /// Note: Consider using `handle_mouse` instead for comprehensive mouse handling.
489    #[deprecated(since = "0.2.0", note = "Use handle_mouse instead for comprehensive mouse handling")]
490    pub fn handle_mouse_down(&mut self, x: u16, y: u16) {
491        let content_x = x.saturating_sub(1) as i32;
492        let content_y = y.saturating_sub(1) as i32;
493
494        let parser = self.parser.lock().unwrap();
495        let screen = parser.screen().clone();
496
497        let start = CopyPos::new(content_x, content_y);
498        self.copy_mode = CopyMode::enter(screen, start);
499    }
500
501    /// Handle mouse drag (update selection)
502    ///
503    /// Note: Consider using `handle_mouse` instead for comprehensive mouse handling.
504    #[deprecated(since = "0.2.0", note = "Use handle_mouse instead for comprehensive mouse handling")]
505    pub fn handle_mouse_drag(&mut self, x: u16, y: u16) {
506        if self.copy_mode.is_active() {
507            let content_x = x.saturating_sub(1) as i32;
508            let content_y = y.saturating_sub(1) as i32;
509
510            // First set anchor if not set
511            self.copy_mode.set_end();
512
513            // Move cursor to new position
514            if let CopyMode::Active { cursor, .. } = &mut self.copy_mode {
515                cursor.x = content_x;
516                cursor.y = content_y;
517            }
518        }
519    }
520
521    /// Handle mouse up
522    ///
523    /// Note: Consider using `handle_mouse` instead for comprehensive mouse handling.
524    #[deprecated(since = "0.2.0", note = "Use handle_mouse instead for comprehensive mouse handling")]
525    pub fn handle_mouse_up(&mut self) {
526        // Keep selection active
527    }
528
529    /// Scroll up
530    pub fn scroll_up(&mut self, lines: usize) {
531        let mut parser = self.parser.lock().unwrap();
532        parser.screen_mut().scroll_screen_up(lines);
533    }
534
535    /// Scroll down
536    pub fn scroll_down(&mut self, lines: usize) {
537        let mut parser = self.parser.lock().unwrap();
538        parser.screen_mut().scroll_screen_down(lines);
539    }
540
541    /// Clear selection
542    pub fn clear_selection(&mut self) {
543        self.copy_mode = CopyMode::None;
544    }
545
546    /// Check if has selection
547    pub fn has_selection(&self) -> bool {
548        self.copy_mode.is_active()
549    }
550
551    /// Get selected text
552    pub fn get_selected_text(&self) -> Option<String> {
553        self.copy_mode.get_selected_text()
554    }
555
556    /// Send input to the terminal
557    pub fn send_input(&self, text: &str) {
558        if let Some(ref writer) = self.writer {
559            let mut writer = writer.lock().unwrap();
560            let _ = writer.write_all(text.as_bytes());
561            let _ = writer.flush();
562        }
563    }
564
565    /// Resize the terminal
566    pub fn resize(&mut self, rows: u16, cols: u16) {
567        let mut parser = self.parser.lock().unwrap();
568        parser.resize(rows as usize, cols as usize);
569    }
570
571    /// Render terminal content (without borders)
572    pub fn render_content(&mut self, frame: &mut Frame, area: Rect) {
573        let parser = self.parser.lock().unwrap();
574        let screen = if let Some(frozen) = self.copy_mode.frozen_screen() {
575            frozen
576        } else {
577            parser.screen()
578        };
579
580        let widget = TermTuiWidget::new(screen)
581            .scroll_offset(screen.scrollback())
582            .copy_mode(&self.copy_mode);
583
584        frame.render_widget(widget, area);
585    }
586
587    /// Render terminal with borders
588    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
589        use ratatui::layout::{Constraint, Direction, Layout};
590        use ratatui::text::{Line, Span};
591
592        let border_style = if self.focused {
593            self.focused_border_style
594        } else {
595            self.border_style
596        };
597
598        // Split area for content and hotkey footer
599        let chunks = Layout::default()
600            .direction(Direction::Vertical)
601            .constraints([Constraint::Min(3), Constraint::Length(1)])
602            .split(area);
603
604        let block = Block::default()
605            .borders(Borders::ALL)
606            .border_type(BorderType::Rounded)
607            .border_style(border_style)
608            .title(self.title.as_str());
609
610        let inner = block.inner(chunks[0]);
611        frame.render_widget(block, chunks[0]);
612        self.render_content(frame, inner);
613
614        // Render hotkey footer based on mode (showing configured keybindings)
615        let kb = &self.keybindings;
616        let hotkeys = if self.copy_mode.is_active() {
617            // Build move keys display string
618            let move_keys = format!(
619                "{}/{}",
620                TermTuiKeyBindings::key_to_display_string(&kb.copy_move_up),
621                TermTuiKeyBindings::key_to_display_string(&kb.copy_move_down)
622            );
623            let select_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_start_selection);
624            let copy_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_and_exit);
625            let word_keys = format!(
626                "{}/{}",
627                TermTuiKeyBindings::key_to_display_string(&kb.copy_word_right),
628                TermTuiKeyBindings::key_to_display_string(&kb.copy_word_left)
629            );
630            let line_keys = format!(
631                "{}/{}",
632                TermTuiKeyBindings::key_to_display_string(&kb.copy_line_start),
633                TermTuiKeyBindings::key_to_display_string(&kb.copy_line_end)
634            );
635            let top_bot_keys = format!(
636                "{}/{}",
637                TermTuiKeyBindings::key_to_display_string(&kb.copy_top),
638                TermTuiKeyBindings::key_to_display_string(&kb.copy_bottom)
639            );
640            let exit_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_exit);
641
642            Line::from(vec![
643                Span::styled(" COPY ", Style::default().fg(RatatuiColor::Black).bg(RatatuiColor::Yellow)),
644                Span::raw(" "),
645                Span::styled(move_keys, Style::default().fg(RatatuiColor::Cyan)),
646                Span::raw(" move "),
647                Span::styled(select_key, Style::default().fg(RatatuiColor::Cyan)),
648                Span::raw(" select "),
649                Span::styled(copy_key, Style::default().fg(RatatuiColor::Cyan)),
650                Span::raw(" copy "),
651                Span::styled(word_keys, Style::default().fg(RatatuiColor::Cyan)),
652                Span::raw(" word "),
653                Span::styled(line_keys, Style::default().fg(RatatuiColor::Cyan)),
654                Span::raw(" line "),
655                Span::styled(top_bot_keys, Style::default().fg(RatatuiColor::Cyan)),
656                Span::raw(" top/bot "),
657                Span::styled(exit_key, Style::default().fg(RatatuiColor::Cyan)),
658                Span::raw(" exit"),
659            ])
660        } else {
661            let enter_copy_key = TermTuiKeyBindings::key_to_display_string(&kb.enter_copy_mode);
662            let copy_selection_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_selection);
663
664            Line::from(vec![
665                Span::styled(enter_copy_key, Style::default().fg(RatatuiColor::Cyan)),
666                Span::raw(" copy mode "),
667                Span::styled(copy_selection_key, Style::default().fg(RatatuiColor::Cyan)),
668                Span::raw(" copy "),
669                Span::styled("scroll", Style::default().fg(RatatuiColor::DarkGray)),
670                Span::raw(" mouse wheel"),
671            ])
672        };
673
674        frame.render_widget(hotkeys, chunks[1]);
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
682
683    #[test]
684    fn test_termtui_creation() {
685        let term = TermTui::new("Test Terminal");
686        assert_eq!(term.title, "Test Terminal");
687        assert!(!term.focused);
688        assert!(!term.copy_mode.is_active());
689    }
690
691    #[test]
692    fn test_termtui_focus() {
693        let mut term = TermTui::new("Test");
694
695        term.focused = true;
696        assert!(term.focused);
697
698        term.focused = false;
699        assert!(!term.focused);
700    }
701
702    #[test]
703    fn test_termtui_copy_mode_enter() {
704        let mut term = TermTui::new("Test");
705
706        assert!(!term.copy_mode.is_active());
707
708        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
709        term.handle_key(key);
710
711        assert!(term.copy_mode.is_active());
712    }
713
714    #[test]
715    fn test_termtui_copy_mode_exit() {
716        let mut term = TermTui::new("Test");
717
718        // Enter copy mode
719        let enter_key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
720        term.handle_key(enter_key);
721        assert!(term.copy_mode.is_active());
722
723        // Exit with Esc
724        let esc_key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
725        term.handle_key(esc_key);
726        assert!(!term.copy_mode.is_active());
727    }
728
729    #[test]
730    fn test_termtui_key_conversion() {
731        let term = TermTui::new("Test");
732
733        // Regular character
734        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
735        assert_eq!(term.key_to_terminal_input(key), "a");
736
737        // Enter
738        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
739        assert_eq!(term.key_to_terminal_input(key), "\r");
740
741        // Ctrl+C
742        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
743        assert_eq!(term.key_to_terminal_input(key), "\x03");
744
745        // Arrow up
746        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
747        assert_eq!(term.key_to_terminal_input(key), "\x1b[A");
748    }
749
750    #[test]
751    fn test_termtui_selection() {
752        use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
753
754        let mut term = TermTui::new("Test");
755        let area = ratatui::layout::Rect::new(0, 0, 80, 24);
756
757        // Start selection via mouse down
758        let mouse_event = MouseEvent {
759            kind: MouseEventKind::Down(MouseButton::Left),
760            column: 5,
761            row: 5,
762            modifiers: KeyModifiers::NONE,
763        };
764        term.handle_mouse(mouse_event, area);
765        assert!(term.has_selection());
766
767        // Clear selection
768        term.clear_selection();
769        assert!(!term.has_selection());
770    }
771
772    #[test]
773    fn test_termtui_keybindings() {
774        // Test default keybindings
775        let kb = TermTuiKeyBindings::default();
776        assert_eq!(kb.enter_copy_mode.code, KeyCode::Char('x'));
777        assert!(kb.enter_copy_mode.modifiers.contains(KeyModifiers::CONTROL));
778
779        // Test key_to_display_string
780        let display = TermTuiKeyBindings::key_to_display_string(&kb.enter_copy_mode);
781        assert_eq!(display, "^X");
782
783        // Test key_matches
784        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
785        assert!(TermTuiKeyBindings::key_matches(&key, &kb.enter_copy_mode));
786
787        let wrong_key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL);
788        assert!(!TermTuiKeyBindings::key_matches(&wrong_key, &kb.enter_copy_mode));
789    }
790
791    #[test]
792    fn test_termtui_with_keybindings() {
793        let custom_kb = TermTuiKeyBindings {
794            enter_copy_mode: KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
795            ..Default::default()
796        };
797
798        let term = TermTui::new("Test").with_keybindings(custom_kb);
799        assert_eq!(term.keybindings.enter_copy_mode.code, KeyCode::Char('c'));
800    }
801
802    #[test]
803    fn test_mouse_scroll() {
804        use crossterm::event::{MouseEvent, MouseEventKind};
805
806        let mut term = TermTui::new("Test");
807        let area = ratatui::layout::Rect::new(0, 0, 80, 24);
808
809        // Test scroll up
810        let scroll_up = MouseEvent {
811            kind: MouseEventKind::ScrollUp,
812            column: 10,
813            row: 10,
814            modifiers: KeyModifiers::NONE,
815        };
816        assert!(term.handle_mouse(scroll_up, area));
817
818        // Test scroll down
819        let scroll_down = MouseEvent {
820            kind: MouseEventKind::ScrollDown,
821            column: 10,
822            row: 10,
823            modifiers: KeyModifiers::NONE,
824        };
825        assert!(term.handle_mouse(scroll_down, area));
826    }
827}