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