ratatui_toolkit/primitives/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(
490        since = "0.2.0",
491        note = "Use handle_mouse instead for comprehensive mouse handling"
492    )]
493    pub fn handle_mouse_down(&mut self, x: u16, y: u16) {
494        let content_x = x.saturating_sub(1) as i32;
495        let content_y = y.saturating_sub(1) as i32;
496
497        let parser = self.parser.lock().unwrap();
498        let screen = parser.screen().clone();
499
500        let start = CopyPos::new(content_x, content_y);
501        self.copy_mode = CopyMode::enter(screen, start);
502    }
503
504    /// Handle mouse drag (update selection)
505    ///
506    /// Note: Consider using `handle_mouse` instead for comprehensive mouse handling.
507    #[deprecated(
508        since = "0.2.0",
509        note = "Use handle_mouse instead for comprehensive mouse handling"
510    )]
511    pub fn handle_mouse_drag(&mut self, x: u16, y: u16) {
512        if self.copy_mode.is_active() {
513            let content_x = x.saturating_sub(1) as i32;
514            let content_y = y.saturating_sub(1) as i32;
515
516            // First set anchor if not set
517            self.copy_mode.set_end();
518
519            // Move cursor to new position
520            if let CopyMode::Active { cursor, .. } = &mut self.copy_mode {
521                cursor.x = content_x;
522                cursor.y = content_y;
523            }
524        }
525    }
526
527    /// Handle mouse up
528    ///
529    /// Note: Consider using `handle_mouse` instead for comprehensive mouse handling.
530    #[deprecated(
531        since = "0.2.0",
532        note = "Use handle_mouse instead for comprehensive mouse handling"
533    )]
534    pub fn handle_mouse_up(&mut self) {
535        // Keep selection active
536    }
537
538    /// Scroll up
539    pub fn scroll_up(&mut self, lines: usize) {
540        let mut parser = self.parser.lock().unwrap();
541        parser.screen_mut().scroll_screen_up(lines);
542    }
543
544    /// Scroll down
545    pub fn scroll_down(&mut self, lines: usize) {
546        let mut parser = self.parser.lock().unwrap();
547        parser.screen_mut().scroll_screen_down(lines);
548    }
549
550    /// Clear selection
551    pub fn clear_selection(&mut self) {
552        self.copy_mode = CopyMode::None;
553    }
554
555    /// Check if has selection
556    pub fn has_selection(&self) -> bool {
557        self.copy_mode.is_active()
558    }
559
560    /// Get selected text
561    pub fn get_selected_text(&self) -> Option<String> {
562        self.copy_mode.get_selected_text()
563    }
564
565    /// Send input to the terminal
566    pub fn send_input(&self, text: &str) {
567        if let Some(ref writer) = self.writer {
568            let mut writer = writer.lock().unwrap();
569            let _ = writer.write_all(text.as_bytes());
570            let _ = writer.flush();
571        }
572    }
573
574    /// Resize the terminal
575    pub fn resize(&mut self, rows: u16, cols: u16) {
576        let mut parser = self.parser.lock().unwrap();
577        parser.resize(rows as usize, cols as usize);
578    }
579
580    /// Render terminal content (without borders)
581    pub fn render_content(&mut self, frame: &mut Frame, area: Rect) {
582        let parser = self.parser.lock().unwrap();
583        let screen = if let Some(frozen) = self.copy_mode.frozen_screen() {
584            frozen
585        } else {
586            parser.screen()
587        };
588
589        let widget = TermTuiWidget::new(screen)
590            .scroll_offset(screen.scrollback())
591            .copy_mode(&self.copy_mode);
592
593        frame.render_widget(widget, area);
594    }
595
596    /// Render terminal with borders
597    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
598        use ratatui::layout::{Constraint, Direction, Layout};
599        use ratatui::text::{Line, Span};
600
601        let border_style = if self.focused {
602            self.focused_border_style
603        } else {
604            self.border_style
605        };
606
607        // Split area for content and hotkey footer
608        let chunks = Layout::default()
609            .direction(Direction::Vertical)
610            .constraints([Constraint::Min(3), Constraint::Length(1)])
611            .split(area);
612
613        let block = Block::default()
614            .borders(Borders::ALL)
615            .border_type(BorderType::Rounded)
616            .border_style(border_style)
617            .title(self.title.as_str());
618
619        let inner = block.inner(chunks[0]);
620        frame.render_widget(block, chunks[0]);
621        self.render_content(frame, inner);
622
623        // Render hotkey footer based on mode (showing configured keybindings)
624        let kb = &self.keybindings;
625        let hotkeys = if self.copy_mode.is_active() {
626            // Build move keys display string
627            let move_keys = format!(
628                "{}/{}",
629                TermTuiKeyBindings::key_to_display_string(&kb.copy_move_up),
630                TermTuiKeyBindings::key_to_display_string(&kb.copy_move_down)
631            );
632            let select_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_start_selection);
633            let copy_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_and_exit);
634            let word_keys = format!(
635                "{}/{}",
636                TermTuiKeyBindings::key_to_display_string(&kb.copy_word_right),
637                TermTuiKeyBindings::key_to_display_string(&kb.copy_word_left)
638            );
639            let line_keys = format!(
640                "{}/{}",
641                TermTuiKeyBindings::key_to_display_string(&kb.copy_line_start),
642                TermTuiKeyBindings::key_to_display_string(&kb.copy_line_end)
643            );
644            let top_bot_keys = format!(
645                "{}/{}",
646                TermTuiKeyBindings::key_to_display_string(&kb.copy_top),
647                TermTuiKeyBindings::key_to_display_string(&kb.copy_bottom)
648            );
649            let exit_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_exit);
650
651            Line::from(vec![
652                Span::styled(
653                    " COPY ",
654                    Style::default()
655                        .fg(RatatuiColor::Black)
656                        .bg(RatatuiColor::Yellow),
657                ),
658                Span::raw(" "),
659                Span::styled(move_keys, Style::default().fg(RatatuiColor::Cyan)),
660                Span::raw(" move "),
661                Span::styled(select_key, Style::default().fg(RatatuiColor::Cyan)),
662                Span::raw(" select "),
663                Span::styled(copy_key, Style::default().fg(RatatuiColor::Cyan)),
664                Span::raw(" copy "),
665                Span::styled(word_keys, Style::default().fg(RatatuiColor::Cyan)),
666                Span::raw(" word "),
667                Span::styled(line_keys, Style::default().fg(RatatuiColor::Cyan)),
668                Span::raw(" line "),
669                Span::styled(top_bot_keys, Style::default().fg(RatatuiColor::Cyan)),
670                Span::raw(" top/bot "),
671                Span::styled(exit_key, Style::default().fg(RatatuiColor::Cyan)),
672                Span::raw(" exit"),
673            ])
674        } else {
675            let enter_copy_key = TermTuiKeyBindings::key_to_display_string(&kb.enter_copy_mode);
676            let copy_selection_key = TermTuiKeyBindings::key_to_display_string(&kb.copy_selection);
677
678            Line::from(vec![
679                Span::styled(enter_copy_key, Style::default().fg(RatatuiColor::Cyan)),
680                Span::raw(" copy mode "),
681                Span::styled(copy_selection_key, Style::default().fg(RatatuiColor::Cyan)),
682                Span::raw(" copy "),
683                Span::styled("scroll", Style::default().fg(RatatuiColor::DarkGray)),
684                Span::raw(" mouse wheel"),
685            ])
686        };
687
688        frame.render_widget(hotkeys, chunks[1]);
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
696
697    #[test]
698    fn test_termtui_creation() {
699        let term = TermTui::new("Test Terminal");
700        assert_eq!(term.title, "Test Terminal");
701        assert!(!term.focused);
702        assert!(!term.copy_mode.is_active());
703    }
704
705    #[test]
706    fn test_termtui_focus() {
707        let mut term = TermTui::new("Test");
708
709        term.focused = true;
710        assert!(term.focused);
711
712        term.focused = false;
713        assert!(!term.focused);
714    }
715
716    #[test]
717    fn test_termtui_copy_mode_enter() {
718        let mut term = TermTui::new("Test");
719
720        assert!(!term.copy_mode.is_active());
721
722        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
723        term.handle_key(key);
724
725        assert!(term.copy_mode.is_active());
726    }
727
728    #[test]
729    fn test_termtui_copy_mode_exit() {
730        let mut term = TermTui::new("Test");
731
732        // Enter copy mode
733        let enter_key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
734        term.handle_key(enter_key);
735        assert!(term.copy_mode.is_active());
736
737        // Exit with Esc
738        let esc_key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
739        term.handle_key(esc_key);
740        assert!(!term.copy_mode.is_active());
741    }
742
743    #[test]
744    fn test_termtui_key_conversion() {
745        let term = TermTui::new("Test");
746
747        // Regular character
748        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
749        assert_eq!(term.key_to_terminal_input(key), "a");
750
751        // Enter
752        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
753        assert_eq!(term.key_to_terminal_input(key), "\r");
754
755        // Ctrl+C
756        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
757        assert_eq!(term.key_to_terminal_input(key), "\x03");
758
759        // Arrow up
760        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
761        assert_eq!(term.key_to_terminal_input(key), "\x1b[A");
762    }
763
764    #[test]
765    fn test_termtui_selection() {
766        use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
767
768        let mut term = TermTui::new("Test");
769        let area = ratatui::layout::Rect::new(0, 0, 80, 24);
770
771        // Start selection via mouse down
772        let mouse_event = MouseEvent {
773            kind: MouseEventKind::Down(MouseButton::Left),
774            column: 5,
775            row: 5,
776            modifiers: KeyModifiers::NONE,
777        };
778        term.handle_mouse(mouse_event, area);
779        assert!(term.has_selection());
780
781        // Clear selection
782        term.clear_selection();
783        assert!(!term.has_selection());
784    }
785
786    #[test]
787    fn test_termtui_keybindings() {
788        // Test default keybindings
789        let kb = TermTuiKeyBindings::default();
790        assert_eq!(kb.enter_copy_mode.code, KeyCode::Char('x'));
791        assert!(kb.enter_copy_mode.modifiers.contains(KeyModifiers::CONTROL));
792
793        // Test key_to_display_string
794        let display = TermTuiKeyBindings::key_to_display_string(&kb.enter_copy_mode);
795        assert_eq!(display, "^X");
796
797        // Test key_matches
798        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
799        assert!(TermTuiKeyBindings::key_matches(&key, &kb.enter_copy_mode));
800
801        let wrong_key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL);
802        assert!(!TermTuiKeyBindings::key_matches(
803            &wrong_key,
804            &kb.enter_copy_mode
805        ));
806    }
807
808    #[test]
809    fn test_termtui_with_keybindings() {
810        let custom_kb = TermTuiKeyBindings {
811            enter_copy_mode: KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
812            ..Default::default()
813        };
814
815        let term = TermTui::new("Test").with_keybindings(custom_kb);
816        assert_eq!(term.keybindings.enter_copy_mode.code, KeyCode::Char('c'));
817    }
818
819    #[test]
820    fn test_mouse_scroll() {
821        use crossterm::event::{MouseEvent, MouseEventKind};
822
823        let mut term = TermTui::new("Test");
824        let area = ratatui::layout::Rect::new(0, 0, 80, 24);
825
826        // Test scroll up
827        let scroll_up = MouseEvent {
828            kind: MouseEventKind::ScrollUp,
829            column: 10,
830            row: 10,
831            modifiers: KeyModifiers::NONE,
832        };
833        assert!(term.handle_mouse(scroll_up, area));
834
835        // Test scroll down
836        let scroll_down = MouseEvent {
837            kind: MouseEventKind::ScrollDown,
838            column: 10,
839            row: 10,
840            modifiers: KeyModifiers::NONE,
841        };
842        assert!(term.handle_mouse(scroll_down, area));
843    }
844}