add basic tui library

master
Ondřej Hruška 2 weeks ago
parent cd213e525b
commit 52632f4072
  1. 217
      Cargo.lock
  2. 9
      Cargo.toml
  3. 5
      README.md
  4. 1
      docs/README.md
  5. 361
      docs/tui.md
  6. 107
      lua/tui_prompts.lua
  7. 79
      lua/tui_styles.lua
  8. 1
      src/lua_tests/mod.rs
  9. 326
      src/lua_tests/tui_tests.rs
  10. 2
      src/stdlib/mod.rs
  11. 1
      src/stdlib/require.rs
  12. 291
      src/stdlib/tui/blocks.rs
  13. 579
      src/stdlib/tui/markup.rs
  14. 1318
      src/stdlib/tui/mod.rs

217
Cargo.lock generated

@ -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"

@ -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.

@ -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.

@ -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

@ -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("<info>saved</info> for <options=bold>" .. 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.<fn>: interrupted`), stopping the script
unless trapped with `pcall`/`utils.try`.
- **A terminal is required.** Prompting with stdin/stdout redirected raises
`tui.<fn>: 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("<error> FAIL </error> expected <info>42</info>, see " ..
"<href=https://example.com/docs>the docs</> for <fg=#8888ff;options=bold>details</>"))
```
### Built-in styles
| Tag | Rendering |
|-----|-----------|
| `<info>` | green text |
| `<comment>` | yellow text |
| `<error>` | white on red |
| `<question>` | black on cyan |
### Inline styles
`<fg=COLOR>`, `<bg=COLOR>` and `<options=...>` can be combined in one tag,
separated by `;`:
```lua
tui.styled("<fg=green>ok</> <bg=yellow;options=bold>careful</> <fg=#c0392b>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 (`</info>`,
`</fg=green>`) work too and do the same thing. Styles nest — an inner tag
overrides only what it sets and inherits the rest:
```lua
tui.styled("<error>fail in <options=bold>step 2</> of job</error>")
-- "step 2" is bold white-on-red; the rest of the sentence plain white-on-red
```
### Hyperlinks
`<href=URL>` 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 <href=https://example.com>the manual</>")
```
### Escaping and invalid tags
A backslash escapes a literal bracket: `"\\<info>"` prints `<info>`. Anything
that does not parse as a tag — `<unknown>`, `<Info>` (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`.

@ -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 = "<comment>(skipped)</comment>"
elseif type(v) == "table" then
v = table.concat(v, ", ")
end
print(tui.styled(" <info>" .. k .. "</info>: " .. 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")

@ -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>info is green</info>, <comment>comment is yellow</comment>"))
print(tui.styled("<error> error: white on red </error> <question> question: black on cyan </question>"))
----------------------------------------------------------------------
banner("2. Inline styles: fg / bg / options")
----------------------------------------------------------------------
print(tui.styled("<fg=magenta>named colors</>, <fg=bright-cyan>bright variants</>, <fg=gray>gray</>"))
print(tui.styled("<fg=#c0392b>hex truecolor #c0392b</> and <fg=#0af>short #0af</>"))
print(tui.styled("<bg=blue>background</> <options=bold>bold</> <options=underscore>underscore</>"))
print(tui.styled("<fg=black;bg=green;options=bold> all combined in one tag </>"))
----------------------------------------------------------------------
banner("3. Nesting, links, escaping")
----------------------------------------------------------------------
-- Inner tags override only what they set; the rest is inherited.
print(tui.styled("<error>outer <options=bold>bold inherits the red bg</> and back</error>"))
-- </> closes the innermost style; fg=default resets to the terminal color.
print(tui.styled("<fg=red>red <fg=default>terminal default</> red again</>"))
-- OSC-8 hyperlink (clickable in supporting terminals, plain text elsewhere).
print(tui.styled("read <href=https://example.com/docs>the docs</> for details"))
-- \< escapes a bracket; unknown tags pass through literally, never error.
print(tui.styled("literal \\<info> stays, <not-a-tag> 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")

@ -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;

@ -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 <info>b</info> <fg=red;options=bold>c</> d")"#)
.eval()
.unwrap();
// Tags are consumed, payload text survives in order.
assert!(!out.contains("<info>"), "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("\\<info>x <notatag> y")"#)
.eval()
.unwrap();
assert!(out.contains("<info>x"), "escape not resolved: {out:?}");
assert!(out.contains("<notatag>"), "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::<String>().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}");
}

@ -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(())
}

@ -29,6 +29,7 @@ const BUILTINS: &[&str] = &[
"sqlite",
"http",
"task",
"tui",
"coroutine",
"math",
"os",

@ -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<String>,
pub style: Option<Style>,
pub prefix: String,
pub padding: bool,
}
/// Greedy word wrap by character count. Explicit newlines are kept (an empty
/// input line stays an empty output line), runs of spaces collapse, and words
/// longer than the width are hard-broken.
fn wrap(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out = Vec::new();
for para in text.replace("\r\n", "\n").split('\n') {
let mut line = String::new();
let mut len = 0usize;
for word in para.split_whitespace() {
let mut rest = word;
let mut rest_len = rest.chars().count();
if len > 0 && len + 1 + rest_len > width {
out.push(std::mem::take(&mut line));
len = 0;
} else if len > 0 {
line.push(' ');
len += 1;
}
while rest_len > width - len {
let cut = rest
.char_indices()
.nth(width - len)
.map(|(i, _)| i)
.unwrap_or(rest.len());
line.push_str(&rest[..cut]);
out.push(std::mem::take(&mut line));
len = 0;
rest = &rest[cut..];
rest_len = rest.chars().count();
}
line.push_str(rest);
len += rest_len;
}
out.push(line);
}
out
}
/// Wrap each message to `width`, with a blank line between messages.
fn wrap_messages(messages: &[String], width: usize) -> Vec<String> {
let mut lines = Vec::new();
for (i, msg) in messages.iter().enumerate() {
if i > 0 {
lines.push(String::new());
}
lines.extend(wrap(msg, width));
}
lines
}
/// A `[LABEL] message` block: wrapped, prefixed, continuation lines indented
/// under the label, every line padded to `line_length` and painted with the
/// block style. Padding (a blank styled line above and below) only applies
/// when colorizing — without a background color it would just be empty lines
/// (same rule as Symfony's `isDecorated()` check).
pub(super) fn render_block(
messages: &[String],
cfg: &BlockCfg,
line_length: usize,
colorize: bool,
) -> Vec<String> {
let label = cfg.label.as_ref().map(|l| format!("[{l}] "));
let prefix_len = cfg.prefix.chars().count();
let indent_len = label.as_ref().map_or(0, |l| l.chars().count());
let text_width = line_length.saturating_sub(prefix_len + indent_len).max(1);
let mut lines = wrap_messages(messages, text_width);
let mut label_line = 0;
if cfg.padding && colorize {
lines.insert(0, String::new());
lines.push(String::new());
label_line = 1;
}
lines
.iter()
.enumerate()
.map(|(i, content)| {
let mut line = String::with_capacity(line_length);
line.push_str(&cfg.prefix);
if let Some(lbl) = &label {
if i == label_line {
line.push_str(lbl);
} else {
line.extend(std::iter::repeat_n(' ', indent_len));
}
}
line.push_str(content);
match (&cfg.style, colorize) {
(Some(style), true) => {
// Pad to full width so the background paints the whole line.
let pad = line_length.saturating_sub(line.chars().count());
line.extend(std::iter::repeat_n(' ', pad));
markup::paint_str(&line, style, true)
}
_ => line.trim_end().to_string(),
}
})
.collect()
}
/// An outlined box: `┌─ Title ───┐` top border, `│ content │` rows, `└───┘`
/// bottom. The style colors the **borders**; content stays plain, which reads
/// well on any terminal color scheme (that is the point of the outline
/// variant).
pub(super) fn render_outline_block(
messages: &[String],
title: Option<&str>,
style: Option<&Style>,
padding: bool,
line_length: usize,
colorize: bool,
) -> Vec<String> {
// Row layout: " │ "(3) + content(line_length - 5) + " │"(2).
let content_width = line_length.saturating_sub(5).max(1);
let mut lines = wrap_messages(messages, content_width);
if padding {
lines.insert(0, String::new());
lines.push(String::new());
}
let paint = |s: &str| match style {
Some(st) => markup::paint_str(s, st, colorize),
None => s.to_string(),
};
let mut out = Vec::new();
out.push(paint(&match title {
Some(t) => format!(
" ┌─ {t} {}┐",
"─".repeat(line_length.saturating_sub(6 + t.chars().count()))
),
None => format!(" ┌{}┐", "─".repeat(line_length.saturating_sub(3))),
}));
for content in &lines {
let pad = content_width.saturating_sub(content.chars().count());
out.push(format!(
"{}{content}{}{}",
paint(" │ "),
" ".repeat(pad),
paint(" │")
));
}
out.push(paint(&format!(" └{}┘", "─".repeat(line_length.saturating_sub(3)))));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn style(spec: &str) -> Style {
markup::resolve_style_spec(spec).unwrap()
}
fn msgs(m: &[&str]) -> Vec<String> {
m.iter().map(|s| s.to_string()).collect()
}
#[test]
fn wrap_basics() {
assert_eq!(wrap("hello world", 20), vec!["hello world"]);
assert_eq!(wrap("hello world", 6), vec!["hello", "world"]);
assert_eq!(wrap("a b c d", 3), vec!["a b", "c d"]);
// Explicit newlines and blank lines are preserved.
assert_eq!(wrap("a\n\nb", 10), vec!["a", "", "b"]);
// Overlong words hard-break.
assert_eq!(wrap("abcdefgh", 3), vec!["abc", "def", "gh"]);
assert_eq!(wrap("xy abcdef", 4), vec!["xy", "abcd", "ef"]);
assert_eq!(wrap("", 10), vec![""]);
}
#[test]
fn block_plain_when_not_colorized() {
let cfg = BlockCfg {
label: Some("OK".into()),
style: Some(style("fg=black;bg=green")),
prefix: " ".into(),
padding: true,
};
// No colors: no padding lines, no escape codes, no trailing spaces.
let lines = render_block(&msgs(&["hello world"]), &cfg, 40, false);
assert_eq!(lines, vec![" [OK] hello world"]);
}
#[test]
fn block_colorized_pads_and_paints_full_width() {
let cfg = BlockCfg {
label: Some("OK".into()),
style: Some(style("fg=black;bg=green")),
prefix: " ".into(),
padding: true,
};
let lines = render_block(&msgs(&["hello world"]), &cfg, 40, true);
assert_eq!(lines.len(), 3, "padding adds a line above and below");
for l in &lines {
assert!(l.starts_with("\x1b[30;42m") && l.ends_with("\x1b[0m"), "{l:?}");
// Full width between the escape codes.
let inner = l.trim_start_matches("\x1b[30;42m").trim_end_matches("\x1b[0m");
assert_eq!(inner.chars().count(), 40, "{inner:?}");
}
assert!(lines[1].contains("[OK] hello world"));
// Padding lines carry no text.
assert!(!lines[0].contains("OK") && !lines[2].contains("OK"));
}
#[test]
fn block_continuation_lines_indent_under_label() {
let cfg = BlockCfg {
label: Some("NOTE".into()),
style: None,
prefix: " ! ".into(),
padding: false,
};
// width 20, prefix 3 + indent 7 => text width 10
let lines = render_block(&msgs(&["aaaa bbbb cccc"]), &cfg, 20, true);
assert_eq!(lines, vec![" ! [NOTE] aaaa bbbb", " ! cccc"]);
}
#[test]
fn block_multiple_messages_are_separated() {
let cfg = BlockCfg { label: None, style: None, prefix: " ".into(), padding: false };
let lines = render_block(&msgs(&["one", "two"]), &cfg, 40, false);
assert_eq!(lines, vec![" one", "", " two"]);
}
#[test]
fn outline_geometry_and_border_painting() {
let lines = render_outline_block(
&msgs(&["hi"]),
Some("Note"),
Some(&style("fg=blue")),
true,
20,
true,
);
// top, blank, content, blank, bottom
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], format!("\x1b[34m ┌─ Note ──────────┐\x1b[0m"));
assert_eq!(lines[4], format!("\x1b[34m └─────────────────┘\x1b[0m"));
// Borders painted, content plain.
assert_eq!(lines[2], format!("\x1b[34m │ \x1b[0mhi{}\x1b[34m │\x1b[0m", " ".repeat(13)));
}
#[test]
fn outline_without_title_or_color() {
let lines =
render_outline_block(&msgs(&["hi"]), None, Some(&style("fg=red")), false, 12, false);
assert_eq!(lines, vec![" ┌─────────┐", " │ hi │", " └─────────┘"]);
for l in &lines {
assert_eq!(l.chars().count(), 12);
assert!(!l.contains('\x1b'));
}
}
#[test]
fn outline_long_title_does_not_underflow() {
let lines = render_outline_block(&msgs(&["x"]), Some("A very long title"), None, false, 10, false);
assert!(lines[0].contains("A very long title"));
}
}

@ -0,0 +1,579 @@
//! Symfony-console-style markup renderer backing `tui.styled`.
//!
//! Input is plain text with HTML-like tags: built-in style names
//! (`<info>`, `<comment>`, `<error>`, `<question>`), inline attribute specs
//! (`<fg=red;bg=blue;options=bold,underscore>`), and hyperlinks
//! (`<href=https://…>text</>`). Styles nest: an inner tag overrides only the
//! attributes it sets, everything else is inherited from the enclosing style.
//! `</>`, `</info>` and `</fg=…>` all simply pop the innermost style.
//!
//! A tag that doesn't parse — unknown name, bad color, `<>`, wrong case,
//! a stray `</>` — is emitted as literal text, never an error, matching
//! Symfony's formatter. `\<` (and `\>`) escape the brackets.
//!
//! Rendering is pure: `render(input, colorize)` has no I/O and consults no
//! globals, so tests can pin exact byte output. Each text segment is emitted
//! as a self-contained `\x1b[…m…\x1b[0m` run computed from the full effective
//! style, rather than Symfony's reset-and-reapply diffing — simpler, and no
//! stale attributes can leak between segments.
use std::fmt::Write;
use colored::Color;
/// A color attribute in a style spec. `Inherit` (attribute not mentioned in
/// the tag) takes the enclosing style's value when nested; `Default`
/// (`fg=default`) explicitly resets to the terminal default, overriding an
/// enclosing color.
#[derive(Clone, Copy, PartialEq, Debug, Default)]
enum ColorSpec {
#[default]
Inherit,
Default,
Set(Color),
}
impl ColorSpec {
fn over(self, base: Self) -> Self {
match self {
ColorSpec::Inherit => base,
other => other,
}
}
}
#[derive(Clone, PartialEq, Debug, Default)]
pub(super) struct Style {
fg: ColorSpec,
bg: ColorSpec,
bold: bool,
underscore: bool,
blink: bool,
reverse: bool,
conceal: bool,
href: Option<String>,
}
impl Style {
fn fg(color: Color) -> Self {
Style { fg: ColorSpec::Set(color), ..Default::default() }
}
fn fg_bg(fg: Color, bg: Color) -> Self {
Style { fg: ColorSpec::Set(fg), bg: ColorSpec::Set(bg), ..Default::default() }
}
/// The effective style of `self` nested inside `base`: attributes set here
/// win, everything else is inherited.
fn merge_over(&self, base: &Style) -> Style {
Style {
fg: self.fg.over(base.fg),
bg: self.bg.over(base.bg),
bold: self.bold || base.bold,
underscore: self.underscore || base.underscore,
blink: self.blink || base.blink,
reverse: self.reverse || base.reverse,
conceal: self.conceal || base.conceal,
href: self.href.clone().or_else(|| base.href.clone()),
}
}
}
/// 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. `<clear>` → `"\x1b[2J\x1b[H"`,
/// `<up=3>` → `"\x1b[3A"`, `<bell>` — added by giving `resolve_tag` more
/// producers; the tokenizer and renderer already handle the variant.
enum Tag {
Style(Style),
#[allow(dead_code)]
Action(String),
}
/// Resolve a style spec outside of tag context — a built-in name (`error`) or
/// an attribute spec (`fg=black;bg=green`) — for callers like the block
/// helpers, whose `style` option uses the same syntax as tags.
pub(super) fn resolve_style_spec(spec: &str) -> Option<Style> {
match resolve_tag(spec) {
Some(Tag::Style(s)) => Some(s),
_ => None,
}
}
/// Render one string with a single style (no tag parsing). The block helpers
/// use this to paint fully-assembled lines without the text being
/// re-interpreted as markup.
pub(super) fn paint_str(text: &str, style: &Style, colorize: bool) -> String {
let mut out = String::new();
paint(&mut out, text, Some(style), colorize);
out
}
/// Parse the inside of `<...>`. `None` means the whole tag (brackets
/// included) is literal text.
fn resolve_tag(body: &str) -> Option<Tag> {
// Built-in named styles (case-sensitive, like Symfony's).
match body {
"info" => return Some(Tag::Style(Style::fg(Color::Green))),
"comment" => return Some(Tag::Style(Style::fg(Color::Yellow))),
"error" => return Some(Tag::Style(Style::fg_bg(Color::White, Color::Red))),
"question" => return Some(Tag::Style(Style::fg_bg(Color::Black, Color::Cyan))),
_ => {}
}
parse_attr_spec(body).map(Tag::Style)
}
/// Parse an inline attribute spec: `key=value` pairs separated by `;`, with
/// keys `fg`, `bg`, `options` (comma-separated) and `href`. Strict — no
/// whitespace trimming, unknown keys/values reject the whole tag.
fn parse_attr_spec(body: &str) -> Option<Style> {
if body.is_empty() {
return None;
}
let mut style = Style::default();
for part in body.split(';') {
let (key, value) = part.split_once('=')?;
match key {
"fg" => style.fg = parse_color(value)?,
"bg" => style.bg = parse_color(value)?,
"options" => {
for opt in value.split(',') {
match opt {
"bold" => style.bold = true,
"underscore" => style.underscore = true,
"blink" => style.blink = true,
"reverse" => style.reverse = true,
"conceal" => style.conceal = true,
_ => return None,
}
}
}
"href" => {
if value.is_empty() {
return None;
}
style.href = Some(value.to_string());
}
_ => return None,
}
}
Some(style)
}
fn parse_color(s: &str) -> Option<ColorSpec> {
Some(ColorSpec::Set(match s {
"default" => return Some(ColorSpec::Default),
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
// Symfony's name for the bright-black ANSI slot.
"gray" | "bright-black" => Color::BrightBlack,
"bright-red" => Color::BrightRed,
"bright-green" => Color::BrightGreen,
"bright-yellow" => Color::BrightYellow,
"bright-blue" => Color::BrightBlue,
"bright-magenta" => Color::BrightMagenta,
"bright-cyan" => Color::BrightCyan,
"bright-white" => Color::BrightWhite,
_ => return parse_hex_color(s),
}))
}
/// `#rrggbb` or `#rgb` (each nibble doubled, CSS-style) → truecolor.
fn parse_hex_color(s: &str) -> Option<ColorSpec> {
let hex = s.strip_prefix('#')?;
let nibble = |i: usize| u8::from_str_radix(&hex[i..i + 1], 16).ok();
let (r, g, b) = match hex.len() {
3 => (nibble(0)? * 17, nibble(1)? * 17, nibble(2)? * 17),
6 => (
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
),
_ => return None,
};
Some(ColorSpec::Set(Color::TrueColor { r, g, b }))
}
/// SGR parameter list for the full effective style. Empty means the segment
/// needs no escape codes at all (explicit `default` colors emit nothing —
/// every segment is already self-terminated by `\x1b[0m`).
fn sgr_params(style: &Style) -> Vec<String> {
fn color_params(spec: ColorSpec, base: u16, truecolor_intro: u16) -> Option<String> {
match spec {
ColorSpec::Inherit | ColorSpec::Default => None,
ColorSpec::Set(Color::TrueColor { r, g, b }) => {
Some(format!("{truecolor_intro};2;{r};{g};{b}"))
}
// Not produced by parse_color today, but the colored enum has it
// (256-color palette); map it correctly rather than panicking.
ColorSpec::Set(Color::AnsiColor(n)) => Some(format!("{truecolor_intro};5;{n}")),
ColorSpec::Set(c) => {
let n = match c {
Color::Black => 0,
Color::Red => 1,
Color::Green => 2,
Color::Yellow => 3,
Color::Blue => 4,
Color::Magenta => 5,
Color::Cyan => 6,
Color::White => 7,
Color::BrightBlack => 60,
Color::BrightRed => 61,
Color::BrightGreen => 62,
Color::BrightYellow => 63,
Color::BrightBlue => 64,
Color::BrightMagenta => 65,
Color::BrightCyan => 66,
Color::BrightWhite => 67,
Color::TrueColor { .. } | Color::AnsiColor(_) => unreachable!(),
};
Some((base + n).to_string())
}
}
}
let mut params = Vec::new();
if style.bold {
params.push("1".to_string());
}
if style.underscore {
params.push("4".to_string());
}
if style.blink {
params.push("5".to_string());
}
if style.reverse {
params.push("7".to_string());
}
if style.conceal {
params.push("8".to_string());
}
params.extend(color_params(style.fg, 30, 38));
params.extend(color_params(style.bg, 40, 48));
params
}
/// Append `text` styled with the effective `style` (or plain when `None` /
/// not colorizing / the style has no visible effect).
fn paint(out: &mut String, text: &str, style: Option<&Style>, colorize: bool) {
let style = match style {
Some(s) if colorize => s,
_ => {
out.push_str(text);
return;
}
};
let params = sgr_params(style);
let open_link = |out: &mut String| {
if let Some(url) = &style.href {
write!(out, "\x1b]8;;{url}\x1b\\").unwrap();
}
};
open_link(out);
if params.is_empty() {
out.push_str(text);
} else {
write!(out, "\x1b[{}m{text}\x1b[0m", params.join(";")).unwrap();
}
if style.href.is_some() {
out.push_str("\x1b]8;;\x1b\\");
}
}
enum Event<'a> {
Text(&'a str),
Open(Tag),
/// A valid closing tag; `raw` is kept so a stray close (empty stack) can
/// be re-emitted literally.
Close {
raw: &'a str,
},
}
/// Single pass over the input. Byte-wise scanning is safe: the only bytes
/// acted on (`\`, `<`, `>`, `\n`) are ASCII and never occur inside a UTF-8
/// multi-byte sequence, and all slice cuts land on those ASCII boundaries.
fn tokenize(input: &str) -> Vec<Event<'_>> {
fn flush<'a>(events: &mut Vec<Event<'a>>, input: &'a str, from: usize, to: usize) {
if from < to {
events.push(Event::Text(&input[from..to]));
}
}
let bytes = input.as_bytes();
let mut events = Vec::new();
let mut run_start = 0; // start of the current literal text run
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
// `\<` and `\>` escape the bracket; a backslash before anything
// else is ordinary text (stays in the run).
b'\\' if matches!(bytes.get(i + 1), Some(b'<') | Some(b'>')) => {
flush(&mut events, input, run_start, i);
events.push(Event::Text(&input[i + 1..i + 2]));
i += 2;
run_start = i;
}
b'<' => {
// A tag body may not contain `<` or a newline; without a
// closing `>` the bracket is literal (`a < b` stays intact).
let mut end = None;
let mut j = i + 1;
while j < bytes.len() {
match bytes[j] {
b'>' => {
end = Some(j);
break;
}
b'<' | b'\n' => break,
_ => j += 1,
}
}
let Some(end) = end else {
i += 1;
continue;
};
let body = &input[i + 1..end];
let raw = &input[i..=end];
let event = if let Some(rest) = body.strip_prefix('/') {
// `</>`, or any spec that would be a valid *style* opener.
(rest.is_empty() || matches!(resolve_tag(rest), Some(Tag::Style(_))))
.then_some(Event::Close { raw })
} else {
resolve_tag(body).map(Event::Open)
};
match event {
Some(ev) => {
flush(&mut events, input, run_start, i);
events.push(ev);
i = end + 1;
run_start = i;
}
// Unparseable tag: leave the whole `<...>` in the text run.
None => i = end + 1,
}
}
_ => i += 1,
}
}
flush(&mut events, input, run_start, bytes.len());
events
}
/// Render markup to a string. With `colorize == false` the tags are still
/// parsed and stripped and escapes resolved, but no escape sequences of any
/// kind are emitted — safe for pipes and NO_COLOR.
pub(super) fn render(input: &str, colorize: bool) -> String {
let mut out = String::with_capacity(input.len());
// Effective (pre-merged) styles; `last()` is what the current text gets.
let mut stack: Vec<Style> = Vec::new();
// Adjacent text events (e.g. either side of an escaped bracket) are
// buffered so a contiguous same-styled run becomes one SGR segment.
let mut pending = String::new();
for event in tokenize(input) {
if !matches!(event, Event::Text(_)) && !pending.is_empty() {
paint(&mut out, &pending, stack.last(), colorize);
pending.clear();
}
match event {
Event::Text(t) => pending.push_str(t),
Event::Open(Tag::Style(delta)) => {
let effective = match stack.last() {
Some(base) => delta.merge_over(base),
None => delta,
};
stack.push(effective);
}
Event::Open(Tag::Action(seq)) => {
if colorize {
out.push_str(&seq);
}
}
Event::Close { raw } => {
if stack.pop().is_none() {
out.push_str(raw);
}
}
}
}
if !pending.is_empty() {
paint(&mut out, &pending, stack.last(), colorize);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_passes_through() {
assert_eq!(render("hello world", true), "hello world");
assert_eq!(render("hello world", false), "hello world");
}
#[test]
fn builtin_styles() {
assert_eq!(render("<info>x</info>", true), "\x1b[32mx\x1b[0m");
assert_eq!(render("<comment>x</>", true), "\x1b[33mx\x1b[0m");
assert_eq!(render("<error>x</>", true), "\x1b[37;41mx\x1b[0m");
assert_eq!(render("<question>x</>", true), "\x1b[30;46mx\x1b[0m");
}
#[test]
fn inline_spec_combined() {
assert_eq!(
render("<fg=red;bg=blue;options=bold,underscore>x</>", true),
"\x1b[1;4;31;44mx\x1b[0m"
);
}
#[test]
fn all_options() {
assert_eq!(
render("<options=bold,underscore,blink,reverse,conceal>x</>", true),
"\x1b[1;4;5;7;8mx\x1b[0m"
);
}
#[test]
fn bright_gray_and_default_colors() {
assert_eq!(render("<fg=bright-cyan>x</>", true), "\x1b[96mx\x1b[0m");
assert_eq!(render("<fg=gray>x</>", true), "\x1b[90mx\x1b[0m");
assert_eq!(render("<bg=bright-red>x</>", true), "\x1b[101mx\x1b[0m");
// Explicit `default` fg emits nothing; the bg still applies.
assert_eq!(render("<fg=default;bg=blue>x</>", true), "\x1b[44mx\x1b[0m");
// A style that boils down to nothing stays plain.
assert_eq!(render("<fg=default>x</>", true), "x");
}
#[test]
fn hex_colors() {
assert_eq!(render("<fg=#ff0000>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m");
assert_eq!(render("<fg=#f0a>x</>", true), "\x1b[38;2;255;0;170mx\x1b[0m");
assert_eq!(render("<bg=#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m");
}
#[test]
fn nesting_merges_and_restores() {
// Inner <info> overrides fg only; bg red is inherited. After the
// close, the outer error style is restored.
assert_eq!(
render("<error>a<info>b</info>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[32;41mb\x1b[0m\x1b[37;41mc\x1b[0m"
);
assert_eq!(
render("<fg=red>a<fg=blue>b</>c</>", true),
"\x1b[31ma\x1b[0m\x1b[34mb\x1b[0m\x1b[31mc\x1b[0m"
);
// fg=default inside a colored parent resets the fg for that span.
assert_eq!(
render("<error>a<fg=default>b</>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[41mb\x1b[0m\x1b[37;41mc\x1b[0m"
);
// Options accumulate.
assert_eq!(
render("<options=bold><options=underscore>x</></>", true),
"\x1b[1;4mx\x1b[0m"
);
}
#[test]
fn closing_tag_forms_all_pop() {
assert_eq!(render("<info>x</info>", true), render("<info>x</>", true));
assert_eq!(
render("<fg=blue;bg=red>x</fg=blue;bg=red>", true),
"\x1b[34;41mx\x1b[0m"
);
// Any style-parseable close pops (it is not matched against the opener).
assert_eq!(render("<info>x</comment>", true), "\x1b[32mx\x1b[0m");
}
#[test]
fn invalid_tags_are_literal() {
for s in [
"<foo>x</foo>",
"<Info>x",
"<>",
"<fg=notacolor>x",
"<fg=red;wat=1>x",
"<options=sparkle>x",
"<fg = red>x", // strict: no whitespace around `=`
"<fg=>x",
"</garbage>",
"a < b and c > d",
"<info", // unclosed at EOF
"<#ff0000>x",
] {
assert_eq!(render(s, true), s, "should pass through literally: {s:?}");
}
// `<` never matches across another `<` or a newline.
assert_eq!(render("a <<info>b</>", true), "a <\x1b[32mb\x1b[0m");
assert_eq!(render("<foo\n>x", true), "<foo\n>x");
}
#[test]
fn stray_close_is_literal() {
assert_eq!(render("x</>", true), "x</>");
assert_eq!(render("<info>a</></>", true), "\x1b[32ma\x1b[0m</>");
}
#[test]
fn escaping() {
assert_eq!(render("\\<info>x", true), "<info>x");
assert_eq!(render("\\<info>x", false), "<info>x");
assert_eq!(render("foo\\<bar", true), "foo<bar");
assert_eq!(render("\\>", true), ">");
// A backslash before anything else is ordinary text.
assert_eq!(render("a\\b\\", true), "a\\b\\");
// Escaped bracket inside a styled span stays styled.
assert_eq!(render("<info>\\<x></info>", true), "\x1b[32m<x>\x1b[0m");
}
#[test]
fn href() {
assert_eq!(
render("<href=https://example.com/>link</>", true),
"\x1b]8;;https://example.com/\x1b\\link\x1b]8;;\x1b\\"
);
// Styled text inside the hyperlink span.
assert_eq!(
render("<href=https://e.com><info>x</info></>", true),
"\x1b]8;;https://e.com\x1b\\\x1b[32mx\x1b[0m\x1b]8;;\x1b\\"
);
// Combined href + color in one tag.
assert_eq!(
render("<href=https://e.com;fg=green>x</>", true),
"\x1b]8;;https://e.com\x1b\\\x1b[32mx\x1b[0m\x1b]8;;\x1b\\"
);
assert_eq!(render("<href=>x", true), "<href=>x");
}
#[test]
fn colorize_false_strips_everything() {
let input = "<error>a<info>b</info></error> <fg=#f00>c</> <href=https://e.com>d</> \\<e>";
let out = render(input, false);
assert_eq!(out, "ab c d <e>");
assert!(!out.contains('\x1b'));
}
#[test]
fn empty_and_tag_only_inputs() {
assert_eq!(render("", true), "");
assert_eq!(render("<info></>", true), "");
assert_eq!(render("<info><comment></></>", true), "");
}
#[test]
fn unclosed_styles_at_eof_are_fine() {
assert_eq!(render("<info>x", true), "\x1b[32mx\x1b[0m");
}
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save