You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
742 lines
27 KiB
742 lines
27 KiB
//! 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. Shared with `term`'s cursor-position
|
|
/// query and the fs module's permission prompt, which also read from the
|
|
/// terminal (re-exported as `stdlib::tui::PROMPT_LOCK`).
|
|
pub(crate) 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());
|
|
}
|
|
}
|
|
|