add funcs for cursor movement and other xterm derived features

master
Ondřej Hruška 2 weeks ago
parent d559eb10ef
commit 1a1b134ffa
  1. 1
      Cargo.lock
  2. 3
      Cargo.toml
  3. 4
      README.md
  4. 141
      docs/tui.md
  5. 75
      lua/tui_term.lua
  6. 109
      src/lua_tests/tui_tests.rs
  7. 15
      src/main.rs
  8. 4
      src/stdlib/mod.rs
  9. 157
      src/stdlib/tui/markup.rs
  10. 9
      src/stdlib/tui/mod.rs
  11. 2
      src/stdlib/tui/output.rs
  12. 5
      src/stdlib/tui/prompts.rs
  13. 586
      src/stdlib/tui/term.rs

1
Cargo.lock generated

@ -6,6 +6,7 @@ version = 4
name = "a" name = "a"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"chrono", "chrono",
"clap", "clap",
"colored", "colored",

@ -5,6 +5,9 @@ edition = "2024"
publish = false publish = false
[dependencies] [dependencies]
# OSC 52 clipboard payloads (tui.setClipboard); already in the tree as a
# transitive dependency.
base64 = "0.22"
chrono = "0.4.45" chrono = "0.4.45"
# Color type + NO_COLOR/CLICOLOR/tty policy for tui.styled; the ANSI SGR # Color type + NO_COLOR/CLICOLOR/tty policy for tui.styled; the ANSI SGR
# sequences themselves are emitted by our own renderer (see stdlib/tui/markup.rs). # sequences themselves are emitted by our own renderer (see stdlib/tui/markup.rs).

@ -42,13 +42,13 @@ The standard library is the main purpose of `a`. Available today:
- **Networking** — HTTP client (WebSocket and MQTT are planned). - **Networking** — HTTP client (WebSocket and MQTT are planned).
- **SQLite** — access to embedded SQLite databases. - **SQLite** — access to embedded SQLite databases.
- **Terminal UI** — interactive prompts (confirm, text, select, password, number, date) and styled/colored output via [`tui`](docs/tui.md). - **Terminal UI** — interactive prompts (confirm, text, select, password, number, date), styled/colored output, and terminal control (cursor addressing, alternate screen, scroll regions) via [`tui`](docs/tui.md).
- **Core helpers**`math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency. - **Core helpers**`math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency.
Planned: Planned:
- **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate. - **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate.
- **Full-screen TUI** — cursor-addressed interfaces comparable to `ncurses`. - **TUI widgets & input** — progress bars, tables, and raw key/mouse events on top of the existing terminal control.
The intent is for the common case to be a single call rather than a multi-step setup. The intent is for the common case to be a single call rather than a multi-step setup.

@ -271,6 +271,48 @@ supporting terminals show clickable text, others show just the text:
tui.styled("see <href=https://example.com>the manual</>") tui.styled("see <href=https://example.com>the manual</>")
``` ```
### Action tags: cursor movement and clearing
Besides styles, markup can move the cursor and clear parts of the display —
handy for single-line redraws without string-formatting escape codes by hand:
```lua
for i = 1, 100 do
tui.raw(tui.styled(("<clear=line>progress <info>%d%%</info>"):format(i)))
-- ... work ...
end
tui.raw("\n")
```
| Tag | Effect |
|-----|--------|
| `<up>` `<down>` `<left>` `<right>` | move the cursor by one cell |
| `<up=3>` … | move by *n* cells |
| `<row=3;col=2>` | absolute move, 1-based; each axis works alone too |
| `<start>` | column 1 of the current line |
| `<home>` | top-left corner |
| `<clear=line>` | clear the whole line, cursor to column 1 |
| `<clear=start>` | clear to the start of the line, cursor to column 1 |
| `<clear=end>` | clear to the end of the line, cursor stays put |
| `<clear=screen>` | clear the whole screen, cursor home |
| `<clear=up>` | clear above the cursor, cursor home |
| `<clear=down>` | clear below the cursor, cursor stays put |
Unlike style tags, action tags emit their escape sequence *in place* — they
don't enclose text and have no closing form (`</up>` is literal text). Ops
combine in one tag, applied left to right: `<up;clear=line>` moves up one
line and blanks it. Whole and to-start clears also return the cursor (column
1 or home, matching `tui.clearLine`/`tui.clearScreen`), so the redraw starts
from a known spot; the `end`/`down` variants leave it in place.
Like all escape output, action tags are dropped when colors are off (pipes,
`NO_COLOR`) — piped output never contains control bytes. A custom preset may
shadow an action name (`addPreset("up", …)` turns `<up>` into a style tag).
Stateful operations — the alternate screen, cursor visibility, window title —
are deliberately **not** tags; they need cleanup tracking and live in the
[terminal control functions](#terminal-control) below.
### Escaping and invalid tags ### Escaping and invalid tags
A backslash escapes a literal bracket: `"\\<info>"` prints `<info>`. Anything A backslash escapes a literal bracket: `"\\<info>"` prints `<info>`. Anything
@ -383,13 +425,104 @@ bars, right-aligned text, …):
| `tty` | whether stdout is a terminal (`false` when piped) | | `tty` | whether stdout is a terminal (`false` when piped) |
| `colors` | whether styled output will actually emit colors (same detection as `tui.styled`) | | `colors` | whether styled output will actually emit colors (same detection as `tui.styled`) |
## Terminal control
Direct terminal manipulation for scripts that render their own UI: cursor
movement, clearing, the alternate screen, scroll regions, the window title
and the clipboard. These are the standard xterm-style escape sequences,
wrapped as functions.
```lua
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
tui.moveTo(2, 4)
tui.raw(tui.styled("<info>fullscreen!</info>"))
os.sleep(2)
-- no explicit restore needed: the runtime cleans up on exit
```
Shared behavior:
- **No-ops without a terminal.** When stdout is not a tty, every function
validates its arguments and then does nothing — piped output never
contains control bytes. This is a plain tty check (unlike color
detection): `NO_COLOR` turns off colors, not cursor control.
- **Bad arguments always raise**, tty or not — typos fail loudly in pipes
and CI too.
- **Automatic cleanup.** The runtime tracks the alternate screen, a hidden
cursor, a changed cursor style, a scroll region and a pushed window title,
and restores all of them when the script ends — on normal exit, on error
(the message prints on the normal screen), and on a crash. Scripts should
still restore what they change for mid-script tidiness, but cannot wreck
the terminal by forgetting.
### Cursor
- **`tui.moveTo(row, col)`** — absolute move, 1-based (row 1, column 1 is
the top-left corner). Passing `nil` for one axis keeps it:
`tui.moveTo(5)` jumps to row 5, `tui.moveTo(nil, 1)` to column 1.
- **`tui.move(dx, dy)`** — relative move: positive `dx` right, positive `dy`
down; negative values go the other way, `nil` counts as 0. The terminal
clamps at the screen edges.
- **`tui.saveCursor()` / `tui.restoreCursor()`** — save and restore the
cursor position (one slot; a save overwrites the previous one).
- **`tui.cursorPos()``row, col`** — ask the terminal where the cursor is
(1-based). Returns `nil` when there is no terminal or it doesn't answer.
Like the prompts this is async and serialized against them, so a
concurrent prompt can't eat the terminal's reply.
- **`tui.showCursor(visible)`** — hide (`false`) or show (`true` or no
argument) the cursor. A hidden cursor is re-shown on exit.
- **`tui.cursorStyle(style, opts)`** — set the cursor shape: `"block"`,
`"underline"` or `"bar"`, with `{ blink = true }` for the blinking
variant. No arguments reset to the terminal default (also done on exit).
### Clearing
- **`tui.clearLine(mode)`** / **`tui.clearScreen(mode)`** — erase the
current line / the screen. `mode` selects what: `0` or `nil` = whole,
`-1` = up to the cursor, `1` = from the cursor to the end. Whole and
to-start clears also return the cursor to a known spot (column 1 / home);
to-end clears leave it in place.
### Alternate screen
- **`tui.altScreen(on)`** — switch to (`true`) or back from (`false`) the
alternate screen buffer, the full-screen-app mode where the normal
scrollback stays untouched. Switching in an already-active direction is
ignored, and the runtime always switches back on exit.
### Scrolling
- **`tui.scrollRegion(top, bottom)`** — restrict scrolling to the given
rows (1-based, inclusive; `top < bottom`) — the basis for fixed
headers/footers. Setting a region moves the cursor home. With no arguments
the region resets to the full screen (also done on exit).
- **`tui.scroll(n)`** — scroll the scroll region: positive `n` scrolls the
content up by `n` lines, negative down, `0` does nothing. The cursor does
not move.
### Terminal integration
- **`tui.setTitle(text)`** — set the terminal window title. The first call
saves the current title on the terminal's title stack, and the runtime
restores it on exit. Control characters in the title are an error.
- **`tui.setClipboard(text)`** — copy `text` into the system clipboard via
the terminal (OSC 52). Write-only: reading the clipboard is disabled by
most terminals for good reasons. Works over SSH in supporting terminals;
some terminals require enabling clipboard access in their settings.
- **`tui.reset()`** — soft-reset the terminal (DECSTR): un-sticks stuck
attributes, a garbled charset, a forgotten scroll region — without
clearing the screen. The heavy-handed fixer for "my output looks insane".
## Notes ## Notes
- **Always loaded.** No `require`; `tui` is a global in every script - **Always loaded.** No `require`; `tui` is a global in every script
(`require "tui"` also works and returns the same table). (`require "tui"` also works and returns the same table).
- **Prompts need a terminal.** In pipes, cron, CI etc. every prompt raises - **Prompts need a terminal.** In pipes, cron, CI etc. every prompt raises
`not a terminal`; `tui.styled` and the blocks still work and degrade to `not a terminal`; `tui.styled` and the blocks still work and degrade to
plain text. plain text, and the terminal-control functions become no-ops.
- **More styling tags are planned** (screen clearing, cursor movement); the - **Progress bars** can be built from `tui.raw` + `tui.screenInfo` + the
markup engine is built to grow them. Progress bars can be built today from `<clear=line>` action tag; a ready-made widget may come later.
`tui.raw` + `tui.screenInfo`. - **Key events** (raw keyboard input, mouse tracking) are not exposed yet;
prompts are the supported way to read input.

@ -0,0 +1,75 @@
-- Showcase of the tui terminal-control functions and markup action tags:
-- inline redraws, the alternate screen, absolute cursor addressing, scroll
-- regions. Needs a real terminal — piped, everything degrades to no-ops.
--
-- Run with: cargo run -- lua/tui_term.lua
tui.setTitle("tui terminal demo")
----------------------------------------------------------------------
-- 1. Inline progress: redraw one line with the <clear=line> action tag
----------------------------------------------------------------------
for i = 1, 30 do
tui.raw(tui.styled(("<clear=line>working <info>%d/30</info> %s"):format(i, ("#"):rep(i))))
os.sleep(0.03)
end
tui.raw(tui.styled("<clear=line>"))
tui.success("progress loop finished")
----------------------------------------------------------------------
-- 2. Full-screen: alternate screen + absolute cursor addressing
----------------------------------------------------------------------
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
local info = tui.screenInfo()
local w, h = info.width or 80, info.height or 24
tui.moveTo(2, 3)
tui.raw(tui.styled(("<info;b>alternate screen</> — %dx%d cells"):format(w, h)))
-- A little box drawn with moveTo.
for row = 4, 8 do
tui.moveTo(row, 3)
if row == 4 or row == 8 then
tui.raw("+" .. ("-"):rep(20) .. "+")
else
tui.raw("|" .. (" "):rep(20) .. "|")
end
end
tui.moveTo(6, 6)
tui.raw(tui.styled("<yellow>boxed content</>"))
tui.moveTo(10, 3)
tui.raw("where is the cursor? ")
local row, col = tui.cursorPos()
tui.raw(tui.styled(("at <b>row %s, col %s</b>"):format(tostring(row), tostring(col))))
tui.moveTo(12, 3)
tui.raw("back to the normal screen in 2 seconds...")
os.sleep(2)
tui.altScreen(false)
tui.showCursor(true)
print("back — scrollback intact.")
----------------------------------------------------------------------
-- 3. Scroll region: log lines scrolling under a fixed header
----------------------------------------------------------------------
print()
print(tui.styled("<b>FIXED HEADER</b> (lines below scroll, this one stays)"))
local top = select(1, tui.cursorPos())
if top then
tui.scrollRegion(top, top + 5)
for i = 1, 15 do
tui.moveTo(top + 5, 1)
tui.raw(("\nlog line %d"):format(i))
os.sleep(0.1)
end
tui.scrollRegion()
tui.moveTo(top + 6, 1)
end
print("scroll region reset.")
-- Deliberately no title restore: the runtime pops the pushed title (and
-- would also leave the alt screen / re-show the cursor) on exit.

@ -40,6 +40,21 @@ fn test_tui_global_has_all_functions() {
"raw", "raw",
"screenInfo", "screenInfo",
"addPreset", "addPreset",
"moveTo",
"move",
"saveCursor",
"restoreCursor",
"cursorPos",
"showCursor",
"cursorStyle",
"clearLine",
"clearScreen",
"altScreen",
"scrollRegion",
"scroll",
"setTitle",
"setClipboard",
"reset",
] { ] {
let is_fn: bool = lua let is_fn: bool = lua
.load(format!("return type(tui.{f}) == 'function'")) .load(format!("return type(tui.{f}) == 'function'"))
@ -151,6 +166,19 @@ fn test_styled_rejects_non_string() {
assert!(err.to_string().contains("tui.styled:"), "{err}"); assert!(err.to_string().contains("tui.styled:"), "{err}");
} }
#[test]
fn test_styled_action_tags_are_consumed() {
let lua = lua();
// Valid action tags are consumed in both color modes (bytes are pinned in
// stdlib/tui/markup.rs); invalid ones stay literal.
let out: String = lua
.load(r#"return tui.styled("a<up=3><clear=line>b <notanop=1> c")"#)
.eval()
.unwrap();
assert!(!out.contains("up=3") && !out.contains("clear"), "{out:?}");
assert!(out.contains("<notanop=1>"), "invalid tag should stay literal: {out:?}");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// prompt argument validation (fires before any terminal interaction) // prompt argument validation (fires before any terminal interaction)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -355,6 +383,87 @@ fn test_screen_info_shape() {
assert!(width_type == "number" || width_type == "nil", "width: {width_type}"); assert!(width_type == "number" || width_type == "nil", "width: {width_type}");
} }
// ---------------------------------------------------------------------------
// terminal control (tui.moveTo & co.)
// ---------------------------------------------------------------------------
// Argument validation fires before the tty check, so these are deterministic
// under the test harness. The emitted bytes are pinned by the unit tests in
// stdlib/tui/term.rs and markup.rs.
#[test]
fn test_term_control_validation() {
let lua = lua();
for (src, expect) in [
("tui.moveTo()", "tui.moveTo: row and col cannot both be nil"),
("tui.moveTo(0, 1)", "tui.moveTo: row must be a positive integer (got 0)"),
("tui.moveTo(nil, 'x')", "tui.moveTo: col must be a positive integer (got string)"),
("tui.move('x')", "tui.move: dx must be an integer (got string)"),
("tui.move(1, 2.5)", "tui.move: dy must be an integer (got 2.5)"),
("tui.clearLine(2)", "tui.clearLine: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got 2"),
("tui.clearScreen(1.5)", "tui.clearScreen: mode must be"),
("tui.altScreen()", "tui.altScreen: expected a boolean (got nil)"),
("tui.altScreen(1)", "tui.altScreen: expected a boolean (got integer)"),
("tui.showCursor('yes')", "tui.showCursor: expected a boolean or nil (got string)"),
("tui.cursorStyle('blocky')", "tui.cursorStyle: style must be 'block', 'underline' or 'bar' (got 'blocky')"),
("tui.cursorStyle('bar', {blnk=true})", "tui.cursorStyle: unknown option 'blnk'"),
("tui.cursorStyle(nil, {blink=true})", "tui.cursorStyle: a style is required when 'blink' is set"),
("tui.scrollRegion(5)", "tui.scrollRegion: top and bottom must both be given"),
("tui.scrollRegion(5, 2)", "tui.scrollRegion: top (5) must be less than bottom (2)"),
("tui.scrollRegion(0, 5)", "tui.scrollRegion: top must be a positive integer (got 0)"),
("tui.scroll()", "tui.scroll: expected an integer (got nil)"),
("tui.setTitle(42)", "tui.setTitle: title must be a string (got integer)"),
("tui.setTitle('a\\nb')", "tui.setTitle: title must not contain control characters"),
("tui.setClipboard(42)", "tui.setClipboard: text must be a string (got integer)"),
] {
let err = lua.load(src).exec().unwrap_err();
let msg = err.to_string();
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
}
}
/// Off-tty every control function validates and then does nothing — no error,
/// no control bytes in piped output. Skipped when the harness's stdout is a
/// terminal (the calls would then really move the cursor around the runner).
#[test]
fn test_term_control_off_tty_is_noop() {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
return;
}
let lua = lua();
lua.load(
r#"
tui.moveTo(1, 1); tui.moveTo(2); tui.moveTo(nil, 3)
tui.move(2, -1); tui.move(0, 0); tui.move()
tui.saveCursor(); tui.restoreCursor()
tui.showCursor(false); tui.showCursor()
tui.cursorStyle("bar", {blink=true}); tui.cursorStyle()
tui.clearLine(); tui.clearLine(-1); tui.clearLine(1)
tui.clearScreen(); tui.clearScreen(-1); tui.clearScreen(1)
tui.altScreen(true); tui.altScreen(false)
tui.scrollRegion(2, 10); tui.scrollRegion()
tui.scroll(3); tui.scroll(-2); tui.scroll(0)
tui.setTitle("test"); tui.setTitle("")
tui.setClipboard("clip")
tui.reset()
"#,
)
.exec()
.unwrap();
}
#[tokio::test]
async fn test_cursor_pos_off_tty_is_nil() {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
return;
}
let lua = lua();
let (row, col): (Option<u32>, Option<u32>) =
lua.load("return tui.cursorPos()").eval_async().await.unwrap();
assert_eq!((row, col), (None, None));
}
/// Without a terminal, an actual prompt attempt must fail — with the /// Without a terminal, an actual prompt attempt must fail — with the
/// function's error prefix, not a panic. The exact wording (NotTTY vs an IO /// function's error prefix, not a panic. The exact wording (NotTTY vs an IO
/// error) varies by environment, so only the prefix is asserted. /// error) varies by environment, so only the prefix is asserted.

@ -24,7 +24,20 @@ struct Args {
async fn main() { async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
if let Err(err) = run().await { // A panicking runtime must not strand the user on the alternate screen or
// with a hidden cursor; restoring first also puts the panic message on
// the normal screen. Normal exits are covered below.
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
stdlib::tui::cleanup();
default_panic(info);
}));
let result = run().await;
// Restore any terminal state the script left behind (tui.altScreen & co.)
// before printing an error, so the message lands on the normal screen.
stdlib::tui::cleanup();
if let Err(err) = result {
// Display (not Debug) so a Lua error's traceback and multi-line messages // Display (not Debug) so a Lua error's traceback and multi-line messages
// read as intended instead of as one escaped line. // read as intended instead of as one escaped line.
eprintln!("{err}"); eprintln!("{err}");

@ -9,7 +9,9 @@ pub(crate) mod require;
mod sqlite; mod sqlite;
mod table; mod table;
mod task; mod task;
mod tui; // pub(crate) for tui::cleanup — `main` restores terminal state (alt screen,
// hidden cursor, …) on every exit path, including panics.
pub(crate) mod tui;
pub(crate) mod utils; pub(crate) mod utils;
pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {

@ -11,7 +11,12 @@
//! name, a color (foreground, including `default` and `#hex`), or //! name, a color (foreground, including `default` and `#hex`), or
//! `on-<color>` (background). Shorthand and `key=value` parts mix freely //! `on-<color>` (background). Shorthand and `key=value` parts mix freely
//! in one tag (`<red;href=https://…>`); //! in one tag (`<red;href=https://…>`);
//! - hyperlinks: `<href=https://…>text</>`. //! - hyperlinks: `<href=https://…>text</>`;
//! - action tags: cursor movement and clearing emitted in place —
//! `<up>`/`<up=3>` (and `down`/`left`/`right`), absolute `<row=3;col=2>`,
//! `<start>` (column 1), `<home>` (top-left), and
//! `<clear=screen|up|down|line|start|end>`; ops combine in one tag
//! (`<up;clear=line>`). See [`parse_action_spec`].
//! //!
//! Styles nest: an inner tag overrides only the attributes it sets, //! Styles nest: an inner tag overrides only the attributes it sets,
//! everything else is inherited from the enclosing style. `</>`, `</info>` //! everything else is inherited from the enclosing style. `</>`, `</info>`
@ -100,13 +105,12 @@ impl Style {
} }
/// What an opening tag does. `Style` pushes onto the style stack and applies /// What an opening tag does. `Style` pushes onto the style stack and applies
/// to the enclosed text. `Action` is the seam for future self-contained /// to the enclosed text. `Action` emits a self-contained escape sequence in
/// emissions with no stack interaction — e.g. `<clear>` → `"\x1b[2J\x1b[H"`, /// place (cursor movement, clearing — see [`parse_action_spec`]) with no
/// `<up=3>` → `"\x1b[3A"`, `<bell>` — added by giving `resolve_tag` more /// stack interaction; like all escape output it is dropped when not
/// producers; the tokenizer and renderer already handle the variant. /// colorizing, so piped `tui.styled` output stays free of control bytes.
enum Tag { enum Tag {
Style(Style), Style(Style),
#[allow(dead_code)]
Action(String), Action(String),
} }
@ -149,10 +153,75 @@ fn named_style(name: &str) -> Option<Style> {
}) })
} }
/// Parse the inside of `<...>`. `None` means the whole tag (brackets /// Parse the inside of `<...>`. Styles win (so a custom preset can shadow an
/// included) is literal text. /// action name like `up`); a body that is neither a style nor an action spec
/// is `None` — the whole tag (brackets included) stays literal text.
fn resolve_tag(body: &str, presets: &Presets) -> Option<Tag> { fn resolve_tag(body: &str, presets: &Presets) -> Option<Tag> {
resolve_spec(body, presets).map(Tag::Style) resolve_spec(body, presets)
.map(Tag::Style)
.or_else(|| parse_action_spec(body).map(Tag::Action))
}
/// Parse an action-tag body: `;`-separated cursor/erase operations, each
/// contributing its escape sequence in order. Strict like the style parser —
/// one unknown part rejects the whole tag. Relative moves default to 1
/// (`<up>` == `<up=1>`); `row`/`col` are absolute and require a value.
/// Clears match the `tui.clearLine`/`tui.clearScreen` functions: whole and
/// to-start clears also return the cursor (column 1 / home) so the redraw
/// starts from a known spot; the to-end variants leave it in place.
fn parse_action_spec(body: &str) -> Option<String> {
if body.is_empty() {
return None;
}
let mut out = String::new();
for part in body.split(';') {
match part.split_once('=') {
Some(("clear", area)) => out.push_str(match area {
"screen" => "\x1b[2J\x1b[H",
"up" => "\x1b[1J\x1b[H",
"down" => "\x1b[0J",
"line" => "\x1b[2K\r",
"start" => "\x1b[1K\r",
"end" => "\x1b[0K",
_ => return None,
}),
Some((op, count)) => {
write!(out, "\x1b[{}{}", action_count(count)?, action_final_byte(op)?).unwrap();
}
None => match part {
"start" => out.push('\r'),
"home" => out.push_str("\x1b[H"),
"up" | "down" | "right" | "left" => {
write!(out, "\x1b[1{}", action_final_byte(part).unwrap()).unwrap();
}
_ => return None,
},
}
}
Some(out)
}
/// CSI final byte of a movement op: relative CUU/CUD/CUF/CUB, absolute
/// VPA (`row`) / CHA (`col`).
fn action_final_byte(op: &str) -> Option<char> {
Some(match op {
"up" => 'A',
"down" => 'B',
"right" => 'C',
"left" => 'D',
"row" => 'd',
"col" => 'G',
_ => return None,
})
}
/// A positive decimal count/coordinate (`up=3`, `row=12`); anything else
/// (zero, sign, empty, non-digits) rejects the tag.
fn action_count(s: &str) -> Option<u16> {
if !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<u16>().ok().filter(|&n| n > 0)
} }
/// Parse an inline spec: `;`-separated parts, each either `key=value` /// Parse an inline spec: `;`-separated parts, each either `key=value`
@ -697,4 +766,74 @@ mod tests {
fn unclosed_styles_at_eof_are_fine() { fn unclosed_styles_at_eof_are_fine() {
assert_eq!(r("<info>x", true), "\x1b[1;32mx\x1b[0m"); assert_eq!(r("<info>x", true), "\x1b[1;32mx\x1b[0m");
} }
#[test]
fn action_tags() {
assert_eq!(r("<up>", true), "\x1b[1A");
assert_eq!(r("<up=3>", true), "\x1b[3A");
assert_eq!(r("<down>x", true), "\x1b[1Bx");
assert_eq!(r("<left=5;right=2>", true), "\x1b[5D\x1b[2C");
assert_eq!(r("<row=3;col=2>", true), "\x1b[3d\x1b[2G");
assert_eq!(r("<row=5>", true), "\x1b[5d");
assert_eq!(r("<start>", true), "\r");
assert_eq!(r("<home>", true), "\x1b[H");
// Whole and to-start clears also return the cursor (like the
// tui.clearLine/clearScreen functions); to-end clears don't move it.
assert_eq!(r("<clear=screen>", true), "\x1b[2J\x1b[H");
assert_eq!(r("<clear=up>", true), "\x1b[1J\x1b[H");
assert_eq!(r("<clear=down>", true), "\x1b[0J");
assert_eq!(r("<clear=line>", true), "\x1b[2K\r");
assert_eq!(r("<clear=start>", true), "\x1b[1K\r");
assert_eq!(r("<clear=end>", true), "\x1b[0K");
// Ops combine in one tag, emitted in order.
assert_eq!(r("<up;clear=line>", true), "\x1b[1A\x1b[2K\r");
assert_eq!(r("<home;clear=down>", true), "\x1b[H\x1b[0J");
}
#[test]
fn action_tags_do_not_affect_the_style_stack() {
assert_eq!(
r("<info>a<up>b</info>", true),
"\x1b[1;32ma\x1b[0m\x1b[1A\x1b[1;32mb\x1b[0m"
);
// An action is not an opener: `</>` after it pops the *style*.
assert_eq!(r("<info>a<up></>b", true), "\x1b[1;32ma\x1b[0m\x1b[1Ab");
}
#[test]
fn action_tags_stripped_without_colorize() {
let out = r("a<up=3>b<clear=line>c<home>", false);
assert_eq!(out, "abc");
assert!(!out.contains('\x1b') && !out.contains('\r'));
}
#[test]
fn invalid_action_tags_are_literal() {
for s in [
"<up=0>",
"<up=-1>",
"<up=+3>",
"<up=x>",
"<up=>",
"<clear>",
"<clear=all>",
"<clear=>",
"<red;up>", // style and action parts don't mix in one tag
"<up;red>",
"<row>", // absolute ops require a value
"<col>",
"<home=2>",
"<start=2>",
"</up>", // actions have no closing form
] {
assert_eq!(r(s, true), s, "should pass through literally: {s:?}");
}
}
#[test]
fn preset_shadows_action_name() {
let mut presets = Presets::default();
presets.0.insert("up".into(), resolve_style_spec("fg=red").unwrap());
assert_eq!(render("<up>x</>", true, &presets), "\x1b[31mx\x1b[0m");
}
} }

@ -11,6 +11,11 @@
//! rendering in [`blocks`]), and the `tui.raw` / `tui.screenInfo` //! rendering in [`blocks`]), and the `tui.raw` / `tui.screenInfo`
//! building blocks. //! building blocks.
//! //!
//! A third piece, **terminal control** ([`term`]), exposes the raw escape
//! sequences behind rich TUIs — cursor movement, clearing, the alternate
//! screen, scroll regions, title/clipboard — as `tui.moveTo`, `tui.altScreen`
//! and friends, with [`cleanup`] restoring the terminal on every exit path.
//!
//! Shared argument parsing (the validated opts table, positional-arg //! Shared argument parsing (the validated opts table, positional-arg
//! helpers) lives in [`opts`]. //! helpers) lives in [`opts`].
@ -19,6 +24,9 @@ mod markup;
mod opts; mod opts;
mod output; mod output;
mod prompts; mod prompts;
mod term;
pub(crate) use term::cleanup;
use mlua::prelude::LuaResult; use mlua::prelude::LuaResult;
use mlua::Lua; use mlua::Lua;
@ -38,6 +46,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let tui = lua.create_table()?; let tui = lua.create_table()?;
prompts::install(lua, &tui)?; prompts::install(lua, &tui)?;
output::install(lua, &tui)?; output::install(lua, &tui)?;
term::install(lua, &tui)?;
lua.globals().raw_set("tui", tui)?; lua.globals().raw_set("tui", tui)?;
Ok(()) Ok(())
} }

@ -86,7 +86,7 @@ fn style_opt(lua: &Lua, opts: &Opts, key: &str) -> LuaResult<Option<markup::Styl
/// bypass the capture and trash the test runner's output. In normal builds it /// bypass the capture and trash the test runner's output. In normal builds it
/// writes the bytes directly, ignores errors (a broken pipe must not panic /// writes the bytes directly, ignores errors (a broken pipe must not panic
/// the runtime) and flushes (tui.raw is used for `\r` redraws mid-line). /// the runtime) and flushes (tui.raw is used for `\r` redraws mid-line).
fn write_stdout(bytes: &[u8]) { pub(super) fn write_stdout(bytes: &[u8]) {
#[cfg(test)] #[cfg(test)]
print!("{}", String::from_utf8_lossy(bytes)); print!("{}", String::from_utf8_lossy(bytes));
#[cfg(not(test))] #[cfg(not(test))]

@ -35,8 +35,9 @@ use super::opts::{
/// Serializes terminal ownership across concurrent Lua coroutines. Locked /// Serializes terminal ownership across concurrent Lua coroutines. Locked
/// inside the blocking closure, so a waiting prompt parks a blocking-pool /// inside the blocking closure, so a waiting prompt parks a blocking-pool
/// thread, never the tokio event loop. /// thread, never the tokio event loop. Shared with `term`'s cursor-position
static PROMPT_LOCK: Mutex<()> = Mutex::new(()); /// query, which also reads from the terminal.
pub(super) static PROMPT_LOCK: Mutex<()> = Mutex::new(());
/// `Send`-safe error carried out of the blocking prompt worker; the Lua-side /// `Send`-safe error carried out of the blocking prompt worker; the Lua-side
/// wrapper prepends the `tui.<fn>:` prefix. /// wrapper prepends the `tui.<fn>:` prefix.

@ -0,0 +1,586 @@
//! Terminal control bindings: cursor movement, line/screen clearing, the
//! alternate screen, scroll regions, window title, clipboard and soft reset —
//! the xterm-style escape sequences behind rich TUIs, exposed as plain
//! functions (`tui.moveTo`, `tui.clearLine`, `tui.altScreen`, …).
//!
//! Two rules hold for every function here:
//!
//! - **Arguments are validated first, unconditionally** — a typo fails loudly
//! even when nothing would be emitted (same contract as the prompts).
//! - **Sequences go only to a real terminal.** When stdout is not a tty the
//! functions validate and then do nothing: piped output must never contain
//! control bytes. This is a plain tty check, deliberately not the color
//! heuristics — `NO_COLOR` turns off colors, not cursor control. (Markup
//! *action tags* on the other hand ride inside `tui.styled` strings and
//! follow its color detection; see [`super::markup`].)
//!
//! Stateful operations (alternate screen, hidden cursor, cursor style, scroll
//! region, pushed title) are tracked in a process-wide [`TermState`];
//! [`cleanup`] undoes whatever is still active and runs on every exit path —
//! normal return, script error, and panic (hooked in `main`). A script that
//! forgets `tui.altScreen(false)` cannot wreck the user's terminal.
use std::io::IsTerminal;
use std::sync::{Mutex, MutexGuard};
use base64::Engine;
use crossterm::{cursor, terminal};
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use mlua::Lua;
use super::opts::{int_of, lua_value_brief, Opts};
use super::output::write_stdout;
use super::{ext_err, prompts};
/// Terminal modes that outlive the call that set them. One instance per
/// process (not per Lua state): there is one terminal, and the cleanup runs
/// from `main` / the panic hook where no Lua state may exist anymore.
struct TermState {
alt_screen: bool,
cursor_hidden: bool,
cursor_styled: bool,
scroll_region: bool,
title_pushed: bool,
}
const CLEAN: TermState = TermState {
alt_screen: false,
cursor_hidden: false,
cursor_styled: false,
scroll_region: false,
title_pushed: false,
};
static STATE: Mutex<TermState> = Mutex::new(CLEAN);
fn state() -> MutexGuard<'static, TermState> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
/// Everything needed to undo `st`, in an order that works from any state:
/// scroll region and cursor first (they may belong to the alternate screen),
/// then leave the alternate screen, then pop the title.
fn restore_sequence(st: &TermState) -> String {
let mut out = String::new();
if st.scroll_region {
out.push_str("\x1b[r");
}
if st.cursor_styled {
out.push_str("\x1b[0 q");
}
if st.cursor_hidden {
out.push_str(&seq(cursor::Show));
}
if st.alt_screen {
out.push_str(&seq(terminal::LeaveAlternateScreen));
}
if st.title_pushed {
out.push_str("\x1b[23;2t");
}
out
}
/// Restore any terminal state a script left behind. Called by `main` on every
/// exit path (normal, error, panic hook); idempotent — the tracked state is
/// cleared, so a second call emits nothing.
pub(crate) fn cleanup() {
let mut st = state();
let out = restore_sequence(&st);
*st = CLEAN;
drop(st);
if !out.is_empty() {
write_stdout(out.as_bytes());
}
}
/// The ANSI bytes of a crossterm command (writing to a `String` cannot fail).
fn seq(cmd: impl crossterm::Command) -> String {
let mut s = String::new();
cmd.write_ansi(&mut s).expect("write_ansi to String");
s
}
/// Whether control sequences are emitted at all — see the module docs.
fn active() -> bool {
std::io::stdout().is_terminal()
}
/// Send a fire-and-forget sequence, or drop it when stdout is not a terminal.
fn emit(s: &str) {
if active() {
write_stdout(s.as_bytes());
}
}
/// `lua_value_brief`, but integral values print their value (a rejected `0`
/// or `2` should say so, not "integer").
fn brief(v: &LuaValue) -> String {
match int_of(v) {
Some(n) => n.to_string(),
None => lua_value_brief(v),
}
}
/// A 1-based screen coordinate, or nil. Values beyond u16 are clamped — the
/// terminal clamps to the screen edge anyway.
fn opt_coord(fname: &str, what: &str, v: &LuaValue) -> LuaResult<Option<u16>> {
if v.is_nil() {
return Ok(None);
}
match int_of(v) {
Some(n) if n >= 1 => Ok(Some(n.min(u16::MAX as i64) as u16)),
_ => Err(ext_err!("{fname}: {what} must be a positive integer (got {})", brief(v))),
}
}
/// A relative-move distance; nil counts as 0 (no movement on that axis).
fn opt_delta(fname: &str, what: &str, v: &LuaValue) -> LuaResult<i64> {
if v.is_nil() {
return Ok(0);
}
int_of(v)
.ok_or_else(|| ext_err!("{fname}: {what} must be an integer (got {})", lua_value_brief(v)))
}
/// Magnitude of a delta as the u16 a CSI parameter can carry.
fn magnitude(n: i64) -> u16 {
n.unsigned_abs().min(u16::MAX as u64) as u16
}
/// The shared clear-mode argument: nil/0 = whole, -1 = to start, 1 = to end.
fn clear_mode(fname: &str, v: &LuaValue) -> LuaResult<i64> {
if v.is_nil() {
return Ok(0);
}
match int_of(v) {
Some(m @ -1..=1) => Ok(m),
_ => Err(ext_err!(
"{fname}: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got {}",
brief(v)
)),
}
}
/// A required string argument that is valid UTF-8.
fn string_arg(fname: &str, what: &str, v: LuaValue) -> LuaResult<String> {
match v {
LuaValue::String(s) => Ok(s
.to_str()
.map_err(|_| ext_err!("{fname}: {what} must be valid UTF-8"))?
.to_string()),
other => Err(ext_err!("{fname}: {what} must be a string (got {})", other.type_name())),
}
}
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
// tui.moveTo(row, col): absolute cursor position, 1-based like the
// terminal itself; nil keeps that axis (CUP / VPA / CHA).
tui.raw_set(
"moveTo",
lua.create_function(|_, (row, col): (LuaValue, LuaValue)| {
const F: &str = "tui.moveTo";
let row = opt_coord(F, "row", &row)?;
let col = opt_coord(F, "col", &col)?;
let s = match (row, col) {
(Some(r), Some(c)) => seq(cursor::MoveTo(c - 1, r - 1)),
(Some(r), None) => seq(cursor::MoveToRow(r - 1)),
(None, Some(c)) => seq(cursor::MoveToColumn(c - 1)),
(None, None) => return Err(ext_err!("{F}: row and col cannot both be nil")),
};
emit(&s);
Ok(())
})?,
)?;
// tui.move(dx, dy): relative move — positive dx right, positive dy down
// (screen coordinates); nil is 0. The terminal clamps at the edges.
tui.raw_set(
"move",
lua.create_function(|_, (dx, dy): (LuaValue, LuaValue)| {
const F: &str = "tui.move";
let dx = opt_delta(F, "dx", &dx)?;
let dy = opt_delta(F, "dy", &dy)?;
let mut s = String::new();
match dx {
1.. => s.push_str(&seq(cursor::MoveRight(magnitude(dx)))),
..0 => s.push_str(&seq(cursor::MoveLeft(magnitude(dx)))),
0 => {}
}
match dy {
1.. => s.push_str(&seq(cursor::MoveDown(magnitude(dy)))),
..0 => s.push_str(&seq(cursor::MoveUp(magnitude(dy)))),
0 => {}
}
emit(&s);
Ok(())
})?,
)?;
// tui.saveCursor() / tui.restoreCursor(): DECSC/DECRC — position, but
// also the SGR state, one slot deep.
tui.raw_set(
"saveCursor",
lua.create_function(|_, ()| {
emit(&seq(cursor::SavePosition));
Ok(())
})?,
)?;
tui.raw_set(
"restoreCursor",
lua.create_function(|_, ()| {
emit(&seq(cursor::RestorePosition));
Ok(())
})?,
)?;
// tui.cursorPos() -> row, col (1-based), or nil off-tty. The DSR-CPR
// round trip reads the terminal's reply from its input, so it runs under
// the prompt lock: a concurrent coroutine's prompt must not eat the reply.
tui.raw_set(
"cursorPos",
lua.create_async_function(|_, ()| async {
if !active() {
return Ok((None, None));
}
let pos = tokio::task::spawn_blocking(|| {
let _guard = prompts::PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
cursor::position().ok()
})
.await
.ok()
.flatten();
Ok(match pos {
Some((col, row)) => (Some(row as u32 + 1), Some(col as u32 + 1)),
None => (None, None),
})
})?,
)?;
// tui.showCursor(visible): DECTCEM; no argument means show. A hidden
// cursor is tracked and re-shown on exit.
tui.raw_set(
"showCursor",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.showCursor";
let show = match v {
LuaValue::Nil => true,
LuaValue::Boolean(b) => b,
other => {
return Err(ext_err!(
"{F}: expected a boolean or nil (got {})",
other.type_name()
))
}
};
if active() {
write_stdout(seq_of_visibility(show).as_bytes());
state().cursor_hidden = !show;
}
Ok(())
})?,
)?;
// tui.cursorStyle("block"|"underline"|"bar", {blink=}): DECSCUSR; no
// style resets to the terminal default. A changed style is restored on
// exit.
tui.raw_set(
"cursorStyle",
lua.create_function(|_, (style, opts): (LuaValue, Option<LuaTable>)| {
const F: &str = "tui.cursorStyle";
let opts = Opts::parse(F, opts, &["blink"])?;
let blink = opts.boolean("blink")?.unwrap_or(false);
let style = match style {
LuaValue::Nil => None,
LuaValue::String(s) => Some(
s.to_str()
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))?
.to_string(),
),
other => {
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name()))
}
};
use cursor::SetCursorStyle::*;
let cmd = match (style.as_deref(), blink) {
(None, false) => DefaultUserShape,
(None, true) => {
return Err(ext_err!("{F}: a style is required when 'blink' is set"))
}
(Some("block"), false) => SteadyBlock,
(Some("block"), true) => BlinkingBlock,
(Some("underline"), false) => SteadyUnderScore,
(Some("underline"), true) => BlinkingUnderScore,
(Some("bar"), false) => SteadyBar,
(Some("bar"), true) => BlinkingBar,
(Some(other), _) => {
return Err(ext_err!(
"{F}: style must be 'block', 'underline' or 'bar' (got '{other}')"
))
}
};
if active() {
write_stdout(seq(cmd).as_bytes());
state().cursor_styled = style.is_some();
}
Ok(())
})?,
)?;
// tui.clearLine(mode): EL. Whole-line and to-start clears also return the
// cursor to column 1 (the natural spot to redraw from); to-end does not
// move it.
tui.raw_set(
"clearLine",
lua.create_function(|_, mode: LuaValue| {
emit(match clear_mode("tui.clearLine", &mode)? {
-1 => "\x1b[1K\r",
1 => "\x1b[0K",
_ => "\x1b[2K\r",
});
Ok(())
})?,
)?;
// tui.clearScreen(mode): ED, same modes; whole-screen and to-start clears
// home the cursor.
tui.raw_set(
"clearScreen",
lua.create_function(|_, mode: LuaValue| {
emit(match clear_mode("tui.clearScreen", &mode)? {
-1 => "\x1b[1J\x1b[H",
1 => "\x1b[0J",
_ => "\x1b[2J\x1b[H",
});
Ok(())
})?,
)?;
// tui.altScreen(on): the 1049 alternate screen (saves/restores the cursor
// too). Idempotent — re-entering is ignored — and tracked, so an exit or
// error always lands back on the normal screen.
tui.raw_set(
"altScreen",
lua.create_function(|_, v: LuaValue| {
let on = match v {
LuaValue::Boolean(b) => b,
other => {
return Err(ext_err!(
"tui.altScreen: expected a boolean (got {})",
other.type_name()
))
}
};
if active() {
let mut st = state();
if on != st.alt_screen {
let s = if on {
seq(terminal::EnterAlternateScreen)
} else {
seq(terminal::LeaveAlternateScreen)
};
write_stdout(s.as_bytes());
st.alt_screen = on;
}
}
Ok(())
})?,
)?;
// tui.scrollRegion(top, bottom): DECSTBM, 1-based inclusive; no arguments
// reset it to the full screen. Setting a region moves the cursor home. A
// set region is tracked and reset on exit.
tui.raw_set(
"scrollRegion",
lua.create_function(|_, (top, bottom): (LuaValue, LuaValue)| {
const F: &str = "tui.scrollRegion";
let top = opt_coord(F, "top", &top)?;
let bottom = opt_coord(F, "bottom", &bottom)?;
match (top, bottom) {
(None, None) => {
if active() {
write_stdout(b"\x1b[r");
state().scroll_region = false;
}
}
(Some(t), Some(b)) => {
if t >= b {
return Err(ext_err!("{F}: top ({t}) must be less than bottom ({b})"));
}
if active() {
write_stdout(format!("\x1b[{t};{b}r").as_bytes());
state().scroll_region = true;
}
}
_ => {
return Err(ext_err!(
"{F}: top and bottom must both be given (or both nil to reset)"
))
}
}
Ok(())
})?,
)?;
// tui.scroll(n): scroll the scroll region up by n lines (content moves
// up); negative scrolls down, 0 does nothing.
tui.raw_set(
"scroll",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.scroll";
let n = int_of(&v)
.ok_or_else(|| ext_err!("{F}: expected an integer (got {})", lua_value_brief(&v)))?;
match n {
1.. => emit(&seq(terminal::ScrollUp(magnitude(n)))),
..0 => emit(&seq(terminal::ScrollDown(magnitude(n)))),
0 => {}
}
Ok(())
})?,
)?;
// tui.setTitle(text): OSC 2. The first call pushes the current title on
// the terminal's title stack (XTWINOPS 22) so exit can pop it back.
tui.raw_set(
"setTitle",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.setTitle";
let title = string_arg(F, "title", v)?;
// A control byte would terminate the OSC string early and leak
// the rest as terminal input — reject rather than sanitize.
if title.chars().any(char::is_control) {
return Err(ext_err!("{F}: title must not contain control characters"));
}
if active() {
let mut st = state();
let mut out = String::new();
if !st.title_pushed {
out.push_str("\x1b[22;2t");
st.title_pushed = true;
}
out.push_str(&format!("\x1b]2;{title}\x07"));
write_stdout(out.as_bytes());
}
Ok(())
})?,
)?;
// tui.setClipboard(text): OSC 52 write (any bytes; base64 on the wire).
// Write-only — querying the clipboard is disabled by most terminals.
tui.raw_set(
"setClipboard",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.setClipboard";
let bytes = match v {
LuaValue::String(s) => s.as_bytes().to_vec(),
other => {
return Err(ext_err!("{F}: text must be a string (got {})", other.type_name()))
}
};
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
emit(&format!("\x1b]52;c;{b64}\x07"));
Ok(())
})?,
)?;
// tui.reset(): DECSTR soft reset — un-sticks a garbled terminal (wrong
// charset, stuck attributes) without clearing the screen.
tui.raw_set(
"reset",
lua.create_function(|_, ()| {
if active() {
write_stdout(b"\x1b[!p");
// DECSTR itself makes the cursor visible and resets the
// scroll region; keep the tracking in sync. It does not leave
// the alternate screen or touch the title stack.
let mut st = state();
st.cursor_hidden = false;
st.scroll_region = false;
}
Ok(())
})?,
)?;
Ok(())
}
fn seq_of_visibility(show: bool) -> String {
if show {
seq(cursor::Show)
} else {
seq(cursor::Hide)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restore_sequence_bytes() {
let all = TermState {
alt_screen: true,
cursor_hidden: true,
cursor_styled: true,
scroll_region: true,
title_pushed: true,
};
assert_eq!(restore_sequence(&all), "\x1b[r\x1b[0 q\x1b[?25h\x1b[?1049l\x1b[23;2t");
assert_eq!(restore_sequence(&CLEAN), "");
assert_eq!(
restore_sequence(&TermState { cursor_hidden: true, ..CLEAN }),
"\x1b[?25h"
);
}
#[test]
fn cleanup_clears_tracked_state_and_is_idempotent() {
*state() = TermState {
alt_screen: true,
cursor_hidden: true,
cursor_styled: true,
scroll_region: true,
title_pushed: true,
};
cleanup();
{
let st = state();
assert!(
!st.alt_screen
&& !st.cursor_hidden
&& !st.cursor_styled
&& !st.scroll_region
&& !st.title_pushed
);
}
cleanup(); // second call must be a no-op (nothing tracked)
}
#[test]
fn coord_and_mode_parsing() {
use LuaValue::{Boolean, Integer, Nil, Number};
assert_eq!(opt_coord("f", "row", &Nil).unwrap(), None);
assert_eq!(opt_coord("f", "row", &Integer(3)).unwrap(), Some(3));
assert_eq!(opt_coord("f", "row", &Number(3.0)).unwrap(), Some(3));
assert_eq!(opt_coord("f", "row", &Integer(1 << 20)).unwrap(), Some(u16::MAX));
for bad in [Integer(0), Integer(-2), Number(1.5), Boolean(true)] {
assert!(opt_coord("f", "row", &bad).is_err(), "{bad:?}");
}
let err = opt_coord("tui.moveTo", "row", &Integer(0)).unwrap_err();
assert!(err.to_string().contains("row must be a positive integer (got 0)"), "{err}");
assert_eq!(clear_mode("f", &Nil).unwrap(), 0);
for m in [-1, 0, 1] {
assert_eq!(clear_mode("f", &Integer(m)).unwrap(), m);
}
for bad in [Integer(2), Integer(-2), Number(0.5)] {
assert!(clear_mode("f", &bad).is_err(), "{bad:?}");
}
assert_eq!(opt_delta("f", "dx", &Nil).unwrap(), 0);
assert_eq!(opt_delta("f", "dx", &Integer(-7)).unwrap(), -7);
assert!(opt_delta("f", "dx", &Boolean(false)).is_err());
assert_eq!(magnitude(-7), 7);
assert_eq!(magnitude(i64::MIN), u16::MAX);
}
}
Loading…
Cancel
Save