1use crate::{
2 app::{
3 state::{AppStatus, Focus, KeyBindingEnum},
4 App,
5 },
6 constants::{
7 LIST_SELECTED_SYMBOL, SCROLLBAR_BEGIN_SYMBOL, SCROLLBAR_END_SYMBOL, SCROLLBAR_TRACK_SYMBOL,
8 },
9 ui::{
10 rendering::{
11 common::{render_blank_styled_canvas, render_close_button},
12 popup::widgets::CommandPalette,
13 utils::{
14 calculate_viewport_corrected_cursor_position, check_if_active_and_get_style,
15 check_if_mouse_is_in_area, get_scrollable_widget_row_bounds,
16 },
17 },
18 Renderable,
19 },
20};
21use ratatui::{
22 layout::{Alignment, Constraint, Direction, Layout, Margin},
23 style::{Modifier, Style},
24 text::{Line, Span},
25 widgets::{
26 Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Scrollbar,
27 ScrollbarOrientation, ScrollbarState,
28 },
29 Frame,
30};
31
32impl Renderable for CommandPalette {
33 fn render(rect: &mut Frame, app: &mut App, is_active: bool) {
34 match app.state.focus {
36 Focus::CommandPaletteCommand => {
37 if app
38 .state
39 .app_list_states
40 .command_palette_command_search
41 .selected()
42 .is_none()
43 {
44 if let Some(results) = &app.widgets.command_palette.command_search_results {
45 if !results.is_empty() {
46 app.state
47 .app_list_states
48 .command_palette_command_search
49 .select(Some(0));
50 }
51 }
52 }
53 }
54 Focus::CommandPaletteCard => {
55 if app
56 .state
57 .app_list_states
58 .command_palette_card_search
59 .selected()
60 .is_none()
61 {
62 if let Some(results) = &app.widgets.command_palette.card_search_results {
63 if !results.is_empty() {
64 app.state
65 .app_list_states
66 .command_palette_card_search
67 .select(Some(0));
68 }
69 }
70 }
71 }
72 Focus::CommandPaletteBoard => {
73 if app
74 .state
75 .app_list_states
76 .command_palette_board_search
77 .selected()
78 .is_none()
79 {
80 if let Some(results) = &app.widgets.command_palette.board_search_results {
81 if !results.is_empty() {
82 app.state
83 .app_list_states
84 .command_palette_board_search
85 .select(Some(0));
86 }
87 }
88 }
89 }
90 _ => {
91 if app.state.app_status != AppStatus::UserInput {
92 app.state.app_status = AppStatus::UserInput;
93 }
94 }
95 }
96
97 let current_search_text_input = app.state.text_buffers.command_palette.get_joined_lines();
98 let horizontal_chunks = Layout::default()
99 .direction(Direction::Horizontal)
100 .constraints(
101 [
102 Constraint::Percentage(10),
103 Constraint::Percentage(80),
104 Constraint::Percentage(10),
105 ]
106 .as_ref(),
107 )
108 .split(rect.area());
109
110 fn get_command_palette_style(app: &App, focus: Focus) -> (Style, Style, Style) {
111 if app.state.focus == focus {
112 (
113 app.current_theme.keyboard_focus_style,
114 app.current_theme.general_style,
115 app.current_theme.list_select_style,
116 )
117 } else {
118 (
119 app.current_theme.inactive_text_style,
120 app.current_theme.inactive_text_style,
121 app.current_theme.inactive_text_style,
122 )
123 }
124 }
125
126 let (
127 command_search_border_style,
128 command_search_text_style,
129 command_search_highlight_style,
130 ) = get_command_palette_style(app, Focus::CommandPaletteCommand);
131 let (card_search_border_style, card_search_text_style, card_search_highlight_style) =
132 get_command_palette_style(app, Focus::CommandPaletteCard);
133 let (board_search_border_style, board_search_text_style, board_search_highlight_style) =
134 get_command_palette_style(app, Focus::CommandPaletteBoard);
135 let keyboard_focus_style = check_if_active_and_get_style(
136 is_active,
137 app.current_theme.inactive_text_style,
138 app.current_theme.keyboard_focus_style,
139 );
140 let general_style = check_if_active_and_get_style(
141 is_active,
142 app.current_theme.inactive_text_style,
143 app.current_theme.general_style,
144 );
145 let help_key_style = check_if_active_and_get_style(
146 is_active,
147 app.current_theme.inactive_text_style,
148 app.current_theme.help_key_style,
149 );
150 let help_text_style = check_if_active_and_get_style(
151 is_active,
152 app.current_theme.inactive_text_style,
153 app.current_theme.help_text_style,
154 );
155 let progress_bar_style = check_if_active_and_get_style(
156 is_active,
157 app.current_theme.inactive_text_style,
158 app.current_theme.progress_bar_style,
159 );
160 let rapid_blink_general_style = if is_active {
161 general_style.add_modifier(Modifier::RAPID_BLINK)
162 } else {
163 general_style
164 };
165
166 let command_search_results =
167 if let Some(raw_search_results) = &app.widgets.command_palette.command_search_results {
168 let mut list_items = vec![];
169 for item in raw_search_results {
170 let mut spans = vec![];
171 for c in item.to_string().chars() {
172 if current_search_text_input
173 .to_lowercase()
174 .contains(c.to_string().to_lowercase().as_str())
175 {
176 spans.push(Span::styled(c.to_string(), keyboard_focus_style));
177 } else {
178 spans.push(Span::styled(c.to_string(), command_search_text_style));
179 }
180 }
181 list_items.push(ListItem::new(Line::from(spans)));
182 }
183 list_items
184 } else {
185 app.widgets
186 .command_palette
187 .available_commands
188 .iter()
189 .map(|c| ListItem::new(Line::from(format!("Command - {}", c))))
190 .collect::<Vec<ListItem>>()
191 };
192
193 let card_search_results = if app.widgets.command_palette.card_search_results.is_some()
194 && !current_search_text_input.is_empty()
195 && current_search_text_input.len() > 1
196 {
197 let raw_search_results = app
198 .widgets
199 .command_palette
200 .card_search_results
201 .as_ref()
202 .unwrap();
203 let mut list_items = vec![];
204 for (item, _) in raw_search_results {
205 let item = if item.len() > (horizontal_chunks[1].width - 2) as usize {
206 format!("{}...", &item[0..(horizontal_chunks[1].width - 5) as usize])
207 } else {
208 item.to_string()
209 };
210 list_items.push(ListItem::new(Line::from(Span::styled(
211 item.to_string(),
212 card_search_text_style,
213 ))));
214 }
215 list_items
216 } else {
217 vec![]
218 };
219
220 let board_search_results = if app.widgets.command_palette.board_search_results.is_some()
221 && !current_search_text_input.is_empty()
222 && current_search_text_input.len() > 1
223 {
224 let raw_search_results = app
225 .widgets
226 .command_palette
227 .board_search_results
228 .as_ref()
229 .unwrap();
230 let mut list_items = vec![];
231 for (item, _) in raw_search_results {
232 let item = if item.len() > (horizontal_chunks[1].width - 2) as usize {
233 format!("{}...", &item[0..(horizontal_chunks[1].width - 5) as usize])
234 } else {
235 item.to_string()
236 };
237 list_items.push(ListItem::new(Line::from(Span::styled(
238 item.to_string(),
239 board_search_text_style,
240 ))));
241 }
242 list_items
243 } else {
244 vec![]
245 };
246
247 let max_height = if app.state.user_login_data.auth_token.is_some() {
248 (rect.area().height - 14) as usize
249 } else {
250 (rect.area().height - 12) as usize
251 };
252 let min_height = 2;
253 let command_search_results_length = command_search_results.len() + 2;
254 let card_search_results_length = card_search_results.len() + 2;
255 let board_search_results_length = board_search_results.len() + 2;
256 let command_search_results_length = if command_search_results_length >= min_height {
257 if (command_search_results_length + (2 * min_height)) < max_height {
258 command_search_results_length
259 } else {
260 let calc = max_height - (2 * min_height);
261 if calc < min_height {
262 min_height
263 } else {
264 calc
265 }
266 }
267 } else {
268 min_height
269 };
270 let card_search_results_length = if card_search_results_length >= min_height {
271 if (command_search_results_length + card_search_results_length + min_height)
272 < max_height
273 {
274 card_search_results_length
275 } else {
276 let calc = max_height - (command_search_results_length + min_height);
277 if calc < min_height {
278 min_height
279 } else {
280 calc
281 }
282 }
283 } else {
284 min_height
285 };
286 let board_search_results_length = if board_search_results_length >= min_height {
287 if (command_search_results_length
288 + card_search_results_length
289 + board_search_results_length)
290 < max_height
291 {
292 board_search_results_length
293 } else {
294 let calc = max_height
295 - (command_search_results_length + card_search_results_length + min_height);
296 if calc < min_height {
297 min_height
298 } else {
299 calc
300 }
301 }
302 } else {
303 min_height
304 };
305
306 let vertical_chunks = if app.state.user_login_data.auth_token.is_some() {
307 Layout::default()
308 .direction(Direction::Vertical)
309 .constraints(
310 [
311 Constraint::Length(3),
312 Constraint::Length(1),
313 Constraint::Length(3),
314 Constraint::Length(
315 ((command_search_results_length
316 + card_search_results_length
317 + board_search_results_length)
318 + 2) as u16,
319 ),
320 Constraint::Fill(1),
321 Constraint::Length(4),
322 ]
323 .as_ref(),
324 )
325 .split(horizontal_chunks[1])
326 } else {
327 Layout::default()
328 .direction(Direction::Vertical)
329 .constraints(
330 [
331 Constraint::Length(2),
332 Constraint::Length(3),
333 Constraint::Length(
334 ((command_search_results_length
335 + card_search_results_length
336 + board_search_results_length)
337 + 2) as u16,
338 ),
339 Constraint::Fill(1),
340 Constraint::Length(4),
341 ]
342 .as_ref(),
343 )
344 .split(horizontal_chunks[1])
345 };
346
347 let search_box_chunk = if app.state.user_login_data.auth_token.is_some() {
348 vertical_chunks[2]
349 } else {
350 vertical_chunks[1]
351 };
352
353 let search_results_chunk = if app.state.user_login_data.auth_token.is_some() {
354 vertical_chunks[3]
355 } else {
356 vertical_chunks[2]
357 };
358
359 let help_chunk = if app.state.user_login_data.auth_token.is_some() {
360 vertical_chunks[5]
361 } else {
362 vertical_chunks[4]
363 };
364
365 if app.state.user_login_data.auth_token.is_some() {
366 let logged_in_indicator = Paragraph::new(format!(
367 "Logged in as: {}",
368 app.state.user_login_data.email_id.clone().unwrap()
369 ))
370 .style(rapid_blink_general_style)
371 .block(
372 Block::default()
373 .borders(Borders::ALL)
374 .border_type(BorderType::Rounded),
375 )
376 .alignment(Alignment::Center);
377 rect.render_widget(Clear, vertical_chunks[0]);
378 rect.render_widget(logged_in_indicator, vertical_chunks[0]);
379 }
380
381 app.state
382 .text_buffers
383 .command_palette
384 .set_placeholder_text("Start typing to search for a command, card or board!");
385
386 let (x_pos, y_pos) = calculate_viewport_corrected_cursor_position(
387 &app.state.text_buffers.command_palette,
388 &app.config.show_line_numbers,
389 &search_box_chunk,
390 );
391 rect.set_cursor_position((x_pos, y_pos));
392
393 let search_box_block = Block::default()
394 .title("Command Palette")
395 .borders(Borders::ALL)
396 .style(general_style)
397 .border_type(BorderType::Rounded);
398 app.state
399 .text_buffers
400 .command_palette
401 .set_block(search_box_block);
402
403 render_blank_styled_canvas(rect, &app.current_theme, search_box_chunk, is_active);
404 rect.render_widget(
405 app.state.text_buffers.command_palette.widget(),
406 search_box_chunk,
407 );
408
409 let results_border = Block::default()
410 .style(general_style)
411 .borders(Borders::ALL)
412 .border_type(BorderType::Rounded);
413
414 let search_results_chunks = Layout::default()
415 .direction(Direction::Vertical)
416 .constraints(
417 [
418 Constraint::Min(command_search_results_length as u16),
419 Constraint::Min(card_search_results_length as u16),
420 Constraint::Min(board_search_results_length as u16),
421 ]
422 .as_ref(),
423 )
424 .margin(1)
425 .split(search_results_chunk);
426
427 let command_search_results_list = List::new(command_search_results.clone())
428 .block(
429 Block::default()
430 .title("Commands")
431 .border_style(command_search_border_style)
432 .borders(Borders::ALL)
433 .border_type(BorderType::Rounded),
434 )
435 .highlight_style(command_search_highlight_style)
436 .highlight_symbol(LIST_SELECTED_SYMBOL);
437
438 let card_search_results_list = List::new(card_search_results.clone())
439 .block(
440 Block::default()
441 .title("Cards")
442 .border_style(card_search_border_style)
443 .borders(Borders::ALL)
444 .border_type(BorderType::Rounded),
445 )
446 .highlight_style(card_search_highlight_style)
447 .highlight_symbol(LIST_SELECTED_SYMBOL);
448
449 let board_search_results_list = List::new(board_search_results.clone())
450 .block(
451 Block::default()
452 .title("Boards")
453 .border_style(board_search_border_style)
454 .borders(Borders::ALL)
455 .border_type(BorderType::Rounded),
456 )
457 .highlight_style(board_search_highlight_style)
458 .highlight_symbol(LIST_SELECTED_SYMBOL);
459
460 let up_key = app
461 .get_first_keybinding(KeyBindingEnum::Up)
462 .unwrap_or("".to_string());
463 let down_key = app
464 .get_first_keybinding(KeyBindingEnum::Down)
465 .unwrap_or("".to_string());
466 let next_focus_key = app
467 .get_first_keybinding(KeyBindingEnum::NextFocus)
468 .unwrap_or("".to_string());
469 let prv_focus_key = app
470 .get_first_keybinding(KeyBindingEnum::PrvFocus)
471 .unwrap_or("".to_string());
472 let accept_key = app
473 .get_first_keybinding(KeyBindingEnum::Accept)
474 .unwrap_or("".to_string());
475
476 let help_spans = Line::from(vec![
477 Span::styled("Use ", help_text_style),
478 Span::styled(up_key, help_key_style),
479 Span::styled(" and ", help_text_style),
480 Span::styled(down_key, help_key_style),
481 Span::styled(
482 " or scroll with the mouse to highlight a Command/Card/Board. Press ",
483 help_text_style,
484 ),
485 Span::styled(accept_key, help_key_style),
486 Span::styled(" to select. Press ", help_text_style),
487 Span::styled(next_focus_key, help_key_style),
488 Span::styled(" or ", help_text_style),
489 Span::styled(prv_focus_key, help_key_style),
490 Span::styled(" to change focus", help_text_style),
491 ]);
492
493 let help_paragraph = Paragraph::new(help_spans)
494 .block(
495 Block::default()
496 .title("Help")
497 .borders(Borders::ALL)
498 .border_type(BorderType::Rounded)
499 .style(general_style),
500 )
501 .alignment(Alignment::Center)
502 .wrap(ratatui::widgets::Wrap { trim: false });
503
504 if check_if_mouse_is_in_area(
505 &app.state.current_mouse_coordinates,
506 &search_results_chunks[0],
507 ) {
508 app.state.mouse_focus = Some(Focus::CommandPaletteCommand);
509 app.state.set_focus(Focus::CommandPaletteCommand);
510 }
511 if check_if_mouse_is_in_area(
512 &app.state.current_mouse_coordinates,
513 &search_results_chunks[1],
514 ) {
515 app.state.mouse_focus = Some(Focus::CommandPaletteCard);
516 app.state.set_focus(Focus::CommandPaletteCard);
517 }
518 if check_if_mouse_is_in_area(
519 &app.state.current_mouse_coordinates,
520 &search_results_chunks[2],
521 ) {
522 app.state.mouse_focus = Some(Focus::CommandPaletteBoard);
523 app.state.set_focus(Focus::CommandPaletteBoard);
524 }
525
526 render_blank_styled_canvas(rect, &app.current_theme, search_results_chunk, is_active);
527 rect.render_widget(results_border, search_results_chunk);
528 if app.state.focus != Focus::CommandPaletteCommand {
529 render_blank_styled_canvas(
530 rect,
531 &app.current_theme,
532 search_results_chunks[0],
533 is_active,
534 );
535 }
536 rect.render_stateful_widget(
537 command_search_results_list,
538 search_results_chunks[0],
539 &mut app.state.app_list_states.command_palette_command_search,
540 );
541 if app.state.focus != Focus::CommandPaletteCard {
542 render_blank_styled_canvas(
543 rect,
544 &app.current_theme,
545 search_results_chunks[1],
546 is_active,
547 );
548 }
549 rect.render_stateful_widget(
550 card_search_results_list,
551 search_results_chunks[1],
552 &mut app.state.app_list_states.command_palette_card_search,
553 );
554 if app.state.focus != Focus::CommandPaletteBoard {
555 render_blank_styled_canvas(
556 rect,
557 &app.current_theme,
558 search_results_chunks[2],
559 is_active,
560 );
561 }
562 rect.render_stateful_widget(
563 board_search_results_list,
564 search_results_chunks[2],
565 &mut app.state.app_list_states.command_palette_board_search,
566 );
567
568 if app.state.focus == Focus::CommandPaletteCommand {
569 let current_index = app
570 .state
571 .app_list_states
572 .command_palette_command_search
573 .selected()
574 .unwrap_or(0);
575 let (row_start_index, _) = get_scrollable_widget_row_bounds(
576 command_search_results_length.saturating_sub(2),
577 current_index,
578 app.state
579 .app_list_states
580 .command_palette_command_search
581 .offset(),
582 (search_results_chunks[0].height - 2) as usize,
583 );
584 let current_mouse_y_position = app.state.current_mouse_coordinates.1;
585 let hovered_index = if current_mouse_y_position > search_results_chunks[0].y
586 && current_mouse_y_position
587 < (search_results_chunks[0].y + search_results_chunks[0].height - 1)
588 {
589 Some(
590 ((current_mouse_y_position - search_results_chunks[0].y - 1)
591 + row_start_index as u16) as usize,
592 )
593 } else {
594 None
595 };
596 if hovered_index.is_some()
597 && (app.state.previous_mouse_coordinates != app.state.current_mouse_coordinates)
598 {
599 app.state
600 .app_list_states
601 .command_palette_command_search
602 .select(hovered_index);
603 }
604 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
605 .begin_symbol(SCROLLBAR_BEGIN_SYMBOL)
606 .style(progress_bar_style)
607 .end_symbol(SCROLLBAR_END_SYMBOL)
608 .track_symbol(SCROLLBAR_TRACK_SYMBOL)
609 .track_style(app.current_theme.inactive_text_style);
610
611 let mut scrollbar_state =
612 ScrollbarState::new(command_search_results.len()).position(current_index);
613 let scrollbar_area = search_results_chunks[0].inner(Margin {
614 horizontal: 0,
615 vertical: 1,
616 });
617 rect.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
618 } else if app.state.focus == Focus::CommandPaletteCard {
619 let current_index = app
620 .state
621 .app_list_states
622 .command_palette_card_search
623 .selected()
624 .unwrap_or(0);
625 let (row_start_index, _) = get_scrollable_widget_row_bounds(
626 card_search_results_length.saturating_sub(2),
627 current_index,
628 app.state
629 .app_list_states
630 .command_palette_card_search
631 .offset(),
632 (search_results_chunks[1].height - 2) as usize,
633 );
634 let current_mouse_y_position = app.state.current_mouse_coordinates.1;
635 let hovered_index = if current_mouse_y_position > search_results_chunks[1].y
636 && current_mouse_y_position
637 < (search_results_chunks[1].y + search_results_chunks[1].height - 1)
638 {
639 Some(
640 ((current_mouse_y_position - search_results_chunks[1].y - 1)
641 + row_start_index as u16) as usize,
642 )
643 } else {
644 None
645 };
646 if hovered_index.is_some()
647 && (app.state.previous_mouse_coordinates != app.state.current_mouse_coordinates)
648 {
649 app.state
650 .app_list_states
651 .command_palette_card_search
652 .select(hovered_index);
653 }
654 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
655 .begin_symbol(SCROLLBAR_BEGIN_SYMBOL)
656 .style(progress_bar_style)
657 .end_symbol(SCROLLBAR_END_SYMBOL)
658 .track_symbol(SCROLLBAR_TRACK_SYMBOL)
659 .track_style(app.current_theme.inactive_text_style);
660
661 let mut scrollbar_state =
662 ScrollbarState::new(card_search_results.len()).position(current_index);
663 let scrollbar_area = search_results_chunks[1].inner(Margin {
664 horizontal: 0,
665 vertical: 1,
666 });
667 rect.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
668 } else if app.state.focus == Focus::CommandPaletteBoard {
669 let current_index = app
670 .state
671 .app_list_states
672 .command_palette_board_search
673 .selected()
674 .unwrap_or(0);
675 let (row_start_index, _) = get_scrollable_widget_row_bounds(
676 board_search_results_length.saturating_sub(2),
677 current_index,
678 app.state
679 .app_list_states
680 .command_palette_board_search
681 .offset(),
682 (search_results_chunks[2].height - 2) as usize,
683 );
684 let current_mouse_y_position = app.state.current_mouse_coordinates.1;
685 let hovered_index = if current_mouse_y_position > search_results_chunks[2].y
686 && current_mouse_y_position
687 < (search_results_chunks[2].y + search_results_chunks[2].height - 1)
688 {
689 Some(
690 ((current_mouse_y_position - search_results_chunks[2].y - 1)
691 + row_start_index as u16) as usize,
692 )
693 } else {
694 None
695 };
696 if hovered_index.is_some()
697 && (app.state.previous_mouse_coordinates != app.state.current_mouse_coordinates)
698 {
699 app.state
700 .app_list_states
701 .command_palette_board_search
702 .select(hovered_index);
703 }
704 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
705 .begin_symbol(SCROLLBAR_BEGIN_SYMBOL)
706 .style(progress_bar_style)
707 .end_symbol(SCROLLBAR_END_SYMBOL)
708 .track_symbol(SCROLLBAR_TRACK_SYMBOL)
709 .track_style(app.current_theme.inactive_text_style);
710
711 let mut scrollbar_state =
712 ScrollbarState::new(board_search_results.len()).position(current_index);
713 let scrollbar_area = search_results_chunks[2].inner(Margin {
714 horizontal: 0,
715 vertical: 1,
716 });
717 rect.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
718 }
719
720 render_blank_styled_canvas(rect, &app.current_theme, help_chunk, is_active);
721 rect.render_widget(help_paragraph, help_chunk);
722 if app.config.enable_mouse_support {
723 render_close_button(rect, app, is_active);
724 }
725 }
726}