//! 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> { 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::(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> { match opts.string(key)? { None => Ok(None), Some(spec) => { let resolved = match lua.app_data_ref::() { 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::() + 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 { 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)| { 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)| { 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::() .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 // 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::() .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| { let tostring: mlua::Function = lua.globals().get("tostring")?; let mut out = Vec::new(); for v in args { let s = tostring.call::(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"); } } }