rust_kanban/ui/widgets/
command_palette.rs

1use crate::{
2    app::{
3        app_helper::reset_preview_boards,
4        handle_exit,
5        state::{AppState, AppStatus, Focus},
6        App, AppReturn,
7    },
8    constants::RANDOM_SEARCH_TERM,
9    io::{io_handler::refresh_visible_boards_and_cards, IoEvent},
10    ui::{widgets::Widget, PopUp, View},
11};
12use log::{debug, error, info};
13use std::{
14    fmt::{self, Display},
15    vec,
16};
17use strum::{EnumIter, EnumString, IntoEnumIterator};
18
19#[derive(Debug)]
20pub struct CommandPaletteWidget {
21    pub already_in_user_input_mode: bool,
22    pub available_commands: Vec<CommandPaletteActions>,
23    pub board_search_results: Option<Vec<(String, (u64, u64))>>,
24    pub card_search_results: Option<Vec<(String, (u64, u64))>>,
25    pub command_search_results: Option<Vec<CommandPaletteActions>>,
26    pub last_focus: Option<Focus>,
27    pub last_search_string: String,
28}
29
30impl CommandPaletteWidget {
31    pub fn new(debug_mode: bool) -> Self {
32        let available_commands = CommandPaletteActions::all(debug_mode);
33        Self {
34            already_in_user_input_mode: false,
35            available_commands,
36            board_search_results: None,
37            card_search_results: None,
38            command_search_results: None,
39            last_focus: None,
40            last_search_string: RANDOM_SEARCH_TERM.to_string(),
41        }
42    }
43
44    pub fn reset(&mut self, app_state: &mut AppState) {
45        self.board_search_results = None;
46        self.card_search_results = None;
47        self.command_search_results = None;
48        self.last_search_string = RANDOM_SEARCH_TERM.to_string();
49        app_state.text_buffers.command_palette.reset();
50        Self::reset_list_states(app_state);
51    }
52
53    pub fn reset_list_states(app_state: &mut AppState) {
54        app_state
55            .app_list_states
56            .command_palette_command_search
57            .select(None);
58        app_state
59            .app_list_states
60            .command_palette_card_search
61            .select(None);
62        app_state
63            .app_list_states
64            .command_palette_board_search
65            .select(None);
66    }
67
68    pub async fn handle_command(app: &mut App<'_>) -> AppReturn {
69        if let Some(command_index) = app
70            .state
71            .app_list_states
72            .command_palette_command_search
73            .selected()
74        {
75            if let Some(command) =
76                if let Some(search_results) = &app.widgets.command_palette.command_search_results {
77                    search_results.get(command_index)
78                } else {
79                    None
80                }
81            {
82                match command {
83                    CommandPaletteActions::Quit => {
84                        info!("Quitting");
85                        return handle_exit(app).await;
86                    }
87                    CommandPaletteActions::ConfigMenu => {
88                        app.close_popup();
89                        app.set_view(View::ConfigMenu);
90                        app.state.app_table_states.config.select(Some(0));
91                    }
92                    CommandPaletteActions::MainMenu => {
93                        app.close_popup();
94                        app.set_view(View::MainMenu);
95                        app.state.app_list_states.main_menu.select(Some(0));
96                    }
97                    CommandPaletteActions::HelpMenu => {
98                        app.close_popup();
99                        app.set_view(View::HelpMenu);
100                        app.state.app_table_states.help.select(Some(0));
101                    }
102                    CommandPaletteActions::SaveKanbanState => {
103                        app.close_popup();
104                        app.dispatch(IoEvent::SaveLocalData).await;
105                    }
106                    CommandPaletteActions::NewBoard => {
107                        if View::views_with_kanban_board().contains(&app.state.current_view) {
108                            app.close_popup();
109                            app.set_view(View::NewBoard);
110                        } else {
111                            app.close_popup();
112                            app.send_error_toast("Cannot create a new board in this view", None);
113                        }
114                    }
115                    CommandPaletteActions::NewCard => {
116                        if View::views_with_kanban_board().contains(&app.state.current_view) {
117                            if app.state.current_board_id.is_none() {
118                                app.send_error_toast("No board Selected / Available", None);
119                                app.close_popup();
120                                app.state.app_status = AppStatus::Initialized;
121                                return AppReturn::Continue;
122                            }
123                            app.close_popup();
124                            app.set_view(View::NewCard);
125                        } else {
126                            app.close_popup();
127                            app.send_error_toast("Cannot create a new card in this view", None);
128                        }
129                    }
130                    CommandPaletteActions::ResetUI => {
131                        app.close_popup();
132                        app.set_view(app.config.default_view);
133                        app.dispatch(IoEvent::ResetVisibleBoardsandCards).await;
134                    }
135                    CommandPaletteActions::ChangeView => {
136                        app.close_popup();
137                        app.set_popup(PopUp::ChangeView);
138                    }
139                    CommandPaletteActions::ChangeCurrentCardStatus => {
140                        if !View::views_with_kanban_board().contains(&app.state.current_view) {
141                            app.send_error_toast("Cannot change card status in this view", None);
142                            return AppReturn::Continue;
143                        }
144                        if let Some(current_board_id) = app.state.current_board_id {
145                            if let Some(current_board) =
146                                app.boards.get_mut_board_with_id(current_board_id)
147                            {
148                                if let Some(current_card_id) = app.state.current_card_id {
149                                    if current_board
150                                        .cards
151                                        .get_card_with_id(current_card_id)
152                                        .is_some()
153                                    {
154                                        app.close_popup();
155                                        app.set_popup(PopUp::CardStatusSelector);
156                                        app.state.app_status = AppStatus::Initialized;
157                                        app.state
158                                            .app_list_states
159                                            .card_status_selector
160                                            .select(Some(0));
161                                        return AppReturn::Continue;
162                                    }
163                                }
164                            }
165                        }
166                        app.send_error_toast("Could not find current card", None);
167                    }
168                    CommandPaletteActions::ChangeCurrentCardPriority => {
169                        if !View::views_with_kanban_board().contains(&app.state.current_view) {
170                            app.send_error_toast("Cannot change card priority in this view", None);
171                            return AppReturn::Continue;
172                        }
173                        if let Some(current_board_id) = app.state.current_board_id {
174                            if let Some(current_board) =
175                                app.boards.get_mut_board_with_id(current_board_id)
176                            {
177                                if let Some(current_card_id) = app.state.current_card_id {
178                                    if current_board
179                                        .cards
180                                        .get_card_with_id(current_card_id)
181                                        .is_some()
182                                    {
183                                        app.close_popup();
184                                        app.set_popup(PopUp::CardPrioritySelector);
185                                        app.state.app_status = AppStatus::Initialized;
186                                        app.state
187                                            .app_list_states
188                                            .card_priority_selector
189                                            .select(Some(0));
190                                        return AppReturn::Continue;
191                                    }
192                                }
193                            }
194                        }
195                        app.send_error_toast("Could not find current card", None);
196                    }
197                    CommandPaletteActions::LoadASaveLocal => {
198                        app.close_popup();
199                        reset_preview_boards(app);
200                        app.set_view(View::LoadLocalSave);
201                    }
202                    CommandPaletteActions::DebugMenu => {
203                        app.state.debug_menu_toggled = !app.state.debug_menu_toggled;
204                        app.close_popup();
205                    }
206                    CommandPaletteActions::ChangeTheme => {
207                        app.close_popup();
208                        app.set_popup(PopUp::ChangeTheme);
209                    }
210                    CommandPaletteActions::CreateATheme => {
211                        app.set_view(View::CreateTheme);
212                        app.close_popup();
213                    }
214                    CommandPaletteActions::FilterByTag => {
215                        let tags = app.calculate_tags();
216                        if tags.is_empty() {
217                            app.send_warning_toast("No tags found to filter with", None);
218                        } else {
219                            app.close_popup();
220                            app.set_popup(PopUp::FilterByTag);
221                            app.state.all_available_tags = Some(tags);
222                        }
223                    }
224                    CommandPaletteActions::ClearFilter => {
225                        if app.filtered_boards.is_empty() {
226                            app.send_warning_toast("No filters to clear", None);
227                            return AppReturn::Continue;
228                        } else {
229                            app.send_info_toast("All Filters Cleared", None);
230                        }
231                        app.state.filter_tags = None;
232                        app.state.all_available_tags = None;
233                        app.state.app_list_states.filter_by_tag_list.select(None);
234                        app.close_popup();
235                        app.filtered_boards.reset();
236                        refresh_visible_boards_and_cards(app);
237                    }
238                    CommandPaletteActions::ChangeDateFormat => {
239                        app.close_popup();
240                        app.set_popup(PopUp::ChangeDateFormatPopup);
241                    }
242                    CommandPaletteActions::NoCommandsFound => {
243                        app.close_popup();
244                        app.state.app_status = AppStatus::Initialized;
245                        return AppReturn::Continue;
246                    }
247                    CommandPaletteActions::Login => {
248                        if app.state.user_login_data.auth_token.is_some() {
249                            app.send_error_toast("Already logged in", None);
250                            app.close_popup();
251                            app.state.app_status = AppStatus::Initialized;
252                            return AppReturn::Continue;
253                        }
254                        app.set_view(View::Login);
255                        app.close_popup();
256                    }
257                    CommandPaletteActions::Logout => {
258                        app.dispatch(IoEvent::Logout).await;
259                        app.close_popup();
260                    }
261                    CommandPaletteActions::SignUp => {
262                        app.set_view(View::SignUp);
263                        app.close_popup();
264                    }
265                    CommandPaletteActions::ResetPassword => {
266                        app.set_view(View::ResetPassword);
267                        app.close_popup();
268                    }
269                    CommandPaletteActions::SyncLocalData => {
270                        app.dispatch(IoEvent::SyncLocalData).await;
271                        app.close_popup();
272                    }
273                    CommandPaletteActions::LoadASaveCloud => {
274                        if app.state.user_login_data.auth_token.is_some() {
275                            app.set_view(View::LoadCloudSave);
276                            reset_preview_boards(app);
277                            app.dispatch(IoEvent::GetCloudData).await;
278                            app.close_popup();
279                        } else {
280                            error!("Not logged in");
281                            app.send_error_toast("Not logged in", None);
282                            app.close_popup();
283                            app.state.app_status = AppStatus::Initialized;
284                            return AppReturn::Continue;
285                        }
286                    }
287                    CommandPaletteActions::MoveBoardLeft => {
288                        if let Some(current_board_id) = app.state.current_board_id {
289                            let current_board_index = app.boards.get_board_index(current_board_id);
290                            if current_board_index.is_none() {
291                                app.send_error_toast("No board selected", None);
292                                return AppReturn::Continue;
293                            }
294                            let current_board_index = current_board_index.unwrap();
295                            let board_name = app
296                                .boards
297                                .get_board_with_id(current_board_id)
298                                .unwrap()
299                                .name
300                                .clone();
301                            if current_board_index == 0 {
302                                app.send_error_toast(
303                                    format!("'{}' is already the first board", board_name).as_str(),
304                                    None,
305                                );
306                                return AppReturn::Continue;
307                            }
308                            let swap_result = app
309                                .boards
310                                .swap(current_board_index, current_board_index - 1);
311
312                            if swap_result.is_err() {
313                                app.send_error_toast(
314                                    format!("Could not move '{}' to the left", board_name).as_str(),
315                                    None,
316                                );
317                            }
318
319                            app.close_popup();
320                            app.send_info_toast(
321                                format!("'{}' moved to the left", board_name).as_str(),
322                                None,
323                            );
324                            refresh_visible_boards_and_cards(app);
325                        } else {
326                            app.send_error_toast("No board selected", None);
327                        }
328                    }
329                    CommandPaletteActions::MoveBoardRight => {
330                        if let Some(current_board_id) = app.state.current_board_id {
331                            let current_board_index = app.boards.get_board_index(current_board_id);
332                            if current_board_index.is_none() {
333                                app.send_error_toast("No board selected", None);
334                                return AppReturn::Continue;
335                            }
336                            let current_board_index = current_board_index.unwrap();
337                            let board_name = app
338                                .boards
339                                .get_board_with_id(current_board_id)
340                                .unwrap()
341                                .name
342                                .clone();
343                            if current_board_index == app.boards.get_boards().len() - 1 {
344                                app.send_error_toast(
345                                    format!("'{}' is already the last board", board_name).as_str(),
346                                    None,
347                                );
348                                return AppReturn::Continue;
349                            }
350                            let swap_result = app
351                                .boards
352                                .swap(current_board_index, current_board_index + 1);
353
354                            if swap_result.is_err() {
355                                app.send_error_toast(
356                                    format!("Could not move '{}' to the right", board_name)
357                                        .as_str(),
358                                    None,
359                                );
360                            }
361
362                            app.close_popup();
363                            app.send_info_toast(
364                                format!("'{}' moved to the right", board_name).as_str(),
365                                None,
366                            );
367                            refresh_visible_boards_and_cards(app);
368                        } else {
369                            app.send_error_toast("No board selected", None);
370                        }
371                    }
372                }
373                app.widgets.command_palette.reset(&mut app.state);
374            } else {
375                debug!("No command found for the command palette");
376            }
377        } else {
378            return AppReturn::Continue;
379        }
380        if app.widgets.command_palette.already_in_user_input_mode {
381            app.widgets.command_palette.already_in_user_input_mode = false;
382            app.widgets.command_palette.last_focus = None;
383        }
384        if app.state.z_stack.last() != Some(&PopUp::CustomHexColorPromptFG)
385            || app.state.z_stack.last() != Some(&PopUp::CustomHexColorPromptBG)
386        {
387            app.state.app_status = AppStatus::Initialized;
388        }
389        AppReturn::Continue
390    }
391}
392
393impl Widget for CommandPaletteWidget {
394    fn update(app: &mut App) {
395        if let Some(PopUp::CommandPalette) = app.state.z_stack.last() {
396            if app
397                .state
398                .text_buffers
399                .command_palette
400                .get_joined_lines()
401                .to_lowercase()
402                == app.widgets.command_palette.last_search_string
403            {
404                return;
405            }
406            let current_search_string = app.state.text_buffers.command_palette.get_joined_lines();
407            let current_search_string = current_search_string.to_lowercase();
408            let search_results = app
409                .widgets
410                .command_palette
411                .available_commands
412                .iter()
413                .filter(|action| {
414                    action
415                        .to_string()
416                        .to_lowercase()
417                        .contains(&current_search_string)
418                })
419                .cloned()
420                .collect::<Vec<CommandPaletteActions>>();
421
422            // Making sure the results which start with the search string are shown first
423            let mut command_search_results = if search_results.is_empty() {
424                if current_search_string.is_empty() {
425                    CommandPaletteActions::all(app.debug_mode)
426                } else {
427                    let all_actions = CommandPaletteActions::all(app.debug_mode);
428                    let mut results = vec![];
429                    for action in all_actions {
430                        if action
431                            .to_string()
432                            .to_lowercase()
433                            .starts_with(&current_search_string)
434                        {
435                            results.push(action);
436                        }
437                    }
438                    results
439                }
440            } else {
441                let mut ordered_command_search_results = vec![];
442                let mut extra_command_results = vec![];
443                for result in search_results {
444                    if result
445                        .to_string()
446                        .to_lowercase()
447                        .starts_with(&current_search_string)
448                    {
449                        ordered_command_search_results.push(result);
450                    } else {
451                        extra_command_results.push(result);
452                    }
453                }
454                ordered_command_search_results.extend(extra_command_results);
455                ordered_command_search_results
456            };
457            if command_search_results.is_empty() {
458                command_search_results = vec![CommandPaletteActions::NoCommandsFound]
459            }
460
461            let mut card_search_results: Vec<(String, (u64, u64))> = vec![];
462            if !current_search_string.is_empty() {
463                for board in app.boards.get_boards() {
464                    for card in board.cards.get_all_cards() {
465                        let search_helper =
466                            if card.name.to_lowercase().contains(&current_search_string) {
467                                format!("{} - Matched in Name", card.name)
468                            } else if card
469                                .description
470                                .to_lowercase()
471                                .contains(&current_search_string)
472                            {
473                                format!("{} - Matched in Description", card.name)
474                            } else if card
475                                .tags
476                                .iter()
477                                .any(|tag| tag.to_lowercase().contains(&current_search_string))
478                            {
479                                format!("{} - Matched in Tags", card.name)
480                            } else if card.comments.iter().any(|comment| {
481                                comment.to_lowercase().contains(&current_search_string)
482                            }) {
483                                format!("{} - Matched in Comments", card.name)
484                            } else {
485                                String::new()
486                            };
487                        if !search_helper.is_empty() {
488                            card_search_results.push((search_helper, card.id));
489                        }
490                    }
491                }
492            }
493            if card_search_results.is_empty() {
494                app.widgets.command_palette.card_search_results = None;
495            } else {
496                app.widgets.command_palette.card_search_results = Some(card_search_results.clone());
497            }
498
499            let mut board_search_results: Vec<(String, (u64, u64))> = vec![];
500            if !current_search_string.is_empty() {
501                for board in app.boards.get_boards() {
502                    let search_helper =
503                        if board.name.to_lowercase().contains(&current_search_string) {
504                            format!("{} - Matched in Name", board.name)
505                        } else if board
506                            .description
507                            .to_lowercase()
508                            .contains(&current_search_string)
509                        {
510                            format!("{} - Matched in Description", board.name)
511                        } else {
512                            String::new()
513                        };
514                    if !search_helper.is_empty() {
515                        board_search_results.push((search_helper, board.id));
516                    }
517                }
518            }
519            if board_search_results.is_empty() {
520                app.widgets.command_palette.board_search_results = None;
521            } else {
522                app.widgets.command_palette.board_search_results =
523                    Some(board_search_results.clone());
524            }
525
526            app.widgets.command_palette.command_search_results = Some(command_search_results);
527            app.widgets.command_palette.last_search_string = current_search_string;
528            if let Some(search_results) = &app.widgets.command_palette.command_search_results {
529                if !search_results.is_empty() {
530                    app.state
531                        .app_list_states
532                        .command_palette_command_search
533                        .select(Some(0));
534                }
535            }
536        }
537    }
538}
539
540#[derive(Clone, Debug, PartialEq, EnumIter, EnumString)]
541pub enum CommandPaletteActions {
542    ChangeCurrentCardStatus,
543    ChangeCurrentCardPriority,
544    ChangeDateFormat,
545    ChangeTheme,
546    ChangeView,
547    ClearFilter,
548    ConfigMenu,
549    CreateATheme,
550    DebugMenu,
551    FilterByTag,
552    HelpMenu,
553    LoadASaveCloud,
554    LoadASaveLocal,
555    Login,
556    Logout,
557    MainMenu,
558    NewBoard,
559    NewCard,
560    NoCommandsFound,
561    Quit,
562    ResetPassword,
563    ResetUI,
564    SaveKanbanState,
565    SignUp,
566    SyncLocalData,
567    MoveBoardLeft,
568    MoveBoardRight,
569}
570
571impl Display for CommandPaletteActions {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
573        match self {
574            Self::ChangeCurrentCardStatus => write!(f, "Change Current Card Status"),
575            Self::ChangeCurrentCardPriority => write!(f, "Change Current Card Priority"),
576            Self::ChangeDateFormat => write!(f, "Change Date Format"),
577            Self::ChangeTheme => write!(f, "Change Theme"),
578            Self::ChangeView => write!(f, "Change View"),
579            Self::ClearFilter => write!(f, "Clear Filter"),
580            Self::CreateATheme => write!(f, "Create a Theme"),
581            Self::DebugMenu => write!(f, "Toggle Debug Panel"),
582            Self::FilterByTag => write!(f, "Filter by Tag"),
583            Self::LoadASaveCloud => write!(f, "Load a Save (Cloud)"),
584            Self::LoadASaveLocal => write!(f, "Load a Save (Local)"),
585            Self::Login => write!(f, "Login"),
586            Self::Logout => write!(f, "Logout"),
587            Self::NewBoard => write!(f, "New Board"),
588            Self::NewCard => write!(f, "New Card"),
589            Self::NoCommandsFound => write!(f, "No Commands Found"),
590            Self::ConfigMenu => write!(f, "Configure"),
591            Self::HelpMenu => write!(f, "Open Help Menu"),
592            Self::MainMenu => write!(f, "Open Main Menu"),
593            Self::Quit => write!(f, "Quit"),
594            Self::ResetPassword => write!(f, "Reset Password"),
595            Self::ResetUI => write!(f, "Reset UI"),
596            Self::SaveKanbanState => write!(f, "Save Kanban State"),
597            Self::SignUp => write!(f, "Sign Up"),
598            Self::SyncLocalData => write!(f, "Sync Local Data"),
599            Self::MoveBoardLeft => write!(f, "Move Current Board Left"),
600            Self::MoveBoardRight => write!(f, "Move Current Board Right"),
601        }
602    }
603}
604
605impl CommandPaletteActions {
606    pub fn all(debug_mode: bool) -> Vec<Self> {
607        let mut all = CommandPaletteActions::iter().collect::<Vec<Self>>();
608        // sort
609        all.sort_by_key(|a| a.to_string());
610        // remove no commands found
611        all.retain(|action| !matches!(action, Self::NoCommandsFound));
612
613        if cfg!(debug_assertions) || debug_mode {
614            all
615        } else {
616            all.into_iter()
617                .filter(|action| !matches!(action, Self::DebugMenu))
618                .collect()
619        }
620    }
621}