use std::io::{self, IsTerminal, Write};
use crossterm::cursor;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::style::Print;
use crossterm::terminal::{
self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
LeaveAlternateScreen,
};
use crossterm::{execute, queue};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickerOutcome {
Selected(String),
SelectedMulti(Vec<String>),
Cancelled,
Unavailable,
}
#[derive(Debug, Clone)]
pub struct PickerOpts {
pub prompt: String,
pub display_from: usize,
pub delimiter: char,
}
impl Default for PickerOpts {
fn default() -> Self {
Self {
prompt: "select > ".to_string(),
display_from: 2,
delimiter: '\t',
}
}
}
pub(crate) fn terminal_available() -> bool {
io::stdin().is_terminal() && io::stdout().is_terminal()
}
pub(crate) fn project_display(row: &str, opts: &PickerOpts) -> String {
let fields: Vec<&str> = row.split(opts.delimiter).collect();
let start = opts.display_from.saturating_sub(1);
if start >= fields.len() {
return String::new();
}
fields[start..].join(" ")
}
pub(crate) fn recover_col1(row: &str, delimiter: char) -> String {
row.split(delimiter).next().unwrap_or(row).to_string()
}
#[derive(Clone)]
struct Candidate {
idx: usize,
display: String,
}
impl AsRef<str> for Candidate {
fn as_ref(&self) -> &str {
&self.display
}
}
pub(crate) fn rank(
rows: &[String],
query: &str,
opts: &PickerOpts,
matcher: &mut Matcher,
) -> Vec<usize> {
if query.trim().is_empty() {
return (0..rows.len()).collect();
}
let candidates: Vec<Candidate> = rows
.iter()
.enumerate()
.map(|(idx, r)| Candidate {
idx,
display: project_display(r, opts),
})
.collect();
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
pattern
.match_list(candidates, matcher)
.into_iter()
.map(|(c, _score)| c.idx)
.collect()
}
pub fn run_picker(rows: &[String], opts: &PickerOpts) -> PickerOutcome {
if rows.is_empty() {
return PickerOutcome::Unavailable;
}
if !terminal_available() {
return PickerOutcome::Unavailable;
}
run_picker_inner(rows, opts).unwrap_or(PickerOutcome::Unavailable)
}
pub fn run_multi_picker(rows: &[String], opts: &PickerOpts) -> PickerOutcome {
if rows.is_empty() {
return PickerOutcome::Unavailable;
}
if !terminal_available() {
return PickerOutcome::Unavailable;
}
run_multi_picker_inner(rows, opts).unwrap_or(PickerOutcome::Unavailable)
}
pub(crate) fn toggle_all(filtered: &[usize], selected: &mut std::collections::BTreeSet<usize>) {
let all_selected = !filtered.is_empty() && filtered.iter().all(|i| selected.contains(i));
if all_selected {
for i in filtered {
selected.remove(i);
}
} else {
for &i in filtered {
selected.insert(i);
}
}
}
fn run_multi_picker_inner(rows: &[String], opts: &PickerOpts) -> io::Result<PickerOutcome> {
use std::collections::BTreeSet;
let _guard = TermGuard::enter()?;
let mut out = io::stderr();
let mut matcher = Matcher::new(Config::DEFAULT);
let mut query = String::new();
let mut filtered: Vec<usize> = rank(rows, &query, opts, &mut matcher);
let mut cursor_pos: usize = 0; let mut selected: BTreeSet<usize> = BTreeSet::new();
loop {
let (cols, term_rows) = terminal::size().unwrap_or((80, 24));
let list_capacity = (term_rows as usize).saturating_sub(2).max(1);
let visible = filtered.len().min(list_capacity);
if visible == 0 {
cursor_pos = 0;
} else if cursor_pos >= visible {
cursor_pos = visible - 1;
}
queue!(out, cursor::MoveTo(0, 0), Clear(ClearType::All))?;
queue!(
out,
Print(format!(
"{}{} [{} selected]",
opts.prompt,
query,
selected.len()
))
)?;
for (screen_row, &row_idx) in filtered.iter().take(list_capacity).enumerate() {
let mut text = project_display(&rows[row_idx], opts);
let max = (cols as usize).saturating_sub(6);
if text.chars().count() > max {
text = text.chars().take(max).collect();
}
let cursor_mark = if screen_row == cursor_pos { ">" } else { " " };
let check = if selected.contains(&row_idx) {
"x"
} else {
" "
};
queue!(
out,
cursor::MoveTo(0, (screen_row + 1) as u16),
Print(format!("{cursor_mark} [{check}] {text}"))
)?;
}
queue!(
out,
cursor::MoveTo(0, (visible + 1) as u16),
Print(" ⏎ confirm · space/tab toggle · ⌃a all · esc cancel")
)?;
out.flush()?;
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
match k.code {
KeyCode::Esc => return Ok(PickerOutcome::Cancelled),
KeyCode::Char('c') if ctrl => return Ok(PickerOutcome::Cancelled),
KeyCode::Char('g') if ctrl => return Ok(PickerOutcome::Cancelled),
KeyCode::Enter => {
let keys: Vec<String> = selected
.iter()
.map(|&i| recover_col1(&rows[i], opts.delimiter))
.collect();
return Ok(PickerOutcome::SelectedMulti(keys));
}
KeyCode::Char(' ') | KeyCode::Tab => {
if let Some(&row_idx) = filtered.get(cursor_pos) {
if !selected.remove(&row_idx) {
selected.insert(row_idx);
}
if cursor_pos + 1 < visible {
cursor_pos += 1;
}
}
}
KeyCode::Char('a') if ctrl => {
toggle_all(&filtered, &mut selected);
}
KeyCode::Up => cursor_pos = cursor_pos.saturating_sub(1),
KeyCode::Char('p') if ctrl => cursor_pos = cursor_pos.saturating_sub(1),
KeyCode::Down => {
if cursor_pos + 1 < visible {
cursor_pos += 1;
}
}
KeyCode::Char('n') if ctrl => {
if cursor_pos + 1 < visible {
cursor_pos += 1;
}
}
KeyCode::Backspace => {
query.pop();
filtered = rank(rows, &query, opts, &mut matcher);
cursor_pos = 0;
}
KeyCode::Char(c) if !ctrl => {
query.push(c);
filtered = rank(rows, &query, opts, &mut matcher);
cursor_pos = 0;
}
_ => {}
}
}
Event::Resize(_, _) => {}
_ => {}
}
}
}
struct TermGuard;
impl TermGuard {
fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut w = io::stderr();
execute!(w, EnterAlternateScreen, cursor::Hide)?;
Ok(TermGuard)
}
}
impl Drop for TermGuard {
fn drop(&mut self) {
let mut w = io::stderr();
let _ = execute!(w, cursor::Show, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
fn run_picker_inner(rows: &[String], opts: &PickerOpts) -> io::Result<PickerOutcome> {
let _guard = TermGuard::enter()?;
let mut out = io::stderr();
let mut matcher = Matcher::new(Config::DEFAULT);
let mut query = String::new();
let mut filtered: Vec<usize> = rank(rows, &query, opts, &mut matcher);
let mut cursor_pos: usize = 0;
loop {
let (cols, term_rows) = terminal::size().unwrap_or((80, 24));
let list_capacity = (term_rows as usize).saturating_sub(1).max(1); let visible = filtered.len().min(list_capacity);
if visible == 0 {
cursor_pos = 0;
} else if cursor_pos >= visible {
cursor_pos = visible - 1;
}
queue!(out, cursor::MoveTo(0, 0), Clear(ClearType::All))?;
queue!(out, Print(format!("{}{}", opts.prompt, query)))?;
for (screen_row, &row_idx) in filtered.iter().take(list_capacity).enumerate() {
let mut text = project_display(&rows[row_idx], opts);
let max = (cols as usize).saturating_sub(2);
if text.chars().count() > max {
text = text.chars().take(max).collect();
}
let marker = if screen_row == cursor_pos { "> " } else { " " };
queue!(
out,
cursor::MoveTo(0, (screen_row + 1) as u16),
Print(format!("{marker}{text}"))
)?;
}
out.flush()?;
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
match k.code {
KeyCode::Esc => return Ok(PickerOutcome::Cancelled),
KeyCode::Char('c') if ctrl => return Ok(PickerOutcome::Cancelled), KeyCode::Char('g') if ctrl => return Ok(PickerOutcome::Cancelled), KeyCode::Enter => {
return Ok(match filtered.get(cursor_pos) {
Some(&i) => {
PickerOutcome::Selected(recover_col1(&rows[i], opts.delimiter))
}
None => PickerOutcome::Unavailable,
});
}
KeyCode::Up => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Char('p') if ctrl => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Down => {
if cursor_pos + 1 < visible {
cursor_pos += 1;
}
}
KeyCode::Char('n') if ctrl => {
if cursor_pos + 1 < visible {
cursor_pos += 1;
}
}
KeyCode::Backspace => {
query.pop();
filtered = rank(rows, &query, opts, &mut matcher);
cursor_pos = 0;
}
KeyCode::Char(c) if !ctrl => {
query.push(c);
filtered = rank(rows, &query, opts, &mut matcher);
cursor_pos = 0;
}
_ => {}
}
}
Event::Resize(_, _) => {}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(display_from: usize) -> PickerOpts {
PickerOpts {
prompt: "p > ".to_string(),
display_from,
delimiter: '\t',
}
}
#[test]
fn project_display_hides_col1() {
let row = "deadbeef\t1700000000\t06-18 12:00\tdefault\tMy session";
assert_eq!(
project_display(row, &opts(3)),
"06-18 12:00 default My session"
);
assert!(!project_display(row, &opts(3)).contains("deadbeef"));
}
#[test]
fn project_display_account_spec() {
let row = "home\tsession 3% week 32%";
assert_eq!(project_display(row, &opts(2)), "session 3% week 32%");
}
#[test]
fn project_display_out_of_range_is_empty() {
let row = "only-one-field";
assert_eq!(project_display(row, &opts(3)), "");
}
#[test]
fn recover_col1_basic() {
assert_eq!(recover_col1("abc\tx\ty", '\t'), "abc");
assert_eq!(recover_col1("solo", '\t'), "solo");
}
#[test]
fn rank_empty_query_returns_all_in_order() {
let rows = vec!["k\talpha".to_string(), "k\tbeta".to_string()];
let mut m = Matcher::new(Config::DEFAULT);
assert_eq!(rank(&rows, "", &opts(2), &mut m), vec![0, 1]);
assert_eq!(rank(&rows, " ", &opts(2), &mut m), vec![0, 1]);
}
#[test]
fn rank_orders_by_match_quality_and_excludes_nonmatches() {
let rows = vec![
"k\talpha".to_string(),
"k\tbeta".to_string(),
"k\talphabet".to_string(),
];
let mut m = Matcher::new(Config::DEFAULT);
let got = rank(&rows, "alpha", &opts(2), &mut m);
assert!(!got.contains(&1), "beta should not match 'alpha': {got:?}");
assert!(got.contains(&0) && got.contains(&2), "got: {got:?}");
assert_eq!(
got.first(),
Some(&0),
"exact match should rank first: {got:?}"
);
}
#[test]
fn picker_opts_default_values() {
let o = PickerOpts::default();
assert_eq!(o.display_from, 2);
assert_eq!(o.delimiter, '\t');
assert!(!o.prompt.is_empty());
}
#[test]
fn picker_outcome_cancelled_differs_from_unavailable() {
assert_ne!(PickerOutcome::Cancelled, PickerOutcome::Unavailable);
assert_ne!(
PickerOutcome::Selected("x".into()),
PickerOutcome::Cancelled
);
}
#[test]
fn toggle_all_selects_then_clears() {
use std::collections::BTreeSet;
let filtered = vec![0usize, 2, 5];
let mut sel: BTreeSet<usize> = BTreeSet::new();
toggle_all(&filtered, &mut sel);
assert_eq!(sel, BTreeSet::from([0, 2, 5]));
toggle_all(&filtered, &mut sel);
assert!(sel.is_empty(), "second toggle_all should clear: {sel:?}");
}
#[test]
fn toggle_all_selects_when_partially_selected() {
use std::collections::BTreeSet;
let filtered = vec![0usize, 1, 2];
let mut sel: BTreeSet<usize> = BTreeSet::from([1]); toggle_all(&filtered, &mut sel);
assert_eq!(sel, BTreeSet::from([0, 1, 2]));
}
#[test]
fn toggle_all_only_touches_filtered_rows() {
use std::collections::BTreeSet;
let filtered = vec![0usize, 1];
let mut sel: BTreeSet<usize> = BTreeSet::from([9]); toggle_all(&filtered, &mut sel);
assert!(
sel.contains(&9),
"out-of-filter selection must persist: {sel:?}"
);
assert!(sel.contains(&0) && sel.contains(&1));
}
#[test]
fn selected_multi_recovers_col1_in_row_order() {
use std::collections::BTreeSet;
let rows = [
"100\tchild\tfoo".to_string(),
"200\tchild\tbar".to_string(),
"300\tchild\tbaz".to_string(),
];
let selected: BTreeSet<usize> = BTreeSet::from([2, 0]); let keys: Vec<String> = selected
.iter()
.map(|&i| recover_col1(&rows[i], '\t'))
.collect();
assert_eq!(keys, vec!["100".to_string(), "300".to_string()]);
}
}