You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
363 lines
15 KiB
363 lines
15 KiB
//! The output half of the tui module: message blocks (`tui.success` & co.,
|
|
//! rendering in [`super::blocks`]), styled markup (`tui.styled` /
|
|
//! `tui.addPreset`, engine in [`super::markup`]), and the low-level
|
|
//! `tui.raw` / `tui.screenInfo` building blocks.
|
|
|
|
use std::io::IsTerminal;
|
|
|
|
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
|
|
use mlua::Lua;
|
|
|
|
use super::opts::Opts;
|
|
use super::{blocks, ext_err, markup};
|
|
|
|
pub(super) fn colorize() -> bool {
|
|
colored::control::SHOULD_COLORIZE.should_colorize()
|
|
}
|
|
|
|
/// Width blocks are rendered to: the terminal width capped at
|
|
/// [`blocks::MAX_LINE_LENGTH`], or the cap itself when there is no terminal.
|
|
fn terminal_line_length() -> usize {
|
|
match crossterm::terminal::size() {
|
|
Ok((w, _)) if w > 0 => (w as usize).min(blocks::MAX_LINE_LENGTH),
|
|
_ => blocks::MAX_LINE_LENGTH,
|
|
}
|
|
}
|
|
|
|
/// The block functions' first argument: one message or an array of messages
|
|
/// (rendered into the same block, separated by a blank line).
|
|
fn messages_arg(fname: &str, v: LuaValue) -> LuaResult<Vec<String>> {
|
|
match v {
|
|
LuaValue::String(s) => Ok(vec![
|
|
s.to_str()
|
|
.map_err(|_| ext_err!("{fname}: message must be valid UTF-8"))?
|
|
.to_string(),
|
|
]),
|
|
LuaValue::Table(t) => {
|
|
let mut out = Vec::with_capacity(t.raw_len());
|
|
for i in 1..=t.raw_len() {
|
|
match t.raw_get::<LuaValue>(i)? {
|
|
LuaValue::String(s) => out.push(
|
|
s.to_str()
|
|
.map_err(|_| ext_err!("{fname}: message[{i}] must be valid UTF-8"))?
|
|
.to_string(),
|
|
),
|
|
other => {
|
|
return Err(ext_err!(
|
|
"{fname}: message[{i}] must be a string (got {})",
|
|
other.type_name()
|
|
))
|
|
}
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
return Err(ext_err!("{fname}: message must not be empty"));
|
|
}
|
|
Ok(out)
|
|
}
|
|
other => Err(ext_err!(
|
|
"{fname}: message must be a string or an array of strings (got {})",
|
|
other.type_name()
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// The `style` option: same syntax as a markup tag body — a preset or
|
|
/// built-in name (`error`), an attribute spec (`fg=black;bg=green`), or
|
|
/// shorthand (`black;on-green`). Unlike markup (where a bad tag passes
|
|
/// through), a bad *argument* fails loudly.
|
|
fn style_opt(lua: &Lua, opts: &Opts, key: &str) -> LuaResult<Option<markup::Style>> {
|
|
match opts.string(key)? {
|
|
None => Ok(None),
|
|
Some(spec) => {
|
|
let resolved = match lua.app_data_ref::<markup::Presets>() {
|
|
Some(presets) => markup::resolve_spec(&spec, &presets),
|
|
None => markup::resolve_style_spec(&spec),
|
|
};
|
|
resolved
|
|
.map(Some)
|
|
.ok_or_else(|| ext_err!("{}: invalid style '{spec}'", opts.fname()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The module's single stdout sink. In test builds output goes through
|
|
/// `print!` so the libtest harness can capture it — direct `stdout()` writes
|
|
/// bypass the capture and trash the test runner's output. In normal builds it
|
|
/// writes the bytes directly, ignores errors (a broken pipe must not panic
|
|
/// the runtime) and flushes (tui.raw is used for `\r` redraws mid-line).
|
|
pub(super) fn write_stdout(bytes: &[u8]) {
|
|
#[cfg(test)]
|
|
print!("{}", String::from_utf8_lossy(bytes));
|
|
#[cfg(not(test))]
|
|
{
|
|
use std::io::Write;
|
|
let mut out = std::io::stdout().lock();
|
|
let _ = out.write_all(bytes);
|
|
let _ = out.flush();
|
|
}
|
|
}
|
|
|
|
/// Print a rendered block surrounded by blank lines (Symfony's block
|
|
/// spacing), best-effort like `print`.
|
|
fn print_block(lines: &[String]) {
|
|
let mut out = String::with_capacity(lines.iter().map(|l| l.len() + 1).sum::<usize>() + 2);
|
|
out.push('\n');
|
|
for line in lines {
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
out.push('\n');
|
|
write_stdout(out.as_bytes());
|
|
}
|
|
|
|
/// The `[LABEL]` block presets (Symfony's SymfonyStyle block styles, labels
|
|
/// without decoration). `tui.block` is the generic form with no defaults.
|
|
struct BlockPreset {
|
|
name: &'static str,
|
|
fname: &'static str,
|
|
label: Option<&'static str>,
|
|
style: Option<&'static str>,
|
|
prefix: &'static str,
|
|
padding: bool,
|
|
}
|
|
|
|
const BLOCK_PRESETS: &[BlockPreset] = &[
|
|
BlockPreset { name: "block", fname: "tui.block", label: None, style: None, prefix: " ", padding: false },
|
|
BlockPreset { name: "success", fname: "tui.success", label: Some("OK"), style: Some("fg=black;bg=green"), prefix: " ", padding: true },
|
|
BlockPreset { name: "error", fname: "tui.error", label: Some("ERROR"), style: Some("fg=white;bg=red"), prefix: " ", padding: true },
|
|
BlockPreset { name: "warning", fname: "tui.warning", label: Some("WARNING"), style: Some("fg=black;bg=yellow"), prefix: " ", padding: true },
|
|
BlockPreset { name: "caution", fname: "tui.caution", label: Some("CAUTION"), style: Some("fg=white;bg=red"), prefix: " ! ", padding: true },
|
|
BlockPreset { name: "info", fname: "tui.info", label: Some("INFO"), style: Some("fg=green"), prefix: " ", padding: false },
|
|
BlockPreset { name: "note", fname: "tui.note", label: Some("NOTE"), style: Some("fg=yellow"), prefix: " ! ", padding: false },
|
|
BlockPreset { name: "comment", fname: "tui.comment", label: None, style: Some("fg=gray"), prefix: " ", padding: false },
|
|
];
|
|
|
|
/// The outlined-box presets. Titles are plain words (no emoji) and are only
|
|
/// fallbacks — `opts.title` overrides them.
|
|
struct OutlinePreset {
|
|
name: &'static str,
|
|
fname: &'static str,
|
|
title: Option<&'static str>,
|
|
style: Option<&'static str>,
|
|
}
|
|
|
|
const OUTLINE_PRESETS: &[OutlinePreset] = &[
|
|
OutlinePreset { name: "outlineBlock", fname: "tui.outlineBlock", title: None, style: None },
|
|
OutlinePreset { name: "outlineSuccess", fname: "tui.outlineSuccess", title: Some("Success"), style: Some("fg=green") },
|
|
OutlinePreset { name: "outlineError", fname: "tui.outlineError", title: Some("Error"), style: Some("fg=red") },
|
|
OutlinePreset { name: "outlineWarning", fname: "tui.outlineWarning", title: Some("Warning"), style: Some("fg=yellow") },
|
|
OutlinePreset { name: "outlineNote", fname: "tui.outlineNote", title: Some("Note"), style: Some("fg=blue") },
|
|
OutlinePreset { name: "outlineInfo", fname: "tui.outlineInfo", title: Some("Info"), style: Some("fg=green") },
|
|
OutlinePreset { name: "outlineCaution", fname: "tui.outlineCaution", title: Some("Caution"), style: Some("fg=red") },
|
|
];
|
|
|
|
/// A preset style spec is a compile-time literal; parsing it cannot fail.
|
|
fn preset_style(spec: Option<&str>) -> Option<markup::Style> {
|
|
spec.map(|s| markup::resolve_style_spec(s).expect("preset style spec is valid"))
|
|
}
|
|
|
|
/// A `tui.addPreset` name: identifier-like, so it can never collide with
|
|
/// attribute specs (which contain `=`) or be mistaken for a closing tag.
|
|
fn valid_preset_name(name: &str) -> bool {
|
|
let mut chars = name.chars();
|
|
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
|
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
}
|
|
|
|
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
|
|
// Message blocks: tui.block + the [LABEL] presets. All print directly to
|
|
// stdout (blank-line separated), like Symfony's SymfonyStyle.
|
|
for p in BLOCK_PRESETS {
|
|
tui.raw_set(
|
|
p.name,
|
|
lua.create_function(move |lua, (msg, opts): (LuaValue, Option<LuaTable>)| {
|
|
let opts = Opts::parse(p.fname, opts, &["label", "style", "prefix", "padding"])?;
|
|
let messages = messages_arg(p.fname, msg)?;
|
|
let cfg = blocks::BlockCfg {
|
|
label: opts.string("label")?.or_else(|| p.label.map(str::to_string)),
|
|
style: style_opt(lua, &opts, "style")?.or_else(|| preset_style(p.style)),
|
|
prefix: opts.string("prefix")?.unwrap_or_else(|| p.prefix.to_string()),
|
|
padding: opts.boolean("padding")?.unwrap_or(p.padding),
|
|
};
|
|
print_block(&blocks::render_block(
|
|
&messages,
|
|
&cfg,
|
|
terminal_line_length(),
|
|
colorize(),
|
|
));
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
// Outlined boxes: tui.outlineBlock + presets. opts.title overrides the
|
|
// preset's fallback title.
|
|
for p in OUTLINE_PRESETS {
|
|
tui.raw_set(
|
|
p.name,
|
|
lua.create_function(move |lua, (msg, opts): (LuaValue, Option<LuaTable>)| {
|
|
let opts = Opts::parse(p.fname, opts, &["title", "style", "padding"])?;
|
|
let messages = messages_arg(p.fname, msg)?;
|
|
let title = opts.string("title")?.or_else(|| p.title.map(str::to_string));
|
|
let style = style_opt(lua, &opts, "style")?.or_else(|| preset_style(p.style));
|
|
let padding = opts.boolean("padding")?.unwrap_or(true);
|
|
print_block(&blocks::render_outline_block(
|
|
&messages,
|
|
title.as_deref(),
|
|
style.as_ref(),
|
|
padding,
|
|
terminal_line_length(),
|
|
colorize(),
|
|
));
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
// tui.styled: render markup to an ANSI string (or plain text when colors
|
|
// are off — pipes, NO_COLOR).
|
|
tui.raw_set(
|
|
"styled",
|
|
lua.create_function(|lua, v: LuaValue| {
|
|
let s = match v {
|
|
LuaValue::String(s) => s,
|
|
other => {
|
|
return Err(ext_err!(
|
|
"tui.styled: expected a string (got {})",
|
|
other.type_name()
|
|
))
|
|
}
|
|
};
|
|
let s = s
|
|
.to_str()
|
|
.map_err(|_| ext_err!("tui.styled: input must be valid UTF-8"))?;
|
|
let presets = lua
|
|
.app_data_ref::<markup::Presets>()
|
|
.ok_or_else(|| ext_err!("tui.styled: preset registry missing"))?;
|
|
Ok(markup::render(&s, colorize(), &presets))
|
|
})?,
|
|
)?;
|
|
|
|
// tui.escape: backslash-escape the markup metacharacters so untrusted
|
|
// text (user input, API data) can be embedded in a tui.styled format
|
|
// string and come out verbatim.
|
|
tui.raw_set(
|
|
"escape",
|
|
lua.create_function(|_, v: LuaValue| {
|
|
let s = match v {
|
|
LuaValue::String(s) => s,
|
|
other => {
|
|
return Err(ext_err!(
|
|
"tui.escape: expected a string (got {})",
|
|
other.type_name()
|
|
))
|
|
}
|
|
};
|
|
let s = s
|
|
.to_str()
|
|
.map_err(|_| ext_err!("tui.escape: input must be valid UTF-8"))?;
|
|
Ok(markup::escape(&s))
|
|
})?,
|
|
)?;
|
|
|
|
// tui.addPreset: register a custom markup tag, e.g.
|
|
// addPreset("keyword", "fg=white;bg=magenta") makes <keyword>…</keyword>
|
|
// work in tui.styled and as a block `style` option. The spec may also
|
|
// reference an existing preset or built-in. Re-registering a name
|
|
// overwrites it; built-in names can be shadowed.
|
|
tui.raw_set(
|
|
"addPreset",
|
|
lua.create_function(|lua, (name, spec): (LuaValue, LuaValue)| {
|
|
const F: &str = "tui.addPreset";
|
|
let name = match name {
|
|
LuaValue::String(s) => s
|
|
.to_str()
|
|
.map_err(|_| ext_err!("{F}: name must be valid UTF-8"))?
|
|
.to_string(),
|
|
other => {
|
|
return Err(ext_err!("{F}: name must be a string (got {})", other.type_name()))
|
|
}
|
|
};
|
|
if !valid_preset_name(&name) {
|
|
return Err(ext_err!(
|
|
"{F}: name must start with a letter and contain only letters, digits, '-' or '_' (got '{name}')"
|
|
));
|
|
}
|
|
let spec = match spec {
|
|
LuaValue::String(s) => s
|
|
.to_str()
|
|
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))?
|
|
.to_string(),
|
|
other => {
|
|
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name()))
|
|
}
|
|
};
|
|
let mut presets = lua
|
|
.app_data_mut::<markup::Presets>()
|
|
.ok_or_else(|| ext_err!("{F}: preset registry missing"))?;
|
|
let style = markup::resolve_spec(&spec, &presets)
|
|
.ok_or_else(|| ext_err!("{F}: invalid style '{spec}'"))?;
|
|
presets.0.insert(name, style);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// tui.raw: write to stdout with no newline and no separator (io.write
|
|
// semantics) and flush — the building block for progress-style output
|
|
// (`tui.raw("\rprogress: 50%")`). Values go through tostring; byte
|
|
// strings pass through unmodified.
|
|
tui.raw_set(
|
|
"raw",
|
|
lua.create_function(|lua, args: mlua::Variadic<LuaValue>| {
|
|
let tostring: mlua::Function = lua.globals().get("tostring")?;
|
|
let mut out = Vec::new();
|
|
for v in args {
|
|
let s = tostring.call::<mlua::String>(v)?;
|
|
out.extend_from_slice(&s.as_bytes());
|
|
}
|
|
write_stdout(&out);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// tui.screenInfo: terminal facts for scripts that render their own UI
|
|
// (progress bars, right-aligned text, …). width/height are nil when
|
|
// there is no terminal to measure.
|
|
tui.raw_set(
|
|
"screenInfo",
|
|
lua.create_function(|lua, ()| {
|
|
let t = lua.create_table()?;
|
|
// Some ptys report 0x0; treat that as "unknown", like no terminal.
|
|
if let Ok((w, h)) = crossterm::terminal::size()
|
|
&& w > 0
|
|
&& h > 0
|
|
{
|
|
t.raw_set("width", w)?;
|
|
t.raw_set("height", h)?;
|
|
}
|
|
t.raw_set("tty", std::io::stdout().is_terminal())?;
|
|
t.raw_set("colors", colorize())?;
|
|
Ok(t)
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn preset_name_validation() {
|
|
// Names that shadow shorthand tokens ("red", "on-red") are allowed —
|
|
// shadowing is the user's explicit, documented choice.
|
|
for ok in ["keyword", "a", "warn2", "my-tag", "my_tag", "B", "red", "on-red"] {
|
|
assert!(valid_preset_name(ok), "{ok} should be valid");
|
|
}
|
|
for bad in ["", "2fast", "-x", "fg=red", "a;b", "a b", "žluť"] {
|
|
assert!(!valid_preset_name(bad), "{bad} should be invalid");
|
|
}
|
|
}
|
|
}
|
|
|