use std::time::{Duration, Instant};
use termlens::{Error, Key, Scroll, Terminal};
mod common;
use common as util;
fn spawn_form_echo() -> termlens::Result<Terminal> {
Terminal::builder()
.size(80, 24)
.timeout(Duration::from_secs(10))
.env_clear()
.spawn(util::fixture_bin("form-echo"))
}
#[test]
fn clicks_round_trip_through_the_apps_tracking_mode() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.click(10, 5)?;
t.wait_frame(|s| s.contains("last: mouse:up:10,5"))?;
t.scroll(3, 2, Scroll::Up)?;
t.wait_frame(|s| s.contains("last: mouse:scrollup:3,2"))?;
t.scroll(3, 2, Scroll::Down)?;
t.wait_frame(|s| s.contains("last: mouse:scrolldown:3,2"))?;
t.scroll_with(Scroll::Up.ctrl(), 3, 2)?;
t.wait_frame(|s| s.contains("last: mouse:ctrl+scrollup:3,2"))?;
t.scroll_with(Scroll::Down.shift(), 4, 1)?;
t.wait_frame(|s| s.contains("last: mouse:shift+scrolldown:4,1"))?;
t.scroll_with(Scroll::Up.ctrl().alt(), 3, 2)?;
t.wait_frame(|s| s.contains("last: mouse:ctrl+alt+scrollup:3,2"))?;
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn modifier_chords_round_trip_through_crossterms_parser() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
for (chord, name) in [
(Key::Right.ctrl(), "last: ctrl+right"),
(Key::Up.shift(), "last: shift+up"),
(Key::PageDown.alt(), "last: alt+pagedown"),
(Key::Home.ctrl(), "last: ctrl+home"),
(Key::F(5).ctrl().shift(), "last: ctrl+shift+f:5"),
(Key::Delete.alt(), "last: alt+delete"),
] {
t.send(chord)?;
t.wait_frame(|s| s.contains(name))?;
}
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn paste_is_one_event_under_bracketed_paste() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.paste("hello world")?;
t.wait_frame(|s| s.contains("input: hello world") && s.contains("last: paste:11"))?;
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn paste_falls_back_to_plain_bytes_without_the_mode() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo; ",
r#"reply=$(head -c 5); printf 'got:%s' "$reply"; read guard"#
),
])
.spawn("/bin/sh")?;
t.paste("plain")?;
t.wait_until(|s| s.contains("got:plain"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn an_embedded_paste_marker_cannot_end_the_paste() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.paste("AB\x1b[201~CD")?;
t.wait_frame(|s| s.contains("input: ABCD") && s.contains("last: paste:4"))?;
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_pasted_line_break_arrives_as_carriage_return() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo -icrnl; printf READY; ",
r"head -c 3 | od -An -tx1 | tr -d ' \n'; printf ' WIRE-EOF'; read guard"
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.paste("a\nb")?;
t.wait_until(|s| s.contains("WIRE-EOF"))?;
let row = t.screen().row_text(0);
assert!(
row.contains("610d62"),
"expected 61 0d 62 (a CR b), got: {row}"
);
t.send_str("\n")?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn mouse_reports_follow_the_utf8_encoding() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(120, 24)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo; printf '\033[?1000h\033[?1005h'; printf READY; ",
r"head -c 7 | od -An -tx1 | tr -d ' \n'; printf ' WIRE-EOF'; ",
r#"while read guard; do [ "$guard" = QUIT ] && exit 0; done"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.click(100, 3)?;
t.send_str("\n\n\n\n\n\n\n")?;
t.wait_until(|s| s.contains("WIRE-EOF"))?;
let wire = t.screen().row_text(0);
assert!(
wire.contains("1b5b4d20c28524"),
"expected ESC [ M 0x20 c2 85 0x24 (UTF-8 column 100), got: {wire}"
);
t.send_str("QUIT\n")?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn buttons_modifiers_drag_and_horizontal_wheel_reach_the_wire() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(200, 24)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; printf '\033[?1003h\033[?1006h'; printf READY; ",
r#"wire=$(dd bs=1 count=200 2>/dev/null | tr '\033' 'E'); printf '|%s|' "$wire"; read guard"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.click_with(termlens::MouseButton::Right, 10, 4)?;
t.click_with(termlens::MouseButton::Left.ctrl(), 3, 1)?;
t.scroll(5, 5, termlens::Scroll::Left)?;
t.scroll_with(termlens::Scroll::Up.ctrl(), 5, 5)?;
t.scroll_with(termlens::Scroll::Down.shift(), 5, 5)?;
t.drag(termlens::MouseButton::Left, (2, 2), (6, 3))?;
t.wait_until(|s| s.row_text(0).contains("|"))?;
let text = t.screen().row_text(0);
for expected in [
"[<2;11;5M",
"[<2;11;5m", "[<16;4;2M",
"[<16;4;2m", "[<66;6;6M", "[<80;6;6M", "[<69;6;6M", "[<0;3;3M",
"[<32;4;3M",
"[<32;5;4M",
"[<32;6;4M",
"[<32;7;4M",
"[<0;7;4m",
] {
assert!(text.contains(expected), "missing {expected} in:\n{text}");
}
Ok(())
}
#[test]
fn a_drag_reports_one_motion_per_cell_crossed() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(200, 24)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; printf '\033[?1002h\033[?1006h'; printf READY; ",
r#"wire=$(dd bs=1 count=300 2>/dev/null | tr '\033' 'E'); printf '|%s|' "$wire"; read guard"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.drag(termlens::MouseButton::Left, (5, 4), (12, 4))?;
t.wait_until(|s| s.row_text(0).contains("|"))?;
let text = t.screen().row_text(0);
for col in 7..=13 {
assert!(
text.contains(&format!("[<32;{col};5M")),
"missing motion at column {col} in:\n{text}"
);
}
assert_eq!(
text.matches("[<32;").count(),
7,
"seven cells crossed, seven motion reports:\n{text}"
);
assert!(text.contains("[<0;6;5M"), "{text}");
assert!(text.contains("[<0;13;5m"), "{text}");
Ok(())
}
#[test]
fn press_release_tracking_still_gets_no_motion_at_all() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(200, 24)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; printf '\033[?1000h\033[?1006h'; printf READY; ",
r#"wire=$(dd bs=1 count=100 2>/dev/null | tr '\033' 'E'); printf '|%s|' "$wire"; read guard"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.drag(termlens::MouseButton::Left, (5, 4), (12, 4))?;
t.wait_until(|s| s.row_text(0).contains("|"))?;
let text = t.screen().row_text(0);
assert!(!text.contains("[<32;"), "no motion under ?1000:\n{text}");
assert!(
text.contains("[<0;6;5M") && text.contains("[<0;13;5m"),
"{text}"
);
Ok(())
}
#[test]
fn drag_is_refused_when_the_mode_cannot_express_it() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args(["-c", r"printf '\033[?9h'; printf READY; read guard"])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
let err = t
.drag(termlens::MouseButton::Left, (1, 1), (4, 4))
.expect_err("X10 reports presses only");
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("X10"), "unhelpful: {err}");
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn arrows_follow_the_apps_cursor_key_mode() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo; printf '[?1hAPP '; ",
r#"a=$(head -c 3 | tr '' 'E'); printf 'got:%s ' "$a"; "#,
r"printf '[?1lNORM '; ",
r#"b=$(head -c 3 | tr '' 'E'); printf 'got:%s' "$b"; read guard"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("APP"))?;
t.send(Key::Up)?;
t.wait_until(|s| s.contains("got:EOA"))?;
t.wait_until(|s| s.contains("NORM"))?;
t.send(Key::Up)?;
t.wait_until(|s| s.contains("got:E[A"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn clicking_without_mouse_tracking_is_a_typed_error() {
let mut t = Terminal::builder()
.size(80, 24)
.timeout(Duration::from_secs(10))
.env_clear()
.spawn(util::fixture_bin("hello-tui"))
.unwrap();
t.wait_until(|s| s.contains("╯")).unwrap();
let err = t.click(1, 1).unwrap_err();
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("mouse tracking"), "{err}");
t.send(Key::Char('q')).unwrap();
assert!(t.wait_exit().unwrap().success());
}
#[test]
fn mouse_events_outside_the_grid_are_refused() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(20, 5)
.timeout(Duration::from_secs(10))
.env_clear()
.spawn(util::fixture_bin("form-echo"))?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.click(19, 4)?;
t.wait_frame(|s| s.contains("last: mouse:up:19,4"))?;
for (col, row) in [(20u16, 0), (0, 5), (20, 5), (9999, 9999)] {
let err = t.click(col, row).expect_err("off-grid click");
assert!(matches!(err, Error::Input(_)), "got: {err}");
let msg = err.to_string();
assert!(msg.contains(&format!("({col}, {row})")), "{msg}");
assert!(msg.contains("20x5"), "size at call time: {msg}");
}
let err = t.scroll(20, 0, Scroll::Up).expect_err("off-grid scroll");
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("20x5"), "{err}");
let err = t
.drag(termlens::MouseButton::Left, (0, 0), (20, 0))
.expect_err("off-grid drag endpoint");
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("(20, 0)"), "{err}");
assert!(err.to_string().contains("20x5"), "{err}");
t.resize(18, 4)?;
t.wait_frame(|s| s.contains("last: resize:18x4"))?;
let err = t.click(19, 4).expect_err("pre-resize coordinate");
assert!(matches!(err, Error::Input(_)), "got: {err}");
let msg = err.to_string();
assert!(msg.contains("(19, 4)"), "{msg}");
assert!(msg.contains("18x4"), "post-resize size: {msg}");
assert!(!msg.contains("20x5"), "must not report the old size: {msg}");
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn focus_events_reach_the_application_in_both_directions() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
let ready = t.wait_frame(|s| s.contains("form-echo ready"))?;
assert!(ready.focus_events(), "form-echo enables mode 1004");
assert!(ready.contains("window: focused"), "{ready}");
t.focus_out()?;
t.wait_frame(|s| s.contains("window: unfocused") && s.contains("last: focus:lost"))?;
t.focus_in()?;
t.wait_frame(|s| s.contains("window: focused") && s.contains("last: focus:gained"))?;
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn focus_events_are_refused_without_mode_1004() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(80, 24)
.timeout(Duration::from_secs(10))
.env_clear()
.spawn(util::fixture_bin("hello-tui"))?;
t.wait_until(|s| s.contains("╯"))?;
assert!(!t.screen().focus_events());
for err in [t.focus_in().unwrap_err(), t.focus_out().unwrap_err()] {
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("focus reporting"), "{err}");
assert!(err.to_string().contains("1004"), "{err}");
}
t.send(Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn typed_input_to_a_departed_child_is_a_typed_error() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args(["-c", "printf bye"])
.spawn("/bin/sh")?;
assert!(t.wait_exit()?.success());
let err = t.send(Key::Enter).unwrap_err();
assert!(matches!(err, Error::Write { .. }), "got: {err}");
assert!(err.screen().is_some_and(|s| s.contains("bye")), "{err}");
assert!(err.to_string().contains("Enter"), "{err}");
assert!(matches!(t.send_str("x"), Err(Error::Write { .. })));
assert!(matches!(t.paste("x"), Err(Error::Write { .. })));
Ok(())
}
#[test]
fn a_mouse_click_at_a_departed_child_blames_the_child() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args(["-c", "printf bye"])
.spawn("/bin/sh")?;
assert!(t.wait_exit()?.success());
for err in [
t.click(1, 1).unwrap_err(),
t.scroll(1, 1, Scroll::Up).unwrap_err(),
t.drag(termlens::MouseButton::Left, (1, 1), (2, 2))
.unwrap_err(),
] {
assert!(matches!(err, Error::Write { .. }), "got: {err}");
assert!(
!err.to_string().contains("mouse tracking"),
"must not blame the tracking mode: {err}"
);
assert!(err.to_string().contains("the child is gone"), "{err}");
}
Ok(())
}
#[test]
fn a_departed_child_is_refused_identically_on_every_platform() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args(["-c", "printf bye"])
.spawn("/bin/sh")?;
assert!(t.wait_exit()?.success());
let err = t.send_str("swallowed?").unwrap_err();
assert!(
err.to_string().contains("the terminal is closed"),
"the refusal must name the closed terminal, not an OS errno: {err}"
);
Ok(())
}
#[test]
fn typed_input_to_a_live_child_that_has_not_read_yet_succeeds() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.args([
"-c",
"printf READY; sleep 0.4; read line; printf ' got:%s' \"$line\"",
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.send_str("pending\n")?;
t.wait_until(|s| s.contains("got:pending"))?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn send_after_delays_then_delivers() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
let at = Instant::now();
t.send_after(Duration::from_millis(120), Key::Char('z'))?;
let waited = at.elapsed();
assert!(
waited >= Duration::from_millis(120),
"the delay must actually happen: {waited:?}"
);
t.wait_frame(|s| s.contains("input: z"))?;
t.send(Key::Esc)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_separated_esc_is_decoded_as_an_esc() -> termlens::Result<()> {
let mut t = spawn_form_echo()?;
t.wait_frame(|s| s.contains("form-echo ready"))?;
t.send(Key::Esc)?;
assert!(
t.wait_exit()?.success(),
"an Esc with nothing behind it is an Esc"
);
assert!(
matches!(
t.send_after(Duration::from_millis(10), Key::Char('j')),
Err(Error::Write { .. })
),
"the follow-up key must not vanish silently"
);
Ok(())
}