use {
ratatui::{
crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
},
prelude::*,
symbols::border,
widgets::{
block::{Position, Title},
Block, List, ListState, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
Terminal,
},
std::{collections::BTreeSet, io},
tap::Tap,
};
#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone)]
pub struct ListEntry<K> {
pub key: K,
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListSearchExtra<'k, K> {
pub title: String,
pub text: String,
pub multi_select: bool,
pub empty_search_list: Option<&'k [ListEntry<K>]>,
pub initial_selection: BTreeSet<K>,
pub preserve_order: bool,
pub action_keys: Vec<(ActionKey, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionKey {
F(u8),
Ctrl(char),
Alt(char),
Insert,
Delete,
Home,
End,
PageUp,
PageDown,
}
impl ActionKey {
fn matches(&self, event: &KeyEvent) -> bool {
use ratatui::crossterm::event::KeyModifiers;
match (self, event.code) {
(ActionKey::F(n), KeyCode::F(m)) => *n == m,
(ActionKey::Ctrl(c), KeyCode::Char(k)) => {
*c == k && event.modifiers.contains(KeyModifiers::CONTROL)
}
(ActionKey::Alt(c), KeyCode::Char(k)) => {
*c == k && event.modifiers.contains(KeyModifiers::ALT)
}
(ActionKey::Insert, KeyCode::Insert) => true,
(ActionKey::Delete, KeyCode::Delete) => true,
(ActionKey::Home, KeyCode::Home) => true,
(ActionKey::End, KeyCode::End) => true,
(ActionKey::PageUp, KeyCode::PageUp) => true,
(ActionKey::PageDown, KeyCode::PageDown) => true,
_ => false,
}
}
fn label(&self) -> String {
match self {
ActionKey::F(n) => format!("F{n}"),
ActionKey::Ctrl(c) => format!("Ctrl+{}", c.to_uppercase()),
ActionKey::Alt(c) => format!("Alt+{}", c.to_uppercase()),
ActionKey::Insert => "Ins".into(),
ActionKey::Delete => "Del".into(),
ActionKey::Home => "Home".into(),
ActionKey::End => "End".into(),
ActionKey::PageUp => "PgUp".into(),
ActionKey::PageDown => "PgDn".into(),
}
}
}
#[derive(Debug, Clone)]
pub struct SelectionResult<K> {
pub selected: BTreeSet<K>,
pub action: Option<String>,
}
#[allow(clippy::type_complexity)]
pub type FilterCallback<K> = Box<dyn Fn(&str, &str, &ListEntry<K>) -> bool>;
pub type PreviewCallback<K> = Box<dyn Fn(&K) -> Option<String>>;
pub struct ListSearch<'k, K> {
pub extra: ListSearchExtra<'k, K>,
pub items: &'k [ListEntry<K>],
pub filter_callback: Option<FilterCallback<K>>,
pub preview_callback: Option<PreviewCallback<K>>,
}
struct ListSearchRunner<'c, 'k, K> {
config: &'c ListSearch<'k, K>,
displayed_list: Vec<&'k ListEntry<K>>,
search_input: String,
selected_items: BTreeSet<K>,
ui_list_state: ListState,
exit: bool,
show_preview: bool,
action: Option<String>,
}
impl<'k, K: Ord + Clone> ListSearch<'k, K> {
pub fn run(&self) -> io::Result<SelectionResult<K>> {
ListSearchRunner {
config: self,
displayed_list: Vec::new(),
search_input: String::new(),
selected_items: self.extra.initial_selection.clone(),
ui_list_state: ListState::default().tap_mut(|v| v.select(Some(0))),
exit: false,
show_preview: false,
action: None,
}
.run()
}
}
impl<'c, 'k, K: Ord + Clone> ListSearchRunner<'c, 'k, K> {
fn update_displayed_list(&mut self) {
if let Some(alt_list) = self.config.extra.empty_search_list {
if self.search_input.is_empty() {
self.displayed_list = alt_list.iter().collect();
return;
}
}
let search = self.search_input.to_lowercase();
let terms: Vec<_> = search.split(',').map(|s| s.trim()).collect();
self.displayed_list = self
.config
.items
.iter()
.filter(|item| search_filter(item, &terms, self.config.filter_callback.as_deref()))
.collect::<Vec<_>>()
.tap_mut(|v| {
if !self.config.extra.preserve_order {
v.sort_by_key(|item| &item.name);
}
});
}
fn selected_key(&self) -> Option<&K> {
let idx = self.ui_list_state.selected()?;
self.displayed_list.get(idx).map(|e| &e.key)
}
pub fn run(self) -> io::Result<SelectionResult<K>> {
let mut stderr = io::stderr();
execute!(stderr, EnterAlternateScreen)?;
enable_raw_mode()?;
let mut terminal = Terminal::new(CrosstermBackend::new(stderr.lock()))?;
let output = self.run_inner(&mut terminal);
execute!(stderr, LeaveAlternateScreen)?;
disable_raw_mode()?;
output
}
fn run_inner<T: ratatui::backend::Backend>(
mut self,
terminal: &mut Terminal<T>,
) -> io::Result<SelectionResult<K>> {
self.update_displayed_list();
while !self.exit {
terminal.draw(|frame| self.render_frame(frame))?;
self.handle_events()?;
}
Ok(SelectionResult {
selected: self.selected_items,
action: self.action,
})
}
fn render_frame(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.size());
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some((_, action_name)) = self
.config
.extra
.action_keys
.iter()
.find(|(ak, _)| ak.matches(&key_event))
{
self.action = Some(action_name.clone());
if let Some(key) = self.selected_key().cloned() {
self.selected_items.insert(key);
}
self.exit = true;
return;
}
match key_event.code {
KeyCode::Esc => {
self.selected_items = BTreeSet::new();
self.exit = true;
}
KeyCode::Enter => {
self.exit = true;
if self.config.extra.multi_select {
return;
}
let Some(selected_index) = self.ui_list_state.selected() else {
return;
};
let Some(item) = self.displayed_list.get(selected_index) else {
return;
};
self.selected_items.insert(item.key.clone());
}
KeyCode::Tab if self.config.preview_callback.is_some() => {
self.show_preview = !self.show_preview;
}
KeyCode::Char(c)
if !key_event.modifiers.intersects(
ratatui::crossterm::event::KeyModifiers::CONTROL
| ratatui::crossterm::event::KeyModifiers::ALT,
) =>
{
self.search_input.push(c);
self.ui_list_state.select(Some(0));
self.update_displayed_list();
}
KeyCode::Backspace => {
self.search_input.pop();
self.ui_list_state.select(Some(0));
self.update_displayed_list();
}
KeyCode::Up => {
self.ui_list_state.select_previous();
}
KeyCode::Down => {
self.ui_list_state.select_next();
}
KeyCode::Left if self.config.extra.multi_select => {
if self
.displayed_list
.iter()
.any(|item| self.selected_items.contains(&item.key))
{
for item in self.displayed_list.iter() {
self.selected_items.remove(&item.key);
}
} else {
for item in self.displayed_list.iter() {
self.selected_items.insert(item.key.clone());
}
}
}
KeyCode::Right if self.config.extra.multi_select => {
let Some(selected_index) = self.ui_list_state.selected() else {
return;
};
let Some(item) = self.displayed_list.get(selected_index) else {
return;
};
if self.selected_items.contains(&item.key) {
self.selected_items.remove(&item.key);
} else {
self.selected_items.insert(item.key.clone());
}
}
_ => {}
}
}
}
impl<'c, 'k, K: Ord + Clone> Widget for &mut ListSearchRunner<'c, 'k, K> {
fn render(self, area: Rect, buf: &mut Buffer) {
let total = self.config.items.len();
let shown = self.displayed_list.len();
let count_str = if shown == total {
format!(" ({total}) ")
} else {
format!(" ({shown}/{total}) ")
};
let title = Title::from(vec![
self.config.extra.title.as_str().bold().magenta(),
count_str.dim(),
]);
let mut instructions = Vec::new();
instructions.add_instruction("Change Line", "Up/Down");
if self.config.extra.multi_select {
instructions.add_instruction("Toggle select", "Right");
instructions.add_instruction("Toggle all", "Left");
}
if self.config.preview_callback.is_some() {
instructions.add_instruction("Preview", "Tab");
}
for (key, name) in &self.config.extra.action_keys {
instructions.add_instruction(name, &key.label());
}
instructions.add_instruction("Confirm", "Enter");
instructions.add_instruction("Quit", "Esc");
instructions.push(" ".into());
let instructions = Title::from(instructions);
let block = Block::bordered()
.title(title.alignment(Alignment::Center))
.title(
instructions
.alignment(Alignment::Center)
.position(Position::Bottom),
)
.border_set(border::ROUNDED)
.padding(Padding::horizontal(1));
let inner = block.inner(area);
let (main_area, preview_area) = if self.show_preview {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(inner);
(chunks[0], Some(chunks[1]))
} else {
(inner, None)
};
let [search_bar, separator, list_area, extra_text] = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(3),
Constraint::Max(3),
])
.areas(main_area);
let [search_label, search_area] = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(9), Constraint::Min(10)])
.areas(search_bar);
Line::from("Search :").render(search_label, buf);
let mut search_spans = vec![Span::styled(
self.search_input.as_str(),
Style::new().underlined(),
)];
search_spans.push(Span::styled("â–ˆ", Style::new().dim()));
Line::from(search_spans).render(search_area, buf);
let sep = "─".repeat(separator.width as usize);
Line::from(sep)
.style(Style::new().dark_gray())
.render(separator, buf);
let list: Vec<_> = self
.displayed_list
.iter()
.map(|item| {
let mut spans = Vec::new();
if self.config.extra.multi_select {
let c = if self.selected_items.contains(&item.key) {
"â—‰ "
} else {
"â—‹ "
};
spans.push(Span::raw(c));
}
spans.push(Span::raw(&item.name));
if let Some(ref desc) = item.description {
spans.push(Span::styled(
format!(" {desc}"),
Style::new().dim().italic(),
));
}
Line::from(spans)
})
.collect();
let list_len = list.len();
let list = List::new(list)
.highlight_style(Style::new().bold().reversed())
.scroll_padding(1);
StatefulWidget::render(&list, list_area, buf, &mut self.ui_list_state);
let visible_height = list_area.height as usize;
if list_len > visible_height {
let mut scrollbar_state =
ScrollbarState::new(list_len).position(self.ui_list_state.selected().unwrap_or(0));
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.render(list_area, buf, &mut scrollbar_state);
}
Paragraph::new(self.config.extra.text.as_str())
.wrap(Wrap { trim: true })
.style(Style::new().dark_gray().italic())
.render(extra_text, buf);
if let Some(preview_area) = preview_area {
let preview_text = self
.selected_key()
.and_then(|key| self.config.preview_callback.as_ref().and_then(|cb| cb(key)))
.unwrap_or_default();
let preview_block = Block::bordered()
.title(" Preview ".bold().cyan())
.border_set(border::ROUNDED)
.padding(Padding::horizontal(1));
Paragraph::new(preview_text)
.wrap(Wrap { trim: false })
.block(preview_block)
.render(preview_area, buf);
}
block.render(area, buf);
}
}
#[allow(clippy::type_complexity)]
fn search_filter<K>(
entry: &ListEntry<K>,
search_terms: &[&str],
filter_callback: Option<&dyn Fn(&str, &str, &ListEntry<K>) -> bool>,
) -> bool {
let name_lower = entry.name.to_lowercase();
let desc_lower = entry.description.as_ref().map(|d| d.to_lowercase());
for term in search_terms {
if let Some((key, value)) = term.split_once(':') {
if let Some(cb) = filter_callback {
if !cb(key.trim(), value.trim(), entry) {
return false;
}
continue;
}
}
let in_name = name_lower.contains(term);
let in_desc = desc_lower.as_ref().is_some_and(|d| d.contains(term));
if !in_name && !in_desc {
return false;
}
}
true
}
trait AddInstruction {
fn add_instruction(&mut self, name: &str, keys: &str);
}
impl<'k> AddInstruction for Vec<Span<'k>> {
fn add_instruction(&mut self, name: &str, keys: &str) {
self.push(format!(" {name} ").dim());
self.push(format!("<{keys}>").blue().bold());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, desc: Option<&str>) -> ListEntry<String> {
ListEntry {
key: name.to_string(),
name: name.to_string(),
description: desc.map(String::from),
}
}
#[test]
fn plain_term_matches_name() {
let e = entry("docker-deploy", None);
assert!(search_filter(&e, &["docker"], None));
}
#[test]
fn plain_term_matches_description() {
let e = entry("deploy", Some("runs docker containers"));
assert!(search_filter(&e, &["docker"], None));
}
#[test]
fn plain_term_matches_neither() {
let e = entry("deploy", Some("runs containers"));
assert!(!search_filter(&e, &["docker"], None));
}
#[test]
fn multiple_terms_all_match() {
let e = entry("docker-deploy", Some("production"));
assert!(search_filter(&e, &["docker", "production"], None));
}
#[test]
fn multiple_terms_one_fails() {
let e = entry("docker-deploy", None);
assert!(!search_filter(&e, &["docker", "missing"], None));
}
#[test]
fn case_insensitive() {
let e = entry("Docker-Deploy", None);
assert!(search_filter(&e, &["docker"], None));
}
#[test]
fn key_value_with_callback() {
let e = entry("deploy", None);
let cb = |key: &str, value: &str, _: &ListEntry<String>| -> bool {
key == "tag" && value == "prod"
};
assert!(search_filter(&e, &["tag:prod"], Some(&cb)));
}
#[test]
fn key_value_callback_rejects() {
let e = entry("deploy", None);
let cb = |_: &str, _: &str, _: &ListEntry<String>| -> bool { false };
assert!(!search_filter(&e, &["tag:prod"], Some(&cb)));
}
#[test]
fn key_value_without_callback_falls_through() {
let e = entry("deploy", None);
assert!(!search_filter(&e, &["tag:prod"], None));
}
#[test]
fn key_value_without_callback_matches_as_text() {
let e = entry("tag:prod-deploy", None);
assert!(search_filter(&e, &["tag:prod"], None));
}
#[test]
fn empty_search_matches_all() {
let e = entry("anything", None);
assert!(search_filter(&e, &[""], None));
}
}