use std::collections::VecDeque;
use std::io::{self, Write};
use std::time::Duration;
use crossterm::event::Event;
#[derive(Debug)]
pub struct MockTty {
output: Vec<u8>,
size: (u16, u16),
raw_mode: bool,
alternate_screen: bool,
cursor_visible: bool,
mouse_captured: bool,
events: VecDeque<Event>,
poll_results: VecDeque<bool>,
}
impl MockTty {
pub fn new(width: u16, height: u16) -> Self {
Self {
output: Vec::new(),
size: (width, height),
raw_mode: false,
alternate_screen: false,
cursor_visible: true,
mouse_captured: false,
events: VecDeque::new(),
poll_results: VecDeque::new(),
}
}
pub fn with_events(mut self, events: Vec<Event>) -> Self {
self.events = events.into_iter().collect();
self
}
pub fn with_polls(mut self, polls: Vec<bool>) -> Self {
self.poll_results = polls.into_iter().collect();
self
}
pub fn size(&self) -> (u16, u16) {
self.size
}
pub fn set_size(&mut self, width: u16, height: u16) {
self.size = (width, height);
}
pub fn is_raw_mode(&self) -> bool {
self.raw_mode
}
pub fn enable_raw_mode(&mut self) {
self.raw_mode = true;
}
pub fn disable_raw_mode(&mut self) {
self.raw_mode = false;
}
pub fn is_alternate_screen(&self) -> bool {
self.alternate_screen
}
pub fn enter_alternate_screen(&mut self) {
self.alternate_screen = true;
let _ = self.output.write_all(b"\x1b[?1049h");
}
pub fn leave_alternate_screen(&mut self) {
self.alternate_screen = false;
let _ = self.output.write_all(b"\x1b[?1049l");
}
pub fn is_cursor_visible(&self) -> bool {
self.cursor_visible
}
pub fn hide_cursor(&mut self) {
self.cursor_visible = false;
let _ = self.output.write_all(b"\x1b[?25l");
}
pub fn show_cursor(&mut self) {
self.cursor_visible = true;
let _ = self.output.write_all(b"\x1b[?25h");
}
pub fn is_mouse_captured(&self) -> bool {
self.mouse_captured
}
pub fn enable_mouse_capture(&mut self) {
self.mouse_captured = true;
let _ = self
.output
.write_all(b"\x1b[?1000h\x1b[?1002h\x1b[?1015h\x1b[?1006h");
}
pub fn disable_mouse_capture(&mut self) {
self.mouse_captured = false;
let _ = self
.output
.write_all(b"\x1b[?1006l\x1b[?1015l\x1b[?1002l\x1b[?1000l");
}
pub fn poll(&mut self, _timeout: Duration) -> io::Result<bool> {
Ok(self.poll_results.pop_front().unwrap_or(false))
}
pub fn read_event(&mut self) -> io::Result<Event> {
self.events
.pop_front()
.ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, "no events available"))
}
pub fn output(&self) -> &[u8] {
&self.output
}
pub fn output_str(&self) -> String {
String::from_utf8_lossy(&self.output).into_owned()
}
pub fn clear_output(&mut self) {
self.output.clear();
}
pub fn output_contains(&self, needle: &[u8]) -> bool {
if needle.is_empty() {
return false;
}
self.output
.windows(needle.len())
.any(|window| window == needle)
}
pub fn output_contains_str(&self, needle: &str) -> bool {
self.output_contains(needle.as_bytes())
}
pub fn contains_escape(&self, seq: &str) -> bool {
let escape_seq = format!("\x1b[{}", seq);
self.output_contains_str(&escape_seq)
}
pub fn parsed_commands(&self) -> Vec<AnsiCommand> {
parse_ansi_commands(&self.output)
}
pub fn queued_events(&self) -> usize {
self.events.len()
}
pub fn push_event(&mut self, event: Event) {
self.events.push_back(event);
}
pub fn push_poll(&mut self, result: bool) {
self.poll_results.push_back(result);
}
}
impl Write for MockTty {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.output.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Default for MockTty {
fn default() -> Self {
Self::new(80, 24)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnsiCommand {
CursorMove {
row: u16,
col: u16,
},
ClearScreen(ClearMode),
ClearLine(ClearMode),
SetAttribute(Vec<u8>),
EnterAlternateScreen,
LeaveAlternateScreen,
HideCursor,
ShowCursor,
EnableMouse,
DisableMouse,
Text(String),
Unknown(Vec<u8>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClearMode {
ToEnd,
ToBeginning,
All,
}
fn parse_ansi_commands(output: &[u8]) -> Vec<AnsiCommand> {
let mut commands = Vec::new();
let mut i = 0;
let mut text_start = 0;
while i < output.len() {
if output[i] == 0x1b && i + 1 < output.len() && output[i + 1] == b'[' {
if text_start < i {
if let Ok(text) = std::str::from_utf8(&output[text_start..i]) {
if !text.is_empty() {
commands.push(AnsiCommand::Text(text.to_string()));
}
}
}
let seq_start = i;
i += 2;
let params_start = i;
while i < output.len() && (0x30..=0x3F).contains(&output[i]) {
i += 1;
}
let params = &output[params_start..i];
while i < output.len() && (0x20..=0x2F).contains(&output[i]) {
i += 1;
}
if i < output.len() && (0x40..=0x7E).contains(&output[i]) {
let final_byte = output[i];
i += 1;
let cmd = parse_csi_command(params, final_byte);
commands.push(cmd);
} else {
commands.push(AnsiCommand::Unknown(output[seq_start..i].to_vec()));
}
text_start = i;
} else {
i += 1;
}
}
if text_start < output.len() {
if let Ok(text) = std::str::from_utf8(&output[text_start..]) {
if !text.is_empty() {
commands.push(AnsiCommand::Text(text.to_string()));
}
}
}
commands
}
fn parse_csi_command(params: &[u8], final_byte: u8) -> AnsiCommand {
let params_str = std::str::from_utf8(params).unwrap_or("");
match final_byte {
b'H' | b'f' => {
let parts: Vec<u16> = params_str
.split(';')
.filter_map(|s| s.parse().ok())
.collect();
let row = parts.first().copied().unwrap_or(1);
let col = parts.get(1).copied().unwrap_or(1);
AnsiCommand::CursorMove { row, col }
}
b'J' => {
let mode = match params_str {
"" | "0" => ClearMode::ToEnd,
"1" => ClearMode::ToBeginning,
"2" | "3" => ClearMode::All,
_ => ClearMode::ToEnd,
};
AnsiCommand::ClearScreen(mode)
}
b'K' => {
let mode = match params_str {
"" | "0" => ClearMode::ToEnd,
"1" => ClearMode::ToBeginning,
"2" => ClearMode::All,
_ => ClearMode::ToEnd,
};
AnsiCommand::ClearLine(mode)
}
b'm' => {
let attrs: Vec<u8> = params_str
.split(';')
.filter_map(|s| s.parse().ok())
.collect();
AnsiCommand::SetAttribute(attrs)
}
b'h' => {
if params_str == "?1049" {
AnsiCommand::EnterAlternateScreen
} else if params_str == "?25" {
AnsiCommand::ShowCursor
} else if params_str.starts_with("?1000") || params_str.starts_with("?1002") {
AnsiCommand::EnableMouse
} else {
AnsiCommand::Unknown(format!("\x1b[{}h", params_str).into_bytes())
}
}
b'l' => {
if params_str == "?1049" {
AnsiCommand::LeaveAlternateScreen
} else if params_str == "?25" {
AnsiCommand::HideCursor
} else if params_str.starts_with("?1000") || params_str.starts_with("?1006") {
AnsiCommand::DisableMouse
} else {
AnsiCommand::Unknown(format!("\x1b[{}l", params_str).into_bytes())
}
}
_ => {
AnsiCommand::Unknown(format!("\x1b[{}{}", params_str, final_byte as char).into_bytes())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn test_new() {
let tty = MockTty::new(120, 40);
assert_eq!(tty.size(), (120, 40));
assert!(!tty.is_raw_mode());
assert!(!tty.is_alternate_screen());
assert!(tty.is_cursor_visible());
assert!(!tty.is_mouse_captured());
}
#[test]
fn test_default() {
let tty = MockTty::default();
assert_eq!(tty.size(), (80, 24));
}
#[test]
fn test_raw_mode() {
let mut tty = MockTty::new(80, 24);
assert!(!tty.is_raw_mode());
tty.enable_raw_mode();
assert!(tty.is_raw_mode());
tty.disable_raw_mode();
assert!(!tty.is_raw_mode());
}
#[test]
fn test_alternate_screen() {
let mut tty = MockTty::new(80, 24);
assert!(!tty.is_alternate_screen());
tty.enter_alternate_screen();
assert!(tty.is_alternate_screen());
assert!(tty.output_contains_str("\x1b[?1049h"));
tty.leave_alternate_screen();
assert!(!tty.is_alternate_screen());
assert!(tty.output_contains_str("\x1b[?1049l"));
}
#[test]
fn test_cursor_visibility() {
let mut tty = MockTty::new(80, 24);
assert!(tty.is_cursor_visible());
tty.hide_cursor();
assert!(!tty.is_cursor_visible());
assert!(tty.output_contains_str("\x1b[?25l"));
tty.show_cursor();
assert!(tty.is_cursor_visible());
assert!(tty.output_contains_str("\x1b[?25h"));
}
#[test]
fn test_mouse_capture() {
let mut tty = MockTty::new(80, 24);
assert!(!tty.is_mouse_captured());
tty.enable_mouse_capture();
assert!(tty.is_mouse_captured());
tty.disable_mouse_capture();
assert!(!tty.is_mouse_captured());
}
#[test]
fn test_write() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"Hello, World!").unwrap();
assert_eq!(tty.output(), b"Hello, World!");
assert_eq!(tty.output_str(), "Hello, World!");
}
#[test]
fn test_output_contains() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"Hello, World!").unwrap();
assert!(tty.output_contains(b"World"));
assert!(tty.output_contains_str("Hello"));
assert!(!tty.output_contains_str("Goodbye"));
}
#[test]
fn test_clear_output() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"Hello").unwrap();
assert!(!tty.output().is_empty());
tty.clear_output();
assert!(tty.output().is_empty());
}
#[test]
fn test_events() {
let tty = MockTty::new(80, 24).with_events(vec![
Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
Event::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
]);
assert_eq!(tty.queued_events(), 2);
}
#[test]
fn test_read_event() {
let mut tty = MockTty::new(80, 24).with_events(vec![Event::Key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
))]);
let event = tty.read_event().unwrap();
assert!(matches!(event, Event::Key(_)));
assert!(tty.read_event().is_err()); }
#[test]
fn test_poll() {
let mut tty = MockTty::new(80, 24).with_polls(vec![true, false, true]);
assert!(tty.poll(Duration::from_millis(100)).unwrap());
assert!(!tty.poll(Duration::from_millis(100)).unwrap());
assert!(tty.poll(Duration::from_millis(100)).unwrap());
assert!(!tty.poll(Duration::from_millis(100)).unwrap()); }
#[test]
fn test_push_event() {
let mut tty = MockTty::new(80, 24);
assert_eq!(tty.queued_events(), 0);
tty.push_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
assert_eq!(tty.queued_events(), 1);
}
#[test]
fn test_push_poll() {
let mut tty = MockTty::new(80, 24);
tty.push_poll(true);
assert!(tty.poll(Duration::ZERO).unwrap());
}
#[test]
fn test_set_size() {
let mut tty = MockTty::new(80, 24);
tty.set_size(120, 40);
assert_eq!(tty.size(), (120, 40));
}
#[test]
fn test_contains_escape() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[2J").unwrap(); assert!(tty.contains_escape("2J"));
assert!(!tty.contains_escape("0J"));
}
#[test]
fn test_parsed_commands_cursor_move() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[10;20H").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::CursorMove { row: 10, col: 20 });
}
#[test]
fn test_parsed_commands_clear_screen() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[2J").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::ClearScreen(ClearMode::All));
}
#[test]
fn test_parsed_commands_sgr() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[1;31m").unwrap(); let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::SetAttribute(vec![1, 31]));
}
#[test]
fn test_parsed_commands_text() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"Hello\x1b[2JWorld").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 3);
assert_eq!(commands[0], AnsiCommand::Text("Hello".to_string()));
assert_eq!(commands[1], AnsiCommand::ClearScreen(ClearMode::All));
assert_eq!(commands[2], AnsiCommand::Text("World".to_string()));
}
#[test]
fn test_parsed_commands_alternate_screen() {
let mut tty = MockTty::new(80, 24);
tty.enter_alternate_screen();
tty.leave_alternate_screen();
let commands = tty.parsed_commands();
assert!(commands.contains(&AnsiCommand::EnterAlternateScreen));
assert!(commands.contains(&AnsiCommand::LeaveAlternateScreen));
}
#[test]
fn test_parsed_commands_cursor_visibility() {
let mut tty = MockTty::new(80, 24);
tty.hide_cursor();
tty.show_cursor();
let commands = tty.parsed_commands();
assert!(commands.contains(&AnsiCommand::HideCursor));
assert!(commands.contains(&AnsiCommand::ShowCursor));
}
#[test]
fn test_cursor_position_f_variant() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[5;10f").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::CursorMove { row: 5, col: 10 });
}
#[test]
fn test_cursor_position_defaults() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[H").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::CursorMove { row: 1, col: 1 });
}
#[test]
fn test_cursor_position_row_only() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[15H").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::CursorMove { row: 15, col: 1 });
}
#[test]
fn test_clear_screen_modes() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[J").unwrap();
tty.write_all(b"\x1b[0J").unwrap();
tty.write_all(b"\x1b[1J").unwrap();
tty.write_all(b"\x1b[2J").unwrap();
tty.write_all(b"\x1b[3J").unwrap();
tty.write_all(b"\x1b[9J").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 6);
assert_eq!(commands[0], AnsiCommand::ClearScreen(ClearMode::ToEnd));
assert_eq!(commands[1], AnsiCommand::ClearScreen(ClearMode::ToEnd));
assert_eq!(
commands[2],
AnsiCommand::ClearScreen(ClearMode::ToBeginning)
);
assert_eq!(commands[3], AnsiCommand::ClearScreen(ClearMode::All));
assert_eq!(commands[4], AnsiCommand::ClearScreen(ClearMode::All));
assert_eq!(commands[5], AnsiCommand::ClearScreen(ClearMode::ToEnd));
}
#[test]
fn test_clear_line_modes() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[K").unwrap();
tty.write_all(b"\x1b[0K").unwrap();
tty.write_all(b"\x1b[1K").unwrap();
tty.write_all(b"\x1b[2K").unwrap();
tty.write_all(b"\x1b[9K").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 5);
assert_eq!(commands[0], AnsiCommand::ClearLine(ClearMode::ToEnd));
assert_eq!(commands[1], AnsiCommand::ClearLine(ClearMode::ToEnd));
assert_eq!(commands[2], AnsiCommand::ClearLine(ClearMode::ToBeginning));
assert_eq!(commands[3], AnsiCommand::ClearLine(ClearMode::All));
assert_eq!(commands[4], AnsiCommand::ClearLine(ClearMode::ToEnd));
}
#[test]
fn test_sgr_empty_params() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[m").unwrap(); let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::SetAttribute(vec![]));
}
#[test]
fn test_unknown_h_mode() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[?9999h").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
match &commands[0] {
AnsiCommand::Unknown(bytes) => {
assert_eq!(bytes, b"\x1b[?9999h");
}
_ => panic!("Expected Unknown command"),
}
}
#[test]
fn test_unknown_l_mode() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[?9999l").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
match &commands[0] {
AnsiCommand::Unknown(bytes) => {
assert_eq!(bytes, b"\x1b[?9999l");
}
_ => panic!("Expected Unknown command"),
}
}
#[test]
fn test_unknown_final_byte() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[5Z").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
match &commands[0] {
AnsiCommand::Unknown(bytes) => {
assert_eq!(bytes, b"\x1b[5Z");
}
_ => panic!("Expected Unknown command"),
}
}
#[test]
fn test_mouse_enable_via_parsing() {
let mut tty = MockTty::new(80, 24);
tty.enable_mouse_capture();
let commands = tty.parsed_commands();
assert!(commands
.iter()
.any(|c| matches!(c, AnsiCommand::EnableMouse)));
}
#[test]
fn test_mouse_disable_via_parsing() {
let mut tty = MockTty::new(80, 24);
tty.disable_mouse_capture();
let commands = tty.parsed_commands();
assert!(commands
.iter()
.any(|c| matches!(c, AnsiCommand::DisableMouse)));
}
#[test]
fn test_mouse_1002_enable() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[?1002h").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::EnableMouse);
}
#[test]
fn test_mouse_1000_disable() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[?1000l").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::DisableMouse);
}
#[test]
fn test_incomplete_escape_sequence() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"text\x1b[123").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0], AnsiCommand::Text("text".to_string()));
match &commands[1] {
AnsiCommand::Unknown(_) => {}
_ => panic!("Expected Unknown for incomplete sequence"),
}
}
#[test]
fn test_write_flush() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"test").unwrap();
assert!(tty.flush().is_ok());
}
#[test]
fn test_output_contains_empty_needle() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"Hello").unwrap();
assert!(!tty.output_contains(b""));
}
#[test]
fn test_intermediate_bytes_in_sequence() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[0 q").unwrap(); let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
match &commands[0] {
AnsiCommand::Unknown(_) => {}
_ => panic!("Expected Unknown command for DECSCUSR"),
}
}
#[test]
fn test_multiple_escape_sequences() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"\x1b[2J\x1b[1;1H\x1b[?25l").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 3);
assert_eq!(commands[0], AnsiCommand::ClearScreen(ClearMode::All));
assert_eq!(commands[1], AnsiCommand::CursorMove { row: 1, col: 1 });
assert_eq!(commands[2], AnsiCommand::HideCursor);
}
#[test]
fn test_text_only_output() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"Just plain text").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(
commands[0],
AnsiCommand::Text("Just plain text".to_string())
);
}
#[test]
fn test_escape_at_end() {
let mut tty = MockTty::new(80, 24);
tty.write_all(b"text\x1b").unwrap();
let commands = tty.parsed_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0], AnsiCommand::Text("text\x1b".to_string()));
}
#[test]
fn test_debug_impl() {
let tty = MockTty::new(80, 24);
let debug_str = format!("{:?}", tty);
assert!(debug_str.contains("MockTty"));
assert!(debug_str.contains("size"));
}
#[test]
fn test_ansi_command_debug_and_clone() {
let cmd = AnsiCommand::CursorMove { row: 5, col: 10 };
let cloned = cmd.clone();
assert_eq!(cmd, cloned);
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("CursorMove"));
}
#[test]
fn test_clear_mode_debug_and_clone() {
let mode = ClearMode::All;
let cloned = mode;
assert_eq!(mode, cloned);
let debug_str = format!("{:?}", mode);
assert!(debug_str.contains("All"));
}
#[test]
fn test_empty_output_parsing() {
let tty = MockTty::new(80, 24);
let commands = tty.parsed_commands();
assert!(commands.is_empty());
}
#[test]
fn test_read_event_error_kind() {
let mut tty = MockTty::new(80, 24);
let err = tty.read_event().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
}
}