parent
52632f4072
commit
d559eb10ef
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,328 @@ |
||||
//! Shared argument parsing for the tui Lua bindings: the validated `opts`
|
||||
//! table view and the positional-argument helpers. All errors follow the
|
||||
//! project convention — prefixed with `tui.<fn>:` and raised via
|
||||
//! `mlua::Error::external`.
|
||||
|
||||
use std::fmt; |
||||
|
||||
use chrono::NaiveDate; |
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
|
||||
use super::ext_err; |
||||
|
||||
pub(super) const DATE_FMT: &str = "%Y-%m-%d"; |
||||
|
||||
/// Validated view over the optional `opts` table. Construction rejects
|
||||
/// unknown keys so a typo (`placholder`) fails loudly instead of being
|
||||
/// silently ignored.
|
||||
#[derive(Debug)] |
||||
pub(super) struct Opts { |
||||
fname: &'static str, |
||||
table: Option<LuaTable>, |
||||
} |
||||
|
||||
impl Opts { |
||||
pub(super) fn parse( |
||||
fname: &'static str, |
||||
table: Option<LuaTable>, |
||||
allowed: &'static [&'static str], |
||||
) -> LuaResult<Opts> { |
||||
if let Some(t) = &table { |
||||
for pair in t.pairs::<LuaValue, LuaValue>() { |
||||
let (key, _) = pair?; |
||||
let name = match &key { |
||||
LuaValue::String(s) => s.to_string_lossy(), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{fname}: option keys must be strings (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
if !allowed.contains(&name.as_str()) { |
||||
return Err(ext_err!( |
||||
"{fname}: unknown option '{name}' (allowed: {})", |
||||
allowed.join(", ") |
||||
)); |
||||
} |
||||
} |
||||
} |
||||
Ok(Opts { fname, table }) |
||||
} |
||||
|
||||
pub(super) fn fname(&self) -> &'static str { |
||||
self.fname |
||||
} |
||||
|
||||
pub(super) fn raw(&self, key: &str) -> LuaResult<LuaValue> { |
||||
match &self.table { |
||||
Some(t) => t.raw_get(key), |
||||
None => Ok(LuaValue::Nil), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn string(&self, key: &str) -> LuaResult<Option<String>> { |
||||
match self.raw(key)? { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::String(s) => Ok(Some( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{}: option '{key}' must be valid UTF-8", self.fname))? |
||||
.to_string(), |
||||
)), |
||||
other => Err(ext_err!( |
||||
"{}: option '{key}' must be a string (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn boolean(&self, key: &str) -> LuaResult<Option<bool>> { |
||||
match self.raw(key)? { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::Boolean(b) => Ok(Some(b)), |
||||
other => Err(ext_err!( |
||||
"{}: option '{key}' must be a boolean (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// A non-negative whole number.
|
||||
pub(super) fn count(&self, key: &str) -> LuaResult<Option<usize>> { |
||||
let v = self.raw(key)?; |
||||
match int_of(&v) { |
||||
_ if v.is_nil() => Ok(None), |
||||
Some(n) if n >= 0 => Ok(Some(n as usize)), |
||||
_ => Err(ext_err!( |
||||
"{}: option '{key}' must be a non-negative integer (got {})", |
||||
self.fname, |
||||
lua_value_brief(&v) |
||||
)), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn number(&self, key: &str) -> LuaResult<Option<f64>> { |
||||
match self.raw(key)? { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::Integer(i) => Ok(Some(i as f64)), |
||||
LuaValue::Number(n) => Ok(Some(n)), |
||||
other => Err(ext_err!( |
||||
"{}: option '{key}' must be a number (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn integer(&self, key: &str) -> LuaResult<Option<i64>> { |
||||
let v = self.raw(key)?; |
||||
match int_of(&v) { |
||||
_ if v.is_nil() => Ok(None), |
||||
Some(n) => Ok(Some(n)), |
||||
None => Err(ext_err!( |
||||
"{}: option '{key}' must be an integer (got {})", |
||||
self.fname, |
||||
lua_value_brief(&v) |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// An array of strings; `key[i]` errors name the offending element.
|
||||
pub(super) fn string_array(&self, key: &str) -> LuaResult<Option<Vec<String>>> { |
||||
let t = match self.raw(key)? { |
||||
LuaValue::Nil => return Ok(None), |
||||
LuaValue::Table(t) => t, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{}: option '{key}' must be an array of strings (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
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!("{}: {key}[{i}] must be valid UTF-8", self.fname))? |
||||
.to_string(), |
||||
), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{}: {key}[{i}] must be a string (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
} |
||||
Ok(Some(out)) |
||||
} |
||||
|
||||
/// A `YYYY-MM-DD` date string.
|
||||
pub(super) fn date(&self, key: &str) -> LuaResult<Option<NaiveDate>> { |
||||
match self.string(key)? { |
||||
None => Ok(None), |
||||
Some(s) => Ok(Some(parse_date(self.fname, &format!("option '{key}'"), &s)?)), |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// The prompt text (first argument): a required, valid-UTF-8 string.
|
||||
pub(super) fn text_arg(fname: &str, v: LuaValue) -> LuaResult<String> { |
||||
match v { |
||||
LuaValue::String(s) => Ok(s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{fname}: prompt text must be valid UTF-8"))? |
||||
.to_string()), |
||||
other => Err(ext_err!( |
||||
"{fname}: prompt text must be a string (got {})", |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// The positional `default` argument, when the prompt expects a string.
|
||||
pub(super) fn default_string(fname: &str, v: LuaValue) -> LuaResult<Option<String>> { |
||||
match v { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::String(s) => Ok(Some( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{fname}: default must be valid UTF-8"))? |
||||
.to_string(), |
||||
)), |
||||
other => Err(ext_err!( |
||||
"{fname}: default must be a string (got {})", |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// Integer value of a Lua number, treating an integral float (`3.0`) as its
|
||||
/// integer; `None` for everything else (including `3.5`).
|
||||
pub(super) fn int_of(v: &LuaValue) -> Option<i64> { |
||||
match v { |
||||
LuaValue::Integer(i) => Some(*i), |
||||
LuaValue::Number(n) if n.fract() == 0.0 && n.is_finite() => Some(*n as i64), |
||||
_ => None, |
||||
} |
||||
} |
||||
|
||||
/// `type_name()` alone hides *why* a number was rejected, so show the value
|
||||
/// for numbers and the type for everything else.
|
||||
pub(super) fn lua_value_brief(v: &LuaValue) -> String { |
||||
match v { |
||||
LuaValue::Number(n) => n.to_string(), |
||||
other => other.type_name().to_string(), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn parse_date(fname: &str, what: &str, s: &str) -> LuaResult<NaiveDate> { |
||||
NaiveDate::parse_from_str(s, DATE_FMT) |
||||
.map_err(|_| ext_err!("{fname}: {what} must be a YYYY-MM-DD string (got '{s}')")) |
||||
} |
||||
|
||||
/// `min`/`max` bounds must be ordered when both are present.
|
||||
pub(super) fn check_bounds<T: PartialOrd + fmt::Display>( |
||||
fname: &str, |
||||
min: &Option<T>, |
||||
max: &Option<T>, |
||||
) -> LuaResult<()> { |
||||
if let (Some(a), Some(b)) = (min, max) |
||||
&& a > b |
||||
{ |
||||
return Err(ext_err!("{fname}: min ({a}) must not be greater than max ({b})")); |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
/// Human message for a violated numeric/length bound, phrased by which bounds
|
||||
/// exist: "must be between 1 and 10" / "must be at least 1" / "must be at most 10".
|
||||
pub(super) fn bounds_message<T: fmt::Display>(noun: &str, min: &Option<T>, max: &Option<T>) -> String { |
||||
match (min, max) { |
||||
(Some(a), Some(b)) => format!("{noun} must be between {a} and {b}"), |
||||
(Some(a), None) => format!("{noun} must be at least {a}"), |
||||
(None, Some(b)) => format!("{noun} must be at most {b}"), |
||||
(None, None) => unreachable!("bounds_message called without bounds"), |
||||
} |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
use mlua::Lua; |
||||
|
||||
#[test] |
||||
fn parse_date_accepts_iso_only() { |
||||
assert_eq!( |
||||
parse_date("tui.promptDate", "default", "2026-07-06").unwrap(), |
||||
NaiveDate::from_ymd_opt(2026, 7, 6).unwrap() |
||||
); |
||||
// chrono's %Y-%m-%d is lenient about zero-padding, so "2026-1-5" is
|
||||
// accepted too — only genuinely malformed inputs are rejected.
|
||||
assert!(parse_date("tui.promptDate", "default", "2026-1-5").is_ok()); |
||||
for bad in ["06.07.2026", "not a date", "2026-13-01", "2026-02-30"] { |
||||
let err = parse_date("tui.promptDate", "default", bad).unwrap_err(); |
||||
assert!(err.to_string().contains("YYYY-MM-DD"), "{bad}: {err}"); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn int_of_accepts_integral_floats_only() { |
||||
assert_eq!(int_of(&LuaValue::Integer(3)), Some(3)); |
||||
assert_eq!(int_of(&LuaValue::Number(3.0)), Some(3)); |
||||
assert_eq!(int_of(&LuaValue::Number(3.5)), None); |
||||
assert_eq!(int_of(&LuaValue::Number(f64::INFINITY)), None); |
||||
assert_eq!(int_of(&LuaValue::Boolean(true)), None); |
||||
} |
||||
|
||||
#[test] |
||||
fn bounds_messages() { |
||||
assert_eq!(bounds_message("value", &Some(1), &Some(10)), "value must be between 1 and 10"); |
||||
assert_eq!(bounds_message("value", &Some(1), &None), "value must be at least 1"); |
||||
assert_eq!(bounds_message::<i64>("value", &None, &Some(10)), "value must be at most 10"); |
||||
} |
||||
|
||||
#[test] |
||||
fn check_bounds_rejects_inverted() { |
||||
assert!(check_bounds("tui.promptInt", &Some(1), &Some(10)).is_ok()); |
||||
assert!(check_bounds("tui.promptInt", &Some(1), &None::<i64>).is_ok()); |
||||
let err = check_bounds("tui.promptInt", &Some(10), &Some(1)).unwrap_err(); |
||||
assert!( |
||||
err.to_string() |
||||
.contains("tui.promptInt: min (10) must not be greater than max (1)"), |
||||
"got: {err}" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn opts_rejects_unknown_keys() { |
||||
let lua = Lua::new(); |
||||
let t = lua.create_table().unwrap(); |
||||
t.raw_set("placholder", "x").unwrap(); |
||||
let err = Opts::parse("tui.promptText", Some(t), &["help", "placeholder"]).unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains("tui.promptText: unknown option 'placholder'"), "got: {msg}"); |
||||
assert!(msg.contains("allowed: help, placeholder"), "got: {msg}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn opts_typed_getters_check_types() { |
||||
let lua = Lua::new(); |
||||
let t = lua.create_table().unwrap(); |
||||
t.raw_set("help", 42).unwrap(); |
||||
t.raw_set("minLen", -1).unwrap(); |
||||
t.raw_set("multiple", "yes").unwrap(); |
||||
let opts = Opts::parse("tui.x", Some(t), &["help", "minLen", "multiple"]).unwrap(); |
||||
assert!(opts.string("help").unwrap_err().to_string().contains("must be a string")); |
||||
assert!(opts |
||||
.count("minLen") |
||||
.unwrap_err() |
||||
.to_string() |
||||
.contains("must be a non-negative integer")); |
||||
assert!(opts.boolean("multiple").unwrap_err().to_string().contains("must be a boolean")); |
||||
} |
||||
} |
||||
@ -0,0 +1,341 @@ |
||||
//! 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).
|
||||
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: None, 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.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"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,740 @@ |
||||
//! The interactive prompts (`tui.confirm`, `tui.prompt*`), backed by the
|
||||
//! `inquire` crate.
|
||||
//!
|
||||
//! inquire prompts block their thread (crossterm raw mode), so each one runs
|
||||
//! under `spawn_blocking` like sqlite's I/O — the calling Lua coroutine
|
||||
//! suspends while timers and sibling coroutines keep running. A process-wide
|
||||
//! mutex serializes prompts so two coroutines under `task.join` can't fight
|
||||
//! over the terminal.
|
||||
//!
|
||||
//! Each Lua binding validates its arguments into a `Send` config struct
|
||||
//! *before* spawning, so malformed calls error deterministically even without
|
||||
//! a terminal; the `run_*` workers then build and drive the inquire prompt on
|
||||
//! the blocking pool.
|
||||
|
||||
use std::fmt; |
||||
use std::sync::Mutex; |
||||
|
||||
use chrono::NaiveDate; |
||||
use inquire::autocompletion::{Autocomplete, Replacement}; |
||||
use inquire::error::CustomUserError; |
||||
use inquire::list_option::ListOption; |
||||
use inquire::validator::Validation; |
||||
use inquire::{ |
||||
Confirm, CustomType, DateSelect, Editor, InquireError, MultiSelect, Password, |
||||
PasswordDisplayMode, Select, Text, |
||||
}; |
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
use super::ext_err; |
||||
use super::opts::{ |
||||
bounds_message, check_bounds, default_string, int_of, lua_value_brief, parse_date, text_arg, |
||||
Opts, DATE_FMT, |
||||
}; |
||||
|
||||
/// Serializes terminal ownership across concurrent Lua coroutines. Locked
|
||||
/// inside the blocking closure, so a waiting prompt parks a blocking-pool
|
||||
/// thread, never the tokio event loop.
|
||||
static PROMPT_LOCK: Mutex<()> = Mutex::new(()); |
||||
|
||||
/// `Send`-safe error carried out of the blocking prompt worker; the Lua-side
|
||||
/// wrapper prepends the `tui.<fn>:` prefix.
|
||||
enum PromptError { |
||||
Interrupted, |
||||
NotTty, |
||||
Other(String), |
||||
} |
||||
|
||||
impl fmt::Display for PromptError { |
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
||||
match self { |
||||
PromptError::Interrupted => f.write_str("interrupted"), |
||||
PromptError::NotTty => f.write_str("not a terminal"), |
||||
PromptError::Other(msg) => f.write_str(msg), |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Fold an inquire outcome into the module's cancellation model:
|
||||
/// Esc → `Ok(None)` (surfaces as `nil`), Ctrl-C / no-tty / everything else →
|
||||
/// error.
|
||||
fn map_inquire_result<T>(r: Result<T, InquireError>) -> Result<Option<T>, PromptError> { |
||||
match r { |
||||
Ok(v) => Ok(Some(v)), |
||||
Err(InquireError::OperationCanceled) => Ok(None), |
||||
Err(InquireError::OperationInterrupted) => Err(PromptError::Interrupted), |
||||
Err(InquireError::NotTTY) => Err(PromptError::NotTty), |
||||
Err(e) => Err(PromptError::Other(e.to_string())), |
||||
} |
||||
} |
||||
|
||||
/// Run a blocking prompt worker on the blocking pool, holding the prompt
|
||||
/// lock, and translate its error with the `tui.<fn>:` prefix.
|
||||
async fn run_prompt<T, F>(fname: &'static str, worker: F) -> LuaResult<Option<T>> |
||||
where |
||||
T: Send + 'static, |
||||
F: FnOnce() -> Result<Option<T>, PromptError> + Send + 'static, |
||||
{ |
||||
tokio::task::spawn_blocking(move || { |
||||
let _guard = PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); |
||||
worker() |
||||
}) |
||||
.await |
||||
.unwrap_or_else(|e| Err(PromptError::Other(format!("background task failed: {e}")))) |
||||
.map_err(|e| ext_err!("{fname}: {e}")) |
||||
} |
||||
|
||||
fn find_option_index(fname: &str, options: &[String], value: &str) -> LuaResult<usize> { |
||||
options |
||||
.iter() |
||||
.position(|o| o == value) |
||||
.ok_or_else(|| ext_err!("{fname}: default '{value}' is not one of the options")) |
||||
} |
||||
|
||||
/// Ensure an editor file extension has its leading dot (`md` → `.md`).
|
||||
fn normalize_extension(ext: &str) -> String { |
||||
if ext.starts_with('.') { |
||||
ext.to_string() |
||||
} else { |
||||
format!(".{ext}") |
||||
} |
||||
} |
||||
|
||||
/// Static-list autocompleter for `promptText`'s `suggestions` option:
|
||||
/// case-insensitive substring filter, Tab completes the highlighted entry.
|
||||
#[derive(Clone)] |
||||
struct StaticSuggester(Vec<String>); |
||||
|
||||
impl Autocomplete for StaticSuggester { |
||||
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> { |
||||
let needle = input.to_lowercase(); |
||||
Ok(self |
||||
.0 |
||||
.iter() |
||||
.filter(|s| s.to_lowercase().contains(&needle)) |
||||
.cloned() |
||||
.collect()) |
||||
} |
||||
|
||||
fn get_completion( |
||||
&mut self, |
||||
_input: &str, |
||||
highlighted_suggestion: Option<String>, |
||||
) -> Result<Replacement, CustomUserError> { |
||||
Ok(highlighted_suggestion) |
||||
} |
||||
} |
||||
|
||||
struct ConfirmCfg { |
||||
text: String, |
||||
default: Option<bool>, |
||||
help: Option<String>, |
||||
placeholder: Option<String>, |
||||
} |
||||
|
||||
fn run_confirm(cfg: &ConfirmCfg) -> Result<Option<bool>, PromptError> { |
||||
let mut p = Confirm::new(&cfg.text); |
||||
if let Some(d) = cfg.default { |
||||
p = p.with_default(d); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(ph) = &cfg.placeholder { |
||||
p = p.with_placeholder(ph); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct TextCfg { |
||||
text: String, |
||||
initial: Option<String>, |
||||
default: Option<String>, |
||||
help: Option<String>, |
||||
placeholder: Option<String>, |
||||
min_len: Option<usize>, |
||||
max_len: Option<usize>, |
||||
suggestions: Option<Vec<String>>, |
||||
page_size: Option<usize>, |
||||
} |
||||
|
||||
fn run_text(cfg: &TextCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Text::new(&cfg.text); |
||||
if let Some(v) = &cfg.initial { |
||||
p = p.with_initial_value(v); |
||||
} |
||||
if let Some(v) = &cfg.default { |
||||
p = p.with_default(v); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(ph) = &cfg.placeholder { |
||||
p = p.with_placeholder(ph); |
||||
} |
||||
if cfg.min_len.is_some() || cfg.max_len.is_some() { |
||||
let (min, max) = (cfg.min_len, cfg.max_len); |
||||
let msg = bounds_message("length", &min, &max); |
||||
p = p.with_validator(move |s: &str| { |
||||
let len = s.chars().count(); |
||||
let ok = min.is_none_or(|m| len >= m) && max.is_none_or(|m| len <= m); |
||||
if ok { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
if let Some(sugg) = &cfg.suggestions { |
||||
p = p.with_autocomplete(StaticSuggester(sugg.clone())); |
||||
} |
||||
if let Some(n) = cfg.page_size { |
||||
p = p.with_page_size(n); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct LongtextCfg { |
||||
text: String, |
||||
predefined: Option<String>, |
||||
help: Option<String>, |
||||
extension: Option<String>, |
||||
editor: Option<String>, |
||||
} |
||||
|
||||
fn run_longtext(cfg: &LongtextCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Editor::new(&cfg.text); |
||||
if let Some(v) = &cfg.predefined { |
||||
p = p.with_predefined_text(v); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(e) = &cfg.extension { |
||||
p = p.with_file_extension(e); |
||||
} |
||||
if let Some(cmd) = &cfg.editor { |
||||
p = p.with_editor_command(std::ffi::OsStr::new(cmd)); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct SelectCfg { |
||||
text: String, |
||||
options: Vec<String>, |
||||
help: Option<String>, |
||||
page_size: Option<usize>, |
||||
vim_mode: Option<bool>, |
||||
filter: bool, |
||||
// single mode
|
||||
starting_cursor: Option<usize>, |
||||
// multiple mode
|
||||
default_indices: Vec<usize>, |
||||
all_selected: bool, |
||||
min_selected: Option<usize>, |
||||
max_selected: Option<usize>, |
||||
} |
||||
|
||||
fn run_select_single(cfg: &SelectCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Select::new(&cfg.text, cfg.options.clone()); |
||||
if let Some(idx) = cfg.starting_cursor { |
||||
p = p.with_starting_cursor(idx); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(n) = cfg.page_size { |
||||
p = p.with_page_size(n); |
||||
} |
||||
if let Some(v) = cfg.vim_mode { |
||||
p = p.with_vim_mode(v); |
||||
} |
||||
if !cfg.filter { |
||||
p = p.without_filtering(); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
fn run_select_multiple(cfg: &SelectCfg) -> Result<Option<Vec<String>>, PromptError> { |
||||
let mut p = MultiSelect::new(&cfg.text, cfg.options.clone()); |
||||
if !cfg.default_indices.is_empty() { |
||||
p = p.with_default(&cfg.default_indices); |
||||
} |
||||
if cfg.all_selected { |
||||
p = p.with_all_selected_by_default(); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(n) = cfg.page_size { |
||||
p = p.with_page_size(n); |
||||
} |
||||
if let Some(v) = cfg.vim_mode { |
||||
p = p.with_vim_mode(v); |
||||
} |
||||
if !cfg.filter { |
||||
p = p.without_filtering(); |
||||
} |
||||
if cfg.min_selected.is_some() || cfg.max_selected.is_some() { |
||||
let (min, max) = (cfg.min_selected, cfg.max_selected); |
||||
let msg = bounds_message("number of selected options", &min, &max); |
||||
p = p.with_validator(move |sel: &[ListOption<&String>]| { |
||||
let n = sel.len(); |
||||
let ok = min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m); |
||||
if ok { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct PasswordCfg { |
||||
text: String, |
||||
help: Option<String>, |
||||
confirm: bool, |
||||
display: PasswordDisplayMode, |
||||
toggle: bool, |
||||
min_len: Option<usize>, |
||||
} |
||||
|
||||
fn run_password(cfg: &PasswordCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Password::new(&cfg.text).with_display_mode(cfg.display); |
||||
if !cfg.confirm { |
||||
p = p.without_confirmation(); |
||||
} |
||||
if cfg.toggle { |
||||
p = p.with_display_toggle_enabled(); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(min) = cfg.min_len { |
||||
let msg = bounds_message("length", &Some(min), &None); |
||||
p = p.with_validator(move |s: &str| { |
||||
if s.chars().count() >= min { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct NumberCfg<T> { |
||||
text: String, |
||||
default: Option<T>, |
||||
help: Option<String>, |
||||
placeholder: Option<String>, |
||||
min: Option<T>, |
||||
max: Option<T>, |
||||
parse_error: &'static str, |
||||
} |
||||
|
||||
fn run_number<T>(cfg: &NumberCfg<T>) -> Result<Option<T>, PromptError> |
||||
where |
||||
T: Clone + Copy + PartialOrd + fmt::Display + std::str::FromStr + Send + Sync + 'static, |
||||
{ |
||||
let mut p = CustomType::<T>::new(&cfg.text).with_error_message(cfg.parse_error); |
||||
if let Some(d) = cfg.default { |
||||
p = p.with_default(d); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(ph) = &cfg.placeholder { |
||||
p = p.with_placeholder(ph); |
||||
} |
||||
if cfg.min.is_some() || cfg.max.is_some() { |
||||
let (min, max) = (cfg.min, cfg.max); |
||||
let msg = bounds_message("value", &min, &max); |
||||
p = p.with_validator(move |v: &T| { |
||||
let ok = min.is_none_or(|m| *v >= m) && max.is_none_or(|m| *v <= m); |
||||
if ok { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct DateCfg { |
||||
text: String, |
||||
default: Option<NaiveDate>, |
||||
help: Option<String>, |
||||
min: Option<NaiveDate>, |
||||
max: Option<NaiveDate>, |
||||
week_start: Option<chrono::Weekday>, |
||||
} |
||||
|
||||
fn run_date(cfg: &DateCfg) -> Result<Option<NaiveDate>, PromptError> { |
||||
let mut p = DateSelect::new(&cfg.text) |
||||
// Show the answer as ISO too, not inquire's "Month Day, Year".
|
||||
.with_formatter(&|d| d.format(DATE_FMT).to_string()); |
||||
if let Some(d) = cfg.default { |
||||
p = p.with_default(d); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(d) = cfg.min { |
||||
p = p.with_min_date(d); |
||||
} |
||||
if let Some(d) = cfg.max { |
||||
p = p.with_max_date(d); |
||||
} |
||||
if let Some(w) = cfg.week_start { |
||||
p = p.with_week_start(w); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
/// Register the prompt functions on the `tui` table. Each binding validates
|
||||
/// its arguments into the matching `*Cfg` struct above, then drives the
|
||||
/// blocking worker through [`run_prompt`].
|
||||
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> { |
||||
tui.raw_set( |
||||
"confirm", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.confirm"; |
||||
let opts = Opts::parse(F, opts, &["help", "placeholder"])?; |
||||
let cfg = ConfirmCfg { |
||||
text: text_arg(F, text)?, |
||||
default: match default { |
||||
LuaValue::Nil => None, |
||||
LuaValue::Boolean(b) => Some(b), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a boolean (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
}; |
||||
run_prompt(F, move || run_confirm(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptText", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptText"; |
||||
let opts = Opts::parse( |
||||
F, |
||||
opts, |
||||
&["help", "placeholder", "default", "minLen", "maxLen", "suggestions", "pageSize"], |
||||
)?; |
||||
let cfg = TextCfg { |
||||
text: text_arg(F, text)?, |
||||
initial: default_string(F, default)?, |
||||
default: opts.string("default")?, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
min_len: opts.count("minLen")?, |
||||
max_len: opts.count("maxLen")?, |
||||
suggestions: opts.string_array("suggestions")?, |
||||
page_size: opts.count("pageSize")?, |
||||
}; |
||||
check_bounds(F, &cfg.min_len, &cfg.max_len)?; |
||||
run_prompt(F, move || run_text(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptLongText", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptLongText"; |
||||
let opts = Opts::parse(F, opts, &["help", "extension", "editor"])?; |
||||
let cfg = LongtextCfg { |
||||
text: text_arg(F, text)?, |
||||
predefined: default_string(F, default)?, |
||||
help: opts.string("help")?, |
||||
extension: opts.string("extension")?.map(|e| normalize_extension(&e)), |
||||
editor: opts.string("editor")?, |
||||
}; |
||||
run_prompt(F, move || run_longtext(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptSelect", |
||||
lua.create_async_function( |
||||
|lua, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptSelect"; |
||||
if opts.is_none() { |
||||
return Err(ext_err!("{F}: opts table with 'options' is required")); |
||||
} |
||||
let opts = Opts::parse( |
||||
F, |
||||
opts, |
||||
&[ |
||||
"options", "multiple", "help", "pageSize", "vimMode", "filter", |
||||
"minSelected", "maxSelected", "allSelected", |
||||
], |
||||
)?; |
||||
let options = opts |
||||
.string_array("options")? |
||||
.ok_or_else(|| ext_err!("{F}: opts table with 'options' is required"))?; |
||||
if options.is_empty() { |
||||
return Err(ext_err!("{F}: options must not be empty")); |
||||
} |
||||
let multiple = opts.boolean("multiple")?.unwrap_or(false); |
||||
|
||||
let mut cfg = SelectCfg { |
||||
text: text_arg(F, text)?, |
||||
help: opts.string("help")?, |
||||
page_size: opts.count("pageSize")?, |
||||
vim_mode: opts.boolean("vimMode")?, |
||||
filter: opts.boolean("filter")?.unwrap_or(true), |
||||
starting_cursor: None, |
||||
default_indices: Vec::new(), |
||||
all_selected: opts.boolean("allSelected")?.unwrap_or(false), |
||||
min_selected: opts.count("minSelected")?, |
||||
max_selected: opts.count("maxSelected")?, |
||||
options, |
||||
}; |
||||
check_bounds(F, &cfg.min_selected, &cfg.max_selected)?; |
||||
if !multiple { |
||||
for key in ["minSelected", "maxSelected", "allSelected"] { |
||||
if !opts.raw(key)?.is_nil() { |
||||
return Err(ext_err!("{F}: option '{key}' requires multiple = true")); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// The default is matched against the options *by value*; a
|
||||
// stale default is a script bug, so it errors rather than
|
||||
// being silently ignored.
|
||||
match (multiple, default) { |
||||
(_, LuaValue::Nil) => {} |
||||
(false, LuaValue::String(s)) => { |
||||
let s = s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?; |
||||
cfg.starting_cursor = Some(find_option_index(F, &cfg.options, &s)?); |
||||
} |
||||
(false, other) => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a string (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
(true, LuaValue::String(s)) => { |
||||
let s = s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?; |
||||
cfg.default_indices = vec![find_option_index(F, &cfg.options, &s)?]; |
||||
} |
||||
(true, LuaValue::Table(t)) => { |
||||
for i in 1..=t.raw_len() { |
||||
match t.raw_get::<LuaValue>(i)? { |
||||
LuaValue::String(s) => { |
||||
let s = s.to_str().map_err(|_| { |
||||
ext_err!("{F}: default[{i}] must be valid UTF-8") |
||||
})?; |
||||
cfg.default_indices |
||||
.push(find_option_index(F, &cfg.options, &s)?); |
||||
} |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: default[{i}] must be a string (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
(true, other) => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a string or an array of strings (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
|
||||
if multiple { |
||||
let chosen = run_prompt(F, move || run_select_multiple(&cfg)).await?; |
||||
match chosen { |
||||
Some(items) => Ok(LuaValue::Table(lua.create_sequence_from(items)?)), |
||||
None => Ok(LuaValue::Nil), |
||||
} |
||||
} else { |
||||
let chosen = run_prompt(F, move || run_select_single(&cfg)).await?; |
||||
match chosen { |
||||
Some(s) => Ok(LuaValue::String(lua.create_string(&s)?)), |
||||
None => Ok(LuaValue::Nil), |
||||
} |
||||
} |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptSecret", |
||||
lua.create_async_function( |
||||
|_, (text, opts): (LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptSecret"; |
||||
let opts = Opts::parse(F, opts, &["help", "confirm", "display", "toggle", "minLen"])?; |
||||
let cfg = PasswordCfg { |
||||
text: text_arg(F, text)?, |
||||
help: opts.string("help")?, |
||||
confirm: opts.boolean("confirm")?.unwrap_or(false), |
||||
display: match opts.string("display")?.as_deref() { |
||||
None | Some("masked") => PasswordDisplayMode::Masked, |
||||
Some("hidden") => PasswordDisplayMode::Hidden, |
||||
Some("full") => PasswordDisplayMode::Full, |
||||
Some(other) => { |
||||
return Err(ext_err!( |
||||
"{F}: option 'display' must be one of 'masked', 'hidden', 'full' (got '{other}')" |
||||
)) |
||||
} |
||||
}, |
||||
toggle: opts.boolean("toggle")?.unwrap_or(false), |
||||
min_len: opts.count("minLen")?, |
||||
}; |
||||
run_prompt(F, move || run_password(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptNumber", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptNumber"; |
||||
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?; |
||||
let cfg = NumberCfg::<f64> { |
||||
text: text_arg(F, text)?, |
||||
default: match &default { |
||||
LuaValue::Nil => None, |
||||
LuaValue::Integer(i) => Some(*i as f64), |
||||
LuaValue::Number(n) => Some(*n), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a number (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
min: opts.number("min")?, |
||||
max: opts.number("max")?, |
||||
parse_error: "please enter a valid number", |
||||
}; |
||||
check_bounds(F, &cfg.min, &cfg.max)?; |
||||
run_prompt(F, move || run_number(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptInt", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptInt"; |
||||
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?; |
||||
let cfg = NumberCfg::<i64> { |
||||
text: text_arg(F, text)?, |
||||
default: match int_of(&default) { |
||||
_ if default.is_nil() => None, |
||||
Some(i) => Some(i), |
||||
None => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be an integer (got {})", |
||||
lua_value_brief(&default) |
||||
)) |
||||
} |
||||
}, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
min: opts.integer("min")?, |
||||
max: opts.integer("max")?, |
||||
parse_error: "please enter a whole number", |
||||
}; |
||||
check_bounds(F, &cfg.min, &cfg.max)?; |
||||
run_prompt(F, move || run_number(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptDate", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptDate"; |
||||
let opts = Opts::parse(F, opts, &["help", "min", "max", "weekStart"])?; |
||||
let cfg = DateCfg { |
||||
text: text_arg(F, text)?, |
||||
default: match default_string(F, default)? { |
||||
None => None, |
||||
Some(s) => Some(parse_date(F, "default", &s)?), |
||||
}, |
||||
help: opts.string("help")?, |
||||
min: opts.date("min")?, |
||||
max: opts.date("max")?, |
||||
week_start: match opts.string("weekStart")? { |
||||
None => None, |
||||
Some(s) => Some(s.parse::<chrono::Weekday>().map_err(|_| { |
||||
ext_err!("{F}: option 'weekStart' must be a weekday name like 'monday' (got '{s}')") |
||||
})?), |
||||
}, |
||||
}; |
||||
check_bounds(F, &cfg.min, &cfg.max)?; |
||||
let picked = run_prompt(F, move || run_date(&cfg)).await?; |
||||
Ok(picked.map(|d| d.format(DATE_FMT).to_string())) |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
#[test] |
||||
fn find_option_index_matches_by_value() { |
||||
let opts = vec!["red".to_string(), "green".to_string()]; |
||||
assert_eq!(find_option_index("tui.promptSelect", &opts, "green").unwrap(), 1); |
||||
let err = find_option_index("tui.promptSelect", &opts, "blue").unwrap_err(); |
||||
assert!( |
||||
err.to_string() |
||||
.contains("tui.promptSelect: default 'blue' is not one of the options"), |
||||
"got: {err}" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn normalize_extension_adds_dot() { |
||||
assert_eq!(normalize_extension("md"), ".md"); |
||||
assert_eq!(normalize_extension(".md"), ".md"); |
||||
} |
||||
|
||||
#[test] |
||||
fn static_suggester_filters_case_insensitively() { |
||||
let mut s = StaticSuggester(vec!["Apple".into(), "banana".into(), "Cherry".into()]); |
||||
assert_eq!(s.get_suggestions("an").unwrap(), vec!["banana".to_string()]); |
||||
assert_eq!( |
||||
s.get_suggestions("A").unwrap(), |
||||
vec!["Apple".to_string(), "banana".to_string()] |
||||
); |
||||
assert_eq!(s.get_suggestions("").unwrap().len(), 3); |
||||
assert_eq!(s.get_suggestions("xyz").unwrap(), Vec::<String>::new()); |
||||
} |
||||
} |
||||
@ -0,0 +1,2 @@ |
||||
* |
||||
!.gitignore |
||||
Loading…
Reference in new issue