From d559eb10ef537e2c45ffcc363c1166dd7066db16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Mon, 6 Jul 2026 20:58:40 +0200 Subject: [PATCH] expand tui with shorthand markup etc --- docs/tui.md | 50 +- lua/tui_prompts.lua | 2 +- lua/tui_styles.lua | 28 +- src/lua_tests/tui_tests.rs | 55 +- src/stdlib/tui/markup.rs | 379 +++++++---- src/stdlib/tui/mod.rs | 1325 +----------------------------------- src/stdlib/tui/opts.rs | 328 +++++++++ src/stdlib/tui/output.rs | 341 ++++++++++ src/stdlib/tui/prompts.rs | 740 ++++++++++++++++++++ tmp/.gitignore | 2 + 10 files changed, 1803 insertions(+), 1447 deletions(-) create mode 100644 src/stdlib/tui/opts.rs create mode 100644 src/stdlib/tui/output.rs create mode 100644 src/stdlib/tui/prompts.rs create mode 100644 tmp/.gitignore diff --git a/docs/tui.md b/docs/tui.md index b3920fe..2945a35 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -197,10 +197,15 @@ print(tui.styled(" FAIL expected 42, see " .. | Tag | Rendering | |-----|-----------| -| `` | green text | -| `` | yellow text | +| `` | bold green text | | `` | white on red | -| `` | black on cyan | +| `` | **bold** | +| `` | *italic* | +| `` | underscore | +| `` | ~~strikethrough~~ | + +(Symfony's `` and `` are deliberately not built in — add +them with `tui.addPreset` if you want them.) ### Inline styles @@ -213,11 +218,39 @@ tui.styled("ok careful brick - 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`. + color — as an *inner* tag it resets an inherited color), and hex + `#rrggbb` / `#rgb` (truecolor). +- Options (comma-separated): `bold`, `italic`, `underscore`, `blink`, + `reverse`, `conceal`, `strikethrough`. - The syntax is strict: no spaces around `=` or `;`. +### Shorthand + +Bare tokens work without the `fg=`/`options=` keys: a color names the +foreground, `on-` the background, and option names apply directly. +Shorthand mixes freely with `key=value` parts and built-in names: + +```lua +tui.styled("alarm <#f0a;u>pink underline bold green") +``` + +### Custom presets — `tui.addPreset(name, style)` + +Register your own tag name for any style spec. The spec uses the same syntax +as tags (attributes, shorthand, or another preset/built-in name); the name +must be identifier-like (start with a letter; letters, digits, `-`, `_`). + +```lua +tui.addPreset("keyword", "fg=white;bg=magenta") +tui.addPreset("em", "i;bold") +print(tui.styled("the local keyword is important")) +tui.note("see the docs") -- NOT styled: block text is plain +tui.block("styled block", { style = "keyword" }) -- but block styles resolve presets +``` + +Re-registering a name overwrites it, and a preset may shadow a built-in name +or shorthand token (e.g. `addPreset("info", "fg=blue")` restyles ``). + ### Closing tags and nesting `` closes the innermost open style; named forms (``, @@ -283,8 +316,9 @@ tui.error("Build failed") 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. + - `style` — same syntax as a `styled` tag body: a preset or built-in name + (`"error"`, anything from `tui.addPreset`), a spec (`"fg=black;bg=cyan"`), + or shorthand (`"black;on-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). diff --git a/lua/tui_prompts.lua b/lua/tui_prompts.lua index 8a35439..49836f1 100644 --- a/lua/tui_prompts.lua +++ b/lua/tui_prompts.lua @@ -86,7 +86,7 @@ banner("Summary") ---------------------------------------------------------------------- local function show(k, v) if v == nil then - v = "(skipped)" + v = "(skipped)" elseif type(v) == "table" then v = table.concat(v, ", ") end diff --git a/lua/tui_styles.lua b/lua/tui_styles.lua index 3e8e707..9997c38 100644 --- a/lua/tui_styles.lua +++ b/lua/tui_styles.lua @@ -11,19 +11,31 @@ end ---------------------------------------------------------------------- banner("1. tui.styled — built-in tags") ---------------------------------------------------------------------- -print(tui.styled("info is green, comment is yellow")) -print(tui.styled(" error: white on red question: black on cyan ")) +print(tui.styled("info is bold green and error is white on red ")) +-- HTML-like shortcuts. +print(tui.styled("bold italic underscore strikethrough")) ---------------------------------------------------------------------- banner("2. Inline styles: fg / bg / options") ---------------------------------------------------------------------- print(tui.styled("named colors, bright variants, gray")) print(tui.styled("hex truecolor #c0392b and short #0af")) -print(tui.styled("background bold underscore")) +print(tui.styled("background bold both")) print(tui.styled(" all combined in one tag ")) +-- Shorthand: bare colors are the foreground, on- the background, +-- option names apply directly; mixes with built-in names and key=value. +print(tui.styled("alarm <#f0a;u>pink underline bold green")) ---------------------------------------------------------------------- -banner("3. Nesting, links, escaping") +banner("3. Custom presets — tui.addPreset") +---------------------------------------------------------------------- +tui.addPreset("keyword", "fg=white;bg=magenta") +tui.addPreset("em", "i;bold") -- specs can use shorthand and reference other presets +print(tui.styled("the local keyword is important")) +tui.block("presets work as block styles too", { style = "keyword" }) + +---------------------------------------------------------------------- +banner("4. Nesting, links, escaping") ---------------------------------------------------------------------- -- Inner tags override only what they set; the rest is inherited. print(tui.styled("outer bold inherits the red bg and back")) @@ -35,14 +47,14 @@ print(tui.styled("read the docs for details")) print(tui.styled("literal \\ stays, too, a < b as well")) ---------------------------------------------------------------------- -banner("4. Message blocks (Symfony style)") +banner("5. 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.note("Long messages word-wrap to the terminal width and continuation lines stay aligned under the label lorem ipsum dolor sit amet") tui.comment("git push --force-with-lease") -- The generic form: everything is configurable. tui.block( @@ -51,7 +63,7 @@ tui.block( ) ---------------------------------------------------------------------- -banner("5. Outline blocks (colored borders, plain content)") +banner("6. Outline blocks (colored borders, plain content)") ---------------------------------------------------------------------- tui.outlineSuccess("All tests passed") tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset @@ -61,7 +73,7 @@ tui.outlineBlock({ "Generic box.", "Multiple messages are separated by a blank l }) ---------------------------------------------------------------------- -banner("6. tui.raw + tui.screenInfo — progress bar building blocks") +banner("7. 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) diff --git a/src/lua_tests/tui_tests.rs b/src/lua_tests/tui_tests.rs index 01484ff..3cb21b9 100644 --- a/src/lua_tests/tui_tests.rs +++ b/src/lua_tests/tui_tests.rs @@ -39,6 +39,7 @@ fn test_tui_global_has_all_functions() { "outlineCaution", "raw", "screenInfo", + "addPreset", ] { let is_fn: bool = lua .load(format!("return type(tui.{f}) == 'function'")) @@ -91,6 +92,58 @@ fn test_styled_resolves_escapes_and_keeps_invalid_tags() { assert!(out.contains(""), "invalid tag should stay literal: {out:?}"); } +#[test] +fn test_styled_shorthand_and_html_tags_are_consumed() { + let lua = lua(); + let out: String = lua + .load(r#"return tui.styled("a b c d e")"#) + .eval() + .unwrap(); + assert!(!out.contains('<'), "tags not consumed: {out:?}"); + let stripped: String = out.chars().filter(|c| " abcde".contains(*c)).collect(); + assert_eq!(stripped, "a b c d e"); +} + +#[test] +fn test_add_preset() { + let lua = lua(); + // A registered preset works as a styled tag and as a block style. + let out: String = lua + .load( + r#" + tui.addPreset("keyword", "fg=white;bg=magenta") + tui.addPreset("kw2", "keyword") -- specs may reference other presets + return tui.styled("foo bar") + "#, + ) + .eval() + .unwrap(); + assert!(!out.contains(""), "preset tag not consumed: {out:?}"); + assert!(out.contains("foo") && out.contains("bar")); + lua.load(r#"tui.block("x", {style = "keyword"})"#).exec().unwrap(); + + // Presets are per Lua state: a fresh state doesn't see "keyword". + let other = super::lua(); + let out: String = other.load(r#"return tui.styled("foo")"#).eval().unwrap(); + assert!(out.contains(""), "preset leaked across states: {out:?}"); +} + +#[test] +fn test_add_preset_validation() { + let lua = lua(); + for (src, expect) in [ + ("tui.addPreset(1, 'fg=red')", "tui.addPreset: name must be a string (got integer)"), + ("tui.addPreset('2fast', 'fg=red')", "tui.addPreset: name must start with a letter"), + ("tui.addPreset('a=b', 'fg=red')", "tui.addPreset: name must start with a letter"), + ("tui.addPreset('kw', 'fg=notacolor')", "tui.addPreset: invalid style 'fg=notacolor'"), + ("tui.addPreset('kw', 42)", "tui.addPreset: style must be a string (got integer)"), + ] { + let err = lua.load(src).exec().unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); + } +} + #[test] fn test_styled_rejects_non_string() { let lua = lua(); @@ -277,7 +330,7 @@ fn test_block_functions_print_without_error() { 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.outlineBlock("custom", {title="Box", style="gray;bold", padding=false}) tui.raw("progress ", 42, "\r") "#, ) diff --git a/src/stdlib/tui/markup.rs b/src/stdlib/tui/markup.rs index 5f75341..797d370 100644 --- a/src/stdlib/tui/markup.rs +++ b/src/stdlib/tui/markup.rs @@ -1,30 +1,46 @@ //! Symfony-console-style markup renderer backing `tui.styled`. //! -//! Input is plain text with HTML-like tags: built-in style names -//! (``, ``, ``, ``), inline attribute specs -//! (``), and hyperlinks -//! (`text`). Styles nest: an inner tag overrides only the -//! attributes it sets, everything else is inherited from the enclosing style. -//! ``, `` and `` all simply pop the innermost style. +//! Input is plain text with HTML-like tags: +//! - built-in style names: `` (bold green), `` (white on red), +//! and the HTML-like `` (bold), `` (italic), `` (underscore), +//! `` (strikethrough); +//! - custom presets registered with `tui.addPreset` (checked first, so they +//! can shadow the built-ins); +//! - attribute specs: ``; +//! - shorthand tokens: `` — a bare token is an option +//! name, a color (foreground, including `default` and `#hex`), or +//! `on-` (background). Shorthand and `key=value` parts mix freely +//! in one tag (``); +//! - hyperlinks: `text`. //! -//! A tag that doesn't parse — unknown name, bad color, `<>`, wrong case, -//! a stray `` — is emitted as literal text, never an error, matching +//! Styles nest: an inner tag overrides only the attributes it sets, +//! everything else is inherited from the enclosing style. ``, `` +//! and `` 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. +//! Rendering is pure: `render(input, colorize, presets)` 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::collections::HashMap; use std::fmt::Write; use colored::Color; +/// Custom styles registered with `tui.addPreset`, stored per Lua state (as +/// mlua app data) and consulted before the built-in tag names. +#[derive(Default)] +pub(super) struct Presets(pub(super) HashMap); + /// 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. +/// (`fg=default` / `` / ``) explicitly resets to the +/// terminal default, overriding an enclosing color. #[derive(Clone, Copy, PartialEq, Debug, Default)] enum ColorSpec { #[default] @@ -47,10 +63,12 @@ pub(super) struct Style { fg: ColorSpec, bg: ColorSpec, bold: bool, + italic: bool, underscore: bool, blink: bool, reverse: bool, conceal: bool, + strikethrough: bool, href: Option, } @@ -70,10 +88,12 @@ impl Style { fg: self.fg.over(base.fg), bg: self.bg.over(base.bg), bold: self.bold || base.bold, + italic: self.italic || base.italic, underscore: self.underscore || base.underscore, blink: self.blink || base.blink, reverse: self.reverse || base.reverse, conceal: self.conceal || base.conceal, + strikethrough: self.strikethrough || base.strikethrough, href: self.href.clone().or_else(|| base.href.clone()), } } @@ -90,14 +110,20 @@ enum Tag { 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