tms/
picker.rs

1use std::{
2    io::{self, Stdout},
3    process,
4    rc::Rc,
5    sync::Arc,
6};
7
8use crossterm::{
9    event::{self, Event, KeyCode, KeyEventKind},
10    execute,
11    style::Colored,
12    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13};
14use nucleo::{
15    pattern::{CaseMatching, Normalization},
16    Nucleo,
17};
18use ratatui::{
19    backend::CrosstermBackend,
20    layout::{self, Constraint, Direction, Layout, Rect},
21    style::{Color, Style, Stylize},
22    text::{Line, Span, Text},
23    widgets::{
24        block::Position, Block, Borders, HighlightSpacing, List, ListDirection, ListItem,
25        ListState, Paragraph, Wrap,
26    },
27    Frame, Terminal,
28};
29
30use crate::{
31    configs::PickerColorConfig,
32    keymap::{default_keymap, Keymap, PickerAction},
33    tmux::Tmux,
34    Result, TmsError,
35};
36
37pub enum Preview {
38    SessionPane,
39    WindowPane,
40    None,
41    Directory,
42}
43
44pub struct Picker<'a> {
45    matcher: Nucleo<String>,
46    preview: Preview,
47
48    colors: Option<&'a PickerColorConfig>,
49
50    selection: ListState,
51    filter: String,
52    cursor_pos: u16,
53    keymap: Keymap,
54    tmux: &'a Tmux,
55}
56
57impl<'a> Picker<'a> {
58    pub fn new(list: &[String], preview: Preview, keymap: Option<&Keymap>, tmux: &'a Tmux) -> Self {
59        let matcher = Nucleo::new(nucleo::Config::DEFAULT, Arc::new(request_redraw), None, 1);
60
61        let injector = matcher.injector();
62
63        for str in list {
64            injector.push(str.to_owned(), |_, dst| dst[0] = str.to_owned().into());
65        }
66
67        let mut default_keymap = default_keymap();
68
69        if let Some(keymap) = keymap {
70            keymap.iter().for_each(|(event, action)| {
71                default_keymap.insert(*event, *action);
72            })
73        }
74
75        Picker {
76            matcher,
77            preview,
78            colors: None,
79            selection: ListState::default(),
80            filter: String::default(),
81            cursor_pos: 0,
82            keymap: default_keymap,
83            tmux,
84        }
85    }
86
87    pub fn set_colors(mut self, colors: Option<&'a PickerColorConfig>) -> Self {
88        self.colors = colors;
89
90        self
91    }
92
93    pub fn run(&mut self) -> Result<Option<String>> {
94        enable_raw_mode().map_err(|e| TmsError::TuiError(e.to_string()))?;
95        let mut stdout = io::stdout();
96        execute!(stdout, EnterAlternateScreen).map_err(|e| TmsError::TuiError(e.to_string()))?;
97        let backend = CrosstermBackend::new(stdout);
98        let mut terminal = Terminal::new(backend).map_err(|e| TmsError::TuiError(e.to_string()))?;
99
100        let selected_str = self
101            .main_loop(&mut terminal)
102            .map_err(|e| TmsError::TuiError(e.to_string()))?;
103
104        disable_raw_mode().map_err(|e| TmsError::TuiError(e.to_string()))?;
105        execute!(terminal.backend_mut(), LeaveAlternateScreen)
106            .map_err(|e| TmsError::TuiError(e.to_string()))?;
107        terminal
108            .show_cursor()
109            .map_err(|e| TmsError::TuiError(e.to_string()))?;
110
111        Ok(selected_str)
112    }
113
114    fn main_loop(
115        &mut self,
116        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
117    ) -> Result<Option<String>> {
118        loop {
119            self.matcher.tick(10);
120            self.update_selection();
121            terminal
122                .draw(|f| self.render(f))
123                .map_err(|e| TmsError::TuiError(e.to_string()))?;
124
125            if let Event::Key(key) = event::read().map_err(|e| TmsError::TuiError(e.to_string()))? {
126                if key.kind == KeyEventKind::Press {
127                    match self.keymap.get(&key.into()) {
128                        Some(PickerAction::Cancel) => return Ok(None),
129                        Some(PickerAction::Confirm) => {
130                            if let Some(selected) = self.get_selected() {
131                                return Ok(Some(selected.to_owned()));
132                            }
133                        }
134                        Some(PickerAction::Backspace) => self.remove_filter(),
135                        Some(PickerAction::Delete) => self.delete(),
136                        Some(PickerAction::DeleteWord) => self.delete_word(),
137                        Some(PickerAction::DeleteToLineStart) => self.delete_to_line(false),
138                        Some(PickerAction::DeleteToLineEnd) => self.delete_to_line(true),
139                        Some(PickerAction::MoveUp) => self.move_up(),
140                        Some(PickerAction::MoveDown) => self.move_down(),
141                        Some(PickerAction::CursorLeft) => self.move_cursor_left(),
142                        Some(PickerAction::CursorRight) => self.move_cursor_right(),
143                        Some(PickerAction::MoveToLineStart) => self.move_to_start(),
144                        Some(PickerAction::MoveToLineEnd) => self.move_to_end(),
145                        Some(PickerAction::Noop) => {}
146                        None => {
147                            if let KeyCode::Char(c) = key.code {
148                                self.update_filter(c)
149                            }
150                        }
151                    }
152                }
153            }
154        }
155    }
156
157    fn update_selection(&mut self) {
158        let snapshot = self.matcher.snapshot();
159        if let Some(selected) = self.selection.selected() {
160            if snapshot.matched_item_count() == 0 {
161                self.selection.select(None);
162            } else if selected > snapshot.matched_item_count() as usize {
163                self.selection
164                    .select(Some(snapshot.matched_item_count() as usize - 1));
165            }
166        } else if snapshot.matched_item_count() > 0 {
167            self.selection.select(Some(0));
168        }
169    }
170
171    fn render(&mut self, f: &mut Frame) {
172        let preview_direction;
173        let picker_pane;
174        let preview_pane;
175
176        let preview_split = if !matches!(self.preview, Preview::None) {
177            preview_direction = if f.area().width.div_ceil(2) >= f.area().height {
178                picker_pane = 0;
179                preview_pane = 1;
180                Direction::Horizontal
181            } else {
182                picker_pane = 1;
183                preview_pane = 0;
184                Direction::Vertical
185            };
186            Layout::new(
187                preview_direction,
188                [Constraint::Percentage(50), Constraint::Percentage(50)],
189            )
190            .split(f.area())
191        } else {
192            picker_pane = 0;
193            preview_pane = 1;
194            preview_direction = Direction::Horizontal;
195            Rc::new([f.area()])
196        };
197
198        let layout = Layout::new(
199            Direction::Vertical,
200            [
201                Constraint::Length(preview_split[picker_pane].height - 1),
202                Constraint::Length(1),
203            ],
204        )
205        .split(preview_split[picker_pane]);
206
207        let snapshot = self.matcher.snapshot();
208        let matches = snapshot
209            .matched_items(..snapshot.matched_item_count())
210            .map(|item| ListItem::new(item.data.as_str()));
211
212        let colors = if let Some(colors) = self.colors {
213            colors.to_owned()
214        } else {
215            PickerColorConfig::default_colors()
216        };
217
218        let table = List::new(matches)
219            .highlight_style(colors.highlight_style())
220            .direction(ListDirection::BottomToTop)
221            .highlight_spacing(HighlightSpacing::Always)
222            .highlight_symbol("> ")
223            .block(
224                Block::default()
225                    .borders(Borders::BOTTOM)
226                    .border_style(Style::default().fg(colors.border_color()))
227                    .title_style(Style::default().fg(colors.info_color()))
228                    .title_position(Position::Bottom)
229                    .title(format!(
230                        "{}/{}",
231                        snapshot.matched_item_count(),
232                        snapshot.item_count()
233                    )),
234            );
235        f.render_stateful_widget(table, layout[0], &mut self.selection);
236
237        let prompt = Span::styled("> ", Style::default().fg(colors.prompt_color()));
238        let input_text = Span::raw(&self.filter);
239        let input_line = Line::from(vec![prompt, input_text]);
240        let input = Paragraph::new(vec![input_line]);
241        f.render_widget(input, layout[1]);
242        f.set_cursor_position(layout::Position {
243            x: layout[1].x + self.cursor_pos + 2,
244            y: layout[1].y,
245        });
246
247        if !matches!(self.preview, Preview::None) {
248            self.render_preview(
249                f,
250                &colors.border_color(),
251                &preview_direction,
252                preview_split[preview_pane],
253            );
254        }
255    }
256
257    fn render_preview(
258        &self,
259        f: &mut Frame,
260        border_color: &Color,
261        direction: &Direction,
262        rect: Rect,
263    ) {
264        let text = if let Some(item_data) = self.get_selected() {
265            let output = match self.preview {
266                Preview::SessionPane => self.tmux.capture_pane(item_data),
267                Preview::WindowPane => self.tmux.capture_pane(
268                    item_data
269                        .split_once(' ')
270                        .map(|val| val.0)
271                        .unwrap_or_default(),
272                ),
273                Preview::Directory => process::Command::new("ls")
274                    .args(["-1", item_data])
275                    .output()
276                    .unwrap_or_else(|_| {
277                        panic!("Failed to execute the command for directory: {}", item_data)
278                    }),
279                Preview::None => panic!("preview rendering should not have occured"),
280            };
281
282            if output.status.success() {
283                String::from_utf8(output.stdout).unwrap()
284            } else {
285                "".to_string()
286            }
287        } else {
288            "".to_string()
289        };
290        let text = str_to_text(&text, (rect.width - 1).into());
291        let border_position = if *direction == Direction::Horizontal {
292            Borders::LEFT
293        } else {
294            Borders::BOTTOM
295        };
296        let preview = Paragraph::new(text)
297            .block(
298                Block::default()
299                    .borders(border_position)
300                    .border_style(Style::default().fg(*border_color)),
301            )
302            .wrap(Wrap { trim: false });
303        f.render_widget(preview, rect);
304    }
305
306    fn get_selected(&self) -> Option<&String> {
307        if let Some(index) = self.selection.selected() {
308            return self
309                .matcher
310                .snapshot()
311                .get_matched_item(index as u32)
312                .map(|item| item.data);
313        }
314
315        None
316    }
317
318    fn move_up(&mut self) {
319        let item_count = self.matcher.snapshot().matched_item_count() as usize;
320        if item_count == 0 {
321            return;
322        }
323
324        let max = item_count - 1;
325
326        match self.selection.selected() {
327            Some(i) if i >= max => {}
328            Some(i) => self.selection.select(Some(i + 1)),
329            None => self.selection.select(Some(0)),
330        }
331    }
332
333    fn move_down(&mut self) {
334        match self.selection.selected() {
335            Some(0) => {}
336            Some(i) => self.selection.select(Some(i - 1)),
337            None => self.selection.select(Some(0)),
338        }
339    }
340
341    fn move_cursor_left(&mut self) {
342        if self.cursor_pos > 0 {
343            self.cursor_pos -= 1;
344        }
345    }
346
347    fn move_cursor_right(&mut self) {
348        if self.cursor_pos < self.filter.len() as u16 {
349            self.cursor_pos += 1;
350        }
351    }
352
353    fn update_filter(&mut self, c: char) {
354        if self.filter.len() == u16::MAX as usize {
355            return;
356        }
357
358        let prev_filter = self.filter.clone();
359        self.filter.insert(self.cursor_pos as usize, c);
360        self.cursor_pos += 1;
361
362        self.update_matcher_pattern(&prev_filter);
363    }
364
365    fn remove_filter(&mut self) {
366        if self.cursor_pos == 0 {
367            return;
368        }
369
370        let prev_filter = self.filter.clone();
371        self.filter.remove(self.cursor_pos as usize - 1);
372
373        self.cursor_pos -= 1;
374
375        if self.filter != prev_filter {
376            self.update_matcher_pattern(&prev_filter);
377        }
378    }
379
380    fn delete(&mut self) {
381        if (self.cursor_pos as usize) == self.filter.len() {
382            return;
383        }
384
385        let prev_filter = self.filter.clone();
386        self.filter.remove(self.cursor_pos as usize);
387
388        if self.filter != prev_filter {
389            self.update_matcher_pattern(&prev_filter);
390        }
391    }
392
393    fn update_matcher_pattern(&mut self, prev_filter: &str) {
394        self.matcher.pattern.reparse(
395            0,
396            self.filter.as_str(),
397            CaseMatching::Smart,
398            Normalization::Smart,
399            self.filter.starts_with(prev_filter),
400        );
401    }
402
403    fn delete_word(&mut self) {
404        let mut chars = self
405            .filter
406            .chars()
407            .rev()
408            .skip(self.filter.chars().count() - self.cursor_pos as usize);
409        let length = std::cmp::min(
410            u16::try_from(
411                1 + chars.by_ref().take_while(|c| *c == ' ').count()
412                    + chars.by_ref().take_while(|c| *c != ' ').count(),
413            )
414            .unwrap_or(self.cursor_pos),
415            self.cursor_pos,
416        );
417
418        let prev_filter = self.filter.clone();
419        let new_cursor_pos = self.cursor_pos - length;
420
421        self.filter
422            .drain((new_cursor_pos as usize)..(self.cursor_pos as usize));
423
424        self.cursor_pos = new_cursor_pos;
425
426        if self.filter != prev_filter {
427            self.update_matcher_pattern(&prev_filter);
428        }
429    }
430
431    fn delete_to_line(&mut self, forward: bool) {
432        let prev_filter = self.filter.clone();
433
434        if forward {
435            self.filter.drain((self.cursor_pos as usize)..);
436        } else {
437            self.filter.drain(..(self.cursor_pos as usize));
438            self.cursor_pos = 0;
439        }
440
441        if self.filter != prev_filter {
442            self.update_matcher_pattern(&prev_filter);
443        }
444    }
445
446    fn move_to_start(&mut self) {
447        self.cursor_pos = 0;
448    }
449
450    fn move_to_end(&mut self) {
451        self.cursor_pos = u16::try_from(self.filter.len()).unwrap_or_default();
452    }
453}
454
455fn request_redraw() {}
456
457fn str_to_text(s: &str, max: usize) -> Text {
458    let mut text = Text::default();
459    let mut style = Style::default();
460    let mut tspan = String::new();
461    let mut ansi_state;
462
463    for l in s.lines() {
464        let mut line = Line::default();
465        ansi_state = false;
466
467        for (i, ch) in l.chars().enumerate() {
468            if !ansi_state {
469                if ch == '\x1b' && l.chars().nth(i + 1) == Some('[') {
470                    if !tspan.is_empty() {
471                        let span = Span::styled(tspan.clone(), style);
472                        line.spans.push(span);
473                    }
474
475                    tspan.clear();
476                    ansi_state = true;
477                } else {
478                    tspan.push(ch);
479
480                    if (line.width() + tspan.chars().count()) == max || i == (l.chars().count() - 1)
481                    {
482                        let span = Span::styled(tspan.clone(), style);
483                        line.spans.push(span);
484                        tspan.clear();
485                        break;
486                    }
487                }
488            } else {
489                match ch {
490                    '[' => {}
491                    'm' => {
492                        style = match tspan.as_str() {
493                            "" => style.reset(),
494                            "0" => style.reset(),
495                            "1" => style.bold(),
496                            "3" => style.italic(),
497                            "4" => style.underlined(),
498                            "5" => style.rapid_blink(),
499                            "6" => style.slow_blink(),
500                            "7" => style.reversed(),
501                            "9" => style.crossed_out(),
502                            "22" => style.not_bold(),
503                            "23" => style.not_italic(),
504                            "24" => style.not_underlined(),
505                            "25" => style.not_rapid_blink().not_slow_blink(),
506                            "27" => style.not_reversed(),
507                            "29" => style.not_crossed_out(),
508                            "30" => style.fg(Color::Black),
509                            "31" => style.fg(Color::Red),
510                            "32" => style.fg(Color::Green),
511                            "33" => style.fg(Color::Yellow),
512                            "34" => style.fg(Color::Blue),
513                            "35" => style.fg(Color::Magenta),
514                            "36" => style.fg(Color::Cyan),
515                            "37" => style.fg(Color::Gray),
516                            "40" => style.bg(Color::Black),
517                            "41" => style.bg(Color::Red),
518                            "42" => style.bg(Color::Green),
519                            "43" => style.bg(Color::Yellow),
520                            "44" => style.bg(Color::Blue),
521                            "45" => style.bg(Color::Magenta),
522                            "46" => style.bg(Color::Cyan),
523                            "47" => style.bg(Color::Gray),
524                            "90" => style.fg(Color::DarkGray),
525                            "91" => style.fg(Color::LightRed),
526                            "92" => style.fg(Color::LightGreen),
527                            "93" => style.fg(Color::LightYellow),
528                            "94" => style.fg(Color::LightBlue),
529                            "95" => style.fg(Color::LightMagenta),
530                            "96" => style.fg(Color::LightCyan),
531                            "97" => style.fg(Color::White),
532                            "100" => style.bg(Color::DarkGray),
533                            "101" => style.bg(Color::LightRed),
534                            "102" => style.bg(Color::LightGreen),
535                            "103" => style.bg(Color::LightYellow),
536                            "104" => style.bg(Color::LightBlue),
537                            "105" => style.bg(Color::LightMagenta),
538                            "106" => style.bg(Color::LightCyan),
539                            "107" => style.bg(Color::White),
540                            code => {
541                                if let Some(colored) = Colored::parse_ansi(code) {
542                                    match colored {
543                                        Colored::ForegroundColor(c) => style.fg(c.into()),
544                                        Colored::BackgroundColor(c) => style.bg(c.into()),
545                                        Colored::UnderlineColor(c) => {
546                                            style.underline_color(c.into())
547                                        }
548                                    }
549                                } else {
550                                    style
551                                }
552                            }
553                        };
554
555                        tspan.clear();
556                        ansi_state = false;
557                    }
558                    _ => tspan.push(ch),
559                }
560            }
561        }
562
563        text.lines.push(line);
564    }
565
566    text
567}