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