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::{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::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> },
}
#[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 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,
},
_ => 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.clone()),
}
}
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,
) -> 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 { "" }
)));
}
}
}
let body = List::new(items).block(panel(" fields ", focus < n, &pal));
fr.render_widget(body, rows[1]);
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 btn = |label: &str, idx: usize, enabled: bool| {
let st = 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);
}
if matches!(widgets.get(focus), Some(Widget::Path { picker: Some(_), .. })) {
if let Some(Widget::Path { buf, picker }) = widgets.get_mut(focus) {
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 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 { .. })
);
if k.code == KeyCode::Char('q') && !editing {
return Ok(false);
}
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 => focus = (focus + 1) % (n + 2),
KeyCode::BackTab | KeyCode::Left if !editing => {
focus = (focus + n + 1) % (n + 2)
}
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(' '),
}
clamp_cur(&mut widgets[focus]);
}
KeyCode::Char(ch) if editing => {
if let Widget::Input { buf, .. } | Widget::Path { buf, .. } =
&mut widgets[focus]
{
buf.push(ch);
}
}
KeyCode::Backspace if editing => {
if let Widget::Input { buf, .. } | Widget::Path { buf, .. } =
&mut widgets[focus]
{
buf.pop();
}
}
KeyCode::Enter => {
if focus == n {
let m = commit(&widgets, &fields);
session.store(m);
if session.back() {
break;
}
} else {
let m = commit(&widgets, &fields);
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::{
group_list, group_mark_multi, item_label, list_dir, vert_nav, visible_rows, GroupDefaults,
Picker, Row,
};
use insmaller_core::Choice;
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"
);
}
}