use std::io;
use std::io::Write;
use crate::theme::Theme;
use crate::{DemandOption, ctrlc, theme};
use console::{Alignment, Key, Term};
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use itertools::Itertools;
use termcolor::{Buffer, WriteColor};
pub struct Select<'a, T> {
pub title: String,
pub theme: &'a Theme,
pub description: String,
pub options: Vec<DemandOption<T>>,
pub filterable: bool,
cursor_x: usize,
cursor_y: usize,
last_frame: String,
term: Term,
filter: String,
filtering: bool,
pages: usize,
cur_page: usize,
capacity: usize,
fuzzy_matcher: SkimMatcherV2,
}
impl<'a, T> Select<'a, T> {
pub fn new<S: Into<String>>(title: S) -> Self {
let mut s = Select {
title: title.into(),
description: String::new(),
options: vec![],
filterable: false,
theme: &theme::DEFAULT,
cursor_x: 0,
cursor_y: 0,
last_frame: String::new(),
term: Term::stderr(),
filter: String::new(),
filtering: false,
pages: 0,
cur_page: 0,
capacity: 0,
fuzzy_matcher: SkimMatcherV2::default().use_cache(true).smart_case(),
};
let max_height = s.term.size().0 as usize;
s.capacity = max_height.max(8) - 6;
s
}
pub fn description(mut self, description: &str) -> Self {
self.description = description.to_string();
self
}
pub fn option(mut self, option: DemandOption<T>) -> Self {
self.options.push(option);
self.pages = self.get_pages();
self.cursor_y = self.get_selected_option_idx();
self
}
pub fn options(mut self, options: Vec<DemandOption<T>>) -> Self {
for option in options {
self.options.push(option);
}
self.pages = self.get_pages();
self.cursor_y = self.get_selected_option_idx();
self
}
pub fn filterable(mut self, filterable: bool) -> Self {
self.filterable = filterable;
self
}
pub fn filtering(mut self, filtering: bool) -> Self {
self.filtering = filtering;
self
}
pub fn theme(mut self, theme: &'a Theme) -> Self {
self.theme = theme;
self
}
pub fn run(mut self) -> io::Result<T> {
let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?;
let mut events = crate::event::EventReader::new()?;
let mut reset_viewport = false;
loop {
self.refresh_layout();
let term = self.term.clone();
crate::synchronized_output::run(&term, || {
if reset_viewport {
self.term.clear_screen()?;
self.last_frame.clear();
reset_viewport = false;
} else {
self.clear()?;
}
self.draw()?;
self.term.hide_cursor()
})?;
let enter = |mut select: Select<T>| {
let id = select.visible_options().get(select.cursor_y).unwrap().id;
let selected = select.options.iter().find(|o| o.id == id).unwrap();
let output = select.render_success(&selected.label)?;
let term = select.term.clone();
crate::synchronized_output::run(&term, || {
select.clear()?;
select.term.show_cursor()?;
select.term.write_all(output.as_bytes())?;
select.term.clear_to_end_of_screen()
})?;
let selected = select.options.into_iter().find(|o| o.id == id).unwrap();
Ok::<T, io::Error>(selected.item)
};
let Some(key) = events.read_key(&self.term)? else {
reset_viewport = true;
continue;
};
if self.filtering {
match key {
Key::ArrowDown => self.handle_down()?,
Key::ArrowUp => self.handle_up()?,
Key::ArrowLeft => self.handle_left()?,
Key::ArrowRight => self.handle_right()?,
Key::Enter if !self.visible_options().is_empty() => {
return enter(self);
}
Key::Escape => self.handle_stop_filtering(false)?,
Key::Backspace => self.handle_filter_backspace()?,
Key::Char(c) => self.handle_filter_key(c)?,
_ => {}
}
} else {
match key {
Key::ArrowDown | Key::Char('j') => self.handle_down()?,
Key::ArrowUp | Key::Char('k') => self.handle_up()?,
Key::ArrowLeft | Key::Char('h') => self.handle_left()?,
Key::ArrowRight | Key::Char('l') => self.handle_right()?,
Key::Char('/') if self.filterable => self.handle_start_filtering(),
Key::Escape => {
if self.filter.is_empty() {
self.clear()?;
self.term.show_cursor()?;
ctrlc_handle.close();
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"user cancelled",
));
}
self.handle_stop_filtering(false)?;
}
Key::Enter if !self.visible_options().is_empty() => {
ctrlc_handle.close();
return enter(self);
}
_ => {}
}
}
}
}
fn filtered_options(&self) -> Vec<&DemandOption<T>> {
self.options
.iter()
.filter_map(|opt| {
if self.filter.is_empty() {
Some((0, opt))
} else {
self.fuzzy_matcher
.fuzzy_match(&opt.label.to_lowercase(), &self.filter.to_lowercase())
.map(|score| (score, opt))
}
})
.sorted_by_key(|(score, _opt)| -1 * *score)
.map(|(_score, opt)| opt)
.collect()
}
fn visible_options(&self) -> Vec<&DemandOption<T>> {
let filtered_options = self.filtered_options();
let start = self.cur_page * self.capacity;
filtered_options
.into_iter()
.skip(start)
.take(self.capacity)
.collect()
}
fn handle_down(&mut self) -> Result<(), io::Error> {
let visible_options = self.visible_options();
if self.cursor_y < visible_options.len().max(1) - 1 {
self.cursor_y += 1;
} else if self.pages > 0 && self.cur_page < self.pages - 1 {
self.cur_page += 1;
self.cursor_y = 0;
self.term.clear_to_end_of_screen()?;
}
Ok(())
}
fn handle_up(&mut self) -> Result<(), io::Error> {
if self.cursor_y > 0 {
self.cursor_y -= 1;
} else if self.cur_page > 0 {
self.cur_page -= 1;
self.cursor_y = self.visible_options().len().max(1) - 1;
self.term.clear_to_end_of_screen()?;
}
Ok(())
}
fn handle_left(&mut self) -> Result<(), io::Error> {
if self.filtering {
if self.cursor_x > 0 {
self.cursor_x -= 1;
}
} else if self.cur_page > 0 {
self.cur_page -= 1;
self.term.clear_to_end_of_screen()?;
}
Ok(())
}
fn handle_right(&mut self) -> Result<(), io::Error> {
if self.filtering {
if self.cursor_x < self.filter.chars().count() {
self.cursor_x += 1;
}
} else if self.pages > 0 && self.cur_page < self.pages - 1 {
self.cur_page += 1;
if self.cursor_y > self.visible_options().len() - 1 {
self.cursor_y = self.visible_options().len() - 1;
}
self.term.clear_to_end_of_screen()?;
}
Ok(())
}
fn handle_start_filtering(&mut self) {
self.filtering = true;
}
fn handle_stop_filtering(&mut self, save: bool) -> Result<(), io::Error> {
self.filtering = false;
self.cur_page = 0;
if !save {
self.filter.clear();
self.pages = self.get_pages();
}
self.term.clear_to_end_of_screen()
}
fn handle_filter_key(&mut self, c: char) -> Result<(), io::Error> {
let idx = self.get_char_idx(&self.filter, self.cursor_x);
self.filter.insert(idx, c);
self.cursor_x += 1;
self.cursor_y = 0;
self.cur_page = 0;
self.pages = self.get_pages();
self.term.clear_to_end_of_screen()
}
fn handle_filter_backspace(&mut self) -> Result<(), io::Error> {
let chars_count = self.filter.chars().count();
if chars_count > 0 && self.cursor_x > 0 {
let idx = self.get_char_idx(&self.filter, self.cursor_x - 1);
self.filter.remove(idx);
}
if self.cursor_x > 0 {
self.cursor_x -= 1;
}
self.cursor_y = 0;
self.cur_page = 0;
self.pages = self.get_pages();
self.term.clear_to_end_of_screen()
}
fn get_pages(&self) -> usize {
((self.filtered_options().len() as f64) / self.capacity as f64).ceil() as usize
}
fn refresh_layout(&mut self) {
self.resize_layout(self.term.size().0 as usize);
}
fn resize_layout(&mut self, rows: usize) {
let capacity = rows.max(8) - 6;
if capacity == self.capacity {
return;
}
let option_count = self.filtered_options().len();
let cursor =
(self.cur_page * self.capacity + self.cursor_y).min(option_count.saturating_sub(1));
self.capacity = capacity;
self.pages = self.get_pages();
self.cur_page = cursor / self.capacity;
self.cursor_y = cursor % self.capacity;
}
fn get_selected_option_idx(&mut self) -> usize {
self.visible_options()
.iter()
.rposition(|o| o.selected)
.unwrap_or(0)
}
fn render(&self) -> io::Result<String> {
let mut out = Buffer::ansi();
out.set_color(&self.theme.title)?;
write!(out, "{}", self.title)?;
writeln!(out)?;
if !self.description.is_empty() || self.pages > 1 {
out.set_color(&self.theme.description)?;
write!(out, "{}", self.description)?;
writeln!(out)?;
}
let max_label_len = self
.visible_options()
.iter()
.map(|o| console::measure_text_width(&o.label))
.max()
.unwrap_or(0);
for (i, option) in self.visible_options().iter().enumerate() {
if self.cursor_y == i {
out.set_color(&self.theme.cursor)?;
write!(out, "{}", self.theme.cursor_str)?;
} else {
write!(
out,
"{}",
" ".repeat(console::measure_text_width(&self.theme.cursor_str))
)?;
}
out.set_color(&self.theme.unselected_option)?;
if let Some(desc) = &option.description {
let label = console::pad_str(&option.label, max_label_len, Alignment::Left, None);
if self.filtering && !self.filter.is_empty() {
self.highlight_matches(&mut out, &label)?;
} else {
write!(out, " {label}")?;
}
out.set_color(&self.theme.description)?;
writeln!(out, " {desc}")?;
} else if self.filtering && !self.filter.is_empty() {
self.highlight_matches(&mut out, &option.label)?;
writeln!(out)?;
} else {
writeln!(out, " {}", option.label)?;
}
}
if !self.filtering && self.pages > 1 {
out.set_color(&self.theme.description)?;
writeln!(out, " (page {}/{})", self.cur_page + 1, self.pages)?;
}
if self.filtering {
out.set_color(&self.theme.input_cursor)?;
write!(out, "/")?;
out.reset()?;
let cursor_idx = self.get_char_idx(&self.filter, self.cursor_x);
write!(out, "{}", &self.filter[..cursor_idx])?;
if cursor_idx < self.filter.len() {
out.set_color(&self.theme.real_cursor_color(None))?;
write!(out, "{}", &self.filter[cursor_idx..cursor_idx + 1])?;
out.reset()?;
}
if cursor_idx + 1 < self.filter.len() {
out.reset()?;
write!(out, "{}", &self.filter[cursor_idx + 1..])?;
}
if cursor_idx >= self.filter.len() {
out.set_color(&self.theme.real_cursor_color(None))?;
write!(out, " ")?;
out.reset()?;
}
writeln!(out)?;
out.reset()?;
}
self.print_help_keys(&mut out)?;
writeln!(out)?;
out.reset()?;
Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string())
}
fn print_help_keys(&self, out: &mut Buffer) -> io::Result<()> {
let mut help_keys = vec![("↑/↓/k/j", "up/down")];
if self.pages > 1 {
help_keys.push(("←/→/h/l", "prev/next page"));
}
if self.filterable {
if self.filtering {
help_keys = vec![("esc", "clear filter")];
} else {
help_keys.push(("/", "filter"));
if !self.filter.is_empty() {
help_keys.push(("esc", "clear filter"));
}
}
}
help_keys.push(("enter", "confirm"));
for (i, (key, desc)) in help_keys.iter().enumerate() {
if i > 0 || (!self.filtering && !self.filter.is_empty()) {
out.set_color(&self.theme.help_sep)?;
write!(out, " • ")?;
}
out.set_color(&self.theme.help_key)?;
write!(out, "{key}")?;
out.set_color(&self.theme.help_desc)?;
write!(out, " {desc}")?;
}
Ok(())
}
fn get_char_idx(&self, input: &str, cursor: usize) -> usize {
input
.char_indices()
.nth(cursor)
.map(|(i, _)| i)
.unwrap_or(input.len())
}
fn highlight_matches(
&self,
out: &mut dyn WriteColor,
label: &str,
) -> Result<(), std::io::Error> {
let matches = self
.fuzzy_matcher
.fuzzy_indices(&label.to_lowercase(), &self.filter.to_lowercase());
if let Some((_, indices)) = matches {
for (j, c) in label.chars().enumerate() {
if indices.contains(&j) {
out.set_color(&self.theme.selected_option)?;
} else {
out.set_color(&self.theme.unselected_option)?;
}
if j == 0 {
write!(out, " ")?;
}
write!(out, "{c}")?;
}
} else {
write!(out, " {label}")?;
}
Ok(())
}
fn render_success(&self, selected: &str) -> io::Result<String> {
let mut out = Buffer::ansi();
out.set_color(&self.theme.title)?;
write!(out, "{}", self.title)?;
out.set_color(&self.theme.selected_option)?;
writeln!(out, " {selected}")?;
out.reset()?;
Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string())
}
fn draw(&mut self) -> io::Result<()> {
let output = self.render()?;
self.term.write_all(output.as_bytes())?;
self.term.flush()?;
self.last_frame = output;
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
let height = crate::height::rendered_height(&self.last_frame, self.term.size().1 as usize);
self.term.clear_last_lines(height)?;
self.last_frame.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::test::without_ansi;
#[cfg(unix)]
use crate::test::{capture_term, replay, snapshot};
use super::*;
use indoc::indoc;
#[test]
fn test_render() {
let select = Select::new("Country")
.description("Select your Country")
.option(DemandOption::new("United States"))
.option(DemandOption::new("Germany"))
.option(DemandOption::new("Brazil").selected(true))
.option(DemandOption::new("Canada"))
.option(DemandOption::new("Mexico"));
assert_eq!(
indoc! {
"Country
Select your Country
United States
Germany
❯ Brazil
Canada
Mexico
↑/↓/k/j up/down • enter confirm
"
},
without_ansi(select.render().unwrap().as_str())
);
}
#[test]
fn non_display() {
struct Thing {
num: u32,
_thing: Option<()>,
}
let things = [
Thing {
num: 1,
_thing: Some(()),
},
Thing {
num: 2,
_thing: None,
},
];
let select = Select::new("things").description("pick a thing").options(
things
.iter()
.enumerate()
.map(|(i, t)| {
if i == 0 {
DemandOption::with_label("First", t).selected(true)
} else {
DemandOption::new(t.num).item(t)
}
})
.collect(),
);
assert_eq!(
indoc! {
"things
pick a thing
❯ First
2
↑/↓/k/j up/down • enter confirm
"
},
without_ansi(select.render().unwrap().as_str())
);
}
#[cfg(unix)]
#[test]
fn a_wrapped_option_does_not_stack_copies_of_the_frame() {
let (term, buf) = capture_term();
let mut select = Select::new("Pick a script")
.option(DemandOption::new("long").label(&"x".repeat(140)))
.option(DemandOption::new("short"));
select.term = term;
let width = select.term.size().1 as usize;
assert!(width < 140, "test needs a label wider than the terminal");
for i in 0..3 {
select.clear().unwrap();
select.draw().unwrap();
select.cursor_y = i % select.options.len();
}
let mut parser = vt100::Parser::new(24, width as u16, 0);
replay(&mut parser, &snapshot(&buf));
let screen = parser.screen().contents();
assert_eq!(
screen.matches("Pick a script").count(),
1,
"prompt drawn more than once:\n{screen}"
);
}
#[test]
fn resize_preserves_focused_option() {
let mut select = Select::new("Pick").options(
(0..20)
.map(|value| DemandOption::new(value.to_string()))
.collect(),
);
select.capacity = 10;
select.pages = 2;
select.cur_page = 1;
select.cursor_y = 5;
select.resize_layout(10);
assert_eq!(select.capacity, 4);
assert_eq!(select.pages, 5);
assert_eq!(select.cur_page, 3);
assert_eq!(select.cursor_y, 3);
assert_eq!(select.visible_options()[select.cursor_y].label, "15");
}
}