use std::collections::HashMap;
use std::io::IsTerminal as _;
use std::path::PathBuf;
use eyre::WrapErr as _;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32String};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::crossterm::{event, execute};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
pub struct PickItem {
pub label: String,
pub path: PathBuf,
pub line: u32,
}
pub fn stderr_is_tty() -> bool {
std::io::stderr().is_terminal()
}
pub fn pick(title: &str, items: &[PickItem]) -> eyre::Result<Option<Vec<usize>>> {
if items.is_empty() {
return Ok(Some(Vec::new()));
}
enable_raw_mode().context("enabling raw terminal mode for the picker")?;
execute!(std::io::stderr(), EnterAlternateScreen)
.context("entering alternate screen for the picker")?;
let result = run(title, items);
let _ = disable_raw_mode();
let _ = execute!(std::io::stderr(), LeaveAlternateScreen);
result
}
struct State<'a> {
items: &'a [PickItem],
haystacks: Vec<Utf32String>,
matcher: Matcher,
query: String,
filtered: Vec<usize>,
cursor: usize,
marked: Vec<bool>,
previews: HashMap<PathBuf, Vec<String>>,
}
impl<'a> State<'a> {
fn new(items: &'a [PickItem]) -> Self {
let haystacks = items
.iter()
.map(|i| Utf32String::from(i.label.as_str()))
.collect();
let mut s = Self {
items,
haystacks,
matcher: Matcher::new(Config::DEFAULT),
query: String::new(),
filtered: (0..items.len()).collect(),
cursor: 0,
marked: vec![false; items.len()],
previews: HashMap::new(),
};
s.refilter();
s
}
fn refilter(&mut self) {
if self.query.is_empty() {
self.filtered = (0..self.items.len()).collect();
} else {
let pattern = Pattern::parse(&self.query, CaseMatching::Ignore, Normalization::Smart);
let mut scored: Vec<(u32, usize)> = self
.haystacks
.iter()
.enumerate()
.filter_map(|(i, hay)| {
pattern
.score(hay.slice(..), &mut self.matcher)
.map(|score| (score, i))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
self.filtered = scored.into_iter().map(|(_, i)| i).collect();
}
self.cursor = self.cursor.min(self.filtered.len().saturating_sub(1));
}
fn current(&self) -> Option<usize> {
self.filtered.get(self.cursor).copied()
}
fn preview_lines(&mut self, idx: usize) -> &[String] {
let path = &self.items[idx].path;
self.previews
.entry(path.clone())
.or_insert_with(|| match std::fs::read_to_string(path) {
Ok(content) => content.lines().map(str::to_owned).collect(),
Err(e) => vec![format!("<couldn't read {}: {e}>", path.display())],
})
}
}
fn run(title: &str, items: &[PickItem]) -> eyre::Result<Option<Vec<usize>>> {
let backend = CrosstermBackend::new(std::io::stderr());
let mut terminal = Terminal::new(backend).context("initialising the picker terminal")?;
let mut state = State::new(items);
loop {
terminal
.draw(|f| draw(f, title, &mut state))
.context("drawing the picker")?;
match event::read().context("reading terminal input")? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => return Ok(None),
KeyCode::Char('c') if ctrl => return Ok(None),
KeyCode::Enter => {
return Ok(Some(
(0..items.len()).filter(|&i| state.marked[i]).collect(),
));
}
KeyCode::Up => move_cursor(&mut state, -1),
KeyCode::Down => move_cursor(&mut state, 1),
KeyCode::Char('p') if ctrl => move_cursor(&mut state, -1),
KeyCode::Char('n') if ctrl => move_cursor(&mut state, 1),
KeyCode::Char('a') if ctrl => {
let any_unmarked = state.filtered.iter().any(|&i| !state.marked[i]);
for &i in &state.filtered {
state.marked[i] = any_unmarked;
}
}
KeyCode::Char(' ') if !ctrl => {
if let Some(i) = state.current() {
state.marked[i] = !state.marked[i];
}
}
KeyCode::Tab => {
if let Some(i) = state.current() {
state.marked[i] = !state.marked[i];
}
move_cursor(&mut state, 1);
}
KeyCode::BackTab => {
if let Some(i) = state.current() {
state.marked[i] = !state.marked[i];
}
move_cursor(&mut state, -1);
}
KeyCode::Backspace => {
state.query.pop();
state.refilter();
}
KeyCode::Char('u') if ctrl => {
state.query.clear();
state.refilter();
}
KeyCode::Char(c) if !ctrl => {
state.query.push(c);
state.refilter();
}
_ => {}
}
}
Event::Resize(_, _) => {}
_ => {}
}
}
}
fn move_cursor(state: &mut State, delta: i64) {
if state.filtered.is_empty() {
return;
}
let len = state.filtered.len() as i64;
let next = (state.cursor as i64 + delta).clamp(0, len - 1);
state.cursor = next as usize;
}
fn draw(f: &mut ratatui::Frame, title: &str, state: &mut State) {
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
.split(f.area());
draw_list(f, panes[0], title, state);
draw_preview(f, panes[1], state);
}
fn draw_list(f: &mut ratatui::Frame, area: Rect, title: &str, state: &State) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(3), ])
.split(area);
let marked_count = state.marked.iter().filter(|&&m| m).count();
let rows: Vec<ListItem> = state
.filtered
.iter()
.map(|&i| {
let mark = if state.marked[i] { "[x] " } else { "[ ] " };
let mark_style = if state.marked[i] {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
ListItem::new(Line::from(vec![
Span::styled(mark, mark_style),
Span::raw(state.items[i].label.clone()),
]))
})
.collect();
let list = List::new(rows)
.block(Block::default().borders(Borders::ALL).title(format!(
" {title} ({marked_count}/{} marked) ",
state.items.len()
)))
.highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.add_modifier(Modifier::BOLD),
);
let mut list_state = ListState::default();
if !state.filtered.is_empty() {
list_state.select(Some(state.cursor));
}
f.render_stateful_widget(list, chunks[0], &mut list_state);
let input = Paragraph::new(Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Cyan)),
Span::raw(state.query.as_str()),
Span::styled("█", Style::default().fg(Color::DarkGray)),
]))
.block(Block::default().borders(Borders::ALL).title(
" type: filter · space: toggle · tab: toggle+move · ctrl-a: all · enter: go · esc: cancel ",
));
f.render_widget(input, chunks[1]);
}
fn draw_preview(f: &mut ratatui::Frame, area: Rect, state: &mut State) {
let Some(idx) = state.current() else {
let empty = Paragraph::new("nothing matches the filter")
.block(Block::default().borders(Borders::ALL).title(" preview "));
f.render_widget(empty, area);
return;
};
let target_line = state.items[idx].line as usize; let title = format!(
" {}:{} ",
state.items[idx].path.display(),
state.items[idx].line
);
let lines = state.preview_lines(idx);
let inner_height = area.height.saturating_sub(2) as usize; let half = inner_height / 2;
let first = target_line.saturating_sub(half + 1); let gutter_width = (first + inner_height).max(1).to_string().len();
let rendered: Vec<Line> = lines
.iter()
.enumerate()
.skip(first)
.take(inner_height)
.map(|(i, text)| {
let lineno = i + 1;
let gutter = format!("{lineno:>gutter_width$} │ ");
if lineno == target_line {
Line::from(vec![
Span::styled(gutter, Style::default().fg(Color::Yellow)),
Span::styled(
text.clone(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
])
} else {
Line::from(vec![
Span::styled(gutter, Style::default().fg(Color::DarkGray)),
Span::raw(text.clone()),
])
}
})
.collect();
let preview =
Paragraph::new(rendered).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(preview, area);
}