1use std::io;
2use std::time::Duration;
3
4use anyhow::Result;
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use crossterm::execute;
7use crossterm::terminal::{
8 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
9};
10use ratatui::backend::{Backend, CrosstermBackend};
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::Style;
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Paragraph};
15use ratatui::{Frame, Terminal};
16
17use crate::ui::{fuzzy_match, theme};
18
19pub trait PickerItem: Clone {
20 fn fuzzy_key(&self) -> &str;
21 fn render(&self, is_cursor: bool) -> Line<'static>;
22}
23
24pub struct PickerConfig<'a> {
25 pub title: &'a str,
26 pub search_placeholder: &'a str,
27 pub empty_message: &'a str,
28 pub item_noun: &'a str,
29}
30
31struct StderrGuard;
34
35impl Drop for StderrGuard {
36 fn drop(&mut self) {
37 let _ = execute!(io::stderr(), LeaveAlternateScreen);
38 let _ = disable_raw_mode();
39 }
40}
41
42pub fn run_picker<T: PickerItem>(items: Vec<T>, config: PickerConfig<'_>) -> Result<Option<T>> {
47 enable_raw_mode()?;
48 let _guard = StderrGuard;
51 execute!(io::stderr(), EnterAlternateScreen)?;
52 let backend = CrosstermBackend::new(io::stderr());
53 let mut terminal = Terminal::new(backend)?;
54 run_loop(&mut terminal, items, &config)
55}
56
57struct Model<T> {
58 items: Vec<T>,
59 filtered_idx: Vec<usize>,
60 cursor: usize,
61 search: String,
62 selected: Option<usize>,
63 quit: bool,
64}
65
66impl<T: PickerItem> Model<T> {
67 fn new(items: Vec<T>) -> Self {
68 let filtered_idx = (0..items.len()).collect();
69 Self {
70 items,
71 filtered_idx,
72 cursor: 0,
73 search: String::new(),
74 selected: None,
75 quit: false,
76 }
77 }
78
79 fn refilter(&mut self) {
80 if self.search.is_empty() {
81 self.filtered_idx = (0..self.items.len()).collect();
82 } else {
83 self.filtered_idx = self
84 .items
85 .iter()
86 .enumerate()
87 .filter(|(_, it)| fuzzy_match(it.fuzzy_key(), &self.search))
88 .map(|(i, _)| i)
89 .collect();
90 }
91 if self.cursor >= self.filtered_idx.len() {
92 self.cursor = self.filtered_idx.len().saturating_sub(1);
93 }
94 }
95
96 fn handle_key(&mut self, key: KeyEvent) {
97 if key.kind != KeyEventKind::Press {
98 return;
99 }
100 match key.code {
101 KeyCode::Esc => self.quit = true,
102 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
103 self.quit = true;
104 }
105 KeyCode::Enter if !self.filtered_idx.is_empty() => {
106 self.selected = Some(self.filtered_idx[self.cursor]);
107 self.quit = true;
108 }
109 KeyCode::Up => self.move_up(),
110 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => self.move_up(),
111 KeyCode::Down => self.move_down(),
112 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => self.move_down(),
113 KeyCode::Backspace => {
114 self.search.pop();
115 self.refilter();
116 }
117 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
118 self.search.push(c);
119 self.refilter();
120 }
121 _ => {}
122 }
123 }
124
125 fn move_up(&mut self) {
126 if self.cursor > 0 {
127 self.cursor -= 1;
128 }
129 }
130
131 fn move_down(&mut self) {
132 if self.cursor + 1 < self.filtered_idx.len() {
133 self.cursor += 1;
134 }
135 }
136}
137
138fn run_loop<B: Backend, T: PickerItem>(
139 terminal: &mut Terminal<B>,
140 items: Vec<T>,
141 config: &PickerConfig<'_>,
142) -> Result<Option<T>> {
143 let mut model = Model::new(items);
144 while !model.quit {
145 terminal.draw(|frame| render(frame, &model, config))?;
146 if event::poll(Duration::from_millis(250))?
147 && let Event::Key(key) = event::read()?
148 {
149 model.handle_key(key);
150 }
151 }
152 Ok(model.selected.map(|i| model.items[i].clone()))
153}
154
155fn render<T: PickerItem>(frame: &mut Frame, model: &Model<T>, config: &PickerConfig<'_>) {
156 let area = frame.area();
157 let layout = Layout::default()
158 .direction(Direction::Vertical)
159 .constraints([
160 Constraint::Length(1),
161 Constraint::Length(1),
162 Constraint::Length(1),
163 Constraint::Length(1),
164 Constraint::Min(1),
165 Constraint::Length(1),
166 ])
167 .split(area);
168
169 let title = format!(" {}", config.title);
170 frame.render_widget(
171 Paragraph::new(Span::styled(title, theme::title_style())),
172 layout[0],
173 );
174
175 let search_line = Line::from(vec![
176 Span::raw(" "),
177 Span::styled("> ", Style::default().fg(theme::VIOLET)),
178 Span::styled(model.search.clone(), Style::default().fg(theme::TEXT)),
179 Span::styled(
180 if model.search.is_empty() {
181 config.search_placeholder.to_string()
182 } else {
183 String::new()
184 },
185 theme::muted_style(),
186 ),
187 ]);
188 frame.render_widget(Paragraph::new(search_line), layout[2]);
189
190 render_list(frame, model, layout[4], config.empty_message);
191
192 let status = format!(
193 " {}/{} {} ↑↓ navigate ⏎ select esc quit",
194 model.filtered_idx.len(),
195 model.items.len(),
196 config.item_noun,
197 );
198 frame.render_widget(
199 Paragraph::new(Span::styled(status, theme::status_bar_style())),
200 layout[5],
201 );
202}
203
204fn render_list<T: PickerItem>(
205 frame: &mut Frame,
206 model: &Model<T>,
207 area: Rect,
208 empty_message: &str,
209) {
210 if model.filtered_idx.is_empty() {
211 let msg = format!(" {empty_message}");
212 frame.render_widget(
213 Paragraph::new(Span::styled(msg, theme::muted_style())),
214 area,
215 );
216 return;
217 }
218
219 let max_visible = area.height.max(1) as usize;
220 let start = model.cursor.saturating_sub(max_visible.saturating_sub(1));
221 let end = (start + max_visible).min(model.filtered_idx.len());
222
223 let lines: Vec<Line> = (start..end)
224 .map(|i| model.items[model.filtered_idx[i]].render(i == model.cursor))
225 .collect();
226 frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
227}