ratatui_toolkit/vt100_term/
mod.rs1mod 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
35pub struct VT100Term {
40 parser: Arc<Mutex<Parser>>,
42
43 pub title: String,
45
46 pub focused: bool,
48
49 pub copy_mode: CopyMode,
51
52 _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 pub border_style: Style,
59 pub focused_border_style: Style,
60}
61
62impl VT100Term {
63 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 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 if key.kind != KeyEventKind::Press {
91 eprintln!("[VT100] Ignoring non-Press event: {:?}", key.kind);
92 return false;
93 }
94
95 if self.copy_mode.is_active() {
97 return self.handle_copy_mode_key(key);
98 }
99
100 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 if key.code == KeyCode::Esc && self.copy_mode.is_active() {
115 self.copy_mode = CopyMode::None;
116 return true;
117 }
118
119 if key.code == KeyCode::Char('b') && key.modifiers.contains(KeyModifiers::CONTROL) {
121 self.enter_copy_mode();
122 return true;
123 }
124
125 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 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 self.copy_mode.set_end();
174 true
175 }
176 KeyCode::Char('c') | KeyCode::Char('y') => {
177 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 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 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 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 pub fn handle_mouse_down(&mut self, x: u16, y: u16) {
246 let content_x = x.saturating_sub(1) as i32;
248 let content_y = y.saturating_sub(1) as i32;
249
250 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 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 if let CopyMode::Active { end, .. } = &mut self.copy_mode {
266 *end = Some(Pos::new(content_x, content_y));
267 }
268 }
269 }
270
271 pub fn handle_mouse_up(&mut self) {
273 }
275
276 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 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 pub fn clear_selection(&mut self) {
290 self.copy_mode = CopyMode::None;
291 }
292
293 pub fn has_selection(&self) -> bool {
295 self.copy_mode.is_active()
296 }
297
298 pub fn get_selected_text(&self) -> Option<String> {
300 self.copy_mode.get_selected_text()
301 }
302
303 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 cmd.env("TERM", "xterm-256color");
330
331 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 let reader = pair.master.try_clone_reader()?;
351 let writer = pair.master.take_writer()?;
352
353 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 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, _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 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 pub fn render_content(&mut self, frame: &mut Frame, area: Rect) {
419 let parser = self.parser.lock().unwrap();
421 let screen = parser.screen();
422
423 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 if let Some((start, end)) = self.copy_mode.get_selection() {
438 self.render_selection(frame, area, start, end, screen.scrollback());
439 }
440
441 let scrollback = screen.scrollback();
443 if scrollback > 0 {
444 let _indicator = format!(" -{} ", scrollback);
445 }
448 }
449
450 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 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 assert!(!term.focused);
526
527 term.focused = true;
529 assert!(term.focused);
530
531 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 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 assert!(!term.copy_mode.is_active());
565
566 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 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 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 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
613 let input = term.key_to_terminal_input(key);
614 assert_eq!(input, "\u{0003}"); }
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 term.handle_mouse_down(5, 5);
641
642 term.handle_mouse_drag(10, 5);
644
645 term.handle_mouse_up();
647
648 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 term.scroll_up(3);
659 }
660
661 #[test]
662 fn test_vt100_term_scroll_down() {
663 let mut term = VT100Term::new("Test");
664
665 term.scroll_down(3);
667 }
668
669 #[test]
670 fn test_spawn_command_sets_term_env() {
671 }
683}