Skip to main content

tmx/ui/
picker.rs

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
31/// RAII guard so ratatui's terminal is restored even if the loop returns an
32/// error via `?`.
33struct 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
53/// RAII guard that renders the picker on **stderr** so stdout can be reserved
54/// for the selected path (consumed by shell wrappers like `twt`).
55struct 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    // Install the guard *before* anything that can fail so raw mode is
70    // disabled if `EnterAlternateScreen` or terminal construction errors out.
71    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}