rust_kanban/ui/rendering/popup/widgets/
command_palette.rs

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        // Housekeeping
35        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}