From 08412ff94df7f9e7133dbc47adac83233f6cfb5a Mon Sep 17 00:00:00 2001 From: exlier <290591388+exlier@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:21:22 +0000 Subject: [PATCH 1/4] feat: Terminal emulator core added --- Cargo.lock | 9 +- Cargo.toml | 1 + src/lib.rs | 1 + src/terminal.rs | 190 +++++++++++++++++++++++++++++++++++++ tests/terminal_emulator.rs | 19 ++++ 5 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/lib.rs create mode 100644 src/terminal.rs create mode 100644 tests/terminal_emulator.rs diff --git a/Cargo.lock b/Cargo.lock index 1d7ec34..7de0273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "bitflags" @@ -183,8 +183,15 @@ dependencies = [ "nix", "once_cell", "signal-hook", + "unicode-width", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 2a85f63..ba9f319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ signal-hook = "0.3" nix = "0.27" once_cell = "1.20" libc = "0.2" +unicode-width = "0.1" 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/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..30fbade --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,190 @@ +use unicode_width::UnicodeWidthChar; + +#[derive(Debug, Clone, PartialEq)] +pub struct Terminal { + width: usize, + height: usize, + rows: Vec, + cursor_row: usize, + cursor_col: usize, +} + +impl Terminal { + pub fn new(height: usize, width: usize) -> Self { + Self { + width: width.max(1), + height: height.max(1), + rows: vec![String::new(); height.max(1)], + cursor_row: 0, + cursor_col: 0, + } + } + + pub fn write(&mut self, input: &str) { + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + self.handle_escape(&mut chars); + } else { + self.write_char(ch); + } + } + } + + pub fn screen(&self) -> Vec { + self.rows.clone() + } + + pub fn cursor_position(&self) -> (usize, usize) { + (self.cursor_row, self.cursor_col) + } + + 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 = ch.width().unwrap_or(1); + if self.cursor_col + width > self.width { + self.newline(); + } + + if self.cursor_row >= self.height { + self.scroll_up(); + } + + self.rows[self.cursor_row].push(ch); + self.cursor_col += width; + } + } + } + + fn handle_escape(&mut self, chars: &mut std::iter::Peekable>) { + match chars.next() { + Some('[') => { + let mut params = String::new(); + let mut final_byte = None; + + while let Some(ch) = chars.next() { + if ch.is_ascii_digit() || ch == ';' { + params.push(ch); + } else { + final_byte = Some(ch); + break; + } + } + + if let Some(final_byte) = final_byte { + self.handle_csi(¶ms, final_byte); + } + } + Some('(') => { + let _ = chars.next(); + } + Some(_) | None => {} + } + } + + fn handle_csi(&mut self, params: &str, final_byte: char) { + match final_byte { + 'J' => { + let mode = params.trim(); + if mode == "2" { + self.clear_screen(); + } else { + self.clear_to_end(); + } + } + 'K' => self.clear_line(), + 'H' | 'f' => { + let (row, col) = parse_cursor_location(params); + self.cursor_row = row.min(self.height.saturating_sub(1)); + self.cursor_col = col.min(self.width.saturating_sub(1)); + } + 'A' => { + let amount = params.parse::().unwrap_or(1); + self.cursor_row = self.cursor_row.saturating_sub(amount); + } + 'B' => { + let amount = params.parse::().unwrap_or(1); + self.cursor_row = (self.cursor_row + amount).min(self.height.saturating_sub(1)); + } + 'C' => { + let amount = params.parse::().unwrap_or(1); + self.cursor_col = (self.cursor_col + amount).min(self.width.saturating_sub(1)); + } + 'D' => { + let amount = params.parse::().unwrap_or(1); + self.cursor_col = self.cursor_col.saturating_sub(amount); + } + 'm' => {} + _ => {} + } + } + + 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) { + let mut chars: Vec = row.chars().collect(); + if !chars.is_empty() { + chars.pop(); + *row = chars.into_iter().collect(); + } + } + } + } + + 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| row.clear()); + self.cursor_row = 0; + self.cursor_col = 0; + } + + fn clear_to_end(&mut self) { + if let Some(row) = self.rows.get_mut(self.cursor_row) { + row.clear(); + } + for row in self.rows.iter_mut().skip(self.cursor_row + 1) { + row.clear(); + } + self.cursor_col = 0; + } + + fn clear_line(&mut self) { + if let Some(row) = self.rows.get_mut(self.cursor_row) { + row.clear(); + } + self.cursor_col = 0; + } + + fn scroll_up(&mut self) { + self.rows.remove(0); + self.rows.push(String::new()); + self.cursor_row = self.height.saturating_sub(1); + } +} + +fn parse_cursor_location(params: &str) -> (usize, usize) { + let mut values = params.split(';').filter(|s| !s.is_empty()); + let row = values.next().unwrap_or("1").parse::().unwrap_or(1).saturating_sub(1); + let col = values.next().unwrap_or("1").parse::().unwrap_or(1).saturating_sub(1); + (row, col) +} diff --git a/tests/terminal_emulator.rs b/tests/terminal_emulator.rs new file mode 100644 index 0000000..ef096cd --- /dev/null +++ b/tests/terminal_emulator.rs @@ -0,0 +1,19 @@ +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()]); +} From ed33651357b1cc7bae4510d6433fe3f2060cef72 Mon Sep 17 00:00:00 2001 From: exlier <290591388+exlier@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:32:42 +0000 Subject: [PATCH 2/4] using vte, and nu-asi-term crates --- Cargo.lock | 68 ++++++++++++++++ Cargo.toml | 2 + src/main.rs | 5 +- src/terminal.rs | 205 ++++++++++++++++++++++++++++-------------------- 4 files changed, 196 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7de0273..46cafa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "bitflags" version = "2.13.0" @@ -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,17 +214,52 @@ 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]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index ba9f319..9d59704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +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/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 index 30fbade..1bf339a 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,42 +1,58 @@ +use nu_ansi_term::{Color, Style}; use unicode_width::UnicodeWidthChar; +use vte::{Params, Parser, Perform}; -#[derive(Debug, Clone, PartialEq)] 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 { - width: width.max(1), - height: height.max(1), - rows: vec![String::new(); height.max(1)], - cursor_row: 0, - cursor_col: 0, + parser: Parser::new(), + buffer: TerminalBuffer::new(height, width), } } pub fn write(&mut self, input: &str) { - let mut chars = input.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - self.handle_escape(&mut chars); - } else { - self.write_char(ch); - } + for byte in input.bytes() { + self.parser.advance(&mut self.buffer, byte); } } pub fn screen(&self) -> Vec { - self.rows.clone() + self.buffer.rows.clone() } pub fn cursor_position(&self) -> (usize, usize) { - (self.cursor_row, self.cursor_col) + (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 { + Self { + width: width.max(1), + height: height.max(1), + rows: vec![String::new(); height.max(1)], + cursor_row: 0, + cursor_col: 0, + current_style: Style::new(), + } } fn write_char(&mut self, ch: char) { @@ -62,69 +78,6 @@ impl Terminal { } } - fn handle_escape(&mut self, chars: &mut std::iter::Peekable>) { - match chars.next() { - Some('[') => { - let mut params = String::new(); - let mut final_byte = None; - - while let Some(ch) = chars.next() { - if ch.is_ascii_digit() || ch == ';' { - params.push(ch); - } else { - final_byte = Some(ch); - break; - } - } - - if let Some(final_byte) = final_byte { - self.handle_csi(¶ms, final_byte); - } - } - Some('(') => { - let _ = chars.next(); - } - Some(_) | None => {} - } - } - - fn handle_csi(&mut self, params: &str, final_byte: char) { - match final_byte { - 'J' => { - let mode = params.trim(); - if mode == "2" { - self.clear_screen(); - } else { - self.clear_to_end(); - } - } - 'K' => self.clear_line(), - 'H' | 'f' => { - let (row, col) = parse_cursor_location(params); - self.cursor_row = row.min(self.height.saturating_sub(1)); - self.cursor_col = col.min(self.width.saturating_sub(1)); - } - 'A' => { - let amount = params.parse::().unwrap_or(1); - self.cursor_row = self.cursor_row.saturating_sub(amount); - } - 'B' => { - let amount = params.parse::().unwrap_or(1); - self.cursor_row = (self.cursor_row + amount).min(self.height.saturating_sub(1)); - } - 'C' => { - let amount = params.parse::().unwrap_or(1); - self.cursor_col = (self.cursor_col + amount).min(self.width.saturating_sub(1)); - } - 'D' => { - let amount = params.parse::().unwrap_or(1); - self.cursor_col = self.cursor_col.saturating_sub(amount); - } - 'm' => {} - _ => {} - } - } - fn newline(&mut self) { self.cursor_row += 1; self.cursor_col = 0; @@ -180,11 +133,97 @@ impl Terminal { self.rows.push(String::new()); 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), + _ => {} + } + } + } } -fn parse_cursor_location(params: &str) -> (usize, usize) { - let mut values = params.split(';').filter(|s| !s.is_empty()); - let row = values.next().unwrap_or("1").parse::().unwrap_or(1).saturating_sub(1); - let col = values.next().unwrap_or("1").parse::().unwrap_or(1).saturating_sub(1); - (row, col) +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) as usize; + self.cursor_row = self.cursor_row.saturating_sub(amount); + } + 'B' => { + let amount = params.first().copied().unwrap_or(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) as usize; + self.cursor_col = (self.cursor_col + amount).min(self.width.saturating_sub(1)); + } + 'D' => { + let amount = params.first().copied().unwrap_or(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) {} } From 846687fe19fad390c5f6e6b5c9aef10656bce8b5 Mon Sep 17 00:00:00 2001 From: exlier <290591388+exlier@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:36:07 +0000 Subject: [PATCH 3/4] fix: refactor terminal screen logic --- src/terminal.rs | 51 +++++++++++++++++++++++--------------- tests/terminal_emulator.rs | 38 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index 1bf339a..1fbe037 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -10,7 +10,7 @@ pub struct Terminal { struct TerminalBuffer { width: usize, height: usize, - rows: Vec, + rows: Vec>, cursor_row: usize, cursor_col: usize, current_style: Style, @@ -31,7 +31,11 @@ impl Terminal { } pub fn screen(&self) -> Vec { - self.buffer.rows.clone() + self.buffer + .rows + .iter() + .map(|row| row.iter().collect::().trim_end().to_string()) + .collect() } pub fn cursor_position(&self) -> (usize, usize) { @@ -45,10 +49,12 @@ impl Terminal { impl TerminalBuffer { fn new(height: usize, width: usize) -> Self { + let height = height.max(1); + let width = width.max(1); Self { - width: width.max(1), - height: height.max(1), - rows: vec![String::new(); height.max(1)], + width, + height, + rows: vec![vec![' '; width]; height], cursor_row: 0, cursor_col: 0, current_style: Style::new(), @@ -63,7 +69,7 @@ impl TerminalBuffer { '\u{8}' => self.backspace(), _ if ch.is_control() => {} _ => { - let width = ch.width().unwrap_or(1); + let width = 1usize; if self.cursor_col + width > self.width { self.newline(); } @@ -72,8 +78,9 @@ impl TerminalBuffer { self.scroll_up(); } - self.rows[self.cursor_row].push(ch); - self.cursor_col += width; + 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)); } } } @@ -90,11 +97,7 @@ impl TerminalBuffer { if self.cursor_col > 0 { self.cursor_col -= 1; if let Some(row) = self.rows.get_mut(self.cursor_row) { - let mut chars: Vec = row.chars().collect(); - if !chars.is_empty() { - chars.pop(); - *row = chars.into_iter().collect(); - } + row[self.cursor_col] = ' '; } } } @@ -106,31 +109,39 @@ impl TerminalBuffer { } fn clear_screen(&mut self) { - self.rows.iter_mut().for_each(|row| row.clear()); + self.rows.iter_mut().for_each(|row| { + for cell in row.iter_mut() { + *cell = ' '; + } + }); self.cursor_row = 0; self.cursor_col = 0; } fn clear_to_end(&mut self) { if let Some(row) = self.rows.get_mut(self.cursor_row) { - row.clear(); + for cell in row.iter_mut().skip(self.cursor_col) { + *cell = ' '; + } } for row in self.rows.iter_mut().skip(self.cursor_row + 1) { - row.clear(); + for cell in row.iter_mut() { + *cell = ' '; + } } - self.cursor_col = 0; } fn clear_line(&mut self) { if let Some(row) = self.rows.get_mut(self.cursor_row) { - row.clear(); + for cell in row.iter_mut().skip(self.cursor_col) { + *cell = ' '; + } } - self.cursor_col = 0; } fn scroll_up(&mut self) { self.rows.remove(0); - self.rows.push(String::new()); + self.rows.push(vec![' '; self.width]); self.cursor_row = self.height.saturating_sub(1); } diff --git a/tests/terminal_emulator.rs b/tests/terminal_emulator.rs index ef096cd..e0079c5 100644 --- a/tests/terminal_emulator.rs +++ b/tests/terminal_emulator.rs @@ -17,3 +17,41 @@ fn handles_newlines_and_cursor_motion() { 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)); +} From 1a2863ebfe1ff2a7ec48784c6f72815f0660aac7 Mon Sep 17 00:00:00 2001 From: exlier <290591388+exlier@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:32:32 +0000 Subject: [PATCH 4/4] Update the cursor movement handlers --- src/terminal.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index 1fbe037..091a68e 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -114,8 +114,6 @@ impl TerminalBuffer { *cell = ' '; } }); - self.cursor_row = 0; - self.cursor_col = 0; } fn clear_to_end(&mut self) { @@ -216,19 +214,19 @@ impl Perform for TerminalBuffer { self.cursor_col = col.min(self.width.saturating_sub(1)); } 'A' => { - let amount = params.first().copied().unwrap_or(1) as usize; + 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) as usize; + 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) as usize; + 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) as usize; + 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),