termlens 0.11.1

Headless PTY test harness for CLI/TUI apps — spawn in a real PTY, assert on the rendered screen
Documentation
//! Tab stops: `HTS`, `TBC`, `CHT`, `CBT` and the plain `HT` they redefine.
//! All four capabilities sit in the terminfo entry termlens hands every
//! child (`hts`, `tbc`, `cbt`), so an application that lays a table out by
//! setting its own stops and tabbing between them is using what it was told
//! it had. Before this the stops were the backend's hardcoded eight and the
//! four escapes vanished, so every column landed in the wrong place — and
//! silently, since the characters were all still on the screen.

use std::time::Duration;

use termlens::{Key, Terminal};

mod common;

/// The `emit` fixture on a 24-column terminal, the width the issue's
/// reproductions use. Steps are documented in `fixtures/emit/src/main.rs`.
fn emit(steps: &[&str]) -> termlens::Result<Terminal> {
    common::spawn_emit(
        Terminal::builder()
            .size(24, 4)
            .timeout(Duration::from_secs(10)),
        steps,
    )
}

/// Where a needle sits, which is the only thing any of these assert.
/// `contains` is deliberately not enough: the failure this file exists for
/// leaves every character present and every column wrong, so a column is
/// what has to be checked.
fn col_of(screen: &termlens::Screen, needle: &str) -> Option<u16> {
    screen.find(needle).map(|(_, col)| col)
}

/// The first reproduction from the issue: a stop set with `HTS` and reached
/// with a plain `\t`. The tab is the point — `HT` used to be answered by the
/// backend's fixed eight, so a stop set here and a tab taken there would
/// have disagreed even with `CHT` working.
#[test]
fn a_tab_lands_on_a_stop_set_by_hts() -> termlens::Result<()> {
    let mut t = emit(&[
        "--csi", "4G", "--esc", "H", "--csi", "1G", "a\tb", " DONE", "--wait",
    ])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "a"), Some(0), "{s}");
    assert_eq!(col_of(&s, "b"), Some(3), "the stop set at column 4:\n{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// The default stops are every eighth column, and setting one adds to them
/// rather than replacing them.
#[test]
fn the_default_stops_survive_a_custom_one() -> termlens::Result<()> {
    let mut t = emit(&[
        "--csi", "4G", "--esc", "H", "--csi", "1G", "a\tb\tc", " DONE", "--wait",
    ])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "b"), Some(3), "{s}");
    assert_eq!(col_of(&s, "c"), Some(8), "the default eighth column:\n{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// `CSI g` clears the stop under the cursor, and the tab that used to land
/// on it runs on to the next one.
#[test]
fn tbc_clears_the_stop_under_the_cursor() -> termlens::Result<()> {
    // Standing on the default stop at column 9 (one-based), clear it: the
    // tab from column 1 runs past it to the next, at column 17.
    let mut t = emit(&[
        "--csi", "9G", "--csi", "g", "--csi", "1G", "a\tb", " DONE", "--wait",
    ])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "b"), Some(16), "{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// `CSI 3 g` — the form terminfo's `tbc` is written as — clears the lot.
/// With no stops at all a tab runs to the last column and stays there, which
/// is what a second tab proves: `b` is written after two of them.
#[test]
fn csi_3_g_clears_every_stop() -> termlens::Result<()> {
    // `DONE` goes on the next row on purpose: the last column is where the
    // tabs end up, so anything printed after them on row 0 would overwrite
    // the very cell under test.
    let mut t = emit(&["--csi", "3g", "a\t\tb\r\nDONE", "--wait"])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "a"), Some(0), "{s}");
    assert_eq!(
        col_of(&s, "b"),
        Some(23),
        "the last column, and the second tab does not move on from it:\n{s}"
    );
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// `CHT` moves forward by whole stops, with a count.
#[test]
fn cht_moves_forward_by_whole_stops() -> termlens::Result<()> {
    let mut t = emit(&["--csi", "2I", "a", " DONE", "--wait"])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "a"), Some(16), "two stops forward:\n{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// `CBT` moves back by whole stops — and is what `Shift-Tab` sends, so an
/// application echoing it emits `CSI Z` on its output side.
#[test]
fn cbt_moves_back_by_whole_stops() -> termlens::Result<()> {
    // From a stop, back-tab reaches the one before it.
    let mut t = emit(&["\t\t", "--csi", "1Z", "y", " DONE", "--wait"])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "y"), Some(8), "{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// The issue's second reproduction, pinned with the answer this crate
/// gives — which is not the one the issue predicts.
///
/// `X` advances the cursor to column 17, and a back-tab goes to the nearest
/// stop *strictly* left of where it starts, so it returns to the stop at 16
/// that `X` is sitting just past. xterm and alacritty both do this. The
/// issue reads the reproduction as landing at column 8, which is what it
/// would do without the `X` in the way — the case above.
#[test]
fn a_back_tab_returns_to_the_stop_the_cursor_is_just_past() -> termlens::Result<()> {
    let mut t = emit(&["\t\tX", "--csi", "1Z", "y", " DONE", "--wait"])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "y"), Some(16), "{s}");
    assert!(!s.contains("X"), "y is written over X:\n{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// `RIS` puts the terminal back to power-on, and the stops with it.
#[test]
fn a_hard_reset_restores_the_default_stops() -> termlens::Result<()> {
    let mut t = emit(&[
        "--csi", "3g", "--csi", "4G", "--esc", "H", "--esc", "c", "\tx", " DONE", "--wait",
    ])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "x"), Some(8), "every eighth column again:\n{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// `DECSTR` restores them too — the soft reset a well-behaved TUI sends on
/// startup and teardown, which leaves the screen alone.
#[test]
#[cfg_attr(windows, ignore = "ConPTY does not forward DECSTR (#149)")]
fn a_soft_reset_restores_the_default_stops() -> termlens::Result<()> {
    let mut t = emit(&[
        "--csi", "3g", "--csi", "4G", "--esc", "H", "--csi", "!p", "--csi", "1G", "\tx", " DONE",
        "--wait",
    ])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "x"), Some(8), "every eighth column again:\n{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// The documented resize rule, through a real `resize`: columns the grid
/// did not have before get the every-eighth pattern, and the stops inside
/// the old width are left exactly as they were.
///
/// This is the rule most likely to be broken by a later change and the one
/// nothing else pins end to end — a set rebuilt from scratch on resize would
/// lose the custom stop and pass every other test in this file.
#[test]
fn a_resize_extends_the_stops_and_keeps_the_ones_it_had() -> termlens::Result<()> {
    // The stop at column 4 is set before the resize; `READY` parks the
    // child so the widen lands between the two halves.
    //
    // One `--wait` and no trailing one: a resize raises `SIGWINCH` in the
    // child, which can cut a pending read short, so a program that paused
    // twice would be racing the signal for which pause our one keypress
    // lands in. With a single pause the second half is printed after the
    // widen either way.
    //
    // Which *row* it is printed on is the other side of that same race, so
    // it is deliberately not asserted: when the wait consumes our Enter the
    // terminal echoes the newline and the second half starts a row lower
    // than when `SIGWINCH` has already ended it. The column is what the
    // resize rule is about, and it is the same either way.
    let mut t = emit(&[
        "--csi",
        "4G",
        "--esc",
        "H",
        "--csi",
        "1G",
        "READY\r\n",
        "--wait",
        "\ta",
        "--csi",
        "25G",
        "\tb\r\nDONE",
    ])?;
    t.wait_until(|s| s.contains("READY"))?;
    t.resize(40, 4)?;
    t.send(Key::Enter)?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(col_of(&s, "a"), Some(3), "the custom stop survives:\n{s}");
    assert_eq!(
        col_of(&s, "b"),
        Some(32),
        "and column 25 tabs on to the every-eighth stop at 33:\n{s}"
    );
    assert!(t.wait_exit()?.success());
    Ok(())
}

/// A table drawn the way the capabilities are meant to be used: clear the
/// stops, set the column ones, then tab between them for every row. This is
/// the failure the issue describes — every character present, every column
/// wrong — so it is asserted by column rather than by `contains`.
///
/// The defaults are cleared first because a cell whose text reaches the next
/// default stop would otherwise tab past it: `name` ends exactly at column 8
/// where `ada` ends at 7, so the two rows would part company on the third
/// column and nothing about the escape handling would be at fault.
#[test]
fn a_table_laid_out_with_its_own_stops_lines_up() -> termlens::Result<()> {
    let mut t = emit(&[
        // Stops at columns 5 and 13, one-based, and nothing else.
        "--csi",
        "3g",
        "--csi",
        "5G",
        "--esc",
        "H",
        "--csi",
        "13G",
        "--esc",
        "H",
        "--csi",
        "1G",
        "id\tname\trole\r\n",
        "7\tada\tdev\r\n",
        "DONE",
        "--wait",
    ])?;
    t.wait_until(|s| s.contains("DONE"))?;
    let s = t.screen();
    assert_eq!(s.row_text(0).trim_end(), "id  name    role", "{s}");
    assert_eq!(s.row_text(1).trim_end(), "7   ada     dev", "{s}");
    // The columns line up, which is the whole point of the capability.
    assert_eq!(col_of(&s, "name"), Some(4), "{s}");
    assert_eq!(col_of(&s, "ada"), Some(4), "{s}");
    assert_eq!(col_of(&s, "role"), Some(12), "{s}");
    assert_eq!(col_of(&s, "dev"), Some(12), "{s}");
    t.send(Key::Enter)?;
    assert!(t.wait_exit()?.success());
    Ok(())
}