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, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use reserve_core::{Catalog, Extension, Family, Group, Suffix};
const MIN_WIDTH: u16 = 40;
const MIN_HEIGHT: u16 = 10;
pub(crate) fn watch_for_signals() -> tokio::task::JoinHandle<()> {
tokio::spawn(async {
let mut term =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(stream) => stream,
Err(_) => return,
};
let mut hup = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) {
Ok(stream) => stream,
Err(_) => return,
};
tokio::select! {
_ = term.recv() => {}
_ = hup.recv() => {}
}
let _ = restore_terminal();
std::process::exit(reserve_core::ExitClass::Interrupted.code().into());
})
}
pub(crate) fn pick(catalog: &Catalog) -> io::Result<Option<Vec<Suffix>>> {
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);
run(&mut terminal, &mut picker)
}
fn run(
terminal: &mut Terminal<CrosstermBackend<Stderr>>,
picker: &mut Picker<'_>,
) -> io::Result<Option<Vec<Suffix>>> {
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(picker.chosen_suffixes())),
Flow::Quit => return Ok(None),
}
}
}
#[derive(Debug)]
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = restore_terminal();
}
}
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>,
}
impl<'a> Picker<'a> {
fn new(catalog: &'a Catalog) -> 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(),
}
}
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();
self.catalog
.extensions
.iter()
.filter(|ext| group.holds(ext))
.filter(|ext| matches_search(ext, &needle))
.collect()
}
fn members(&self, group: &Group) -> Vec<Suffix> {
self.catalog
.extensions
.iter()
.filter(|ext| group.holds(ext))
.map(|ext| ext.suffix.clone())
.collect()
}
fn group_counts(&self, group: &Group) -> (usize, usize) {
let total = self.catalog.group_size(group);
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) {
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) {
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 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 picker.is_searching() {
match key.code {
KeyCode::Esc => picker.cancel_search(),
KeyCode::Enter => return Flow::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) => 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::Enter => return Flow::Confirm,
KeyCode::Char('q') | KeyCode::Esc => return Flow::Quit,
_ => {}
}
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));
}
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 hints = Paragraph::new(hint_text(area.width, picker.is_searching()))
.style(Style::new().fg(Color::DarkGray));
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 >= 98 {
"Up/Down move Tab switch pane Space toggle / search a all n none Enter confirm q quit"
} else if width >= 60 {
"Space pick / find a all n none Enter done q quit"
} else {
"Space pick Enter done q quit"
}
}
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 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);
assert_eq!(picker.group_rows.len(), 5);
assert_eq!(
picker.current_group().map(|group| group.key.as_str()),
Some("tech")
);
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);
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);
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("classic")
);
}
#[test]
fn moving_down_past_the_last_extension_stays_on_the_last_extension() {
let catalog = catalog();
let mut picker = Picker::new(&catalog);
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);
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);
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);
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);
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);
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);
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);
picker.move_down();
picker.move_down();
assert_eq!(
picker.current_group().map(|group| group.key.as_str()),
Some("south-asia")
);
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);
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);
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);
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);
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);
picker.switch_focus();
picker.toggle_current();
picker.switch_focus();
picker.move_down();
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);
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);
assert_eq!(
on_key(&mut picker, KeyEvent::from(KeyCode::Char('q'))),
Flow::Quit
);
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);
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);
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());
assert!(hint_text(40, false).len() <= 40);
assert!(hint_text(40, true).len() <= 40);
}
}