use std::collections::BTreeSet;
use std::io::{self, Stderr};
use std::panic;
use std::sync::Once;
use crossterm::cursor::{Hide, Show};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use reserve_core::{Catalog, Extension, Family, Filter, Group, Sort, Suffix};
use crate::plan::{Plan, Setting};
const MIN_WIDTH: u16 = 40;
const MIN_HEIGHT: u16 = 10;
#[cfg(unix)]
pub(crate) fn watch_for_signals() -> tokio::task::JoinHandle<()> {
use tokio::signal::unix::{SignalKind, signal};
tokio::spawn(async {
let mut term = match signal(SignalKind::terminate()) {
Ok(stream) => stream,
Err(_) => return,
};
let mut hup = match signal(SignalKind::hangup()) {
Ok(stream) => stream,
Err(_) => return,
};
let mut quit = match signal(SignalKind::quit()) {
Ok(stream) => stream,
Err(_) => return,
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
_ = hup.recv() => {}
_ = quit.recv() => {}
}
give_terminal_back();
std::process::exit(reserve_core::ExitClass::Interrupted.code().into());
})
}
#[cfg(not(unix))]
pub(crate) fn watch_for_signals() -> tokio::task::JoinHandle<()> {
tokio::spawn(async {
if tokio::signal::ctrl_c().await.is_ok() {
give_terminal_back();
std::process::exit(reserve_core::ExitClass::Interrupted.code().into());
}
})
}
#[derive(Debug, Clone)]
pub(crate) struct Chosen {
pub suffixes: Vec<Suffix>,
pub plan: Plan,
}
pub(crate) fn pick(catalog: &Catalog, plan: Plan) -> io::Result<Option<Chosen>> {
install_restore_hook();
enable_raw_mode()?;
let _guard = TerminalGuard;
let mut screen = io::stderr();
execute!(screen, EnterAlternateScreen, Hide)?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stderr()))?;
let mut picker = Picker::new(catalog, plan);
run(&mut terminal, &mut picker)
}
fn run(
terminal: &mut Terminal<CrosstermBackend<Stderr>>,
picker: &mut Picker<'_>,
) -> io::Result<Option<Chosen>> {
loop {
terminal.draw(|frame| render(picker, frame))?;
let event = event::read()?;
let Event::Key(key) = event else {
continue;
};
if !is_press(key) {
continue;
}
match on_key(picker, key) {
Flow::Continue => {}
Flow::Confirm => {
return Ok(Some(Chosen {
suffixes: picker.chosen_suffixes(),
plan: picker.plan.clone(),
}));
}
Flow::Quit => return Ok(None),
}
}
}
#[derive(Debug)]
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
give_terminal_back();
}
}
fn give_terminal_back() {
use std::io::Write as _;
if restore_terminal().is_err() {
let _ = writeln!(
io::stderr(),
"reserve: could not restore your terminal; run `stty sane` to fix it"
);
}
}
fn restore_terminal() -> io::Result<()> {
let mut screen = io::stderr();
let screen_back = execute!(screen, Show, LeaveAlternateScreen);
let raw_off = disable_raw_mode();
screen_back.and(raw_off)
}
fn install_restore_hook() {
static INSTALLED: Once = Once::new();
INSTALLED.call_once(|| {
let previous = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = restore_terminal();
previous(info);
}));
});
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pane {
Groups,
Extensions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Flow {
Continue,
Confirm,
Quit,
}
#[derive(Debug)]
enum Row<'a> {
Family(Family),
Group(&'a Group),
}
#[derive(Debug)]
struct Picker<'a> {
catalog: &'a Catalog,
rows: Vec<Row<'a>>,
group_rows: Vec<usize>,
group_cursor: usize,
extension_cursor: usize,
focus: Pane,
query: Option<String>,
chosen: BTreeSet<Suffix>,
notice: Option<&'static str>,
showing_help: bool,
on_settings: bool,
setting_cursor: usize,
plan: Plan,
filter: Filter,
sort: Sort,
}
impl<'a> Picker<'a> {
fn new(catalog: &'a Catalog, plan: Plan) -> Self {
let mut rows = Vec::new();
let mut group_rows = Vec::new();
for family in Family::all() {
let groups = catalog.groups_in(family);
if groups.is_empty() {
continue;
}
rows.push(Row::Family(family));
for group in groups {
group_rows.push(rows.len());
rows.push(Row::Group(group));
}
}
Self {
catalog,
rows,
group_rows,
group_cursor: 0,
extension_cursor: 0,
focus: Pane::Groups,
query: None,
chosen: BTreeSet::new(),
notice: None,
showing_help: false,
on_settings: false,
setting_cursor: 0,
filter: plan.filter(),
sort: plan.sort(),
plan,
}
}
fn settings_changed(&mut self) {
self.filter = self.plan.filter();
self.sort = self.plan.sort();
let kept: BTreeSet<Suffix> = self
.catalog
.extensions
.iter()
.filter(|ext| self.filter.admits(ext) && self.chosen.contains(&ext.suffix))
.map(|ext| ext.suffix.clone())
.collect();
self.chosen = kept;
self.clamp_extension_cursor();
}
fn current_group(&self) -> Option<&'a Group> {
let position = self.group_rows.get(self.group_cursor)?;
match self.rows.get(*position) {
Some(Row::Group(group)) => Some(*group),
_ => None,
}
}
fn shown(&self) -> Vec<&'a Extension> {
let Some(group) = self.current_group() else {
return Vec::new();
};
let needle = self.query.as_deref().unwrap_or_default().to_lowercase();
let mut listed: Vec<&'a Extension> = self
.catalog
.extensions
.iter()
.filter(|ext| group.holds(ext))
.filter(|ext| self.filter.admits(ext))
.filter(|ext| matches_search(ext, &needle))
.collect();
listed.sort_by(|left, right| self.sort.compare(left, right));
listed
}
fn members(&self, group: &Group) -> Vec<Suffix> {
self.catalog
.extensions
.iter()
.filter(|ext| group.holds(ext) && self.filter.admits(ext))
.map(|ext| ext.suffix.clone())
.collect()
}
fn group_counts(&self, group: &Group) -> (usize, usize) {
let total = self
.catalog
.extensions
.iter()
.filter(|ext| group.holds(ext) && self.filter.admits(ext))
.count();
let picked = self
.catalog
.extensions
.iter()
.filter(|ext| group.holds(ext) && self.chosen.contains(&ext.suffix))
.count();
(picked, total)
}
fn is_chosen(&self, suffix: &Suffix) -> bool {
self.chosen.contains(suffix)
}
fn is_searching(&self) -> bool {
self.query.is_some()
}
fn query(&self) -> Option<&str> {
self.query.as_deref()
}
fn move_up(&mut self) {
match self.focus {
Pane::Groups => {
let moved = self.group_cursor.saturating_sub(1);
if moved != self.group_cursor {
self.group_cursor = moved;
self.extension_cursor = 0;
}
}
Pane::Extensions => self.extension_cursor = self.extension_cursor.saturating_sub(1),
}
}
fn move_down(&mut self) {
match self.focus {
Pane::Groups => {
let last = self.group_rows.len().saturating_sub(1);
if self.group_cursor < last {
self.group_cursor = self.group_cursor.saturating_add(1);
self.extension_cursor = 0;
}
}
Pane::Extensions => {
let last = self.shown().len().saturating_sub(1);
if self.extension_cursor < last {
self.extension_cursor = self.extension_cursor.saturating_add(1);
}
}
}
}
fn switch_focus(&mut self) {
self.focus = match self.focus {
Pane::Groups => Pane::Extensions,
Pane::Extensions => Pane::Groups,
};
}
fn toggle_current(&mut self) {
self.notice = None;
match self.focus {
Pane::Groups => {
let Some(group) = self.current_group() else {
return;
};
let members = self.members(group);
let all_chosen =
!members.is_empty() && members.iter().all(|suffix| self.is_chosen(suffix));
for suffix in members {
if all_chosen {
self.chosen.remove(&suffix);
} else {
self.chosen.insert(suffix);
}
}
}
Pane::Extensions => {
let shown = self.shown();
let Some(ext) = shown.get(self.extension_cursor) else {
return;
};
let suffix = ext.suffix.clone();
if !self.chosen.remove(&suffix) {
self.chosen.insert(suffix);
}
}
}
}
fn choose_all_shown(&mut self) {
self.notice = None;
for ext in self.shown() {
self.chosen.insert(ext.suffix.clone());
}
}
fn clear_chosen(&mut self) {
self.chosen.clear();
}
fn start_search(&mut self) {
if self.query.is_none() {
self.query = Some(String::new());
}
self.focus = Pane::Extensions;
self.extension_cursor = 0;
}
fn cancel_search(&mut self) {
self.query = None;
self.clamp_extension_cursor();
}
fn push_search(&mut self, letter: char) {
if let Some(query) = self.query.as_mut() {
query.push(letter);
}
self.extension_cursor = 0;
}
fn pop_search(&mut self) {
if let Some(query) = self.query.as_mut() {
query.pop();
}
self.clamp_extension_cursor();
}
fn clamp_extension_cursor(&mut self) {
let last = self.shown().len().saturating_sub(1);
if self.extension_cursor > last {
self.extension_cursor = last;
}
}
fn chosen_suffixes(&self) -> Vec<Suffix> {
self.chosen.iter().cloned().collect()
}
fn confirm(&mut self) -> Flow {
if self.chosen.is_empty() {
self.notice =
Some("Nothing picked yet. Press Space to pick an extension, or q to leave.");
return Flow::Continue;
}
self.notice = None;
Flow::Confirm
}
fn notice(&self) -> Option<&'static str> {
self.notice
}
const fn showing_help(&self) -> bool {
self.showing_help
}
fn open_settings(&mut self) {
self.on_settings = true;
self.notice = None;
}
fn close_settings(&mut self) {
self.on_settings = false;
}
fn current_setting(&self) -> Setting {
Setting::ALL
.get(self.setting_cursor)
.copied()
.unwrap_or(Setting::Names)
}
fn settings_up(&mut self) {
self.setting_cursor = self.setting_cursor.saturating_sub(1);
}
fn settings_down(&mut self) {
let last = Setting::ALL.len().saturating_sub(1);
self.setting_cursor = self.setting_cursor.saturating_add(1).min(last);
}
fn toggle_help(&mut self) {
self.showing_help = !self.showing_help;
self.notice = None;
}
}
fn matches_search(ext: &Extension, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
if ext.suffix.as_str().to_lowercase().contains(needle) {
return true;
}
if ext
.country
.as_deref()
.is_some_and(|country| country.to_lowercase().contains(needle))
{
return true;
}
ext.industries
.iter()
.any(|industry| industry.to_lowercase().contains(needle))
}
fn on_key(picker: &mut Picker<'_>, key: KeyEvent) -> Flow {
if is_interrupt(key) {
return Flow::Quit;
}
if matches!(key.code, KeyCode::Char(_))
&& key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Flow::Continue;
}
if picker.showing_help() {
picker.toggle_help();
return Flow::Continue;
}
if matches!(key.code, KeyCode::Char('?') | KeyCode::F(1))
&& !picker.is_searching()
&& !picker.on_settings
{
picker.toggle_help();
return Flow::Continue;
}
if picker.on_settings {
return on_settings_key(picker, key);
}
if picker.is_searching() {
match key.code {
KeyCode::Esc => picker.cancel_search(),
KeyCode::Enter => return picker.confirm(),
KeyCode::Backspace => picker.pop_search(),
KeyCode::Up => picker.move_up(),
KeyCode::Down => picker.move_down(),
KeyCode::Tab | KeyCode::BackTab => picker.switch_focus(),
KeyCode::Char(letter) if !letter.is_control() => picker.push_search(letter),
_ => {}
}
return Flow::Continue;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
KeyCode::Tab | KeyCode::BackTab => picker.switch_focus(),
KeyCode::Char(' ') => picker.toggle_current(),
KeyCode::Char('/') => picker.start_search(),
KeyCode::Char('a') => picker.choose_all_shown(),
KeyCode::Char('n') => picker.clear_chosen(),
KeyCode::Char('o') => picker.open_settings(),
KeyCode::Enter => return picker.confirm(),
KeyCode::Char('q') | KeyCode::Esc => return Flow::Quit,
_ => {}
}
Flow::Continue
}
fn on_settings_key(picker: &mut Picker<'_>, key: KeyEvent) -> Flow {
let setting = picker.current_setting();
match key.code {
KeyCode::Esc => picker.close_settings(),
KeyCode::Up => picker.settings_up(),
KeyCode::Down => picker.settings_down(),
KeyCode::Left => {
picker.plan.cycle(setting, false);
picker.settings_changed();
}
KeyCode::Right => {
picker.plan.cycle(setting, true);
picker.settings_changed();
}
KeyCode::Backspace if setting.is_typed() => {
picker.plan.backspace(setting);
picker.settings_changed();
}
KeyCode::Char(letter) if setting.is_typed() => {
picker.plan.type_into(setting, letter);
picker.settings_changed();
}
KeyCode::Char(' ') => {
picker.plan.cycle(setting, true);
picker.settings_changed();
}
KeyCode::Enter => return picker.confirm(),
KeyCode::Tab => picker.close_settings(),
_ => {}
}
Flow::Continue
}
fn is_press(key: KeyEvent) -> bool {
key.kind == KeyEventKind::Press
}
fn is_interrupt(key: KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c' | 'C'))
}
fn render(picker: &Picker<'_>, frame: &mut Frame<'_>) {
let bounds = frame.area();
if bounds.width < MIN_WIDTH || bounds.height < MIN_HEIGHT {
render_too_small(frame, bounds);
return;
}
let search_height = if picker.is_searching() { 1 } else { 0 };
let [main, search_area, hint_area] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(search_height),
Constraint::Length(1),
])
.areas(bounds);
let [group_area, ext_area] =
Layout::horizontal([Constraint::Percentage(38), Constraint::Min(0)]).areas(main);
render_groups(frame, picker, clip(group_area, bounds));
render_extensions(frame, picker, clip(ext_area, bounds));
render_search(frame, picker, clip(search_area, bounds));
render_hints(frame, picker, clip(hint_area, bounds));
if picker.on_settings {
render_settings(frame, picker, bounds);
}
if picker.showing_help() {
render_help(frame, bounds);
}
}
fn render_settings(frame: &mut Frame<'_>, picker: &Picker<'_>, bounds: Rect) {
let [_, middle, _] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
])
.areas(bounds);
let area = clip(middle, bounds);
if area.is_empty() {
return;
}
let widest = Setting::ALL
.iter()
.map(|setting| setting.label().len())
.max()
.unwrap_or(0);
let rows: Vec<ListItem<'_>> = Setting::ALL
.iter()
.enumerate()
.map(|(at, setting)| {
let here = at == picker.setting_cursor;
let value = picker.plan.shown(*setting);
let typing = here && setting.is_typed();
let shown = if typing { format!("{value}_") } else { value };
ListItem::new(Line::from(vec![
Span::styled(
format!("{:<widest$}", setting.label()),
Style::new().fg(if here { Color::Cyan } else { Color::Gray }),
),
Span::raw(" "),
Span::styled(
shown,
Style::new().fg(Color::Yellow).add_modifier(if here {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
]))
})
.collect();
let mut state = ListState::default();
state.select(Some(picker.setting_cursor));
let title = format!(
" Settings {} of {} - Left/Right change Esc back Enter run ",
picker.setting_cursor.saturating_add(1),
Setting::ALL.len()
);
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(rows)
.block(
Block::bordered()
.title(title)
.border_style(Style::new().fg(Color::Cyan)),
)
.highlight_symbol("> "),
area,
&mut state,
);
}
fn render_help(frame: &mut Frame<'_>, bounds: Rect) {
let wanted_height = u16::try_from(HELP_ROWS.len())
.unwrap_or(u16::MAX)
.saturating_add(4);
let width = bounds.width.saturating_sub(4).clamp(1, 56);
let height = bounds.height.saturating_sub(2).clamp(1, wanted_height);
let [_, middle, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(height),
Constraint::Fill(1),
])
.areas(bounds);
let [_, area, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(width),
Constraint::Fill(1),
])
.areas(middle);
let area = clip(area, bounds);
if area.is_empty() {
return;
}
let widest = HELP_ROWS
.iter()
.map(|(key, _)| key.len())
.max()
.unwrap_or(0);
let mut lines: Vec<Line<'_>> = Vec::with_capacity(HELP_ROWS.len());
for (key, what) in HELP_ROWS {
lines.push(Line::from(vec![
Span::styled(
format!(" {key:>widest$} "),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled((*what).to_owned(), Style::new().fg(Color::Gray)),
]));
}
lines.push(Line::from(Span::styled(
" press any key to close",
Style::new().fg(Color::DarkGray),
)));
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines).block(
Block::bordered()
.title(" Keys ")
.border_style(Style::new().fg(Color::Yellow)),
),
area,
);
}
fn clip(area: Rect, bounds: Rect) -> Rect {
area.intersection(bounds)
}
fn render_too_small(frame: &mut Frame<'_>, area: Rect) {
if area.is_empty() {
return;
}
let note = Paragraph::new(format!(
"Window too small. Make it at least {MIN_WIDTH} columns by {MIN_HEIGHT} rows."
))
.wrap(Wrap { trim: true })
.style(Style::new().fg(Color::Yellow));
frame.render_widget(note, area);
}
fn render_groups(frame: &mut Frame<'_>, picker: &Picker<'_>, area: Rect) {
if area.is_empty() {
return;
}
let focused = picker.focus == Pane::Groups;
let items: Vec<ListItem<'_>> = picker
.rows
.iter()
.map(|row| match row {
Row::Family(family) => ListItem::new(Line::from(Span::styled(
family.title(),
Style::new()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
Row::Group(group) => {
let (picked, total) = picker.group_counts(group);
let (mark, style) = if total > 0 && picked == total {
("all ", Style::new().fg(Color::Green))
} else if picked > 0 {
("some", Style::new().fg(Color::Cyan))
} else {
(" ", Style::new())
};
ListItem::new(Line::from(vec![
Span::styled(mark, style),
Span::raw(format!(" {} ", group.title)),
Span::styled(
format!("{picked}/{total}"),
Style::new().fg(Color::DarkGray),
),
]))
}
})
.collect();
let list = List::new(items)
.block(pane_block("Groups", focused))
.highlight_symbol(if focused { "> " } else { " " })
.highlight_style(highlight_style(focused));
let mut state =
ListState::default().with_selected(picker.group_rows.get(picker.group_cursor).copied());
frame.render_stateful_widget(list, area, &mut state);
}
fn render_extensions(frame: &mut Frame<'_>, picker: &Picker<'_>, area: Rect) {
if area.is_empty() {
return;
}
let focused = picker.focus == Pane::Extensions;
let title = format!("Extensions ({} chosen)", picker.chosen.len());
let block = pane_block(&title, focused);
let shown = picker.shown();
if shown.is_empty() {
let reason = if picker.is_searching() {
"Nothing matches what you typed. Press Esc to clear the search."
} else {
"This group is empty."
};
let note = Paragraph::new(reason)
.block(block)
.wrap(Wrap { trim: true })
.style(Style::new().fg(Color::DarkGray));
frame.render_widget(note, area);
return;
}
let items: Vec<ListItem<'_>> = shown
.iter()
.map(|ext| {
let chosen = picker.is_chosen(&ext.suffix);
let (mark, mark_style) = if chosen {
(
"[x] ",
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
)
} else {
("[ ] ", Style::new().fg(Color::DarkGray))
};
let mut spans = vec![
Span::styled(mark, mark_style),
Span::raw(format!(".{}", ext.suffix)),
];
let detail = describe_extension(ext);
if !detail.is_empty() {
spans.push(Span::styled(
format!(" {detail}"),
Style::new().fg(Color::DarkGray),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(block)
.highlight_symbol(if focused { "> " } else { " " })
.highlight_style(highlight_style(focused));
let mut state = ListState::default().with_selected(Some(picker.extension_cursor));
frame.render_stateful_widget(list, area, &mut state);
}
fn render_search(frame: &mut Frame<'_>, picker: &Picker<'_>, area: Rect) {
if area.is_empty() {
return;
}
let Some(query) = picker.query() else {
return;
};
let line = Line::from(vec![
Span::styled(
" Search ",
Style::new()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(query.to_owned()),
Span::styled(" ", Style::new().add_modifier(Modifier::REVERSED)),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn render_hints(frame: &mut Frame<'_>, picker: &Picker<'_>, area: Rect) {
if area.is_empty() {
return;
}
let (text, style) = match picker.notice() {
Some(notice) => (notice, Style::new().fg(Color::Yellow)),
None => (
hint_text(area.width, picker.is_searching()),
Style::new().fg(Color::DarkGray),
),
};
let hints = Paragraph::new(text).style(style);
frame.render_widget(hints, area);
}
fn hint_text(width: u16, searching: bool) -> &'static str {
if searching {
if width >= 44 {
"Type to filter Esc cancel Enter confirm"
} else {
"Esc cancel Enter done"
}
} else if width >= 104 {
"Up/Down move Tab pane Space toggle / search a all n none o settings Enter run ? help"
} else if width >= 60 {
"Space pick / find o settings Enter run ? help"
} else {
"Space pick Enter run ? help"
}
}
const HELP_ROWS: &[(&str, &str)] = &[
("o", "open the settings, where every option lives"),
("Up / Down", "move through the list"),
("k / j", "move through the list"),
("Tab", "switch between groups and extensions"),
("Space", "pick or unpick the row under the cursor"),
("/", "search by extension, country, or industry"),
("a", "pick everything currently shown"),
("n", "unpick everything"),
("Enter", "finish and check what you picked"),
("Esc", "close the search, or leave the picker"),
("q", "leave without checking anything"),
("Ctrl-C", "leave straight away"),
("?", "open and close this card"),
(
"Left / Right",
"on settings, change the row under the cursor",
),
("Backspace", "on settings, delete a letter from a typed row"),
];
fn pane_block(title: &str, focused: bool) -> Block<'static> {
let (label, style) = if focused {
(
format!(" {title} - active "),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)
} else {
(format!(" {title} "), Style::new().fg(Color::DarkGray))
};
Block::bordered().title(label).border_style(style)
}
fn highlight_style(focused: bool) -> Style {
if focused {
Style::new().add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::new().add_modifier(Modifier::BOLD)
}
}
fn describe_extension(ext: &Extension) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(country) = ext.country.as_deref() {
parts.push(country.to_owned());
}
if !ext.industries.is_empty() {
parts.push(ext.industries.join(", "));
}
if !ext.registrable {
parts.push("restricted".to_owned());
}
parts.join(" - ")
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = r#"{
"version": 1,
"generated_on": "2026-01-01",
"extensions": [
{"suffix": "com", "kind": "generic", "rank": 1, "industries": ["shop", "tech"]},
{"suffix": "dev", "kind": "generic", "rank": 2, "industries": ["tech"]},
{"suffix": "io", "kind": "generic", "rank": 3, "industries": ["tech"]},
{"suffix": "uk", "kind": "country", "rank": 4, "region": "europe", "country": "United Kingdom"},
{"suffix": "bd", "kind": "country", "region": "south-asia", "country": "Bangladesh"}
],
"groups": [
{"key": "tech", "family": "industry", "title": "Tech", "summary": "For software",
"selector": {"rule": "industry", "key": "tech"}, "order": 1},
{"key": "shop", "family": "industry", "title": "Shop", "summary": "For retail",
"selector": {"rule": "industry", "key": "shop"}, "order": 2},
{"key": "south-asia", "family": "region", "title": "South Asia", "summary": "South Asian codes",
"selector": {"rule": "region", "key": "south-asia"}, "order": 1},
{"key": "top-3", "family": "popularity", "title": "Top 3", "summary": "Most registered",
"selector": {"rule": "top-rank", "max_rank": 3}, "order": 1},
{"key": "classic", "family": "curated", "title": "Classic", "summary": "The old guard",
"selector": {"rule": "explicit", "suffixes": ["com"]}, "order": 1}
]
}"#;
fn test_plan() -> Plan {
use clap::Parser as _;
Plan::from_args(&crate::cli::Cli::parse_from(["reserve", "example"]))
}
fn catalog() -> Catalog {
Catalog::from_json(FIXTURE).expect("the fixture catalog must parse")
}
fn shown_names(picker: &Picker<'_>) -> Vec<String> {
picker
.shown()
.iter()
.map(|ext| ext.suffix.as_str().to_owned())
.collect()
}
fn chosen_names(picker: &Picker<'_>) -> Vec<String> {
picker
.chosen_suffixes()
.iter()
.map(|suffix| suffix.as_str().to_owned())
.collect()
}
#[test]
fn the_first_group_is_highlighted_when_the_picker_opens() {
let catalog = catalog();
let picker = Picker::new(&catalog, test_plan());
assert_eq!(picker.group_rows.len(), 5);
assert_eq!(
picker.current_group().map(|group| group.key.as_str()),
Some("top-3"),
"the most-asked-for list is the one the cursor opens on"
);
assert_eq!(shown_names(&picker), vec!["com", "dev", "io"]);
}
#[test]
fn moving_up_from_the_first_group_stays_on_the_first_group() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.move_up();
picker.move_up();
assert_eq!(picker.group_cursor, 0);
}
#[test]
fn moving_down_past_the_last_group_stays_on_the_last_group() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
for _ in 0..20 {
picker.move_down();
}
assert_eq!(picker.group_cursor, 4);
assert_eq!(
picker.current_group().map(|group| group.key.as_str()),
Some("south-asia")
);
}
#[test]
fn moving_down_past_the_last_extension_stays_on_the_last_extension() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
for _ in 0..20 {
picker.move_down();
}
assert_eq!(picker.extension_cursor, 2);
}
#[test]
fn moving_up_from_the_first_extension_stays_on_the_first_extension() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.move_down();
picker.move_up();
picker.move_up();
picker.move_up();
assert_eq!(picker.extension_cursor, 0);
}
#[test]
fn changing_group_puts_the_extension_cursor_back_at_the_top() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.move_down();
assert_eq!(picker.extension_cursor, 1);
picker.switch_focus();
picker.move_down();
assert_eq!(picker.extension_cursor, 0);
}
#[test]
fn toggling_one_extension_twice_adds_it_then_removes_it() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.toggle_current();
assert_eq!(chosen_names(&picker), vec!["com"]);
picker.toggle_current();
assert!(picker.chosen_suffixes().is_empty());
}
#[test]
fn toggling_a_group_takes_every_extension_in_it_then_gives_them_all_back() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.toggle_current();
assert_eq!(chosen_names(&picker), vec!["com", "dev", "io"]);
picker.toggle_current();
assert!(picker.chosen_suffixes().is_empty());
}
#[test]
fn selecting_everything_shown_then_clearing_leaves_nothing() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.choose_all_shown();
assert_eq!(chosen_names(&picker), vec!["com", "dev", "io"]);
picker.clear_chosen();
assert!(picker.chosen_suffixes().is_empty());
}
#[test]
fn a_search_narrows_the_list_and_cancelling_it_restores_the_list() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.start_search();
picker.push_search('d');
picker.push_search('e');
assert_eq!(shown_names(&picker), vec!["dev"]);
picker.cancel_search();
assert_eq!(shown_names(&picker), vec!["com", "dev", "io"]);
}
#[test]
fn a_search_matches_the_country_name_as_well_as_the_extension() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
while picker.current_group().map(|group| group.key.as_str()) != Some("south-asia") {
picker.move_down();
}
picker.start_search();
for letter in "bangla".chars() {
picker.push_search(letter);
}
assert_eq!(shown_names(&picker), vec!["bd"]);
}
#[test]
fn a_search_that_matches_nothing_leaves_the_extension_cursor_on_the_list() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.move_down();
picker.move_down();
picker.start_search();
for letter in "zzz".chars() {
picker.push_search(letter);
}
assert!(picker.shown().is_empty());
assert_eq!(picker.extension_cursor, 0);
picker.toggle_current();
assert!(picker.chosen_suffixes().is_empty());
}
#[test]
fn deleting_the_last_search_letter_widens_the_list_again() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.start_search();
picker.push_search('d');
picker.push_search('e');
assert_eq!(shown_names(&picker), vec!["dev"]);
picker.pop_search();
picker.pop_search();
assert_eq!(shown_names(&picker), vec!["com", "dev", "io"]);
}
#[test]
fn selecting_all_while_searching_takes_only_what_is_shown() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.start_search();
picker.push_search('i');
picker.push_search('o');
picker.choose_all_shown();
assert_eq!(chosen_names(&picker), vec!["io"]);
}
#[test]
fn confirming_returns_exactly_what_was_toggled() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.toggle_current();
picker.move_down();
picker.move_down();
picker.toggle_current();
assert_eq!(chosen_names(&picker), vec!["com", "io"]);
}
#[test]
fn a_choice_made_in_one_group_survives_moving_to_another() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.toggle_current();
picker.switch_focus();
while picker.current_group().map(|group| group.key.as_str()) != Some("south-asia") {
picker.move_down();
}
picker.switch_focus();
picker.toggle_current();
assert_eq!(chosen_names(&picker), vec!["bd", "com"]);
}
#[test]
fn the_group_tally_counts_only_the_extensions_that_group_holds() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
picker.toggle_current();
let group = picker.current_group().expect("a group must be highlighted");
assert_eq!(picker.group_counts(group), (1, 3));
}
#[test]
fn quitting_and_confirming_are_told_apart_by_the_key() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Char('q'))),
Flow::Quit
);
picker.toggle_current();
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Enter)),
Flow::Confirm,
"with something picked, Enter finishes"
);
}
#[test]
fn every_binding_the_picker_answers_to_is_listed_on_the_help_card() {
let listed: Vec<&str> = HELP_ROWS.iter().map(|(key, _)| *key).collect();
for key in [
"Up / Down",
"k / j",
"Tab",
"Space",
"/",
"a",
"n",
"o",
"Enter",
"Esc",
"q",
"Ctrl-C",
"?",
] {
assert!(listed.contains(&key), "{key} is answered but never listed");
}
for (_, what) in HELP_ROWS {
assert!(!what.is_empty(), "every key says what it does");
}
}
#[test]
fn the_help_key_is_offered_at_every_width_because_the_bar_drops_the_rest() {
for width in [40, 60, 80, 100, 120, 200] {
assert!(
hint_text(width, false).contains("? help"),
"width {width} hides the way to find the other keys"
);
}
}
#[test]
fn every_setting_the_command_line_accepts_is_reachable_in_the_picker() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
on_key(&mut picker, KeyEvent::from(KeyCode::Char('o')));
assert!(picker.on_settings, "o opens the settings");
let mut seen = 1;
for _ in 0..Setting::ALL.len() {
on_key(&mut picker, KeyEvent::from(KeyCode::Down));
seen += 1;
}
assert!(
seen > Setting::ALL.len(),
"the cursor must reach the last row"
);
assert_eq!(
picker.setting_cursor,
Setting::ALL.len() - 1,
"and stop there rather than running off the end"
);
on_key(&mut picker, KeyEvent::from(KeyCode::Esc));
assert!(!picker.on_settings, "Esc goes back to the panes");
}
#[test]
fn a_setting_changes_from_the_keyboard_and_survives_leaving_the_screen() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
let before = picker.plan.source;
on_key(&mut picker, KeyEvent::from(KeyCode::Char('o')));
while picker.current_setting() != Setting::Source {
on_key(&mut picker, KeyEvent::from(KeyCode::Down));
}
on_key(&mut picker, KeyEvent::from(KeyCode::Right));
assert_ne!(picker.plan.source, before, "Right changes the value");
let changed = picker.plan.source;
on_key(&mut picker, KeyEvent::from(KeyCode::Esc));
assert_eq!(picker.plan.source, changed, "the change is kept");
}
#[test]
fn typing_into_a_settings_row_is_text_rather_than_a_command() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
on_key(&mut picker, KeyEvent::from(KeyCode::Char('o')));
while picker.current_setting() != Setting::Names {
on_key(&mut picker, KeyEvent::from(KeyCode::Up));
}
picker.plan.names.clear();
for letter in ['q', 'u', 'i', 'z'] {
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Char(letter))),
Flow::Continue
);
}
assert_eq!(picker.plan.names, "quiz");
on_key(&mut picker, KeyEvent::from(KeyCode::Backspace));
assert_eq!(picker.plan.names, "qui");
}
#[test]
fn the_settings_screen_carries_the_plan_out_with_the_extensions() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
on_key(&mut picker, KeyEvent::from(KeyCode::Char('o')));
while picker.current_setting() != Setting::AvailableOnly {
on_key(&mut picker, KeyEvent::from(KeyCode::Down));
}
on_key(&mut picker, KeyEvent::from(KeyCode::Char(' ')));
assert!(picker.plan.available_only);
on_key(&mut picker, KeyEvent::from(KeyCode::Esc));
picker.toggle_current();
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Enter)),
Flow::Confirm,
"Enter finishes once something is picked"
);
assert!(
picker.plan.available_only,
"what was set on the settings screen reaches the run"
);
}
#[test]
fn a_selection_setting_narrows_the_list_the_picker_shows() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
while picker.current_group().map(|g| g.key.as_str()) != Some("south-asia") {
picker.move_down();
}
picker.switch_focus();
assert_eq!(shown_names(&picker), ["bd"], "the group starts unfiltered");
picker.plan.exclude = "bd".to_owned();
picker.settings_changed();
assert!(
shown_names(&picker).is_empty(),
"an excluded extension leaves the list it was in"
);
}
#[test]
fn hiding_an_extension_also_takes_it_out_of_what_was_already_picked() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
while picker.current_group().map(|g| g.key.as_str()) != Some("south-asia") {
picker.move_down();
}
picker.switch_focus();
picker.toggle_current();
assert_eq!(chosen_names(&picker), ["bd"]);
picker.plan.exclude = "bd".to_owned();
picker.settings_changed();
assert!(
chosen_names(&picker).is_empty(),
"an extension the settings hid must not still be checked"
);
}
#[test]
fn the_order_setting_reorders_the_list_on_screen() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.switch_focus();
let by_popularity = shown_names(&picker);
picker.plan.sort = crate::cli::SortFieldArg::Name;
picker.plan.order = Some(crate::cli::SortOrderArg::Desc);
picker.settings_changed();
let mut reversed = by_popularity.clone();
reversed.sort();
reversed.reverse();
assert_eq!(
shown_names(&picker),
reversed,
"the list follows the setting"
);
}
#[test]
fn the_card_opens_on_its_key_and_any_key_closes_it() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
assert!(!picker.showing_help());
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Char('?'))),
Flow::Continue
);
assert!(picker.showing_help(), "? opens the card");
let before = picker.chosen_suffixes();
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Char(' '))),
Flow::Continue
);
assert!(!picker.showing_help(), "the next key closes the card");
assert_eq!(
picker.chosen_suffixes(),
before,
"the closing key must not also toggle a row"
);
}
#[test]
fn the_help_key_is_text_while_the_search_box_is_open() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
on_key(&mut picker, KeyEvent::from(KeyCode::Char('/')));
on_key(&mut picker, KeyEvent::from(KeyCode::Char('?')));
assert!(!picker.showing_help(), "a typed ? is a search character");
assert_eq!(picker.query(), Some("?"));
}
#[test]
fn confirming_with_nothing_picked_explains_rather_than_leaving() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Enter)),
Flow::Continue,
"an empty confirm must never end the run"
);
assert!(
picker.notice().is_some_and(|n| n.contains("Space")),
"the picker says what is missing and how to fix it"
);
picker.toggle_current();
assert!(picker.notice().is_none(), "acting on the advice clears it");
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Enter)),
Flow::Confirm
);
}
#[test]
fn a_letter_typed_into_the_search_box_is_text_rather_than_a_command() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
on_key(&mut picker, KeyEvent::from(KeyCode::Char('/')));
let flow = on_key(&mut picker, KeyEvent::from(KeyCode::Char('q')));
assert_eq!(flow, Flow::Continue);
assert_eq!(picker.query(), Some("q"));
}
#[test]
fn control_c_quits_even_while_the_search_box_is_open() {
let catalog = catalog();
let mut picker = Picker::new(&catalog, test_plan());
picker.start_search();
let interrupt = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(on_key(&mut picker, interrupt), Flow::Quit);
}
#[test]
fn the_hint_bar_shrinks_with_the_terminal() {
assert!(hint_text(120, false).len() > hint_text(70, false).len());
assert!(hint_text(70, false).len() > hint_text(40, false).len());
for width in [40_u16, 60, 70, 104, 120, 200] {
for searching in [false, true] {
let text = hint_text(width, searching);
assert!(
text.len() <= usize::from(width),
"the bar at width {width} is {} characters and would be clipped mid-word",
text.len()
);
}
}
}
}