From 1a1b134ffa2f0cbd5f76afe5a3af5552169a1d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Mon, 6 Jul 2026 21:56:03 +0200 Subject: [PATCH] add funcs for cursor movement and other xterm derived features --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 4 +- docs/tui.md | 141 ++++++++- lua/tui_term.lua | 75 +++++ src/lua_tests/tui_tests.rs | 109 +++++++ src/main.rs | 15 +- src/stdlib/mod.rs | 4 +- src/stdlib/tui/markup.rs | 157 +++++++++- src/stdlib/tui/mod.rs | 9 + src/stdlib/tui/output.rs | 2 +- src/stdlib/tui/prompts.rs | 5 +- src/stdlib/tui/term.rs | 586 +++++++++++++++++++++++++++++++++++++ 13 files changed, 1091 insertions(+), 20 deletions(-) create mode 100644 lua/tui_term.lua create mode 100644 src/stdlib/tui/term.rs diff --git a/Cargo.lock b/Cargo.lock index b83905c..aee6592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "a" version = "0.1.0" dependencies = [ + "base64", "chrono", "clap", "colored", diff --git a/Cargo.toml b/Cargo.toml index b5671d4..781c74b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" publish = false [dependencies] +# OSC 52 clipboard payloads (tui.setClipboard); already in the tree as a +# transitive dependency. +base64 = "0.22" chrono = "0.4.45" # 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). diff --git a/README.md b/README.md index e2c2296..1fc786e 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ The standard library is the main purpose of `a`. Available today: - **Networking** — HTTP client (WebSocket and MQTT are planned). - **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. Planned: - **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. diff --git a/docs/tui.md b/docs/tui.md index 2945a35..05fb8b1 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -271,6 +271,48 @@ supporting terminals show clickable text, others show just the text: tui.styled("see 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(("progress %d%%"):format(i))) + -- ... work ... +end +tui.raw("\n") +``` + +| Tag | Effect | +|-----|--------| +| `` `` `` `` | move the cursor by one cell | +| `` … | move by *n* cells | +| `` | absolute move, 1-based; each axis works alone too | +| `` | column 1 of the current line | +| `` | top-left corner | +| `` | clear the whole line, cursor to column 1 | +| `` | clear to the start of the line, cursor to column 1 | +| `` | clear to the end of the line, cursor stays put | +| `` | clear the whole screen, cursor home | +| `` | clear above the cursor, cursor home | +| `` | 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 (`` is literal text). Ops +combine in one tag, applied left to right: `` 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 `` 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 A backslash escapes a literal bracket: `"\\"` prints ``. Anything @@ -383,13 +425,104 @@ bars, right-aligned text, …): | `tty` | whether stdout is a terminal (`false` when piped) | | `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("fullscreen!")) +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 - **Always loaded.** No `require`; `tui` is a global in every script (`require "tui"` also works and returns the same table). - **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 - plain text. -- **More styling tags are planned** (screen clearing, cursor movement); the - markup engine is built to grow them. Progress bars can be built today from - `tui.raw` + `tui.screenInfo`. + plain text, and the terminal-control functions become no-ops. +- **Progress bars** can be built from `tui.raw` + `tui.screenInfo` + the + `` action tag; a ready-made widget may come later. +- **Key events** (raw keyboard input, mouse tracking) are not exposed yet; + prompts are the supported way to read input. diff --git a/lua/tui_term.lua b/lua/tui_term.lua new file mode 100644 index 0000000..6733494 --- /dev/null +++ b/lua/tui_term.lua @@ -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 action tag +---------------------------------------------------------------------- +for i = 1, 30 do + tui.raw(tui.styled(("working %d/30 %s"):format(i, ("#"):rep(i)))) + os.sleep(0.03) +end +tui.raw(tui.styled("")) +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(("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("boxed content")) + +tui.moveTo(10, 3) +tui.raw("where is the cursor? ") +local row, col = tui.cursorPos() +tui.raw(tui.styled(("at row %s, col %s"):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("FIXED HEADER (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. diff --git a/src/lua_tests/tui_tests.rs b/src/lua_tests/tui_tests.rs index 3cb21b9..f4d50c1 100644 --- a/src/lua_tests/tui_tests.rs +++ b/src/lua_tests/tui_tests.rs @@ -40,6 +40,21 @@ fn test_tui_global_has_all_functions() { "raw", "screenInfo", "addPreset", + "moveTo", + "move", + "saveCursor", + "restoreCursor", + "cursorPos", + "showCursor", + "cursorStyle", + "clearLine", + "clearScreen", + "altScreen", + "scrollRegion", + "scroll", + "setTitle", + "setClipboard", + "reset", ] { let is_fn: bool = lua .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}"); } +#[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("ab c")"#) + .eval() + .unwrap(); + assert!(!out.contains("up=3") && !out.contains("clear"), "{out:?}"); + assert!(out.contains(""), "invalid tag should stay literal: {out:?}"); +} + // --------------------------------------------------------------------------- // 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}"); } +// --------------------------------------------------------------------------- +// 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, Option) = + 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 /// function's error prefix, not a panic. The exact wording (NotTTY vs an IO /// error) varies by environment, so only the prefix is asserted. diff --git a/src/main.rs b/src/main.rs index c675f45..dabe096 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,20 @@ struct Args { async fn main() { 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 // read as intended instead of as one escaped line. eprintln!("{err}"); diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 497935b..d53eef8 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -9,7 +9,9 @@ pub(crate) mod require; mod sqlite; mod table; 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) fn install(lua: &mlua::Lua) -> mlua::Result<()> { diff --git a/src/stdlib/tui/markup.rs b/src/stdlib/tui/markup.rs index 797d370..96cec0d 100644 --- a/src/stdlib/tui/markup.rs +++ b/src/stdlib/tui/markup.rs @@ -11,7 +11,12 @@ //! name, a color (foreground, including `default` and `#hex`), or //! `on-` (background). Shorthand and `key=value` parts mix freely //! in one tag (``); -//! - hyperlinks: `text`. +//! - hyperlinks: `text`; +//! - action tags: cursor movement and clearing emitted in place — +//! ``/`` (and `down`/`left`/`right`), absolute ``, +//! `` (column 1), `` (top-left), and +//! ``; ops combine in one tag +//! (``). See [`parse_action_spec`]. //! //! Styles nest: an inner tag overrides only the attributes it sets, //! everything else is inherited from the enclosing style. ``, `` @@ -100,13 +105,12 @@ impl Style { } /// 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 -/// emissions with no stack interaction — e.g. `` → `"\x1b[2J\x1b[H"`, -/// `` → `"\x1b[3A"`, `` — added by giving `resolve_tag` more -/// producers; the tokenizer and renderer already handle the variant. +/// to the enclosed text. `Action` emits a self-contained escape sequence in +/// place (cursor movement, clearing — see [`parse_action_spec`]) with no +/// stack interaction; like all escape output it is dropped when not +/// colorizing, so piped `tui.styled` output stays free of control bytes. enum Tag { Style(Style), - #[allow(dead_code)] Action(String), } @@ -149,10 +153,75 @@ fn named_style(name: &str) -> Option