use chrono::{Datelike, Days, Local, Months, NaiveDate};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use indicatif::{ProgressBar, ProgressStyle};
use insmaller_core::{check_field_assert, Field, FieldType, Reporter, WizardSession};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph},
Terminal,
};
use crate::theme::{gradient, Palette};
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{self, IsTerminal, Stdout};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = io::stdout().execute(LeaveAlternateScreen);
}
}
enum Widget {
Multi {
choices: Vec<insmaller_core::Choice>,
on: Vec<bool>,
groups: Vec<String>,
collapsed: Vec<bool>,
cur: usize,
},
Single {
choices: Vec<insmaller_core::Choice>,
sel: Option<usize>,
groups: Vec<String>,
collapsed: Vec<bool>,
cur: usize,
},
Toggle { on: bool },
Input { buf: String, secret: bool },
Path { buf: String, picker: Option<Picker> },
Dropdown {
choices: Vec<String>,
sel: usize,
open: bool,
filter: String,
cur: usize,
},
Textarea {
buf: String,
cursor_row: usize,
cursor_col: usize,
scroll: usize,
active: bool,
},
Date { digits: [u8; 8], dcur: usize, cal: Option<CalPicker> },
Datetime { digits: [u8; 14], dcur: usize, cal: Option<CalPicker> },
}
struct CalPicker {
date: NaiveDate,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Row {
Header(usize),
Item(usize),
}
fn group_list(choices: &[insmaller_core::Choice]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for c in choices {
if let Some(g) = &c.group {
if !out.iter().any(|x| x == g) {
out.push(g.clone());
}
}
}
out
}
fn item_label(c: &insmaller_core::Choice) -> &str {
if let Some(g) = &c.group {
if let Some(rest) = c.label.strip_prefix(&format!("[{g}] ")) {
return rest;
}
}
&c.label
}
fn group_mark_multi(choices: &[insmaller_core::Choice], on: &[bool], group: &str) -> &'static str {
let idxs: Vec<usize> = (0..choices.len())
.filter(|&i| choices[i].group.as_deref() == Some(group))
.collect();
let sel = idxs.iter().filter(|&&i| on[i]).count();
if sel == 0 {
"[ ]"
} else if sel == idxs.len() {
"[x]"
} else {
"[~]"
}
}
fn visible_rows(
choices: &[insmaller_core::Choice],
groups: &[String],
collapsed: &[bool],
) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
for (i, c) in choices.iter().enumerate() {
if c.group.is_none() {
rows.push(Row::Item(i));
}
}
for (gi, g) in groups.iter().enumerate() {
rows.push(Row::Header(gi));
if !collapsed.get(gi).copied().unwrap_or(false) {
for (i, c) in choices.iter().enumerate() {
if c.group.as_deref() == Some(g.as_str()) {
rows.push(Row::Item(i));
}
}
}
}
rows
}
fn tree_rows_of(w: &Widget) -> Option<Vec<Row>> {
match w {
Widget::Multi { choices, groups, collapsed, .. }
| Widget::Single { choices, groups, collapsed, .. } => {
Some(visible_rows(choices, groups, collapsed))
}
_ => None,
}
}
fn cur_of(w: &Widget) -> usize {
match w {
Widget::Multi { cur, .. } | Widget::Single { cur, .. } => *cur,
_ => 0,
}
}
fn current_row(w: &Widget) -> Option<Row> {
tree_rows_of(w).and_then(|rows| rows.get(cur_of(w)).copied())
}
fn widget_has_groups(w: &Widget) -> bool {
matches!(
w,
Widget::Multi { groups, .. } | Widget::Single { groups, .. } if !groups.is_empty()
)
}
fn clamp_cur(w: &mut Widget) {
let max = match tree_rows_of(w) {
Some(rows) => rows.len().saturating_sub(1),
None => return,
};
if let Widget::Multi { cur, .. } | Widget::Single { cur, .. } = w {
*cur = (*cur).min(max);
}
}
fn cursor_to_header_of(w: &mut Widget, item: usize) {
let rows = match tree_rows_of(w) {
Some(r) => r,
None => return,
};
let gi = match &*w {
Widget::Multi { choices, groups, .. } | Widget::Single { choices, groups, .. } => choices
.get(item)
.and_then(|c| c.group.as_ref())
.and_then(|g| groups.iter().position(|x| x == g)),
_ => None,
};
let Some(gi) = gi else { return };
let Some(pos) = rows.iter().position(|r| *r == Row::Header(gi)) else {
return;
};
if let Widget::Multi { cur, .. } | Widget::Single { cur, .. } = w {
*cur = pos;
}
}
struct Entry {
name: String,
is_dir: bool,
}
struct Picker {
cwd: PathBuf,
entries: Vec<Entry>,
readable: bool,
cursor: usize,
}
#[cfg(windows)]
fn windows_drives() -> Vec<Entry> {
#[link(name = "kernel32")]
extern "system" {
fn GetLogicalDrives() -> u32;
}
let mask = unsafe { GetLogicalDrives() };
('A'..='Z')
.enumerate()
.filter(|(i, _)| mask & (1 << i) != 0)
.map(|(_, d)| Entry { name: format!("{d}:"), is_dir: true })
.collect()
}
fn list_dir(p: &Path) -> (Vec<Entry>, bool) {
#[cfg(windows)]
if p.as_os_str().is_empty() {
return (windows_drives(), true);
}
let mut entries: Vec<Entry> = Vec::new();
entries.push(Entry { name: ".".into(), is_dir: true });
let has_parent = p.parent().is_some();
if has_parent || cfg!(windows) {
entries.push(Entry { name: "..".into(), is_dir: true });
}
match std::fs::read_dir(p) {
Ok(rd) => {
let mut items: Vec<Entry> = rd
.flatten()
.map(|d| Entry {
name: d.file_name().to_string_lossy().into_owned(),
is_dir: d.file_type().map(|t| t.is_dir()).unwrap_or(false),
})
.collect();
items.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
entries.extend(items);
(entries, true)
}
Err(_) => (entries, false),
}
}
impl Picker {
fn open(buf: &str) -> Picker {
let mut p = Picker {
cwd: PathBuf::new(),
entries: Vec::new(),
readable: true,
cursor: 0,
};
p.set_dir(Self::seed_dir(buf));
p
}
fn set_dir(&mut self, dir: PathBuf) {
let (entries, readable) = list_dir(&dir);
self.cwd = dir;
self.entries = entries;
self.readable = readable;
self.cursor = 0;
}
fn seed_dir(buf: &str) -> PathBuf {
let home = || dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
if buf.is_empty() {
return home();
}
let p = PathBuf::from(buf);
if p.is_dir() {
return p;
}
match p.parent() {
Some(parent) if parent.is_dir() => parent.to_path_buf(),
_ => home(),
}
}
fn up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
fn down(&mut self) {
if self.cursor + 1 < self.entries.len() {
self.cursor += 1;
}
}
fn at_drive_selector(&self) -> bool {
self.cwd.as_os_str().is_empty()
}
fn ascend(&mut self) {
if let Some(parent) = self.cwd.parent().map(Path::to_path_buf) {
self.set_dir(parent);
} else {
self.goto_drives();
}
}
fn goto_drives(&mut self) {
if cfg!(windows) && !self.at_drive_selector() {
self.set_dir(PathBuf::new());
}
}
fn activate(&mut self) -> Option<String> {
let entry = self.entries.get(self.cursor)?;
if entry.name == "." {
return self.select_cwd();
}
if entry.name == ".." {
self.ascend();
return None;
}
let target = if self.at_drive_selector() {
PathBuf::from(format!("{}\\", entry.name))
} else {
self.cwd.join(&entry.name)
};
if entry.is_dir {
self.set_dir(target);
None
} else {
Some(target.to_string_lossy().into_owned())
}
}
fn select_cwd(&self) -> Option<String> {
if self.at_drive_selector() {
None
} else {
Some(self.cwd.to_string_lossy().into_owned())
}
}
}
#[derive(Default, Clone)]
pub struct GroupDefaults {
pub collapsed_default: bool,
pub collapsed: Vec<String>,
pub expanded: Vec<String>,
}
impl GroupDefaults {
fn is_collapsed(&self, group: &str) -> bool {
if self.expanded.iter().any(|g| g == group) {
false
} else if self.collapsed.iter().any(|g| g == group) {
true
} else {
self.collapsed_default
}
}
fn for_groups(&self, field_id: &str, groups: &[String], cache: &HashMap<String, bool>) -> Vec<bool> {
groups
.iter()
.map(|g| {
cache
.get(&collapse_key(field_id, g))
.copied()
.unwrap_or_else(|| self.is_collapsed(g))
})
.collect()
}
}
fn collapse_key(field_id: &str, group: &str) -> String {
format!("{field_id}\u{0}{group}")
}
fn date_sep(str_idx: usize) -> Option<char> {
match str_idx { 4 | 7 => Some('-'), _ => None }
}
fn datetime_sep(str_idx: usize) -> Option<char> {
match str_idx {
4 | 7 => Some('-'),
10 => Some('T'),
13 | 16 => Some(':'),
_ => None,
}
}
fn render_date_mask(digits: &[u8; 8]) -> String {
let mut s = String::with_capacity(10);
let mut di = 0usize;
for si in 0..10usize {
if let Some(sep) = date_sep(si) {
s.push(sep);
} else {
s.push(if digits[di] == b'_' { '_' } else { digits[di] as char });
di += 1;
}
}
s
}
fn render_datetime_mask(digits: &[u8; 14]) -> String {
let mut s = String::with_capacity(19);
let mut di = 0usize;
for si in 0..19usize {
if let Some(sep) = datetime_sep(si) {
s.push(sep);
} else {
s.push(if digits[di] == b'_' { '_' } else { digits[di] as char });
di += 1;
}
}
s
}
fn parse_date_digits(s: &str) -> [u8; 8] {
let mut d = [b'_'; 8];
let s = s.trim();
if s.len() >= 10 {
let bytes = s.as_bytes();
let slots = [0usize, 1, 2, 3, 5, 6, 8, 9];
for (i, &si) in slots.iter().enumerate() {
let b = bytes[si];
if b.is_ascii_digit() { d[i] = b; }
}
}
d
}
fn parse_datetime_digits(s: &str) -> [u8; 14] {
let mut d = [b'_'; 14];
let s = s.trim();
if s.len() >= 19 {
let bytes = s.as_bytes();
let slots = [0usize, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18];
for (i, &si) in slots.iter().enumerate() {
let b = bytes[si];
if b.is_ascii_digit() { d[i] = b; }
}
}
d
}
fn digits_to_date_str(digits: &[u8; 8]) -> Option<String> {
if digits.contains(&b'_') {
return None;
}
Some(render_date_mask(digits))
}
fn digits_to_datetime_str(digits: &[u8; 14]) -> Option<String> {
if digits.contains(&b'_') {
return None;
}
Some(render_datetime_mask(digits))
}
fn date_from_date_digits(digits: &[u8; 8]) -> Option<NaiveDate> {
let s = digits_to_date_str(digits)?;
NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()
}
fn date_to_date_digits(date: NaiveDate) -> [u8; 8] {
let s = date.format("%Y-%m-%d").to_string();
parse_date_digits(&s)
}
fn date_to_datetime_digits(date: NaiveDate, existing: &[u8; 14]) -> [u8; 14] {
let date_str = date.format("%Y-%m-%d").to_string();
let mut new = parse_datetime_digits(&format!("{}T00:00:00", date_str));
for i in 8..14 {
if existing[i] != b'_' {
new[i] = existing[i];
}
}
new
}
fn date_type_digit(digits: &mut [u8; 8], dcur: usize, ch: u8) -> usize {
if dcur >= 8 { return dcur; }
digits[dcur] = ch;
(dcur + 1).min(8)
}
fn date_backspace(digits: &mut [u8; 8], dcur: usize) -> usize {
if dcur == 0 { return 0; }
let prev = dcur - 1;
digits[prev] = b'_';
prev
}
fn datetime_type_digit(digits: &mut [u8; 14], dcur: usize, ch: u8) -> usize {
if dcur >= 14 { return dcur; }
digits[dcur] = ch;
(dcur + 1).min(14)
}
fn datetime_backspace(digits: &mut [u8; 14], dcur: usize) -> usize {
if dcur == 0 { return 0; }
let prev = dcur - 1;
digits[prev] = b'_';
prev
}
fn first_empty_slot_8(digits: &[u8; 8]) -> usize {
digits.iter().position(|&b| b == b'_').unwrap_or(8)
}
fn first_empty_slot_14(digits: &[u8; 14]) -> usize {
digits.iter().position(|&b| b == b'_').unwrap_or(14)
}
fn date_widget_value(digits: &[u8; 8]) -> String {
digits_to_date_str(digits).unwrap_or_default()
}
fn datetime_widget_value(digits: &[u8; 14]) -> String {
digits_to_datetime_str(digits).unwrap_or_default()
}
fn days_in_month(year: i32, month: u32) -> u32 {
let (y, m) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
NaiveDate::from_ymd_opt(y, m, 1)
.and_then(|d| d.pred_opt())
.map(|d| d.day())
.unwrap_or(30)
}
fn render_calendar(year: i32, month: u32, sel: NaiveDate) -> Vec<String> {
let header = ["Su ", "Mo ", "Tu ", "We ", "Th ", "Fr ", "Sa "]
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(" ");
let mut lines: Vec<String> = vec![header];
let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(sel);
let start_wd = first.weekday().num_days_from_sunday() as usize;
let dim = days_in_month(year, month);
let mut cells: Vec<String> = Vec::with_capacity(start_wd + dim as usize);
for _ in 0..start_wd {
cells.push(" ".to_string());
}
for day in 1..=dim {
let d = NaiveDate::from_ymd_opt(year, month, day).unwrap_or(sel);
let marker = if d == sel { '>' } else { ' ' };
cells.push(format!("{marker}{day:02}"));
}
for week in cells.chunks(7) {
let row = week.join(" ");
lines.push(row);
}
lines
}
fn textarea_insert(buf: &mut String, cursor_row: &mut usize, cursor_col: &mut usize, ch: char) {
let byte_pos = textarea_byte_pos(buf, *cursor_row, *cursor_col);
buf.insert(byte_pos, ch);
if ch == '\n' {
*cursor_row += 1;
*cursor_col = 0;
} else {
*cursor_col += 1;
}
}
fn textarea_backspace(buf: &mut String, cursor_row: &mut usize, cursor_col: &mut usize) {
if *cursor_row == 0 && *cursor_col == 0 {
return;
}
let byte_pos = textarea_byte_pos(buf, *cursor_row, *cursor_col);
if byte_pos == 0 {
return;
}
let prev = buf[..byte_pos]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
let removed_ch = buf.chars().nth(buf[..prev].chars().count()).unwrap_or(' ');
if removed_ch == '\n' && *cursor_row > 0 {
let prev_line_len = buf.split('\n')
.nth(*cursor_row - 1)
.unwrap_or("")
.chars()
.count();
buf.remove(prev);
*cursor_row -= 1;
*cursor_col = prev_line_len;
} else {
buf.remove(prev);
if *cursor_col > 0 {
*cursor_col -= 1;
}
}
}
const TEXTAREA_VISIBLE_ROWS: usize = 4;
fn textarea_fix_scroll(scroll: &mut usize, cursor_row: usize) {
if cursor_row < *scroll {
*scroll = cursor_row;
} else if cursor_row >= *scroll + TEXTAREA_VISIBLE_ROWS {
*scroll = cursor_row + 1 - TEXTAREA_VISIBLE_ROWS;
}
}
fn textarea_byte_pos(buf: &str, row: usize, col: usize) -> usize {
let mut offset = 0usize;
for (li, line) in buf.split('\n').enumerate() {
if li == row {
let char_count = line.chars().count().min(col);
offset += line.char_indices().nth(char_count).map(|(i, _)| i).unwrap_or(line.len());
return offset;
}
offset += line.len() + 1; }
buf.len()
}
fn textarea_line_char_len(buf: &str, row: usize) -> usize {
buf.split('\n').nth(row).map(|l| l.chars().count()).unwrap_or(0)
}
fn textarea_line_count(buf: &str) -> usize {
buf.split('\n').count()
}
fn check_partial_dates(fields: &[Field], widgets: &[Widget]) -> Option<(usize, String)> {
for (idx, (field, widget)) in fields.iter().zip(widgets.iter()).enumerate() {
let label = field.prompt.as_deref().unwrap_or(&field.id);
match widget {
Widget::Date { digits, .. } => {
let filled = digits.iter().filter(|&&b| b != b'_').count();
if filled > 0 && filled < 8 {
return Some((idx, format!("{label}: incomplete date (YYYY-MM-DD)")));
}
}
Widget::Datetime { digits, .. } => {
let filled = digits.iter().filter(|&&b| b != b'_').count();
if filled > 0 && filled < 14 {
return Some((idx, format!("{label}: incomplete datetime (YYYY-MM-DDTHH:MM:SS)")));
}
}
_ => {}
}
}
None
}
fn validate_path_value(
label: &str,
value: &str,
exists_fn: impl Fn(&std::path::Path) -> bool,
is_dir_fn: impl Fn(&std::path::Path) -> bool,
) -> Result<(), String> {
let p = std::path::Path::new(value);
if exists_fn(p) {
return Ok(());
}
let parent = p.parent();
match parent {
None => Ok(()),
Some(par) if par.as_os_str().is_empty() => Ok(()),
Some(par) => {
if exists_fn(par) && is_dir_fn(par) {
Ok(())
} else {
Err(format!(
"{label}: directory not found — check the path for typos (parent '{}' does not exist)",
par.display()
))
}
}
}
}
fn run_path_validation(fields: &[Field], answers: &Map<String, Value>) -> Option<(usize, String)> {
for (idx, field) in fields.iter().enumerate() {
if field.field_type != FieldType::Path {
continue;
}
let value = match answers.get(&field.id) {
Some(Value::String(s)) if !s.is_empty() => s.as_str(),
_ => continue,
};
let label = field.prompt.as_deref().unwrap_or(&field.id);
if let Err(msg) = validate_path_value(
label,
value,
|p| p.exists(),
|p| p.is_dir(),
) {
return Some((idx, msg));
}
}
None
}
fn run_assert_validation(
fields: &[Field],
candidate_vars: &Map<String, Value>,
) -> Option<(usize, String)> {
for (idx, field) in fields.iter().enumerate() {
if field.assert.is_none() {
continue;
}
if let Err(e) = check_field_assert(field, candidate_vars) {
return Some((idx, format!("{e}")));
}
}
None
}
fn run_api_validation(
fields: &[Field],
answers: &Map<String, Value>,
term: &mut Terminal<CrosstermBackend<Stdout>>,
pal: &Palette,
frame: &mut u64,
) -> Option<(usize, String)> {
for (field_idx, field) in fields.iter().enumerate() {
let api = match &field.validate.api {
Some(a) => a.clone(),
None => continue,
};
let value = match answers.get(&field.id) {
Some(Value::String(s)) if !s.is_empty() => s.clone(),
_ => continue,
};
let field_label = field.prompt.as_deref().unwrap_or(&field.id).to_string();
let (tx, rx) = mpsc::channel::<insmaller_core::Result<()>>();
let api_clone = api.clone();
let value_clone = value.clone();
let label_clone = field_label.clone();
std::thread::spawn(move || {
let result = api_clone.call(&label_clone, &value_clone);
let _ = tx.send(result);
});
let spinner_chars = ['|', '/', '-', '\\'];
let mut spin_idx = 0usize;
loop {
let spin = spinner_chars[spin_idx % spinner_chars.len()];
spin_idx += 1;
let msg = format!("validating… {spin}");
let _ = term.draw(|fr| {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(3),
Constraint::Length(3),
])
.split(fr.area());
let foot = Line::from(vec![
Span::styled(msg.clone(), Style::default().fg(pal.muted)),
]);
fr.render_widget(
Paragraph::new(foot).block(panel("", false, pal)),
rows[2],
);
});
*frame = frame.wrapping_add(1);
match rx.recv_timeout(Duration::from_millis(80)) {
Ok(Ok(())) => break,
Ok(Err(e)) => return Some((field_idx, format!("{e}"))),
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Some((
field_idx,
format!("api validation: thread disconnected for '{field_label}'"),
));
}
}
}
}
None
}
fn init_widget(
f: &Field,
s: &WizardSession,
gd: &GroupDefaults,
collapse: &HashMap<String, bool>,
) -> Widget {
let prior = s.answer_for(&f.id).cloned();
match f.field_type {
FieldType::Multiselect => {
let choices = s.choices(f);
let on = choices
.iter()
.map(|c| match &prior {
Some(Value::Array(a)) => a.iter().any(|v| v.as_str() == Some(&c.value)),
_ => c.default,
})
.collect();
let groups = group_list(&choices);
let collapsed = gd.for_groups(&f.id, &groups, collapse);
Widget::Multi { choices, on, groups, collapsed, cur: 0 }
}
FieldType::SingleSelect => {
let choices = s.choices(f);
let sel = match &prior {
Some(Value::String(v)) => choices.iter().position(|c| &c.value == v),
_ => None,
};
let groups = group_list(&choices);
let collapsed = gd.for_groups(&f.id, &groups, collapse);
Widget::Single { choices, sel, groups, collapsed, cur: 0 }
}
FieldType::Toggle => Widget::Toggle {
on: matches!(prior, Some(Value::Bool(true))),
},
FieldType::Path => Widget::Path {
buf: match prior {
Some(Value::String(s)) => s,
_ => f.default.clone().unwrap_or_default(),
},
picker: None,
},
FieldType::Dropdown => {
let choices: Vec<String> = f.options.to_vec();
let default_val = match prior {
Some(Value::String(ref s)) => s.clone(),
_ => f.default.clone().unwrap_or_default(),
};
let sel = choices.iter().position(|c| c == &default_val).unwrap_or(0);
Widget::Dropdown { choices, sel, open: false, filter: String::new(), cur: 0 }
}
FieldType::Textarea => Widget::Textarea {
buf: match prior {
Some(Value::String(s)) => s,
_ => f.default.clone().unwrap_or_default(),
},
cursor_row: 0,
cursor_col: 0,
scroll: 0,
active: false,
},
FieldType::Date => {
let s = match prior {
Some(Value::String(ref v)) => v.clone(),
_ => f.default.clone().unwrap_or_default(),
};
let digits = parse_date_digits(&s);
let dcur = first_empty_slot_8(&digits);
Widget::Date { digits, dcur, cal: None }
}
FieldType::Datetime => {
let s = match prior {
Some(Value::String(ref v)) => v.clone(),
_ => f.default.clone().unwrap_or_default(),
};
let digits = parse_datetime_digits(&s);
let dcur = first_empty_slot_14(&digits);
Widget::Datetime { digits, dcur, cal: None }
}
_ => Widget::Input {
buf: match prior {
Some(Value::String(s)) => s,
_ => f.default.clone().unwrap_or_default(),
},
secret: f.field_type == FieldType::Secret,
},
}
}
fn widget_value(w: &Widget) -> Value {
match w {
Widget::Multi { choices, on, .. } => Value::Array(
choices
.iter()
.zip(on)
.filter(|(_, &o)| o)
.map(|(c, _)| Value::String(c.value.clone()))
.collect(),
),
Widget::Single { choices, sel, .. } => Value::String(
sel.and_then(|i| choices.get(i)).map(|c| c.value.clone()).unwrap_or_default(),
),
Widget::Toggle { on } => Value::Bool(*on),
Widget::Input { buf, .. } => Value::String(buf.clone()),
Widget::Path { buf, .. } => Value::String(buf.trim().to_string()),
Widget::Dropdown { choices, sel, .. } => Value::String(
choices.get(*sel).cloned().unwrap_or_default(),
),
Widget::Textarea { buf, .. } => Value::String(buf.clone()),
Widget::Date { digits, .. } => Value::String(date_widget_value(digits)),
Widget::Datetime { digits, .. } => Value::String(datetime_widget_value(digits)),
}
}
fn vert_nav(cur: usize, len: usize, down: bool, focus: usize, n: usize) -> (usize, usize) {
if down {
if len > 0 && cur + 1 < len {
(cur + 1, focus)
} else {
(cur, (focus + 1).min(n + 1))
}
} else if len > 0 && cur > 0 {
(cur - 1, focus)
} else {
(cur, focus.saturating_sub(1))
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(v[1])[1]
}
fn panel<'a>(title: impl Into<Line<'a>>, focused: bool, pal: &Palette) -> Block<'a> {
let mut b = Block::default().borders(Borders::ALL).title(title);
if pal.colored() {
let bc = if focused { pal.border_focus } else { pal.border };
b = b
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(bc));
}
b
}
pub fn run_wizard_tui(
session: &mut WizardSession,
pal: Palette,
gd: &GroupDefaults,
no_api_validate: bool,
) -> anyhow::Result<bool> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let _g = TermGuard;
let mut term: Terminal<CrosstermBackend<Stdout>> =
Terminal::new(CrosstermBackend::new(io::stdout()))?;
let mut collapse: HashMap<String, bool> = HashMap::new();
let animate = pal.colored() && io::stdout().is_terminal();
let mut frame: u64 = 0;
let mut grad_cache: (usize, Vec<ratatui::style::Color>) = (0, Vec::new());
while !session.is_done() {
let fields: Vec<Field> = session.fields();
let mut widgets: Vec<Widget> =
fields.iter().map(|f| init_widget(f, session, gd, &collapse)).collect();
let n = fields.len();
let mut focus = 0usize;
let mut err: Option<String> = None;
let (title, desc) = session
.current()
.map(|p| (p.title.clone(), p.description.clone()))
.unwrap_or_default();
let (step, total) = session.progress();
loop {
term.draw(|fr| {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(3),
Constraint::Length(3),
])
.split(fr.area());
let ratio = (step as f64 / total as f64).clamp(0.0, 1.0);
let htitle = format!(" insmaller setup — {title} (step {step}/{total}) ");
if pal.colored() {
let block = panel(htitle, false, &pal);
let inner = block.inner(rows[0]);
fr.render_widget(block, rows[0]);
let w = inner.width.max(1) as usize;
let filled = (ratio * w as f64).round() as usize;
if grad_cache.0 != w {
grad_cache = (w, gradient(pal.accent, pal.accent2, w));
}
let cols = &grad_cache.1;
let phase = (frame as usize) % w;
let bar: Vec<Span> = (0..w)
.map(|i| {
let col = cols[(i + phase) % w];
if i < filled {
Span::styled("▰", Style::default().fg(col))
} else {
Span::styled("▱", Style::default().fg(pal.border))
}
})
.collect();
let lines = vec![
Line::from(bar),
Line::from(Span::styled(desc.clone(), Style::default().fg(pal.muted))),
];
fr.render_widget(Paragraph::new(lines), inner);
} else {
let g = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(htitle))
.gauge_style(Style::default().fg(pal.accent))
.ratio(ratio)
.label(desc.clone());
fr.render_widget(g, rows[0]);
}
let mut items: Vec<ListItem> = Vec::new();
for (i, f) in fields.iter().enumerate() {
let focused = focus == i;
let head = format!(
"{} {}",
if focused { "▶" } else { " " },
f.prompt.as_deref().unwrap_or(&f.id)
);
items.push(ListItem::new(Span::styled(
head,
Style::default().add_modifier(Modifier::BOLD),
)));
match &widgets[i] {
Widget::Multi { choices, on, groups, collapsed, cur } => {
for (pos, row) in
visible_rows(choices, groups, collapsed).iter().enumerate()
{
let p = if focused && *cur == pos { ">" } else { " " };
match row {
Row::Header(gi) => {
let g = &groups[*gi];
let tri = if collapsed[*gi] { "▶" } else { "▼" };
let mark = group_mark_multi(choices, on, g);
items.push(ListItem::new(format!(
" {p}{tri} {mark} {g}"
)));
}
Row::Item(i) => {
let mark = if on[*i] { "[x]" } else { "[ ]" };
let indent =
if choices[*i].group.is_some() { " " } else { " " };
items.push(ListItem::new(format!(
"{indent}{p}{mark} {}",
item_label(&choices[*i])
)));
}
}
}
}
Widget::Single { choices, sel, groups, collapsed, cur } => {
for (pos, row) in
visible_rows(choices, groups, collapsed).iter().enumerate()
{
let p = if focused && *cur == pos { ">" } else { " " };
match row {
Row::Header(gi) => {
let g = &groups[*gi];
let tri = if collapsed[*gi] { "▶" } else { "▼" };
items.push(ListItem::new(format!(" {p}{tri} {g}")));
}
Row::Item(i) => {
let mark = if *sel == Some(*i) { "(o)" } else { "( )" };
let indent =
if choices[*i].group.is_some() { " " } else { " " };
items.push(ListItem::new(format!(
"{indent}{p}{mark} {}",
item_label(&choices[*i])
)));
}
}
}
}
Widget::Toggle { on } => items.push(ListItem::new(format!(
" [{}] (space toggles)",
if *on { "x" } else { " " }
))),
Widget::Input { buf, secret } => {
let shown = if *secret {
"*".repeat(buf.chars().count())
} else {
buf.clone()
};
items.push(ListItem::new(format!(
" {}{}",
shown,
if focused { "_" } else { "" }
)));
}
Widget::Path { buf, .. } => {
items.push(ListItem::new(format!(
" {}{}",
buf,
if focused { "_ [Ctrl+B browse]" } else { "" }
)));
}
Widget::Dropdown { choices, sel, open, .. } => {
let selected = choices.get(*sel).cloned().unwrap_or_default();
if *open {
items.push(ListItem::new(format!(" {selected} ▲ [type to filter · ↑↓ · Enter select · Esc cancel]")));
} else {
items.push(ListItem::new(format!(
" {selected} ▼{}",
if focused { " [Enter/Space to open]" } else { "" }
)));
}
}
Widget::Textarea { buf, cursor_row, cursor_col, scroll, active } => {
let lines: Vec<&str> = buf.split('\n').collect();
let total = lines.len();
let start = *scroll;
let end = (start + TEXTAREA_VISIBLE_ROWS).min(total);
for (li, line) in lines[start..end].iter().enumerate() {
let abs_row = li + start;
let rendered = if focused && *active && abs_row == *cursor_row {
let col = (*cursor_col).min(line.chars().count());
let before: String = line.chars().take(col).collect();
let after: String = line.chars().skip(col).collect();
format!(" {before}\u{258c}{after}")
} else {
format!(" {line}")
};
items.push(ListItem::new(rendered));
}
if focused {
if *active {
items.push(ListItem::new(format!(
" [editing \u{2014} Esc: stop · Tab: next · Enter: newline · \u{2191}\u{2193}\u{2190}\u{2192}: navigate (line {}/{})]",
cursor_row + 1,
total
)));
} else {
items.push(ListItem::new(
" [Enter to edit]".to_string()
));
}
}
}
Widget::Date { digits, .. } => {
let mask = render_date_mask(digits);
items.push(ListItem::new(format!(
" {}{}",
mask,
if focused { " [digits only · Space: calendar]" } else { "" }
)));
}
Widget::Datetime { digits, .. } => {
let mask = render_datetime_mask(digits);
items.push(ListItem::new(format!(
" {}{}",
mask,
if focused { " [digits only · Space: calendar]" } else { "" }
)));
}
}
}
let body = List::new(items).block(panel(" fields ", focus < n, &pal));
fr.render_widget(body, rows[1]);
if let Some(Widget::Dropdown { choices, sel: _, open: true, filter, cur }) =
widgets.get(focus)
{
let area = centered_rect(60, 60, fr.area());
let filtered: Vec<&String> = choices
.iter()
.filter(|c| {
filter.is_empty()
|| c.to_lowercase().contains(&filter.to_lowercase())
})
.collect();
let search_line = if filter.is_empty() {
" Search: \u{258c} (type to filter)".to_string()
} else {
format!(" Search: {filter}\u{258c}")
};
let header_item = ListItem::new(Span::styled(
search_line,
Style::default().add_modifier(Modifier::BOLD),
));
let mut rows_d: Vec<ListItem> = vec![header_item];
if filtered.is_empty() {
rows_d.push(ListItem::new(Span::styled(
" [no matches]",
Style::default().add_modifier(Modifier::DIM),
)));
} else {
rows_d.extend(filtered.iter().map(|c| ListItem::new((*c).clone())));
}
let title = " \u{2191}\u{2193} move \u{b7} Enter select \u{b7} Esc cancel ";
let list = List::new(rows_d)
.block(panel(title, true, &pal))
.highlight_style(
Style::default()
.fg(pal.accent_fg)
.bg(pal.accent)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut st = ListState::default();
let clamped_cur = (*cur).min(filtered.len().saturating_sub(1));
st.select(if filtered.is_empty() { None } else { Some(clamped_cur + 1) });
if pal.colored() {
let fa = fr.area();
let sx = area.x + 1;
let sy = area.y + 1;
let shadow = Rect {
x: sx,
y: sy,
width: area.width.min(fa.width.saturating_sub(sx)),
height: area.height.min(fa.height.saturating_sub(sy)),
};
fr.render_widget(
Block::default().style(Style::default().bg(pal.shadow)),
shadow,
);
}
fr.render_widget(Clear, area);
fr.render_stateful_widget(list, area, &mut st);
}
if let Some(Widget::Path { picker: Some(p), .. }) = widgets.get(focus) {
let area = centered_rect(70, 70, fr.area());
let rows_p: Vec<ListItem> = p
.entries
.iter()
.map(|e| {
let name = match e.name.as_str() {
"." => ". (select this folder)".to_string(),
".." => ".. (parent folder)".to_string(),
_ if e.is_dir => format!("{}/", e.name),
_ => e.name.clone(),
};
ListItem::new(name)
})
.collect();
let state = if p.readable { "" } else { " [unreadable]" };
let loc = if p.at_drive_selector() {
"Drives".to_string()
} else {
p.cwd.display().to_string()
};
let drives_hint = if cfg!(windows) { " · d drives" } else { "" };
let title = format!(
" {loc}{state} (↑↓ move · ↵ open/select · ← up{drives_hint} · Esc cancel) "
);
let list = List::new(rows_p)
.block(panel(title, true, &pal))
.highlight_style(
Style::default()
.fg(pal.accent_fg)
.bg(pal.accent)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut st = ListState::default();
st.select(Some(p.cursor));
if pal.colored() {
let fa = fr.area();
let sx = area.x + 1;
let sy = area.y + 1;
let shadow = Rect {
x: sx,
y: sy,
width: area.width.min(fa.width.saturating_sub(sx)),
height: area.height.min(fa.height.saturating_sub(sy)),
};
fr.render_widget(
Block::default().style(Style::default().bg(pal.shadow)),
shadow,
);
}
fr.render_widget(Clear, area);
fr.render_stateful_widget(list, area, &mut st);
}
let cal_opt: Option<(&CalPicker, bool)> = match widgets.get(focus) {
Some(Widget::Date { cal: Some(c), .. }) => Some((c, false)),
Some(Widget::Datetime { cal: Some(c), .. }) => Some((c, true)),
_ => None,
};
if let Some((cal, is_datetime)) = cal_opt {
let area = centered_rect(36, 60, fr.area());
let month_name = cal.date.format("%B %Y").to_string();
let title = format!(" {month_name} (←→ day · ↑↓ week · PgUp/Dn month · Enter · Esc) ");
let cal_lines = render_calendar(cal.date.year(), cal.date.month(), cal.date);
let hint = if is_datetime { " (date only; time preserved)" } else { "" };
let mut rows_cal: Vec<ListItem> = cal_lines
.iter()
.map(|l| ListItem::new(format!(" {l}")))
.collect();
rows_cal.push(ListItem::new(format!(" {hint}")));
let list = List::new(rows_cal).block(panel(title, true, &pal));
if pal.colored() {
let fa = fr.area();
let sx = area.x + 1;
let sy = area.y + 1;
let shadow = Rect {
x: sx,
y: sy,
width: area.width.min(fa.width.saturating_sub(sx)),
height: area.height.min(fa.height.saturating_sub(sy)),
};
fr.render_widget(
Block::default().style(Style::default().bg(pal.shadow)),
shadow,
);
}
fr.render_widget(Clear, area);
fr.render_widget(list, area);
}
let btn = |label: &str, idx: usize, enabled: bool| {
let st = if focus == idx && !enabled {
Style::default()
.fg(pal.muted)
.add_modifier(Modifier::REVERSED)
} else if !enabled {
Style::default().fg(pal.muted)
} else if focus == idx {
Style::default()
.fg(pal.accent_fg)
.bg(pal.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(pal.accent)
};
Span::styled(format!(" {label} "), st)
};
let foot = Line::from(vec![
btn("◄ Back", n, session.can_back()),
Span::raw(" "),
btn("Next ►", n + 1, true),
Span::raw(" "),
Span::styled(
err.clone().unwrap_or_else(|| {
"Tab focus · ↑↓ move · ←→ expand/collapse · Space toggle · Enter next · Esc back · q quit".into()
}),
Style::default().fg(if err.is_some() { pal.error } else { pal.muted }),
),
]);
fr.render_widget(
Paragraph::new(foot).block(panel("", focus >= n, &pal)),
rows[2],
);
})?;
if animate && !event::poll(Duration::from_millis(80))? {
frame = frame.wrapping_add(1);
continue;
}
let Event::Key(k) = event::read()? else { continue };
if k.kind != KeyEventKind::Press {
continue;
}
if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(false);
}
let path_picker_open = matches!(
widgets.get(focus),
Some(Widget::Path { picker: Some(_), .. })
);
let dropdown_open_pre = matches!(
widgets.get(focus),
Some(Widget::Dropdown { open: true, .. })
);
let cal_open_pre = matches!(
widgets.get(focus),
Some(Widget::Date { cal: Some(_), .. })
| Some(Widget::Datetime { cal: Some(_), .. })
);
if path_picker_open {
let picker_buf_pair: Option<(&mut String, &mut Option<Picker>)> =
match widgets.get_mut(focus) {
Some(Widget::Path { buf, picker }) => Some((buf, picker)),
_ => None,
};
if let Some((buf, picker)) = picker_buf_pair {
let p = picker.as_mut().expect("picker is Some");
match k.code {
KeyCode::Up => p.up(),
KeyCode::Down => p.down(),
KeyCode::Left | KeyCode::Backspace => p.ascend(),
KeyCode::Enter | KeyCode::Right => {
if let Some(path) = p.activate() {
*buf = path;
*picker = None;
}
}
KeyCode::Char('s') => {
if let Some(path) = p.select_cwd() {
*buf = path;
*picker = None;
}
}
KeyCode::Char('d') => p.goto_drives(),
KeyCode::Esc => *picker = None,
_ => {}
}
}
continue;
}
if dropdown_open_pre {
if let Some(Widget::Dropdown { choices, sel, open, filter, cur }) =
widgets.get_mut(focus)
{
match k.code {
KeyCode::Esc => {
*open = false;
filter.clear();
}
KeyCode::Enter => {
let filtered: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, c)| {
filter.is_empty()
|| c.to_lowercase().contains(&filter.to_lowercase())
})
.map(|(i, _)| i)
.collect();
if filtered.is_empty() {
} else {
let clamped = (*cur).min(filtered.len() - 1);
*sel = filtered[clamped];
*open = false;
filter.clear();
}
}
KeyCode::Up => *cur = cur.saturating_sub(1),
KeyCode::Down => {
let filtered_len = choices
.iter()
.filter(|c| {
filter.is_empty()
|| c.to_lowercase().contains(&filter.to_lowercase())
})
.count();
if filtered_len > 0 && *cur + 1 < filtered_len {
*cur += 1;
}
}
KeyCode::Backspace => {
filter.pop();
let new_len = choices
.iter()
.filter(|c| {
filter.is_empty()
|| c.to_lowercase().contains(&filter.to_lowercase())
})
.count();
*cur = (*cur).min(new_len.saturating_sub(1));
}
KeyCode::Char(ch) => {
filter.push(ch);
*cur = 0;
}
_ => {}
}
}
continue;
}
if cal_open_pre {
match widgets.get_mut(focus) {
Some(Widget::Date { digits, cal, .. }) => {
if let Some(c) = cal.as_mut() {
match k.code {
KeyCode::Esc => *cal = None,
KeyCode::Enter => {
*digits = date_to_date_digits(c.date);
*cal = None;
}
KeyCode::Left => {
c.date = c.date.pred_opt().unwrap_or(c.date);
}
KeyCode::Right => {
c.date = c.date.succ_opt().unwrap_or(c.date);
}
KeyCode::Up => {
c.date = c.date.checked_sub_days(Days::new(7)).unwrap_or(c.date);
}
KeyCode::Down => {
c.date = c.date.checked_add_days(Days::new(7)).unwrap_or(c.date);
}
KeyCode::PageUp => {
c.date = c.date.checked_sub_months(Months::new(1)).unwrap_or(c.date);
}
KeyCode::PageDown => {
c.date = c.date.checked_add_months(Months::new(1)).unwrap_or(c.date);
}
_ => {}
}
}
}
Some(Widget::Datetime { digits, cal, .. }) => {
if let Some(c) = cal.as_mut() {
match k.code {
KeyCode::Esc => *cal = None,
KeyCode::Enter => {
*digits = date_to_datetime_digits(c.date, digits);
*cal = None;
}
KeyCode::Left => {
c.date = c.date.pred_opt().unwrap_or(c.date);
}
KeyCode::Right => {
c.date = c.date.succ_opt().unwrap_or(c.date);
}
KeyCode::Up => {
c.date = c.date.checked_sub_days(Days::new(7)).unwrap_or(c.date);
}
KeyCode::Down => {
c.date = c.date.checked_add_days(Days::new(7)).unwrap_or(c.date);
}
KeyCode::PageUp => {
c.date = c.date.checked_sub_months(Months::new(1)).unwrap_or(c.date);
}
KeyCode::PageDown => {
c.date = c.date.checked_add_months(Months::new(1)).unwrap_or(c.date);
}
_ => {}
}
}
}
_ => {}
}
continue;
}
if k.code == KeyCode::Char('b') && k.modifiers.contains(KeyModifiers::CONTROL) {
if let Some(Widget::Path { buf, picker }) = widgets.get_mut(focus) {
*picker = Some(Picker::open(buf));
}
continue;
}
let editing = matches!(
widgets.get(focus),
Some(Widget::Input { .. })
| Some(Widget::Path { .. })
| Some(Widget::Textarea { active: true, .. })
| Some(Widget::Date { .. })
| Some(Widget::Datetime { .. })
);
if k.code == KeyCode::Char('q') && !editing && !dropdown_open_pre && !cal_open_pre {
return Ok(false);
}
let can_back = session.can_back();
let commit = |ws: &[Widget], fs: &[Field]| -> Map<String, Value> {
let mut m = Map::new();
for (w, f) in ws.iter().zip(fs) {
m.insert(f.id.clone(), widget_value(w));
}
m
};
match k.code {
KeyCode::Right if focus < n && widget_has_groups(&widgets[focus]) => {
if let Some(Row::Header(gi)) = current_row(&widgets[focus]) {
if let Widget::Multi { collapsed, .. }
| Widget::Single { collapsed, .. } = &mut widgets[focus]
{
collapsed[gi] = false;
}
}
}
KeyCode::Left if focus < n && widget_has_groups(&widgets[focus]) => {
match current_row(&widgets[focus]) {
Some(Row::Header(gi)) => {
if let Widget::Multi { collapsed, .. }
| Widget::Single { collapsed, .. } = &mut widgets[focus]
{
collapsed[gi] = true;
}
clamp_cur(&mut widgets[focus]);
}
Some(Row::Item(i)) => cursor_to_header_of(&mut widgets[focus], i),
None => {}
}
}
KeyCode::Tab | KeyCode::Right if !editing => {
let next = (focus + 1) % (n + 2);
focus = if next == n && !can_back { (next + 1) % (n + 2) } else { next };
}
KeyCode::BackTab | KeyCode::Left if !editing => {
let prev = (focus + n + 1) % (n + 2);
focus = if prev == n && !can_back { (prev + n + 1) % (n + 2) } else { prev };
}
KeyCode::Up
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
if *cursor_row > 0 {
*cursor_row -= 1;
*cursor_col =
(*cursor_col).min(textarea_line_char_len(buf, *cursor_row));
textarea_fix_scroll(scroll, *cursor_row);
}
}
}
KeyCode::Down
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
let last = textarea_line_count(buf).saturating_sub(1);
if *cursor_row < last {
*cursor_row += 1;
*cursor_col =
(*cursor_col).min(textarea_line_char_len(buf, *cursor_row));
textarea_fix_scroll(scroll, *cursor_row);
}
}
}
KeyCode::Left
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
if *cursor_col > 0 {
*cursor_col -= 1;
} else if *cursor_row > 0 {
*cursor_row -= 1;
*cursor_col = textarea_line_char_len(buf, *cursor_row);
textarea_fix_scroll(scroll, *cursor_row);
}
}
}
KeyCode::Right
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
let line_len = textarea_line_char_len(buf, *cursor_row);
if *cursor_col < line_len {
*cursor_col += 1;
} else {
let last = textarea_line_count(buf).saturating_sub(1);
if *cursor_row < last {
*cursor_row += 1;
*cursor_col = 0;
textarea_fix_scroll(scroll, *cursor_row);
}
}
}
}
KeyCode::Home
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { cursor_col, .. } = &mut widgets[focus] {
*cursor_col = 0;
}
}
KeyCode::End
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, .. } =
&mut widgets[focus]
{
*cursor_col = textarea_line_char_len(buf, *cursor_row);
}
}
KeyCode::PageUp
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
*cursor_row = cursor_row.saturating_sub(TEXTAREA_VISIBLE_ROWS);
*cursor_col =
(*cursor_col).min(textarea_line_char_len(buf, *cursor_row));
textarea_fix_scroll(scroll, *cursor_row);
}
}
KeyCode::PageDown
if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) =>
{
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
let last = textarea_line_count(buf).saturating_sub(1);
*cursor_row = (*cursor_row + TEXTAREA_VISIBLE_ROWS).min(last);
*cursor_col =
(*cursor_col).min(textarea_line_char_len(buf, *cursor_row));
textarea_fix_scroll(scroll, *cursor_row);
}
}
KeyCode::Esc if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) => {
if let Widget::Textarea { active, .. } = &mut widgets[focus] {
*active = false;
}
}
KeyCode::Esc => {
let m = commit(&widgets, &fields);
session.store(m);
if session.back() {
break;
}
}
KeyCode::Up | KeyCode::Down if focus < n => {
let down = k.code == KeyCode::Down;
let len = tree_rows_of(&widgets[focus]).map_or(0, |r| r.len());
let cur = cur_of(&widgets[focus]);
let (new_cur, new_focus) = vert_nav(cur, len, down, focus, n);
if let Widget::Multi { cur, .. } | Widget::Single { cur, .. } =
&mut widgets[focus]
{
*cur = new_cur;
}
focus = new_focus;
}
KeyCode::Char(' ') if focus < n => {
let row = current_row(&widgets[focus]);
match &mut widgets[focus] {
Widget::Multi { on, collapsed, .. } => match row {
Some(Row::Item(i)) => on[i] = !on[i],
Some(Row::Header(gi)) => collapsed[gi] = !collapsed[gi],
None => {}
},
Widget::Single { sel, collapsed, .. } => match row {
Some(Row::Item(i)) => *sel = Some(i),
Some(Row::Header(gi)) => collapsed[gi] = !collapsed[gi],
None => {}
},
Widget::Toggle { on } => *on = !*on,
Widget::Input { buf, .. } | Widget::Path { buf, .. } => buf.push(' '),
Widget::Textarea { buf, cursor_row, cursor_col, scroll, active } => {
if *active {
textarea_insert(buf, cursor_row, cursor_col, ' ');
textarea_fix_scroll(scroll, *cursor_row);
}
}
Widget::Date { digits, cal, .. } => {
let seed = date_from_date_digits(digits)
.unwrap_or_else(|| Local::now().date_naive());
*cal = Some(CalPicker { date: seed });
}
Widget::Datetime { digits, cal, .. } => {
let date_only = {
let date_digits: [u8; 8] = digits[..8].try_into().unwrap_or([b'_'; 8]);
date_from_date_digits(&date_digits)
.unwrap_or_else(|| Local::now().date_naive())
};
*cal = Some(CalPicker { date: date_only });
}
Widget::Dropdown { open, filter, cur, .. } => {
*open = true;
filter.clear();
*cur = 0;
}
}
clamp_cur(&mut widgets[focus]);
}
KeyCode::Char(ch) if editing => {
match &mut widgets[focus] {
Widget::Input { buf, .. } | Widget::Path { buf, .. } => buf.push(ch),
Widget::Date { digits, dcur, .. } if ch.is_ascii_digit() => {
*dcur = date_type_digit(digits, *dcur, ch as u8);
}
Widget::Date { .. } => {} Widget::Datetime { digits, dcur, .. } if ch.is_ascii_digit() => {
*dcur = datetime_type_digit(digits, *dcur, ch as u8);
}
Widget::Datetime { .. } => {} Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } => {
textarea_insert(buf, cursor_row, cursor_col, ch);
textarea_fix_scroll(scroll, *cursor_row);
}
_ => {}
}
}
KeyCode::Backspace if editing => {
match &mut widgets[focus] {
Widget::Input { buf, .. } | Widget::Path { buf, .. } => { buf.pop(); }
Widget::Date { digits, dcur, .. } => {
*dcur = date_backspace(digits, *dcur);
}
Widget::Datetime { digits, dcur, .. } => {
*dcur = datetime_backspace(digits, *dcur);
}
Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } => {
textarea_backspace(buf, cursor_row, cursor_col);
textarea_fix_scroll(scroll, *cursor_row);
}
_ => {}
}
}
KeyCode::Enter if focus < n && matches!(widgets[focus], Widget::Textarea { active: false, .. }) => {
if let Widget::Textarea { active, .. } = &mut widgets[focus] {
*active = true;
}
}
KeyCode::Enter if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) => {
if let Widget::Textarea { buf, cursor_row, cursor_col, scroll, .. } =
&mut widgets[focus]
{
textarea_insert(buf, cursor_row, cursor_col, '\n');
textarea_fix_scroll(scroll, *cursor_row);
}
}
KeyCode::Tab if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) => {
if let Widget::Textarea { active, .. } = &mut widgets[focus] { *active = false; }
let next = (focus + 1) % (n + 2);
focus = if next == n && !can_back { (next + 1) % (n + 2) } else { next };
}
KeyCode::BackTab if focus < n && matches!(widgets[focus], Widget::Textarea { active: true, .. }) => {
if let Widget::Textarea { active, .. } = &mut widgets[focus] { *active = false; }
let prev = (focus + n + 1) % (n + 2);
focus = if prev == n && !can_back { (prev + n + 1) % (n + 2) } else { prev };
}
KeyCode::Enter if focus < n && matches!(widgets[focus], Widget::Dropdown { open: false, .. }) => {
if let Widget::Dropdown { open, filter, cur, .. } = &mut widgets[focus] {
*open = true;
filter.clear();
*cur = 0;
}
}
KeyCode::Enter => {
if focus == n {
let m = commit(&widgets, &fields);
session.store(m);
if session.back() {
break;
}
} else {
let m = commit(&widgets, &fields);
if let Some((fail_idx, date_err)) = check_partial_dates(&fields, &widgets) {
err = Some(date_err);
focus = fail_idx;
continue;
}
if let Some((fail_idx, path_err)) = run_path_validation(&fields, &m) {
err = Some(path_err);
focus = fail_idx;
continue;
}
if !no_api_validate {
if let Some((fail_idx, api_err)) = run_api_validation(&fields, &m, &mut term, &pal, &mut frame) {
err = Some(api_err);
focus = fail_idx;
continue;
}
}
let candidate_vars: Map<String, Value> = {
let mut cv = session.vars_snapshot();
cv.extend(m.clone());
cv
};
if let Some((fail_idx, assert_err)) = run_assert_validation(&fields, &candidate_vars) {
err = Some(assert_err);
focus = fail_idx;
continue;
}
match session.submit(m) {
Ok(()) => break,
Err(e) => err = Some(format!("{e}")),
}
}
}
_ => {}
}
}
for (w, f) in widgets.iter().zip(&fields) {
if let Widget::Multi { groups, collapsed, .. }
| Widget::Single { groups, collapsed, .. } = w
{
for (g, c) in groups.iter().zip(collapsed) {
collapse.insert(collapse_key(&f.id, g), *c);
}
}
}
}
Ok(true)
}
pub struct BarReporter {
bar: ProgressBar,
}
impl BarReporter {
pub fn new(pal: Palette) -> Self {
let bar = ProgressBar::new_spinner();
let tmpl = if pal.colored() {
"{spinner:.cyan} {wide_msg}"
} else {
"{spinner} {wide_msg}"
};
bar.set_style(
ProgressStyle::with_template(tmpl)
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
bar.enable_steady_tick(std::time::Duration::from_millis(120));
Self { bar }
}
pub fn finish(&self) {
self.bar.finish_and_clear();
}
}
impl Reporter for BarReporter {
fn step_start(&self, key: &str, step_type: &str) {
self.bar.set_message(format!("{key} · {step_type}"));
}
fn step_end(&self, key: &str, step_type: &str, ok: bool) {
if !ok {
self.bar
.println(format!(" ✗ {key} · {step_type}"));
}
}
fn log(&self, msg: &str) {
self.bar.println(msg);
}
}
#[cfg(test)]
mod tests {
use super::{
check_partial_dates, date_backspace, date_from_date_digits, date_to_date_digits,
date_type_digit, date_widget_value, datetime_backspace, datetime_type_digit,
datetime_widget_value, days_in_month, first_empty_slot_14, first_empty_slot_8,
group_list, group_mark_multi, item_label, list_dir, parse_date_digits,
parse_datetime_digits, render_calendar, render_date_mask, render_datetime_mask,
textarea_backspace, textarea_byte_pos, textarea_insert, textarea_line_char_len,
textarea_line_count, validate_path_value, vert_nav, visible_rows, GroupDefaults,
Picker, Row, run_assert_validation,
};
use chrono::{Datelike, NaiveDate};
use insmaller_core::Choice;
#[test]
fn textarea_byte_pos_empty() {
assert_eq!(textarea_byte_pos("", 0, 0), 0);
}
#[test]
fn textarea_byte_pos_single_line() {
let buf = "hello";
assert_eq!(textarea_byte_pos(buf, 0, 0), 0);
assert_eq!(textarea_byte_pos(buf, 0, 3), 3);
assert_eq!(textarea_byte_pos(buf, 0, 5), 5);
assert_eq!(textarea_byte_pos(buf, 0, 100), 5);
}
#[test]
fn textarea_byte_pos_multiline() {
let buf = "ab\ncd\nef";
assert_eq!(textarea_byte_pos(buf, 0, 1), 1);
assert_eq!(textarea_byte_pos(buf, 1, 0), 3);
assert_eq!(textarea_byte_pos(buf, 1, 1), 4);
assert_eq!(textarea_byte_pos(buf, 2, 0), 6);
}
#[test]
fn textarea_insert_char_advances_col() {
let mut buf = String::from("ac");
let mut row = 0;
let mut col = 1; textarea_insert(&mut buf, &mut row, &mut col, 'b');
assert_eq!(buf, "abc");
assert_eq!(row, 0);
assert_eq!(col, 2);
}
#[test]
fn textarea_insert_newline_advances_row() {
let mut buf = String::from("hello");
let mut row = 0;
let mut col = 5;
textarea_insert(&mut buf, &mut row, &mut col, '\n');
assert_eq!(buf, "hello\n");
assert_eq!(row, 1);
assert_eq!(col, 0);
}
#[test]
fn textarea_backspace_deletes_char() {
let mut buf = String::from("abc");
let mut row = 0;
let mut col = 3;
textarea_backspace(&mut buf, &mut row, &mut col);
assert_eq!(buf, "ab");
assert_eq!(col, 2);
}
#[test]
fn textarea_backspace_at_start_noop() {
let mut buf = String::from("abc");
let mut row = 0;
let mut col = 0;
textarea_backspace(&mut buf, &mut row, &mut col);
assert_eq!(buf, "abc");
assert_eq!(col, 0);
}
#[test]
fn textarea_backspace_deletes_newline_joins_lines() {
let mut buf = String::from("ab\ncd");
let mut row = 1;
let mut col = 0;
textarea_backspace(&mut buf, &mut row, &mut col);
assert_eq!(buf, "abcd");
assert_eq!(row, 0);
assert_eq!(col, 2); }
fn ch(value: &str, group: Option<&str>) -> Choice {
Choice {
value: value.into(),
label: value.into(),
default: false,
group: group.map(str::to_string),
}
}
#[test]
fn down_within_select_then_to_next_field() {
assert_eq!(vert_nav(0, 3, true, 0, 2), (1, 0));
assert_eq!(vert_nav(1, 3, true, 0, 2), (2, 0));
assert_eq!(vert_nav(2, 3, true, 0, 2), (2, 1));
}
#[test]
fn up_within_select_then_to_prev_field() {
assert_eq!(vert_nav(2, 3, false, 1, 2), (1, 1));
assert_eq!(vert_nav(1, 3, false, 1, 2), (0, 1));
assert_eq!(vert_nav(0, 3, false, 1, 2), (0, 0));
}
#[test]
fn fieldless_widget_moves_focus_both_ways() {
assert_eq!(vert_nav(0, 0, true, 0, 2), (0, 1));
assert_eq!(vert_nav(0, 0, false, 1, 2), (0, 0));
}
#[test]
fn focus_clamps_at_edges() {
assert_eq!(vert_nav(0, 0, true, 2, 2), (0, 3));
assert_eq!(vert_nav(0, 0, true, 3, 2), (0, 3));
assert_eq!(vert_nav(0, 0, false, 0, 2), (0, 0));
}
#[test]
fn list_dir_dot_dotdot_then_dirs_before_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("zdir")).unwrap();
std::fs::write(dir.path().join("afile.txt"), b"x").unwrap();
let (entries, readable) = list_dir(dir.path());
assert!(readable);
assert_eq!(entries[0].name, ".");
assert_eq!(entries[1].name, "..");
assert_eq!(entries[2].name, "zdir");
assert!(entries[2].is_dir);
assert_eq!(entries[3].name, "afile.txt");
assert!(!entries[3].is_dir);
}
#[test]
fn list_dir_reports_unreadable() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not_a_dir.txt");
std::fs::write(&file, b"x").unwrap();
let (entries, readable) = list_dir(&file);
assert!(!readable);
assert_eq!(
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
vec![".", ".."]
);
}
#[test]
fn picker_descends_ascends_and_selects_file() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("f.txt"), b"x").unwrap();
let mut p = Picker::open(&dir.path().to_string_lossy());
assert_eq!(p.entries[p.cursor].name, ".");
assert_eq!(
p.activate().map(std::path::PathBuf::from),
Some(dir.path().to_path_buf()),
"'.' selects the current folder"
);
p.down();
p.down();
assert_eq!(p.entries[p.cursor].name, "sub");
assert_eq!(p.activate(), None);
assert_eq!(p.cwd, sub);
p.down();
p.down();
assert_eq!(p.entries[p.cursor].name, "f.txt");
let got = p.activate().expect("file selection returns a path");
assert_eq!(std::path::PathBuf::from(got), sub.join("f.txt"));
let mut q = Picker::open(&sub.to_string_lossy());
q.down(); assert_eq!(q.entries[q.cursor].name, "..");
assert_eq!(q.activate(), None);
assert_eq!(q.cwd, dir.path());
}
#[cfg(windows)]
#[test]
fn drive_root_offers_dotdot_to_selector() {
let (entries, readable) = list_dir(std::path::Path::new("C:\\"));
assert!(readable);
assert!(entries.iter().any(|e| e.name == ".."));
let mut p = Picker::open("C:\\");
assert_eq!(p.cwd, std::path::PathBuf::from("C:\\"));
p.ascend();
assert!(p.cwd.as_os_str().is_empty(), "ascends to the drive selector");
p.ascend();
assert!(p.cwd.as_os_str().is_empty());
}
#[cfg(windows)]
#[test]
fn selector_lists_drives_and_activate_descends() {
let (drives, readable) = list_dir(&std::path::PathBuf::new());
assert!(readable);
assert!(!drives.is_empty(), "at least the system drive is present");
assert!(drives.iter().all(|e| e.is_dir));
let mut p = Picker::open("C:\\");
p.set_dir(std::path::PathBuf::new()); assert_eq!(p.activate(), None);
assert!(!p.cwd.as_os_str().is_empty());
let s = p.cwd.to_string_lossy();
assert!(s.ends_with('\\'), "drive root keeps a trailing separator: {s}");
}
#[cfg(windows)]
#[test]
fn d_shortcut_jumps_to_selector_from_any_depth() {
let dir = tempfile::tempdir().unwrap();
let mut p = Picker::open(&dir.path().to_string_lossy());
assert!(!p.cwd.as_os_str().is_empty());
p.goto_drives();
assert!(p.cwd.as_os_str().is_empty(), "d jumps to the drive selector");
p.goto_drives();
assert!(p.cwd.as_os_str().is_empty(), "no-op once already there");
}
#[cfg(windows)]
#[test]
fn select_at_drive_selector_yields_no_value() {
let mut p = Picker::open("C:\\");
p.goto_drives();
assert!(p.at_drive_selector());
assert_eq!(p.select_cwd(), None, "no folder to take at the drive list");
}
#[test]
fn group_list_first_appearance_order_excludes_ungrouped() {
let choices = vec![
ch("a", None),
ch("bun", Some("runtime")),
ch("node", Some("runtime")),
ch("claude", Some("ai")),
];
assert_eq!(group_list(&choices), vec!["runtime".to_string(), "ai".to_string()]);
}
#[test]
fn visible_rows_ungrouped_first_then_headers_and_collapse() {
let choices = vec![
ch("a", None),
ch("bun", Some("runtime")),
ch("node", Some("runtime")),
ch("claude", Some("ai")),
];
let groups = group_list(&choices);
let rows = visible_rows(&choices, &groups, &[false, false]);
assert_eq!(
rows,
vec![
Row::Item(0),
Row::Header(0),
Row::Item(1),
Row::Item(2),
Row::Header(1),
Row::Item(3),
]
);
let rows = visible_rows(&choices, &groups, &[true, false]);
assert_eq!(
rows,
vec![Row::Item(0), Row::Header(0), Row::Header(1), Row::Item(3)]
);
}
#[test]
fn no_groups_renders_flat() {
let choices = vec![ch("a", None), ch("b", None)];
let groups = group_list(&choices);
assert!(groups.is_empty());
assert_eq!(
visible_rows(&choices, &groups, &[]),
vec![Row::Item(0), Row::Item(1)]
);
}
#[test]
fn group_mark_all_some_none() {
let choices = vec![ch("bun", Some("runtime")), ch("node", Some("runtime"))];
assert_eq!(group_mark_multi(&choices, &[false, false], "runtime"), "[ ]");
assert_eq!(group_mark_multi(&choices, &[true, false], "runtime"), "[~]");
assert_eq!(group_mark_multi(&choices, &[true, true], "runtime"), "[x]");
}
#[test]
fn item_label_strips_group_prefix() {
let c = Choice {
value: "bun".into(),
label: "[runtime] bun — fast".into(),
default: false,
group: Some("runtime".into()),
};
assert_eq!(item_label(&c), "bun — fast");
assert_eq!(item_label(&ch("x", None)), "x");
}
#[test]
fn group_defaults_precedence() {
let gd = GroupDefaults {
collapsed_default: true,
collapsed: vec!["x".into()],
expanded: vec!["y".into()],
};
assert!(gd.is_collapsed("other"));
assert!(!gd.is_collapsed("y"));
assert!(gd.is_collapsed("x"));
let open = GroupDefaults {
collapsed_default: false,
collapsed: vec!["git".into()],
expanded: vec![],
};
assert!(!open.is_collapsed("runtime"));
assert!(open.is_collapsed("git"));
let empty = std::collections::HashMap::new();
assert_eq!(
open.for_groups("f", &["runtime".into(), "git".into()], &empty),
vec![false, true]
);
let mut cache = std::collections::HashMap::new();
cache.insert(super::collapse_key("f", "git"), false);
assert_eq!(
open.for_groups("f", &["runtime".into(), "git".into()], &cache),
vec![false, false],
"cached expand of git overrides collapsed_groups default"
);
}
#[test]
fn textarea_scroll_follows_cursor_down() {
let mut buf = String::new();
let mut row = 0usize;
let mut col = 0usize;
let mut scroll = 0usize;
let n = super::TEXTAREA_VISIBLE_ROWS + 2;
for _ in 0..n {
super::textarea_insert(&mut buf, &mut row, &mut col, '\n');
super::textarea_fix_scroll(&mut scroll, row);
assert!(
row >= scroll && row < scroll + super::TEXTAREA_VISIBLE_ROWS,
"cursor_row {row} outside visible window [{scroll}, {})",
scroll + super::TEXTAREA_VISIBLE_ROWS,
);
}
}
#[test]
fn textarea_scroll_follows_cursor_up_after_backspace() {
let mut buf = String::new();
let mut row = 0usize;
let mut col = 0usize;
let mut scroll = 0usize;
let n = super::TEXTAREA_VISIBLE_ROWS + 3;
for _ in 0..n {
super::textarea_insert(&mut buf, &mut row, &mut col, '\n');
super::textarea_fix_scroll(&mut scroll, row);
}
for _ in 0..n {
super::textarea_backspace(&mut buf, &mut row, &mut col);
super::textarea_fix_scroll(&mut scroll, row);
assert!(
row >= scroll && row < scroll + super::TEXTAREA_VISIBLE_ROWS,
"after backspace cursor_row {row} outside visible window [{scroll}, {})",
scroll + super::TEXTAREA_VISIBLE_ROWS,
);
}
}
#[test]
fn dropdown_filter_selects_correct_original_index() {
let choices = vec!["alpha".to_string(), "beta".to_string(), "alphabet".to_string()];
let filter = "bet".to_string();
let filtered: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, c)| c.to_lowercase().contains(&filter.to_lowercase()))
.map(|(i, _)| i)
.collect();
let cur = 0usize;
assert!(!filtered.is_empty());
let clamped = cur.min(filtered.len() - 1);
let selected_original_idx = filtered[clamped];
assert_eq!(selected_original_idx, 1, "filter 'bet' cur=0 should select 'beta' at original idx 1");
assert_eq!(choices[selected_original_idx], "beta");
}
#[test]
fn dropdown_filter_empty_result_keeps_popup_open() {
let choices = vec!["alpha".to_string(), "beta".to_string()];
let filter = "zzz".to_string();
let filtered: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, c)| c.to_lowercase().contains(&filter.to_lowercase()))
.map(|(i, _)| i)
.collect();
assert!(filtered.is_empty(), "no match → filtered is empty, popup should stay open");
}
#[test]
fn dropdown_cursor_clamped_within_filtered_list() {
let choices: Vec<String> = (0..10).map(|i| format!("item{i}")).collect();
let filter = "item1".to_string(); let filtered: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, c)| c.to_lowercase().contains(&filter.to_lowercase()))
.map(|(i, _)| i)
.collect();
let cur = 5usize; if !filtered.is_empty() {
let clamped = cur.min(filtered.len() - 1);
assert!(clamped < filtered.len(), "clamped cursor must be within filtered list");
}
}
#[test]
fn parse_date_digits_full_string() {
let d = parse_date_digits("2026-09-15");
assert_eq!(&d, b"20260915");
}
#[test]
fn parse_date_digits_empty() {
let d = parse_date_digits("");
assert_eq!(d, [b'_'; 8]);
}
#[test]
fn render_date_mask_all_empty() {
let d = [b'_'; 8];
assert_eq!(render_date_mask(&d), "____-__-__");
}
#[test]
fn render_date_mask_partial() {
let mut d = [b'_'; 8];
d[0] = b'2'; d[1] = b'0'; d[2] = b'2'; d[3] = b'6';
assert_eq!(render_date_mask(&d), "2026-__-__");
}
#[test]
fn render_date_mask_full() {
let d = parse_date_digits("2026-09-15");
assert_eq!(render_date_mask(&d), "2026-09-15");
}
#[test]
fn render_datetime_mask_all_empty() {
let d = [b'_'; 14];
assert_eq!(render_datetime_mask(&d), "____-__-__T__:__:__");
}
#[test]
fn render_datetime_mask_full() {
let d = parse_datetime_digits("2026-09-15T12:30:00");
assert_eq!(render_datetime_mask(&d), "2026-09-15T12:30:00");
}
#[test]
fn date_type_digit_fills_slots_and_advances() {
let mut d = [b'_'; 8];
let mut cur = 0usize;
for ch in b"20260915" {
cur = date_type_digit(&mut d, cur, *ch);
}
assert_eq!(cur, 8);
assert_eq!(render_date_mask(&d), "2026-09-15");
}
#[test]
fn date_type_digit_stops_at_end() {
let mut d = parse_date_digits("2026-09-15");
let cur = date_type_digit(&mut d, 8, b'9'); assert_eq!(cur, 8);
}
#[test]
fn date_backspace_clears_last_digit() {
let mut d = parse_date_digits("2026-09-15");
let cur = date_backspace(&mut d, 8);
assert_eq!(cur, 7);
assert_eq!(d[7], b'_');
assert_eq!(render_date_mask(&d), "2026-09-1_");
}
#[test]
fn date_backspace_at_zero_is_noop() {
let mut d = [b'_'; 8];
let cur = date_backspace(&mut d, 0);
assert_eq!(cur, 0);
}
#[test]
fn datetime_type_and_backspace() {
let mut d = [b'_'; 14];
let mut cur = 0usize;
for ch in b"20260915123000" {
cur = datetime_type_digit(&mut d, cur, *ch);
}
assert_eq!(cur, 14);
assert_eq!(render_datetime_mask(&d), "2026-09-15T12:30:00");
cur = datetime_backspace(&mut d, cur);
assert_eq!(cur, 13);
assert_eq!(d[13], b'_');
}
#[test]
fn date_widget_value_incomplete_is_empty() {
let d = parse_date_digits("2026-09-__");
assert_eq!(date_widget_value(&d), "");
}
#[test]
fn date_widget_value_complete() {
let d = parse_date_digits("2026-09-15");
assert_eq!(date_widget_value(&d), "2026-09-15");
}
#[test]
fn datetime_widget_value_complete() {
let d = parse_datetime_digits("2026-09-15T12:30:00");
assert_eq!(datetime_widget_value(&d), "2026-09-15T12:30:00");
}
#[test]
fn days_in_month_regular() {
assert_eq!(days_in_month(2026, 9), 30); assert_eq!(days_in_month(2026, 1), 31); assert_eq!(days_in_month(2026, 2), 28); assert_eq!(days_in_month(2024, 2), 29); }
#[test]
fn days_in_month_december_wraps() {
assert_eq!(days_in_month(2026, 12), 31);
}
#[test]
fn date_to_date_digits_roundtrip() {
let d = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
let digits = date_to_date_digits(d);
assert_eq!(render_date_mask(&digits), "2026-09-15");
}
#[test]
fn date_from_date_digits_parses() {
let d = parse_date_digits("2026-09-15");
let nd = date_from_date_digits(&d).unwrap();
assert_eq!(nd.year(), 2026);
assert_eq!(nd.month(), 9);
assert_eq!(nd.day(), 15);
}
#[test]
fn date_from_date_digits_incomplete_is_none() {
let d = [b'_'; 8];
assert!(date_from_date_digits(&d).is_none());
}
#[test]
fn cal_navigate_forward_backward() {
let start = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
let next = start.succ_opt().unwrap();
assert_eq!(next.day(), 16);
let prev = start.pred_opt().unwrap();
assert_eq!(prev.day(), 14);
}
#[test]
fn cal_navigate_week() {
use chrono::Days;
let start = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
let next_week = start.checked_add_days(Days::new(7)).unwrap();
assert_eq!(next_week.day(), 22);
let prev_week = start.checked_sub_days(Days::new(7)).unwrap();
assert_eq!(prev_week.day(), 8);
}
#[test]
fn cal_navigate_month() {
use chrono::Months;
let start = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
let next_m = start.checked_add_months(Months::new(1)).unwrap();
assert_eq!(next_m.month(), 10);
let prev_m = start.checked_sub_months(Months::new(1)).unwrap();
assert_eq!(prev_m.month(), 8);
}
#[test]
fn cal_enter_commits_date_to_digits() {
let date = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
let digits = date_to_date_digits(date);
assert_eq!(date_widget_value(&digits), "2026-09-15");
}
#[test]
fn cal_esc_leaves_digits_unchanged() {
let original = parse_date_digits("2026-09-01");
assert_eq!(date_widget_value(&original), "2026-09-01");
}
fn exists_yes(_: &std::path::Path) -> bool { true }
fn exists_no(_: &std::path::Path) -> bool { false }
fn is_dir_yes(_: &std::path::Path) -> bool { true }
fn is_dir_no(_: &std::path::Path) -> bool { false }
#[test]
fn path_existing_path_accepted() {
let r = validate_path_value("My path", "/some/existing/dir", exists_yes, is_dir_yes);
assert!(r.is_ok());
}
#[test]
fn path_new_leaf_under_existing_parent_accepted() {
let exists = |p: &std::path::Path| p != std::path::Path::new("/parent/newleaf");
let r = validate_path_value("My path", "/parent/newleaf", exists, is_dir_yes);
assert!(r.is_ok());
}
#[test]
fn path_nonexistent_parent_rejected() {
let r = validate_path_value("My path", "/missing/parent/leaf", exists_no, is_dir_yes);
assert!(r.is_err());
let msg = r.unwrap_err();
assert!(msg.contains("My path"), "label missing from error: {msg}");
assert!(msg.contains("missing/parent"), "parent dir missing from error: {msg}");
}
#[test]
fn path_parent_exists_but_is_not_a_dir_rejected() {
let exists = |p: &std::path::Path| p == std::path::Path::new("/parent");
let r = validate_path_value("dest", "/parent/leaf", exists, is_dir_no);
assert!(r.is_err());
}
#[test]
fn path_bare_name_no_parent_accepted() {
let r = validate_path_value("dir", "newdir", exists_no, is_dir_yes);
assert!(r.is_ok());
}
#[test]
fn path_trim_via_real_fs() {
let trimmed = " /tmp ".trim().to_string();
assert_eq!(trimmed, "/tmp");
}
#[test]
fn path_trailing_space_trimmed_and_real_parent_accepted() {
let raw = format!(" {} ", std::env::temp_dir().display());
let trimmed = raw.trim();
let r = validate_path_value("dir", trimmed, |p| p.exists(), |p| p.is_dir());
assert!(r.is_ok(), "trimmed temp_dir must be accepted: {:?}", r);
}
#[test]
fn path_new_leaf_under_temp_dir_accepted() {
let leaf = std::env::temp_dir().join("__insmaller_test_nonexistent_leaf_xyz__");
let _ = std::fs::remove_file(&leaf); let r = validate_path_value(
"out",
&leaf.to_string_lossy(),
|p| p.exists(),
|p| p.is_dir(),
);
assert!(r.is_ok(), "new leaf under existing parent must be accepted: {:?}", r);
}
#[test]
fn path_typo_parent_rejected_real_fs() {
let bad = std::env::temp_dir()
.join("__definitely_absent_parent_xyz_abc__")
.join("leaf");
let r = validate_path_value(
"output path",
&bad.to_string_lossy(),
|p| p.exists(),
|p| p.is_dir(),
);
assert!(r.is_err(), "nonexistent parent must be rejected");
let msg = r.unwrap_err();
assert!(msg.contains("output path"), "label in error: {msg}");
}
fn dropdown_search_line(filter: &str) -> String {
if filter.is_empty() {
" Search: \u{258c} (type to filter)".to_string()
} else {
format!(" Search: {filter}\u{258c}")
}
}
fn dropdown_rows(choices: &[&str], filter: &str) -> Vec<String> {
let filtered: Vec<&&str> = choices
.iter()
.filter(|c| filter.is_empty() || c.to_lowercase().contains(&filter.to_lowercase()))
.collect();
let mut rows = vec![dropdown_search_line(filter)];
if filtered.is_empty() {
rows.push(" [no matches]".to_string());
} else {
rows.extend(filtered.iter().map(|c| c.to_string()));
}
rows
}
#[test]
fn dropdown_search_header_empty_filter() {
let line = dropdown_search_line("");
assert!(line.contains("Search:"), "must contain 'Search:': {line}");
assert!(line.contains("type to filter"), "hint text missing: {line}");
assert!(line.contains('\u{258c}'), "cursor block missing: {line}");
}
#[test]
fn dropdown_search_header_with_filter() {
let line = dropdown_search_line("ph");
assert!(line.contains("Search: ph"), "filter text not shown: {line}");
assert!(line.contains('\u{258c}'), "cursor block missing: {line}");
assert!(!line.contains("type to filter"), "hint must be absent when typing: {line}");
}
#[test]
fn dropdown_rows_header_is_always_first() {
let choices = ["alpha", "beta", "gamma"];
let rows = dropdown_rows(&choices, "");
assert!(rows[0].contains("Search:"), "header must be row 0: {:?}", rows[0]);
}
#[test]
fn dropdown_rows_choice_count_with_filter() {
let choices = ["US", "PH", "DE", "PL"];
let rows = dropdown_rows(&choices, "p");
assert_eq!(rows.len(), 3, "header + 2 matches: {rows:?}");
assert!(rows[1].contains("PH") || rows[2].contains("PH"));
assert!(rows[1].contains("PL") || rows[2].contains("PL"));
}
#[test]
fn dropdown_rows_no_matches_shows_placeholder() {
let choices = ["alpha", "beta"];
let rows = dropdown_rows(&choices, "zzz");
assert_eq!(rows.len(), 2, "header + no-matches: {rows:?}");
assert!(rows[1].contains("no matches"), "placeholder missing: {:?}", rows[1]);
}
#[test]
fn dropdown_highlight_offset_skips_header() {
let filtered_len = 3usize;
let cur = 0usize;
let clamped = cur.min(filtered_len.saturating_sub(1));
let st_idx = clamped + 1; assert_eq!(st_idx, 1, "index 0 in filtered → row 1 in list (skip header)");
}
#[test]
fn dropdown_highlight_offset_clamped_last() {
let filtered_len = 3usize;
let cur = 99usize;
let clamped = cur.min(filtered_len.saturating_sub(1));
let st_idx = clamped + 1;
assert_eq!(st_idx, 3, "clamped last choice (idx 2) → row 3 in list");
}
fn ta_up(buf: &str, row: &mut usize, col: &mut usize) {
if *row > 0 {
*row -= 1;
*col = (*col).min(textarea_line_char_len(buf, *row));
}
}
fn ta_down(buf: &str, row: &mut usize, col: &mut usize) {
let last = textarea_line_count(buf).saturating_sub(1);
if *row < last {
*row += 1;
*col = (*col).min(textarea_line_char_len(buf, *row));
}
}
fn ta_left(buf: &str, row: &mut usize, col: &mut usize) {
if *col > 0 {
*col -= 1;
} else if *row > 0 {
*row -= 1;
*col = textarea_line_char_len(buf, *row);
}
}
fn ta_right(buf: &str, row: &mut usize, col: &mut usize) {
let line_len = textarea_line_char_len(buf, *row);
if *col < line_len {
*col += 1;
} else {
let last = textarea_line_count(buf).saturating_sub(1);
if *row < last {
*row += 1;
*col = 0;
}
}
}
#[test]
fn ta_up_moves_row_and_clamps_col_on_shorter_line() {
let buf = "hello\nhi\nworld";
let mut row = 2usize;
let mut col = 4usize;
ta_up(buf, &mut row, &mut col);
assert_eq!(row, 1);
assert_eq!(col, 2, "col clamped to len of 'hi'");
}
#[test]
fn ta_down_moves_row_and_clamps_col_on_shorter_line() {
let buf = "hello\nhi";
let mut row = 0usize;
let mut col = 4usize;
ta_down(buf, &mut row, &mut col);
assert_eq!(row, 1);
assert_eq!(col, 2, "col clamped to len of 'hi'");
}
#[test]
fn ta_up_at_first_row_is_noop() {
let buf = "hello\nworld";
let mut row = 0usize;
let mut col = 3usize;
ta_up(buf, &mut row, &mut col);
assert_eq!(row, 0);
assert_eq!(col, 3);
}
#[test]
fn ta_down_at_last_row_is_noop() {
let buf = "hello\nworld";
let mut row = 1usize;
let mut col = 2usize;
ta_down(buf, &mut row, &mut col);
assert_eq!(row, 1);
assert_eq!(col, 2);
}
#[test]
fn ta_left_at_col_zero_wraps_to_end_of_prev_line() {
let buf = "hello\nworld";
let mut row = 1usize;
let mut col = 0usize;
ta_left(buf, &mut row, &mut col);
assert_eq!(row, 0);
assert_eq!(col, 5, "end of 'hello'");
}
#[test]
fn ta_left_within_line_decrements_col() {
let buf = "hello\nworld";
let mut row = 0usize;
let mut col = 3usize;
ta_left(buf, &mut row, &mut col);
assert_eq!(row, 0);
assert_eq!(col, 2);
}
#[test]
fn ta_left_at_very_start_is_noop() {
let buf = "hello";
let mut row = 0usize;
let mut col = 0usize;
ta_left(buf, &mut row, &mut col);
assert_eq!(row, 0);
assert_eq!(col, 0);
}
#[test]
fn ta_right_at_line_end_wraps_to_next_line_start() {
let buf = "hello\nworld";
let mut row = 0usize;
let mut col = 5usize; ta_right(buf, &mut row, &mut col);
assert_eq!(row, 1);
assert_eq!(col, 0);
}
#[test]
fn ta_right_within_line_increments_col() {
let buf = "hello\nworld";
let mut row = 0usize;
let mut col = 2usize;
ta_right(buf, &mut row, &mut col);
assert_eq!(row, 0);
assert_eq!(col, 3);
}
#[test]
fn ta_right_at_very_end_is_noop() {
let buf = "hello";
let mut row = 0usize;
let mut col = 5usize;
ta_right(buf, &mut row, &mut col);
assert_eq!(row, 0);
assert_eq!(col, 5);
}
#[test]
fn ta_home_end_semantics() {
let buf = "hello\nworld";
let col_after_home = 0usize;
assert_eq!(col_after_home, 0);
let end_col = textarea_line_char_len(buf, 0);
assert_eq!(end_col, 5);
}
#[test]
fn textarea_scroll_scrolls_up_when_cursor_above_viewport() {
use super::{textarea_fix_scroll, TEXTAREA_VISIBLE_ROWS};
let mut scroll = 3usize;
let cursor_row = 2usize; textarea_fix_scroll(&mut scroll, cursor_row);
assert_eq!(scroll, 2, "scroll must move up so cursor is visible");
assert!(
cursor_row >= scroll && cursor_row < scroll + TEXTAREA_VISIBLE_ROWS,
"cursor must be in visible window"
);
}
#[test]
fn textarea_line_counter_string() {
let buf = "a\nb\nc";
let total = textarea_line_count(buf);
assert_eq!(total, 3);
let cursor_row = 1usize;
let hint = format!("(line {}/{})", cursor_row + 1, total);
assert_eq!(hint, "(line 2/3)");
}
#[test]
fn textarea_line_char_len_multibyte() {
let buf = "café\nhi";
assert_eq!(textarea_line_char_len(buf, 0), 4); assert_eq!(textarea_line_char_len(buf, 1), 2);
}
#[test]
fn calendar_header_and_body_columns_align() {
let sel = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
let rows = render_calendar(2026, 9, sel);
let header = &rows[0];
let week1 = &rows[1];
for col in 0..7usize {
let offset = col * 4;
let h_cell: String = header.chars().skip(offset).take(3).collect();
let b_cell: String = week1.chars().skip(offset).take(3).collect();
if col == 0 || col == 1 {
assert_eq!(h_cell.trim(), ["Su", "Mo"][col],
"header col {col} mismatch: {h_cell:?}");
assert_eq!(b_cell, " ",
"body col {col} should be blank: {b_cell:?}");
} else if col == 2 {
assert_eq!(h_cell.trim(), "Tu",
"header col 2 (Tuesday) mismatch: {h_cell:?}");
assert_eq!(b_cell.trim(), "01",
"body col 2 should be day 01: {b_cell:?}");
}
}
}
#[test]
fn calendar_header_has_correct_structure() {
let sel = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
let rows = render_calendar(2026, 9, sel);
assert!(rows[0].contains("Su"), "header must contain Su");
assert!(rows[0].contains("Sa"), "header must contain Sa");
assert_eq!(rows[0].len(), 27, "header length must be 27: {:?}", rows[0]);
}
#[test]
fn partial_date_detected() {
use super::Widget;
use insmaller_core::{Field, FieldType, Validate};
let mut digits = [b'_'; 8];
digits[0] = b'2'; digits[1] = b'0'; digits[2] = b'2'; digits[3] = b'6';
digits[4] = b'0'; digits[5] = b'9'; digits[6] = b'1';
let field = Field {
id: "go_live".to_string(),
field_type: FieldType::Date,
prompt: Some("Go-live date".to_string()),
default: None, required: true, source: None,
options: vec![], condition: None,
assert: None, assert_error: None,
validate: Validate::default(),
};
let widget = Widget::Date { digits, dcur: 7, cal: None };
let result = check_partial_dates(&[field], &[widget]);
assert!(result.is_some(), "partial date must be detected");
let (idx, msg) = result.unwrap();
assert_eq!(idx, 0);
assert!(msg.contains("Go-live date"), "label in error: {msg}");
assert!(msg.contains("incomplete"), "error must say incomplete: {msg}");
}
#[test]
fn all_empty_date_not_partial() {
use super::Widget;
use insmaller_core::{Field, FieldType, Validate};
let field = Field {
id: "dt".to_string(), field_type: FieldType::Date,
prompt: None, default: None, required: false, source: None,
options: vec![], condition: None, assert: None, assert_error: None,
validate: Validate::default(),
};
let widget = Widget::Date { digits: [b'_'; 8], dcur: 0, cal: None };
assert!(check_partial_dates(&[field], &[widget]).is_none(),
"all-empty date must not trigger partial error");
}
#[test]
fn full_date_not_partial() {
use super::Widget;
use insmaller_core::{Field, FieldType, Validate};
let digits = parse_date_digits("2026-09-15");
let field = Field {
id: "dt".to_string(), field_type: FieldType::Date,
prompt: None, default: None, required: true, source: None,
options: vec![], condition: None, assert: None, assert_error: None,
validate: Validate::default(),
};
let widget = Widget::Date { digits, dcur: 8, cal: None };
assert!(check_partial_dates(&[field], &[widget]).is_none(),
"fully-filled date must not trigger partial error");
}
#[test]
fn first_empty_slot_8_all_empty() {
assert_eq!(first_empty_slot_8(&[b'_'; 8]), 0);
}
#[test]
fn first_empty_slot_8_all_filled() {
let d = parse_date_digits("2026-09-15");
assert_eq!(first_empty_slot_8(&d), 8);
}
#[test]
fn first_empty_slot_8_partial() {
let mut d = [b'_'; 8];
d[0] = b'2'; d[1] = b'0'; d[2] = b'2'; d[3] = b'6';
assert_eq!(first_empty_slot_8(&d), 4);
}
#[test]
fn first_empty_slot_14_all_filled() {
let d = parse_datetime_digits("2026-09-15T12:30:00");
assert_eq!(first_empty_slot_14(&d), 14);
}
#[test]
fn first_empty_slot_14_partial() {
let mut d = [b'_'; 14];
let date_d = parse_date_digits("2026-09-15");
d[..8].copy_from_slice(&date_d);
assert_eq!(first_empty_slot_14(&d), 8);
}
#[test]
fn dcur_for_reentry_after_partial_type() {
let mut digits = [b'_'; 8];
digits[0] = b'2'; digits[1] = b'0'; digits[2] = b'2'; digits[3] = b'6';
digits[4] = b'0'; digits[5] = b'9'; digits[6] = b'1';
assert_eq!(digits[7], b'_', "slot 7 must be empty");
let dcur = first_empty_slot_8(&digits);
assert_eq!(dcur, 7, "cursor must resume at slot 7, not 0");
}
fn tab_next(focus: usize, n: usize, can_back: bool) -> usize {
let next = (focus + 1) % (n + 2);
if next == n && !can_back { (next + 1) % (n + 2) } else { next }
}
fn tab_prev(focus: usize, n: usize, can_back: bool) -> usize {
let prev = (focus + n + 1) % (n + 2);
if prev == n && !can_back { (prev + n + 1) % (n + 2) } else { prev }
}
#[test]
fn tab_skips_disabled_back_forward() {
assert_eq!(tab_next(1, 2, false), 3, "should skip disabled Back");
assert_eq!(tab_next(1, 2, true), 2, "enabled Back must not be skipped");
}
#[test]
fn tab_skips_disabled_back_backward() {
assert_eq!(tab_prev(3, 2, false), 1, "BackTab must skip disabled Back");
assert_eq!(tab_prev(3, 2, true), 2, "BackTab must land on enabled Back");
}
#[test]
fn tab_wraps_correctly_when_back_enabled() {
assert_eq!(tab_next(2, 1, true), 0);
assert_eq!(tab_prev(0, 1, true), 2);
}
#[test]
fn textarea_starts_inactive() {
let w = super::Widget::Textarea {
buf: "hello".to_string(),
cursor_row: 0,
cursor_col: 0,
scroll: 0,
active: false,
};
assert!(matches!(w, super::Widget::Textarea { active: false, .. }));
}
#[test]
fn textarea_enter_activates() {
let mut active = false;
if !active { active = true; }
assert!(active);
}
#[test]
fn textarea_esc_deactivates() {
let mut active = true;
if active { active = false; }
assert!(!active);
}
#[test]
fn textarea_inactive_ignores_typing() {
let active = false;
let editing = active; assert!(!editing, "inactive textarea must not be in editing state");
}
#[test]
fn textarea_active_accepts_typing() {
let active = true;
let editing = active;
assert!(editing, "active textarea must be in editing state");
}
fn assert_field(id: &str, assert: &str, assert_error: Option<&str>) -> insmaller_core::Field {
insmaller_core::Field {
id: id.to_string(),
field_type: insmaller_core::FieldType::Date,
prompt: Some(id.to_string()),
default: None,
required: false,
source: None,
options: vec![],
condition: None,
assert: Some(assert.to_string()),
assert_error: assert_error.map(str::to_string),
validate: insmaller_core::Validate::default(),
}
}
#[test]
fn assert_gate_passes_when_condition_true() {
let field = assert_field("end_date", "${end_date} >= ${start_date}", None);
let mut vars = serde_json::Map::new();
vars.insert("start_date".to_string(), serde_json::Value::String("2026-09-01".into()));
vars.insert("end_date".to_string(), serde_json::Value::String("2026-09-15".into()));
let result = run_assert_validation(&[field], &vars);
assert!(result.is_none(), "assert must pass when end >= start");
}
#[test]
fn assert_gate_fails_when_condition_false() {
let field = assert_field(
"end_date",
"${end_date} >= ${start_date}",
Some("End date must be on or after start date."),
);
let mut vars = serde_json::Map::new();
vars.insert("start_date".to_string(), serde_json::Value::String("2026-09-15".into()));
vars.insert("end_date".to_string(), serde_json::Value::String("2026-09-01".into()));
let result = run_assert_validation(&[field], &vars);
assert!(result.is_some(), "assert must fail when end < start");
let (idx, msg) = result.unwrap();
assert_eq!(idx, 0);
assert!(msg.contains("End date must be on or after start date."), "custom error in message: {msg}");
}
#[test]
fn assert_gate_skips_fields_without_assert() {
use insmaller_core::{Field, FieldType, Validate};
let plain = Field {
id: "name".to_string(),
field_type: FieldType::Text,
prompt: None,
default: None,
required: false,
source: None,
options: vec![],
condition: None,
assert: None,
assert_error: None,
validate: Validate::default(),
};
let result = run_assert_validation(&[plain], &serde_json::Map::new());
assert!(result.is_none(), "no assert set → must always pass");
}
}