parent
cd213e525b
commit
52632f4072
@ -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") |
||||||
@ -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}"); |
||||||
|
} |
||||||
@ -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…
Reference in new issue