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}