Skip to main content

tms/picker/
mod.rs

1mod preview;
2
3use std::{process, rc::Rc, sync::Arc};
4
5use crossterm::event::{self, Event, KeyCode, KeyEventKind};
6use nucleo::{
7    pattern::{CaseMatching, Normalization},
8    Nucleo,
9};
10use preview::PreviewWidget;
11use ratatui::{
12    layout::{self, Constraint, Direction, Layout},
13    style::{Style, Stylize},
14    text::{Line, Span},
15    widgets::{
16        block::Position, Block, Borders, HighlightSpacing, List, ListDirection, ListItem,
17        ListState, Paragraph,
18    },
19    DefaultTerminal, Frame,
20};
21use serde::{Deserialize, Serialize};
22
23use crate::{
24    configs::PickerColorConfig,
25    keymap::{Keymap, PickerAction},
26    tmux::Tmux,
27    Result, TmsError,
28};
29
30pub enum Preview {
31    SessionPane,
32    WindowPane,
33    Directory,
34}
35
36#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize, Clone, Copy)]
37pub enum InputPosition {
38    Top,
39    #[default]
40    Bottom,
41}
42
43pub struct Picker<'a> {
44    matcher: Nucleo<String>,
45    preview: Option<Preview>,
46
47    colors: Option<&'a PickerColorConfig>,
48
49    selection: ListState,
50    filter: String,
51    cursor_pos: u16,
52    keymap: Keymap,
53    input_position: InputPosition,
54    tmux: &'a Tmux,
55    active_sessions: Option<std::collections::HashSet<String>>,
56}
57
58impl<'a> Picker<'a> {
59    pub fn new(
60        list: &[String],
61        preview: Option<Preview>,
62        keymap: Option<&Keymap>,
63        input_position: InputPosition,
64        tmux: &'a Tmux,
65    ) -> Self {
66        let matcher = Nucleo::new(nucleo::Config::DEFAULT, Arc::new(request_redraw), None, 1);
67
68        let injector = matcher.injector();
69
70        for str in list {
71            injector.push(str.to_owned(), |_, dst| dst[0] = str.to_owned().into());
72        }
73
74        let keymap = if let Some(keymap) = keymap {
75            Keymap::with_defaults(keymap)
76        } else {
77            Keymap::default()
78        };
79
80        Picker {
81            matcher,
82            preview,
83            colors: None,
84            selection: ListState::default(),
85            filter: String::default(),
86            cursor_pos: 0,
87            keymap,
88            input_position,
89            tmux,
90            active_sessions: None,
91        }
92    }
93
94    pub fn set_colors(mut self, colors: Option<&'a PickerColorConfig>) -> Self {
95        self.colors = colors;
96
97        self
98    }
99
100    pub fn set_active_sessions(mut self, active: std::collections::HashSet<String>) -> Self {
101        self.active_sessions = Some(active);
102
103        self
104    }
105
106    pub fn run(&mut self) -> Result<Option<String>> {
107        let mut terminal = ratatui::init();
108
109        let selected_str = self
110            .main_loop(&mut terminal)
111            .map_err(|e| TmsError::TuiError(e.to_string()));
112
113        ratatui::restore();
114
115        Ok(selected_str?)
116    }
117
118    fn main_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<String>> {
119        loop {
120            self.matcher.tick(10);
121            self.update_selection();
122            terminal
123                .draw(|f| self.render(f))
124                .map_err(|e| TmsError::TuiError(e.to_string()))?;
125
126            if let Event::Key(key) = event::read().map_err(|e| TmsError::TuiError(e.to_string()))? {
127                if key.kind == KeyEventKind::Press {
128                    match self.keymap.0.get(&key.into()) {
129                        Some(PickerAction::Cancel) => return Ok(None),
130                        Some(PickerAction::Confirm) => {
131                            if let Some(selected) = self.get_selected() {
132                                return Ok(Some(selected.to_owned()));
133                            }
134                        }
135                        Some(PickerAction::Backspace) => self.remove_filter(),
136                        Some(PickerAction::Delete) => self.delete(),
137                        Some(PickerAction::DeleteWord) => self.delete_word(),
138                        Some(PickerAction::DeleteToLineStart) => self.delete_to_line(false),
139                        Some(PickerAction::DeleteToLineEnd) => self.delete_to_line(true),
140                        Some(PickerAction::MoveUp) => self.move_up(),
141                        Some(PickerAction::MoveDown) => self.move_down(),
142                        Some(PickerAction::CursorLeft) => self.move_cursor_left(),
143                        Some(PickerAction::CursorRight) => self.move_cursor_right(),
144                        Some(PickerAction::MoveToLineStart) => self.move_to_start(),
145                        Some(PickerAction::MoveToLineEnd) => self.move_to_end(),
146                        Some(PickerAction::Noop) => {}
147                        None => {
148                            if let KeyCode::Char(c) = key.code {
149                                self.update_filter(c)
150                            }
151                        }
152                    }
153                }
154            }
155        }
156    }
157
158    fn update_selection(&mut self) {
159        let snapshot = self.matcher.snapshot();
160        if let Some(selected) = self.selection.selected() {
161            if snapshot.matched_item_count() == 0 {
162                self.selection.select(None);
163            } else if selected > snapshot.matched_item_count() as usize {
164                self.selection
165                    .select(Some(snapshot.matched_item_count() as usize - 1));
166            }
167        } else if snapshot.matched_item_count() > 0 {
168            self.selection.select(Some(0));
169        }
170    }
171
172    fn render(&mut self, f: &mut Frame) {
173        let preview_direction;
174        let picker_pane;
175        let preview_pane;
176        let area = f.area();
177        let mut input_position = self.input_position;
178
179        let preview_split = if self.preview.is_some() {
180            preview_direction = if area.width.div_ceil(2) >= area.height {
181                picker_pane = 0;
182                preview_pane = 1;
183                Direction::Horizontal
184            } else {
185                picker_pane = 1;
186                preview_pane = 0;
187                input_position = InputPosition::Bottom;
188                Direction::Vertical
189            };
190            Layout::new(
191                preview_direction,
192                [Constraint::Percentage(50), Constraint::Percentage(50)],
193            )
194            .split(area)
195        } else {
196            picker_pane = 0;
197            preview_pane = 1;
198            preview_direction = Direction::Horizontal;
199            Rc::new([area])
200        };
201
202        let top_constraint;
203        let bottom_constraint;
204        let list_direction;
205        let input_index;
206        let list_index;
207        let borders;
208        let title_position;
209        match input_position {
210            InputPosition::Top => {
211                top_constraint = Constraint::Length(1);
212                bottom_constraint = Constraint::Length(preview_split[picker_pane].height - 1);
213                list_direction = ListDirection::TopToBottom;
214                input_index = 0;
215                list_index = 1;
216                borders = Borders::TOP;
217                title_position = Position::Top;
218            }
219            InputPosition::Bottom => {
220                top_constraint = Constraint::Length(preview_split[picker_pane].height - 1);
221                bottom_constraint = Constraint::Length(1);
222                list_direction = ListDirection::BottomToTop;
223                input_index = 1;
224                list_index = 0;
225                borders = Borders::BOTTOM;
226                title_position = Position::Bottom;
227            }
228        }
229        let layout = Layout::new(Direction::Vertical, [top_constraint, bottom_constraint])
230            .split(preview_split[picker_pane]);
231
232        let snapshot = self.matcher.snapshot();
233        let matches = snapshot
234            .matched_items(..snapshot.matched_item_count())
235            .map(|item| {
236                let text = item.data.as_str();
237                // Check if this is an active session (make it bold)
238                if let Some(ref active) = self.active_sessions {
239                    // Tmux normalizes both dots and hyphens to underscores in session names
240                    let normalized = text.replace(['.', '-'], "_");
241                    if active.contains(text) || active.contains(&normalized) {
242                        return ListItem::new(Span::styled(text, Style::default().bold()));
243                    }
244                }
245                ListItem::new(text)
246            });
247
248        let colors = if let Some(colors) = self.colors {
249            colors.to_owned()
250        } else {
251            PickerColorConfig::default_colors()
252        };
253
254        let table = List::new(matches)
255            .highlight_style(colors.highlight_style())
256            .direction(list_direction)
257            .highlight_spacing(HighlightSpacing::Always)
258            .highlight_symbol("> ")
259            .block(
260                Block::default()
261                    .borders(borders)
262                    .border_style(Style::default().fg(colors.border_color()))
263                    .title_style(Style::default().fg(colors.info_color()))
264                    .title_position(title_position)
265                    .title(format!(
266                        "{}/{}",
267                        snapshot.matched_item_count(),
268                        snapshot.item_count()
269                    )),
270            );
271        f.render_stateful_widget(table, layout[list_index], &mut self.selection);
272
273        let prompt = Span::styled("> ", Style::default().fg(colors.prompt_color()));
274        let input_text = Span::raw(&self.filter);
275        let input_line = Line::from(vec![prompt, input_text]);
276        let input = Paragraph::new(vec![input_line]);
277        f.render_widget(input, layout[input_index]);
278        f.set_cursor_position(layout::Position {
279            x: layout[input_index].x + self.cursor_pos + 2,
280            y: layout[input_index].y,
281        });
282
283        if self.preview.is_some() {
284            let preview = PreviewWidget::new(
285                self.get_preview_text(),
286                colors.border_color(),
287                preview_direction,
288            );
289            f.render_widget(preview, preview_split[preview_pane]);
290        }
291    }
292
293    fn get_preview_text(&self) -> String {
294        if let Some(item_data) = self.get_selected() {
295            let output = match self.preview {
296                Some(Preview::SessionPane) => self.tmux.capture_pane(item_data),
297                Some(Preview::WindowPane) => self.tmux.capture_pane(
298                    item_data
299                        .split_once(' ')
300                        .map(|val| val.0)
301                        .unwrap_or_default(),
302                ),
303                Some(Preview::Directory) => process::Command::new("ls")
304                    .args(["-1", item_data])
305                    .output()
306                    .unwrap_or_else(|_| {
307                        panic!("Failed to execute the command for directory: {}", item_data)
308                    }),
309                None => panic!("preview rendering should not have occured"),
310            };
311
312            if output.status.success() {
313                String::from_utf8(output.stdout).unwrap()
314            } else {
315                String::default()
316            }
317        } else {
318            String::default()
319        }
320    }
321
322    fn get_selected(&self) -> Option<&String> {
323        if let Some(index) = self.selection.selected() {
324            return self
325                .matcher
326                .snapshot()
327                .get_matched_item(index as u32)
328                .map(|item| item.data);
329        }
330
331        None
332    }
333
334    fn move_up(&mut self) {
335        if self.input_position == InputPosition::Bottom {
336            self.do_move_up()
337        } else {
338            self.do_move_down()
339        }
340    }
341
342    fn move_down(&mut self) {
343        if self.input_position == InputPosition::Bottom {
344            self.do_move_down()
345        } else {
346            self.do_move_up()
347        }
348    }
349
350    fn do_move_up(&mut self) {
351        let item_count = self.matcher.snapshot().matched_item_count() as usize;
352        if item_count == 0 {
353            return;
354        }
355
356        let max = item_count - 1;
357
358        match self.selection.selected() {
359            Some(i) if i >= max => self.selection.select(Some(0)),
360            Some(i) => self.selection.select(Some(i + 1)),
361            None => self.selection.select(Some(0)),
362        }
363    }
364
365    fn do_move_down(&mut self) {
366        match self.selection.selected() {
367            Some(0) => {
368                let item_count = self.matcher.snapshot().matched_item_count() as usize;
369                if item_count == 0 {
370                    return;
371                }
372                self.selection.select(Some(item_count - 1))
373            }
374            Some(i) => self.selection.select(Some(i - 1)),
375            None => self.selection.select(Some(0)),
376        }
377    }
378
379    fn move_cursor_left(&mut self) {
380        if self.cursor_pos > 0 {
381            self.cursor_pos -= 1;
382        }
383    }
384
385    fn move_cursor_right(&mut self) {
386        if self.cursor_pos < self.filter.len() as u16 {
387            self.cursor_pos += 1;
388        }
389    }
390
391    fn update_filter(&mut self, c: char) {
392        if self.filter.len() == u16::MAX as usize {
393            return;
394        }
395
396        let prev_filter = self.filter.clone();
397        self.filter.insert(self.cursor_pos as usize, c);
398        self.cursor_pos += 1;
399
400        self.update_matcher_pattern(&prev_filter);
401    }
402
403    fn remove_filter(&mut self) {
404        if self.cursor_pos == 0 {
405            return;
406        }
407
408        let prev_filter = self.filter.clone();
409        self.filter.remove(self.cursor_pos as usize - 1);
410
411        self.cursor_pos -= 1;
412
413        if self.filter != prev_filter {
414            self.update_matcher_pattern(&prev_filter);
415        }
416    }
417
418    fn delete(&mut self) {
419        if (self.cursor_pos as usize) == self.filter.len() {
420            return;
421        }
422
423        let prev_filter = self.filter.clone();
424        self.filter.remove(self.cursor_pos as usize);
425
426        if self.filter != prev_filter {
427            self.update_matcher_pattern(&prev_filter);
428        }
429    }
430
431    fn update_matcher_pattern(&mut self, prev_filter: &str) {
432        self.matcher.pattern.reparse(
433            0,
434            self.filter.as_str(),
435            CaseMatching::Smart,
436            Normalization::Smart,
437            self.filter.starts_with(prev_filter),
438        );
439    }
440
441    fn delete_word(&mut self) {
442        let mut chars = self
443            .filter
444            .chars()
445            .rev()
446            .skip(self.filter.chars().count() - self.cursor_pos as usize);
447        let length = std::cmp::min(
448            u16::try_from(
449                1 + chars.by_ref().take_while(|c| *c == ' ').count()
450                    + chars.by_ref().take_while(|c| *c != ' ').count(),
451            )
452            .unwrap_or(self.cursor_pos),
453            self.cursor_pos,
454        );
455
456        let prev_filter = self.filter.clone();
457        let new_cursor_pos = self.cursor_pos - length;
458
459        self.filter
460            .drain((new_cursor_pos as usize)..(self.cursor_pos as usize));
461
462        self.cursor_pos = new_cursor_pos;
463
464        if self.filter != prev_filter {
465            self.update_matcher_pattern(&prev_filter);
466        }
467    }
468
469    fn delete_to_line(&mut self, forward: bool) {
470        let prev_filter = self.filter.clone();
471
472        if forward {
473            self.filter.drain((self.cursor_pos as usize)..);
474        } else {
475            self.filter.drain(..(self.cursor_pos as usize));
476            self.cursor_pos = 0;
477        }
478
479        if self.filter != prev_filter {
480            self.update_matcher_pattern(&prev_filter);
481        }
482    }
483
484    fn move_to_start(&mut self) {
485        self.cursor_pos = 0;
486    }
487
488    fn move_to_end(&mut self) {
489        self.cursor_pos = u16::try_from(self.filter.len()).unwrap_or_default();
490    }
491}
492
493fn request_redraw() {}