diff --git a/Cargo.lock b/Cargo.lock index 1d7ec34..46cafa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,12 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "bitflags" @@ -93,6 +99,15 @@ dependencies = [ "libc", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -122,6 +137,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -181,8 +214,50 @@ dependencies = [ "crossterm", "libc", "nix", + "nu-ansi-term", "once_cell", "signal-hook", + "unicode-width", + "vte", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vte" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2a85f63..9d59704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,6 @@ signal-hook = "0.3" nix = "0.27" once_cell = "1.20" libc = "0.2" +nu-ansi-term = "0.50" +unicode-width = "0.1" +vte = "0.13" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a566381 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod terminal; diff --git a/src/main.rs b/src/main.rs index eb246a2..b9723a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod parser; mod job_control; +mod terminal; use std::io::{self, Write}; @@ -8,9 +9,11 @@ fn main() { // Initialize job control and signal handlers job_control::init_signal_handlers(); + let mut terminal = terminal::Terminal::new(24, 80); loop { - print!("trushell ❯ "); + let prompt = terminal.prompt(); + print!("{prompt}"); if let Err(e) = io::stdout().flush() { eprintln!("Prompt flush error: {}", e); continue; diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..091a68e --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,238 @@ +use nu_ansi_term::{Color, Style}; +use unicode_width::UnicodeWidthChar; +use vte::{Params, Parser, Perform}; + +pub struct Terminal { + parser: Parser, + buffer: TerminalBuffer, +} + +struct TerminalBuffer { + width: usize, + height: usize, + rows: Vec>, + cursor_row: usize, + cursor_col: usize, + current_style: Style, +} + +impl Terminal { + pub fn new(height: usize, width: usize) -> Self { + Self { + parser: Parser::new(), + buffer: TerminalBuffer::new(height, width), + } + } + + pub fn write(&mut self, input: &str) { + for byte in input.bytes() { + self.parser.advance(&mut self.buffer, byte); + } + } + + pub fn screen(&self) -> Vec { + self.buffer + .rows + .iter() + .map(|row| row.iter().collect::().trim_end().to_string()) + .collect() + } + + pub fn cursor_position(&self) -> (usize, usize) { + (self.buffer.cursor_row, self.buffer.cursor_col) + } + + pub fn prompt(&self) -> String { + Style::new().bold().fg(Color::Cyan).paint("trushell ❯").to_string() + } +} + +impl TerminalBuffer { + fn new(height: usize, width: usize) -> Self { + let height = height.max(1); + let width = width.max(1); + Self { + width, + height, + rows: vec![vec![' '; width]; height], + cursor_row: 0, + cursor_col: 0, + current_style: Style::new(), + } + } + + fn write_char(&mut self, ch: char) { + match ch { + '\n' => self.newline(), + '\r' => self.cursor_col = 0, + '\t' => self.advance_tab(), + '\u{8}' => self.backspace(), + _ if ch.is_control() => {} + _ => { + let width = 1usize; + if self.cursor_col + width > self.width { + self.newline(); + } + + if self.cursor_row >= self.height { + self.scroll_up(); + } + + let row = &mut self.rows[self.cursor_row]; + row[self.cursor_col] = ch; + self.cursor_col = (self.cursor_col + width).min(self.width.saturating_sub(1)); + } + } + } + + fn newline(&mut self) { + self.cursor_row += 1; + self.cursor_col = 0; + if self.cursor_row >= self.height { + self.scroll_up(); + } + } + + fn backspace(&mut self) { + if self.cursor_col > 0 { + self.cursor_col -= 1; + if let Some(row) = self.rows.get_mut(self.cursor_row) { + row[self.cursor_col] = ' '; + } + } + } + + fn advance_tab(&mut self) { + let step = 4usize; + let next_col = ((self.cursor_col / step) + 1) * step; + self.cursor_col = next_col.min(self.width.saturating_sub(1)); + } + + fn clear_screen(&mut self) { + self.rows.iter_mut().for_each(|row| { + for cell in row.iter_mut() { + *cell = ' '; + } + }); + } + + fn clear_to_end(&mut self) { + if let Some(row) = self.rows.get_mut(self.cursor_row) { + for cell in row.iter_mut().skip(self.cursor_col) { + *cell = ' '; + } + } + for row in self.rows.iter_mut().skip(self.cursor_row + 1) { + for cell in row.iter_mut() { + *cell = ' '; + } + } + } + + fn clear_line(&mut self) { + if let Some(row) = self.rows.get_mut(self.cursor_row) { + for cell in row.iter_mut().skip(self.cursor_col) { + *cell = ' '; + } + } + } + + fn scroll_up(&mut self) { + self.rows.remove(0); + self.rows.push(vec![' '; self.width]); + self.cursor_row = self.height.saturating_sub(1); + } + + fn handle_sgr(&mut self, params: &[i64]) { + if params.is_empty() { + self.current_style = Style::new(); + return; + } + + for param in params { + match *param { + 0 => self.current_style = Style::new(), + 1 => self.current_style = self.current_style.bold(), + 4 => self.current_style = self.current_style.underline(), + 31 => self.current_style = self.current_style.fg(Color::Red), + 32 => self.current_style = self.current_style.fg(Color::Green), + 33 => self.current_style = self.current_style.fg(Color::Yellow), + 34 => self.current_style = self.current_style.fg(Color::Blue), + 35 => self.current_style = self.current_style.fg(Color::Purple), + 36 => self.current_style = self.current_style.fg(Color::Cyan), + 37 => self.current_style = self.current_style.fg(Color::White), + 90..=97 => self.current_style = self.current_style.fg(Color::White), + _ => {} + } + } + } +} + +impl Perform for TerminalBuffer { + fn print(&mut self, ch: char) { + self.write_char(ch); + } + + fn execute(&mut self, byte: u8) { + match byte { + b'\n' => self.newline(), + b'\r' => self.cursor_col = 0, + b'\t' => self.advance_tab(), + b'\x08' => self.backspace(), + _ => {} + } + } + + fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _c: char) {} + + fn put(&mut self, _ch: u8) {} + + fn unhook(&mut self) {} + + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} + + fn csi_dispatch(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, action: char) { + let params: Vec = params + .iter() + .flat_map(|param| param.iter().copied().map(|value| i64::from(value))) + .collect(); + + match action { + 'J' => { + let mode = params.first().copied().unwrap_or(0); + if mode == 2 { + self.clear_screen(); + } else { + self.clear_to_end(); + } + } + 'K' => self.clear_line(), + 'H' | 'f' => { + let row = params.first().copied().unwrap_or(1).saturating_sub(1) as usize; + let col = params.get(1).copied().unwrap_or(1).saturating_sub(1) as usize; + self.cursor_row = row.min(self.height.saturating_sub(1)); + self.cursor_col = col.min(self.width.saturating_sub(1)); + } + 'A' => { + let amount = params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_row = self.cursor_row.saturating_sub(amount); + } + 'B' => { + let amount = params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_row = (self.cursor_row + amount).min(self.height.saturating_sub(1)); + } + 'C' => { + let amount = params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_col = (self.cursor_col + amount).min(self.width.saturating_sub(1)); + } + 'D' => { + let amount = params.first().copied().unwrap_or(1).max(1) as usize; + self.cursor_col = self.cursor_col.saturating_sub(amount); + } + 'm' => self.handle_sgr(¶ms), + _ => {} + } + } + + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} +} diff --git a/tests/terminal_emulator.rs b/tests/terminal_emulator.rs new file mode 100644 index 0000000..e0079c5 --- /dev/null +++ b/tests/terminal_emulator.rs @@ -0,0 +1,57 @@ +use trushell::terminal::Terminal; + +#[test] +fn renders_unicode_text() { + let mut terminal = Terminal::new(3, 8); + terminal.write("你好"); + + assert_eq!(terminal.screen(), vec!["你好".to_string(), String::new(), String::new()]); +} + +#[test] +fn handles_newlines_and_cursor_motion() { + let mut terminal = Terminal::new(3, 5); + terminal.write("AB"); + terminal.write("\n"); + terminal.write("CD"); + + assert_eq!(terminal.screen(), vec!["AB".to_string(), "CD".to_string(), String::new()]); +} + +#[test] +fn overwrites_existing_cells_and_preserves_gaps() { + let mut terminal = Terminal::new(3, 5); + terminal.write("A"); + terminal.write("\r"); + terminal.write("B"); + + assert_eq!(terminal.screen(), vec!["B".to_string(), String::new(), String::new()]); + + let mut terminal = Terminal::new(3, 5); + terminal.write("AB"); + terminal.write("\x1b[4C"); + terminal.write("C"); + + assert_eq!(terminal.screen(), vec!["AB C".to_string(), String::new(), String::new()]); +} + +#[test] +fn clear_line_and_clear_to_end_preserve_cursor_position() { + let mut terminal = Terminal::new(3, 5); + terminal.write("ABC"); + terminal.write("\x1b[2D"); + terminal.write("\x1b[K"); + + assert_eq!(terminal.screen(), vec!["A".to_string(), String::new(), String::new()]); + assert_eq!(terminal.cursor_position(), (0, 1)); + + let mut terminal = Terminal::new(3, 5); + terminal.write("AB"); + terminal.write("\n"); + terminal.write("CD"); + terminal.write("\x1b[2;1H"); + terminal.write("\x1b[J"); + + assert_eq!(terminal.screen(), vec!["AB".to_string(), "".to_string(), String::new()]); + assert_eq!(terminal.cursor_position(), (1, 0)); +}