diff --git a/Cargo.lock b/Cargo.lock index 833a2d3..b83905c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,10 +8,13 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "colored", + "crossterm", "digest_auth", "dotenvy", "env_logger", "futures", + "inquire", "log", "mlua", "reqwest", @@ -261,6 +264,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -271,6 +283,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -306,6 +327,33 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -348,6 +396,28 @@ dependencies = [ "thiserror", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -382,12 +452,27 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -477,6 +562,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fd-lock" version = "4.0.4" @@ -597,6 +688,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -618,6 +718,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "h2" version = "0.4.14" @@ -919,6 +1030,22 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.13.0", + "chrono", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "tempfile", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1051,6 +1178,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1114,6 +1247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1324,6 +1458,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -1361,7 +1501,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1447,7 +1587,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1591,7 +1731,7 @@ dependencies = [ "nix", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "utf8parse", "windows-sys 0.52.0", ] @@ -1719,6 +1859,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1837,6 +1998,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1857,6 +2031,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2018,6 +2201,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -2168,6 +2357,22 @@ dependencies = [ "libc", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2177,6 +2382,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 059f307..b5671d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,20 @@ publish = false [dependencies] 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). +colored = "3" env_logger = "0.11" log = "0.4" clap = { version = "4.6.1", features = ["derive"] } dotenvy = "0.15" futures = "0.3" +# Terminal size + raw-mode backend; already in the tree as inquire's backend +# (keep the version in lockstep with inquire's own crossterm dependency). +crossterm = "0.29" +# Interactive prompts for the tui module. `date` enables DateSelect (chrono), +# `editor` the $EDITOR-based prompt; neither is in the default set. +inquire = { version = "0.9", features = ["date", "editor"] } mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]} # reqwest's default rustls provider (aws-lc-rs) null-derefs during the TLS # handshake in this environment, and native-tls would link system OpenSSL. diff --git a/README.md b/README.md index 29ba8dd..e2c2296 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # `a` -`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client and SQLite, with more of the surface below planned — without additional setup. +`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client, SQLite and interactive terminal prompts, with more of the surface below planned — without additional setup. ```sh a file.lua # run a script @@ -42,12 +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). - **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. -- **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`. +- **Full-screen TUI** — cursor-addressed interfaces comparable to `ncurses`. The intent is for the common case to be a single call rather than a multi-step setup. diff --git a/docs/README.md b/docs/README.md index 149d253..6f5d1dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ also works and returns the same table). | [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. | | [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. | | [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. | +| [`tui`](tui.md) | Interactive terminal prompts (confirm/text/select/…), ANSI-styled output, message blocks. | ## Concepts diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 0000000..b3920fe --- /dev/null +++ b/docs/tui.md @@ -0,0 +1,361 @@ +# `tui` + +The `tui` global is for simple terminal interactivity: asking the user +questions (**prompts**), producing colored, styled output (**`tui.styled`**), +and printing ready-made **message blocks** (`tui.success`, `tui.outlineError`, +…). Prompts render an interactive one-line UI on the terminal — a yes/no +question, a text field, a filterable option list, a calendar — and return the +answer as a plain Lua value. It is always available as the global `tui`. + +```lua +local name = tui.promptText("Your name?") +local lang = tui.promptSelect("Favorite language?", "lua", { options = { "lua", "rust", "c" } }) +if tui.confirm("Save results?", true) then + print(tui.styled("saved for " .. name .. "")) +end +``` + +## Prompts + +All prompts share one shape: + +```lua +tui.promptXxx(text, default, opts) +``` + +- `text` — the question shown to the user (required string). +- `default` — the prompt's default value; `nil` means none. (Exceptions: + `promptSelect` matches the default against the options *by value*; + `promptSecret` has no default parameter at all.) +- `opts` — an optional table of extended options, listed per prompt below. + Every prompt accepts `help` (a hint line shown below the prompt). Unknown + keys are an error, so typos fail loudly. + +Shared behavior: + +- **Esc returns `nil`.** Declining a prompt is a normal, checkable outcome — + this holds for `tui.confirm` too, since `false` ("answered no") is distinct + from `nil` ("didn't answer"). +- **Ctrl-C raises an error** (`tui.: interrupted`), stopping the script + unless trapped with `pcall`/`utils.try`. +- **A terminal is required.** Prompting with stdin/stdout redirected raises + `tui.: not a terminal`. +- **Prompts are async.** Like `os.sleep` or `http`, a prompt suspends only the + calling coroutine (see [Concurrency](concurrency.md)). Prompts from + concurrent `task.join` branches are serialized — one owns the terminal at a + time — but `print`/`log` output from sibling coroutines can still interleave + visually with a live prompt. +- **Validation options re-prompt inline.** Constraint options like `min`, + `maxLen` or `minSelected` are enforced by the prompt UI itself (an inline + message, then the user tries again) — they do not raise Lua errors. + Malformed *arguments* (a default that is not among the options, `min > max`, + a bad date string) raise immediately, before the terminal is touched. + +### `tui.confirm(text, default, opts)` → `boolean | nil` + +Yes/no question, answered with `y`/`n`. + +```lua +local ok = tui.confirm("Deploy to production?", false, { help = "y/N" }) +if ok == nil then return end -- Esc: user backed out +``` + +- `default` — boolean returned when the user just presses Enter; with `nil` + the user must type an answer. +- opts: `help`, `placeholder`. + +### `tui.promptText(text, default, opts)` → `string | nil` + +One-line text input. + +```lua +local host = tui.promptText("Host?", "localhost", { + placeholder = "hostname or IP", + suggestions = { "localhost", "db.internal", "cache.internal" }, +}) +``` + +- `default` — pre-filled **editable** text (the user edits it in place). +- opts: + - `placeholder` — dim hint shown while the input is empty. + - `default` — string returned when the input is submitted empty (shown as a + hint; distinct from the pre-filled positional default, and both can be + used together). + - `minLen`, `maxLen` — length bounds (characters), enforced inline. + - `suggestions` — array of strings offered as autocompletion + (case-insensitive substring match; Tab completes the highlighted one). + - `pageSize` — how many suggestions are visible at once. + +### `tui.promptLongText(text, default, opts)` → `string | nil` + +Multi-line input via an external editor: opens `$VISUAL`/`$EDITOR` (falling +back to a platform default) on a temp file and returns its contents on save. + +```lua +local body = tui.promptLongText("Release notes", "## Changes\n\n", { extension = ".md" }) +``` + +- `default` — text the editor buffer starts with. +- opts: + - `extension` — temp-file extension (drives editor syntax highlighting); + a missing leading dot is added, default `.txt`. + - `editor` — editor command to use instead of `$VISUAL`/`$EDITOR`. + +### `tui.promptSelect(text, default, opts)` → `string | string[] | nil` + +Pick from a list. The third argument is **required** — it carries the options. +The list is filterable by typing (fuzzy match); arrows or `vimMode` keys move +the cursor. + +```lua +local color = tui.promptSelect("Color?", "green", { options = { "red", "green", "blue" } }) + +local picks = tui.promptSelect("Toppings?", { "cheese" }, { + options = { "cheese", "ham", "mushrooms", "olives" }, + multiple = true, + minSelected = 1, +}) +``` + +- `default` — matched against the options **by value**; a value that is not + among the options raises an error (a stale default is a script bug). Single + mode: a string (the cursor starts there). Multiple mode: a string or an + array of strings (pre-checked). +- opts: + - `options` — non-empty array of strings (**required**). + - `multiple` — `true` switches to checkbox-style multi-select (Space + toggles, Enter submits) and the result becomes an array of the chosen + strings (possibly empty). + - `pageSize` — visible rows (default 7). + - `vimMode` — `hjkl` navigation. + - `filter` — set `false` to disable the type-to-filter input. + - multiple-only: `minSelected` / `maxSelected` (selection-count bounds, + enforced inline), `allSelected` (start with everything checked). + +### `tui.promptSecret(text, opts)` → `string | nil` + +Secret input. Deliberately has **no default parameter**. + +```lua +local secret = tui.promptSecret("API token", { confirm = true, minLen = 8 }) +``` + +- opts: + - `confirm` — ask twice and require both entries to match (default `false`). + - `display` — `"masked"` (default, `*` per character), `"hidden"` (no echo + at all), or `"full"` (plain text). + - `toggle` — allow Ctrl-R to toggle revealing the input. + - `minLen` — minimum length, enforced inline. + +### `tui.promptNumber(text, default, opts)` → `number | nil` +### `tui.promptInt(text, default, opts)` → `integer | nil` + +Numeric input; the entry is parsed and re-prompted until it is a valid number +(`promptNumber`) or whole number (`promptInt`). + +```lua +local ratio = tui.promptNumber("Ratio?", 0.5, { min = 0, max = 1 }) +local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 }) +``` + +- `default` — returned when the input is submitted empty. `promptInt` + rejects a fractional default (`1.5`) at call time. +- opts: `placeholder`, `min`, `max` (bounds enforced inline; `min > max` is a + call-time error). + +### `tui.promptDate(text, default, opts)` → `string | nil` + +Date picker: an interactive calendar navigated with the arrow keys. Dates +cross the API as `"YYYY-MM-DD"` strings in both directions. + +```lua +local day = tui.promptDate("Start date?", "2026-07-06", { + min = "2026-01-01", + max = "2026-12-31", + weekStart = "monday", +}) +``` + +- `default` — the initially selected date, `"YYYY-MM-DD"`. +- opts: + - `min`, `max` — selectable range, `"YYYY-MM-DD"`. + - `weekStart` — first day of the calendar week: a weekday name like + `"monday"` or `"sun"` (default Sunday). + +## `tui.styled(markup)` → `string` + +Renders text with HTML-like style tags into a string with ANSI escape +sequences, ready to `print`. The tag syntax follows +[Symfony console formatting](https://symfony.com/doc/current/console/coloring.html). + +```lua +print(tui.styled(" FAIL expected 42, see " .. + "the docs for details")) +``` + +### Built-in styles + +| Tag | Rendering | +|-----|-----------| +| `` | green text | +| `` | yellow text | +| `` | white on red | +| `` | black on cyan | + +### Inline styles + +``, `` and `` can be combined in one tag, +separated by `;`: + +```lua +tui.styled("ok careful brick red") +``` + +- Colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, + `white`, `gray`, their `bright-*` variants, `default` (the terminal's own + color), and hex `#rrggbb` / `#rgb` (truecolor). +- Options (comma-separated): `bold`, `underscore`, `blink`, `reverse`, + `conceal`. +- The syntax is strict: no spaces around `=` or `;`. + +### Closing tags and nesting + +`` closes the innermost open style; named forms (``, +``) work too and do the same thing. Styles nest — an inner tag +overrides only what it sets and inherits the rest: + +```lua +tui.styled("fail in step 2 of job") +-- "step 2" is bold white-on-red; the rest of the sentence plain white-on-red +``` + +### Hyperlinks + +`` emits an [OSC-8 hyperlink](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda); +supporting terminals show clickable text, others show just the text: + +```lua +tui.styled("see the manual") +``` + +### Escaping and invalid tags + +A backslash escapes a literal bracket: `"\\"` prints ``. Anything +that does not parse as a tag — ``, `` (tags are +case-sensitive), a lone `<`, a bad color — is passed through as literal text, +never an error. + +### Color detection + +When output is piped or `NO_COLOR` is set, `tui.styled` returns the plain +text: tags are still consumed, but no escape codes (colors or hyperlinks) are +emitted. Detection follows the usual conventions (`NO_COLOR`, `CLICOLOR`, +`CLICOLOR_FORCE`, tty check on stdout). + +## Message blocks + +Ready-made status messages in the style of Symfony's console: a labeled, +word-wrapped block, padded to the terminal width (capped at 120 columns) and +painted with a background color. They **print directly to stdout**, surrounded +by blank lines. When output is piped (or `NO_COLOR` is set) they degrade to +plain aligned text. + +```lua +tui.success("Deployment finished") +tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" }) +tui.error("Build failed") +``` + +| Function | Label | Style | +|---|---|---| +| `tui.success(message, opts)` | `[OK]` | black on green | +| `tui.error(message, opts)` | `[ERROR]` | white on red | +| `tui.warning(message, opts)` | `[WARNING]` | black on yellow | +| `tui.caution(message, opts)` | `[CAUTION]` | white on red, `!` prefix | +| `tui.info(message, opts)` | `[INFO]` | green text | +| `tui.note(message, opts)` | `[NOTE]` | yellow text, `!` prefix | +| `tui.comment(message, opts)` | — | plain, `//` prefix | +| `tui.block(message, opts)` | — | generic: everything from opts | + +- `message` — a string or an array of strings (rendered into one block, + separated by a blank line). Treated as **plain text**: markup tags are not + interpreted here (compose with `tui.styled` on your own lines instead), and + lines wrap by word to fit the width. +- `opts` (each overrides the preset's default): + - `label` — the `[LABEL]` text; continuation lines align under it. + - `style` — same syntax as a `styled` tag body: a built-in name (`"error"`) + or a spec (`"fg=black;bg=cyan"`). An invalid style is an error. + - `prefix` — string prepended to every line (default `" "`). + - `padding` — blank styled line above and below the text (only applies when + colors are on; without a background it would just be empty lines). + +### Outline blocks + +The same messages as a bordered box — colored **borders** instead of colored +backgrounds, which stays readable on any terminal color scheme: + +``` + ┌─ Success ──────────────────────────┐ + │ │ + │ All tests passed │ + │ │ + └────────────────────────────────────┘ +``` + +```lua +tui.outlineSuccess("All tests passed") +tui.outlineError("Build failed", { title = "Compile error" }) +tui.outlineBlock("Anything", { title = "Stats", style = "fg=magenta" }) +``` + +| Function | Fallback title | Border style | +|---|---|---| +| `tui.outlineSuccess` | Success | green | +| `tui.outlineError` | Error | red | +| `tui.outlineWarning` | Warning | yellow | +| `tui.outlineNote` | Note | blue | +| `tui.outlineInfo` | Info | green | +| `tui.outlineCaution` | Caution | red | +| `tui.outlineBlock` | — | — (generic) | + +- `opts`: `title` (shown in the top border; overrides the preset's fallback), + `style` (border color, tag-body syntax), `padding` (blank line above/below + the content **inside** the box, default `true`). + +## Low-level output + +### `tui.raw(...)` + +Writes its arguments to stdout with **no newline and no separator** (each is +converted with `tostring`), then flushes. This is the building block for +progress-style output that redraws one line: + +```lua +for i = 1, 100 do + tui.raw("\rprocessing ", i, "/100") + -- ... work ... +end +tui.raw("\n") +``` + +### `tui.screenInfo()` → `table` + +Facts about the terminal, for scripts that render their own UI (progress +bars, right-aligned text, …): + +| Field | Meaning | +|---|---| +| `width`, `height` | terminal size in cells; `nil` when there is no terminal to measure | +| `tty` | whether stdout is a terminal (`false` when piped) | +| `colors` | whether styled output will actually emit colors (same detection as `tui.styled`) | + +## 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`. diff --git a/lua/tui_prompts.lua b/lua/tui_prompts.lua new file mode 100644 index 0000000..8a35439 --- /dev/null +++ b/lua/tui_prompts.lua @@ -0,0 +1,107 @@ +-- Showcase of the tui module's interactive prompts. Requires a terminal. +-- Esc returns nil from any prompt (handled below); Ctrl-C aborts the script. +-- +-- Run with: cargo run -- lua/tui_prompts.lua + +local function banner(s) + print("\n=== " .. s .. " ===") +end + +---------------------------------------------------------------------- +banner("1. confirm — yes/no") +---------------------------------------------------------------------- +local go = tui.confirm("Run the whole showcase?", true, { help = "Esc anywhere skips that prompt" }) +if go == nil then + print("(Esc pressed — nil means the user backed out, false means they answered no)") +elseif not go then + return +end + +---------------------------------------------------------------------- +banner("2. promptText — defaults, placeholder, suggestions") +---------------------------------------------------------------------- +-- The positional default is pre-filled and editable in place. +local name = tui.promptText("Your name?", "ondra") or "anonymous" + +-- suggestions: type to filter (case-insensitive substring), Tab completes. +-- opts.default (unlike the positional one) is only used on empty submit. +local editor = tui.promptText("Favorite editor?", nil, { + placeholder = "type to autocomplete", + suggestions = { "vim", "neovim", "emacs", "helix", "vscode", "zed" }, + default = "vim", +}) + +-- minLen/maxLen re-prompt inline until satisfied. +local tag = tui.promptText("Release tag? (3-10 chars)", nil, { minLen = 3, maxLen = 10 }) + +---------------------------------------------------------------------- +banner("3. promptSelect — single and multiple choice") +---------------------------------------------------------------------- +-- Single: returns the chosen string; the default sets the cursor. +local lang = tui.promptSelect("Favorite language?", "lua", { + options = { "lua", "rust", "c", "python", "javascript" }, + help = "type to filter the list", +}) + +-- Multiple: space toggles, enter submits; returns an array of strings. +local toppings = tui.promptSelect("Pizza toppings?", { "cheese" }, { + options = { "cheese", "ham", "mushrooms", "olives", "pineapple" }, + multiple = true, + minSelected = 1, + pageSize = 4, +}) + +---------------------------------------------------------------------- +banner("4. Numbers and dates") +---------------------------------------------------------------------- +-- promptNumber accepts any number, promptInt only whole numbers; both +-- re-prompt inline when the input doesn't parse or is out of range. +local ratio = tui.promptNumber("Quality (0-1)?", 0.8, { min = 0, max = 1 }) +local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 }) + +-- promptDate opens a calendar; dates are YYYY-MM-DD strings both ways. +local day = tui.promptDate("Release day?", os.date("%Y-%m-%d"), { + min = os.date("%Y-01-01"), + max = os.date("%Y-12-31"), + weekStart = "monday", +}) + +---------------------------------------------------------------------- +banner("5. promptSecret — hidden input") +---------------------------------------------------------------------- +-- No default parameter on purpose. display: "masked" (default) shows *, +-- "hidden" echoes nothing, "full" shows the text; toggle allows Ctrl-R. +local secret = tui.promptSecret("API token?", { minLen = 4, toggle = true }) + +---------------------------------------------------------------------- +banner("6. promptLongText — opens $EDITOR") +---------------------------------------------------------------------- +local notes +if tui.confirm("Open your editor for release notes?", false) then + notes = tui.promptLongText("Release notes", "## " .. (tag or "next") .. "\n\n- ", { extension = ".md" }) +end + +---------------------------------------------------------------------- +banner("Summary") +---------------------------------------------------------------------- +local function show(k, v) + if v == nil then + v = "(skipped)" + elseif type(v) == "table" then + v = table.concat(v, ", ") + end + print(tui.styled(" " .. k .. ": " .. tostring(v))) +end + +show("name", name) +show("editor", editor) +show("tag", tag) +show("language", lang) +show("toppings", toppings) +show("quality", ratio) +show("port", port) +show("release day", day) +show("token", secret and string.rep("*", #secret)) +show("notes", notes and (#notes .. " chars")) + +tui.success("Showcase finished — see lua/tui_styles.lua for the output half of the module") diff --git a/lua/tui_styles.lua b/lua/tui_styles.lua new file mode 100644 index 0000000..3e8e707 --- /dev/null +++ b/lua/tui_styles.lua @@ -0,0 +1,79 @@ +-- Showcase of the tui module's output features: styled markup, message +-- blocks, outline blocks, and the raw/screenInfo building blocks. +-- Non-interactive — safe to run piped (colors degrade to plain text). +-- +-- Run with: cargo run -- lua/tui_styles.lua + +local function banner(s) + print("\n=== " .. s .. " ===") +end + +---------------------------------------------------------------------- +banner("1. tui.styled — built-in tags") +---------------------------------------------------------------------- +print(tui.styled("info is green, comment is yellow")) +print(tui.styled(" error: white on red question: black on cyan ")) + +---------------------------------------------------------------------- +banner("2. Inline styles: fg / bg / options") +---------------------------------------------------------------------- +print(tui.styled("named colors, bright variants, gray")) +print(tui.styled("hex truecolor #c0392b and short #0af")) +print(tui.styled("background bold underscore")) +print(tui.styled(" all combined in one tag ")) + +---------------------------------------------------------------------- +banner("3. Nesting, links, escaping") +---------------------------------------------------------------------- +-- Inner tags override only what they set; the rest is inherited. +print(tui.styled("outer bold inherits the red bg and back")) +-- closes the innermost style; fg=default resets to the terminal color. +print(tui.styled("red terminal default red again")) +-- OSC-8 hyperlink (clickable in supporting terminals, plain text elsewhere). +print(tui.styled("read the docs for details")) +-- \< escapes a bracket; unknown tags pass through literally, never error. +print(tui.styled("literal \\ stays, too, a < b as well")) + +---------------------------------------------------------------------- +banner("4. Message blocks (Symfony style)") +---------------------------------------------------------------------- +tui.success("Deployment finished without errors") +tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" }) +tui.error("Build failed") +tui.caution("This will overwrite production data") +tui.info("14 files processed") +tui.note("Long messages word-wrap to the terminal width and continuation lines stay aligned under the label") +tui.comment("git push --force-with-lease") +-- The generic form: everything is configurable. +tui.block( + "Custom label, style and prefix", + { label = "HEY", style = "fg=black;bg=cyan", prefix = " > ", padding = true } +) + +---------------------------------------------------------------------- +banner("5. Outline blocks (colored borders, plain content)") +---------------------------------------------------------------------- +tui.outlineSuccess("All tests passed") +tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset +tui.outlineBlock({ "Generic box.", "Multiple messages are separated by a blank line." }, { + title = "Stats", + style = "fg=magenta", +}) + +---------------------------------------------------------------------- +banner("6. tui.raw + tui.screenInfo — progress bar building blocks") +---------------------------------------------------------------------- +local info = tui.screenInfo() +print("screen:", (info.width or "?") .. "x" .. (info.height or "?"), "tty:", info.tty, "colors:", info.colors) + +-- A minimal in-place progress bar: \r rewinds the line, tui.raw prints +-- without a newline and flushes. Width adapts to the terminal; without a +-- tty the \r redraw trick is pointless, so print the final state once. +local width = math.clamp((info.width or 80) - 20, 10, 40) +local start = info.tty and 0 or width +for i = start, width do + local bar = string.rep("#", i) .. string.rep(".", width - i) + tui.raw("\r[", bar, "] ", math.floor(i / width * 100 + 0.5), "%") + os.sleep(0.02) +end +tui.raw("\n") diff --git a/src/lua_tests/mod.rs b/src/lua_tests/mod.rs index ee9b9c7..87fa350 100644 --- a/src/lua_tests/mod.rs +++ b/src/lua_tests/mod.rs @@ -7,6 +7,7 @@ mod math_tests; mod os_tests; mod require_tests; mod table_tests; +mod tui_tests; mod utils_tests; use mlua::Lua; diff --git a/src/lua_tests/tui_tests.rs b/src/lua_tests/tui_tests.rs new file mode 100644 index 0000000..01484ff --- /dev/null +++ b/src/lua_tests/tui_tests.rs @@ -0,0 +1,326 @@ +//! Tests for the tui module: `tui.styled` behavior and the prompt argument +//! validation that fires *before* the terminal is touched (so it's +//! deterministic in CI, tty or not). The interactive halves of the prompts +//! are exercised manually — see docs/tui.md. + +use super::lua; + +// --------------------------------------------------------------------------- +// module surface +// --------------------------------------------------------------------------- + +#[test] +fn test_tui_global_has_all_functions() { + let lua = lua(); + for f in [ + "confirm", + "promptText", + "promptLongText", + "promptSelect", + "promptSecret", + "promptNumber", + "promptInt", + "promptDate", + "styled", + "block", + "success", + "error", + "warning", + "caution", + "info", + "note", + "comment", + "outlineBlock", + "outlineSuccess", + "outlineError", + "outlineWarning", + "outlineNote", + "outlineInfo", + "outlineCaution", + "raw", + "screenInfo", + ] { + let is_fn: bool = lua + .load(format!("return type(tui.{f}) == 'function'")) + .eval() + .unwrap(); + assert!(is_fn, "tui.{f} is missing or not a function"); + } +} + +#[test] +fn test_require_returns_the_global() { + let lua = lua(); + // require needs roots installed; builtins resolve without touching the fs. + crate::stdlib::require::install(&lua, vec![]).unwrap(); + let same: bool = lua.load(r#"return require("tui") == tui"#).eval().unwrap(); + assert!(same); +} + +// --------------------------------------------------------------------------- +// tui.styled +// --------------------------------------------------------------------------- + +// NOTE: whether escape codes are emitted depends on the test process's +// stdout (colored's tty/NO_COLOR detection), so these assertions only check +// properties that hold in both color modes. Exact byte output is pinned by +// the unit tests in stdlib/tui/markup.rs, which bypass the global detection. + +#[test] +fn test_styled_returns_payload_text() { + let lua = lua(); + let out: String = lua + .load(r#"return tui.styled("a b c d")"#) + .eval() + .unwrap(); + // Tags are consumed, payload text survives in order. + assert!(!out.contains(""), "tag not consumed: {out:?}"); + assert!(!out.contains("fg=red"), "tag not consumed: {out:?}"); + let stripped: String = out.chars().filter(|c| " abcd".contains(*c)).collect(); + assert_eq!(stripped, "a b c d"); +} + +#[test] +fn test_styled_resolves_escapes_and_keeps_invalid_tags() { + let lua = lua(); + let out: String = lua + .load(r#"return tui.styled("\\x y")"#) + .eval() + .unwrap(); + assert!(out.contains("x"), "escape not resolved: {out:?}"); + assert!(out.contains(""), "invalid tag should stay literal: {out:?}"); +} + +#[test] +fn test_styled_rejects_non_string() { + let lua = lua(); + let err = lua.load(r#"return tui.styled(42)"#).eval::().unwrap_err(); + assert!(err.to_string().contains("tui.styled:"), "{err}"); +} + +// --------------------------------------------------------------------------- +// prompt argument validation (fires before any terminal interaction) +// --------------------------------------------------------------------------- + +/// Run a prompt call and assert it fails with a message carrying `expect`. +async fn assert_prompt_error(src: &str, expect: &str) { + let lua = lua(); + let err = lua.load(src).exec_async().await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); +} + +#[tokio::test] +async fn test_prompt_text_arg_validation() { + assert_prompt_error("tui.confirm(42)", "tui.confirm: prompt text must be a string (got integer)") + .await; + assert_prompt_error("tui.confirm('q', 'yes')", "tui.confirm: default must be a boolean").await; + assert_prompt_error( + "tui.promptText('q', 42)", + "tui.promptText: default must be a string (got integer)", + ) + .await; +} + +#[tokio::test] +async fn test_unknown_and_mistyped_options() { + assert_prompt_error( + "tui.promptText('q', nil, {placholder='x'})", + "tui.promptText: unknown option 'placholder' (allowed: ", + ) + .await; + assert_prompt_error( + "tui.confirm('q', nil, {help=42})", + "tui.confirm: option 'help' must be a string (got integer)", + ) + .await; + assert_prompt_error( + "tui.promptText('q', nil, {maxLen='five'})", + "tui.promptText: option 'maxLen' must be a non-negative integer", + ) + .await; + assert_prompt_error( + "tui.promptText('q', nil, {minLen=5, maxLen=2})", + "tui.promptText: min (5) must not be greater than max (2)", + ) + .await; +} + +#[tokio::test] +async fn test_select_validation() { + assert_prompt_error( + "tui.promptSelect('q')", + "tui.promptSelect: opts table with 'options' is required", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', nil, {})", + "tui.promptSelect: opts table with 'options' is required", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', nil, {options={}})", + "tui.promptSelect: options must not be empty", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', nil, {options={'a', {}}})", + "tui.promptSelect: options[2] must be a string (got table)", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', 'nope', {options={'a','b'}})", + "tui.promptSelect: default 'nope' is not one of the options", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', {'a'}, {options={'a','b'}})", + "tui.promptSelect: default must be a string (got table)", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', {'a','nope'}, {options={'a','b'}, multiple=true})", + "tui.promptSelect: default 'nope' is not one of the options", + ) + .await; + assert_prompt_error( + "tui.promptSelect('q', nil, {options={'a'}, minSelected=1})", + "tui.promptSelect: option 'minSelected' requires multiple = true", + ) + .await; +} + +#[tokio::test] +async fn test_password_and_number_validation() { + assert_prompt_error( + "tui.promptSecret('q', {display='partial'})", + "tui.promptSecret: option 'display' must be one of 'masked', 'hidden', 'full' (got 'partial')", + ) + .await; + assert_prompt_error( + "tui.promptNumber('q', 'five')", + "tui.promptNumber: default must be a number (got string)", + ) + .await; + assert_prompt_error( + "tui.promptNumber('q', nil, {min=10, max=1})", + "tui.promptNumber: min (10) must not be greater than max (1)", + ) + .await; + assert_prompt_error( + "tui.promptInt('q', 1.5)", + "tui.promptInt: default must be an integer (got 1.5)", + ) + .await; + assert_prompt_error( + "tui.promptInt('q', nil, {min=1.5})", + "tui.promptInt: option 'min' must be an integer (got 1.5)", + ) + .await; +} + +#[tokio::test] +async fn test_date_validation() { + assert_prompt_error( + "tui.promptDate('q', 'tomorrow')", + "tui.promptDate: default must be a YYYY-MM-DD string (got 'tomorrow')", + ) + .await; + assert_prompt_error( + "tui.promptDate('q', nil, {min='2026-02-30'})", + "tui.promptDate: option 'min' must be a YYYY-MM-DD string", + ) + .await; + assert_prompt_error( + "tui.promptDate('q', nil, {min='2026-06-01', max='2026-01-01'})", + "tui.promptDate: min (2026-06-01) must not be greater than max (2026-01-01)", + ) + .await; + assert_prompt_error( + "tui.promptDate('q', nil, {weekStart='someday'})", + "tui.promptDate: option 'weekStart' must be a weekday name like 'monday' (got 'someday')", + ) + .await; +} + +// --------------------------------------------------------------------------- +// blocks / raw / screenInfo +// --------------------------------------------------------------------------- +// The block functions print to stdout; their rendering is pinned by unit +// tests in stdlib/tui/blocks.rs. Here we exercise argument validation and +// the informational surface. + +#[test] +fn test_block_validation() { + let lua = lua(); + for (src, expect) in [ + ("tui.success(42)", "tui.success: message must be a string or an array of strings (got integer)"), + ("tui.block({})", "tui.block: message must not be empty"), + ("tui.block({'a', 1})", "tui.block: message[2] must be a string (got integer)"), + ("tui.block('x', {style='notastyle'})", "tui.block: invalid style 'notastyle'"), + ("tui.outlineBlock('x', {label='X'})", "tui.outlineBlock: unknown option 'label'"), + ("tui.note('x', {title='X'})", "tui.note: unknown option 'title'"), + ] { + let err = lua.load(src).exec().unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); + } +} + +#[test] +fn test_block_functions_print_without_error() { + let lua = lua(); + // Not asserting on stdout (captured by the test harness); this pins that + // valid calls succeed for both shapes and with option overrides. + lua.load( + r#" + tui.success("all good") + tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true}) + tui.outlineError("boom") + tui.outlineBlock("custom", {title="Box", style="comment", padding=false}) + tui.raw("progress ", 42, "\r") + "#, + ) + .exec() + .unwrap(); +} + +#[test] +fn test_screen_info_shape() { + let lua = lua(); + let (tty, colors, width_type): (bool, bool, String) = lua + .load( + r#" + local i = tui.screenInfo() + return i.tty, i.colors, type(i.width) + "#, + ) + .eval() + .unwrap(); + // Under cargo test stdout is not a terminal; just pin the field types. + let _ = (tty, colors); + assert!(width_type == "number" || width_type == "nil", "width: {width_type}"); +} + +/// 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. +/// +/// Skipped when `cargo test` has any way to reach a terminal: the prompt +/// would then really render (garbling the runner output with raw-mode +/// artifacts) and hang waiting for a key. That includes redirected runs in an +/// interactive shell — crossterm falls back to `/dev/tty` when stdin is not a +/// terminal. Only truly terminal-less environments (CI) exercise this. +#[tokio::test] +async fn test_prompting_off_tty_errors_with_prefix() { + use std::io::IsTerminal; + let has_tty = std::io::stdin().is_terminal() + || std::io::stderr().is_terminal() + || std::fs::File::open("/dev/tty").is_ok(); + if has_tty { + return; + } + let lua = lua(); + let err = lua.load("tui.confirm('proceed?')").exec_async().await.unwrap_err(); + assert!(err.to_string().contains("tui.confirm:"), "{err}"); +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 2fd3154..497935b 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod require; mod sqlite; mod table; mod task; +mod tui; pub(crate) mod utils; pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { @@ -20,5 +21,6 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { sqlite::install(lua)?; http::install(lua)?; task::install(lua)?; + tui::install(lua)?; Ok(()) } diff --git a/src/stdlib/require.rs b/src/stdlib/require.rs index 514f865..89654a2 100644 --- a/src/stdlib/require.rs +++ b/src/stdlib/require.rs @@ -29,6 +29,7 @@ const BUILTINS: &[&str] = &[ "sqlite", "http", "task", + "tui", "coroutine", "math", "os", diff --git a/src/stdlib/tui/blocks.rs b/src/stdlib/tui/blocks.rs new file mode 100644 index 0000000..0194a97 --- /dev/null +++ b/src/stdlib/tui/blocks.rs @@ -0,0 +1,291 @@ +//! SymfonyStyle-like message blocks: `[LABEL]`-prefixed colored blocks +//! (`tui.success`, `tui.error`, …) and outlined boxes (`tui.outlineBlock` +//! and its presets). +//! +//! Rendering is pure — `render_block`/`render_outline_block` take the line +//! length and a `colorize` flag and return finished lines — so unit tests pin +//! exact output; the Lua bindings in `mod.rs` supply the real terminal width +//! and color policy and do the printing. +//! +//! Message text is treated as **plain text** here (matching Symfony's blocks, +//! which escape their input): it is word-wrapped and padded by character +//! count, and the single block style is applied to whole lines. Rendering +//! wrapped text that itself contains markup is not supported — compose with +//! `tui.styled` output on your own lines instead. + +use super::markup::{self, Style}; + +/// Blocks never grow wider than this, even on wide terminals (Symfony's +/// MAX_LINE_LENGTH). +pub(super) const MAX_LINE_LENGTH: usize = 120; + +pub(super) struct BlockCfg { + pub label: Option, + pub style: Option