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