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 if let Some(ref active) = self.active_sessions {
239 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() {}