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 that restores stderr's terminal state (raw mode + alternate
32/// screen) even if the loop returns an error or panics partway through setup.
33struct 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
42/// Run the picker with stderr as the TUI backend. Stdout stays untouched so
43/// callers like `tmx worktree list` can print the selection on stdout for
44/// `$(...)` capture. Also avoids a macOS-specific freeze observed when
45/// rendering to stdout inside a tmux popup.
46pub fn run_picker<T: PickerItem>(items: Vec<T>, config: PickerConfig<'_>) -> Result<Option<T>> {
47    enable_raw_mode()?;
48    // Install the guard *before* anything that can fail so raw mode is
49    // disabled if `EnterAlternateScreen` or terminal construction errors out.
50    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}