Skip to main content

mxr_tui/ui/
command_palette.rs

1use crate::action::Action;
2use ratatui::prelude::*;
3use ratatui::widgets::*;
4
5#[derive(Debug, Clone)]
6pub struct PaletteCommand {
7    pub label: String,
8    pub shortcut: String,
9    pub action: Action,
10    pub category: String,
11}
12
13pub struct CommandPalette {
14    pub visible: bool,
15    pub input: String,
16    pub commands: Vec<PaletteCommand>,
17    pub filtered: Vec<usize>,
18    pub selected: usize,
19}
20
21impl Default for CommandPalette {
22    fn default() -> Self {
23        let commands = default_commands();
24        let filtered: Vec<usize> = (0..commands.len()).collect();
25        Self {
26            visible: false,
27            input: String::new(),
28            commands,
29            filtered,
30            selected: 0,
31        }
32    }
33}
34
35impl CommandPalette {
36    pub fn toggle(&mut self) {
37        self.visible = !self.visible;
38        if self.visible {
39            self.input.clear();
40            self.selected = 0;
41            self.update_filtered();
42        }
43    }
44
45    pub fn on_char(&mut self, c: char) {
46        self.input.push(c);
47        self.selected = 0;
48        self.update_filtered();
49    }
50
51    pub fn on_backspace(&mut self) {
52        self.input.pop();
53        self.selected = 0;
54        self.update_filtered();
55    }
56
57    pub fn select_next(&mut self) {
58        if !self.filtered.is_empty() {
59            self.selected = (self.selected + 1) % self.filtered.len();
60        }
61    }
62
63    pub fn select_prev(&mut self) {
64        if !self.filtered.is_empty() {
65            self.selected = self
66                .selected
67                .checked_sub(1)
68                .unwrap_or(self.filtered.len() - 1);
69        }
70    }
71
72    pub fn confirm(&mut self) -> Option<Action> {
73        if let Some(&idx) = self.filtered.get(self.selected) {
74            self.visible = false;
75            Some(self.commands[idx].action.clone())
76        } else {
77            None
78        }
79    }
80
81    pub fn update_filtered(&mut self) {
82        let query = self.input.to_lowercase();
83        self.filtered = self
84            .commands
85            .iter()
86            .enumerate()
87            .filter(|(_, cmd)| {
88                if query.is_empty() {
89                    return true;
90                }
91                cmd.label.to_lowercase().contains(&query)
92                    || cmd.shortcut.to_lowercase().contains(&query)
93            })
94            .map(|(i, _)| i)
95            .collect();
96    }
97}
98
99pub fn default_commands() -> Vec<PaletteCommand> {
100    vec![
101        PaletteCommand {
102            label: "Compose".into(),
103            shortcut: "c".into(),
104            action: Action::Compose,
105            category: "Mail".into(),
106        },
107        PaletteCommand {
108            label: "Reply".into(),
109            shortcut: "r".into(),
110            action: Action::Reply,
111            category: "Mail".into(),
112        },
113        PaletteCommand {
114            label: "Reply All".into(),
115            shortcut: "a".into(),
116            action: Action::ReplyAll,
117            category: "Mail".into(),
118        },
119        PaletteCommand {
120            label: "Forward".into(),
121            shortcut: "f".into(),
122            action: Action::Forward,
123            category: "Mail".into(),
124        },
125        PaletteCommand {
126            label: "Archive".into(),
127            shortcut: "e".into(),
128            action: Action::Archive,
129            category: "Mail".into(),
130        },
131        PaletteCommand {
132            label: "Mark Read and Archive".into(),
133            shortcut: "".into(),
134            action: Action::MarkReadAndArchive,
135            category: "Mail".into(),
136        },
137        PaletteCommand {
138            label: "Delete".into(),
139            shortcut: "#".into(),
140            action: Action::Trash,
141            category: "Mail".into(),
142        },
143        PaletteCommand {
144            label: "Mark Spam".into(),
145            shortcut: "!".into(),
146            action: Action::Spam,
147            category: "Mail".into(),
148        },
149        PaletteCommand {
150            label: "Star / Unstar".into(),
151            shortcut: "s".into(),
152            action: Action::Star,
153            category: "Mail".into(),
154        },
155        PaletteCommand {
156            label: "Mark Read".into(),
157            shortcut: "I".into(),
158            action: Action::MarkRead,
159            category: "Mail".into(),
160        },
161        PaletteCommand {
162            label: "Mark Unread".into(),
163            shortcut: "U".into(),
164            action: Action::MarkUnread,
165            category: "Mail".into(),
166        },
167        PaletteCommand {
168            label: "Apply Label".into(),
169            shortcut: "l".into(),
170            action: Action::ApplyLabel,
171            category: "Mail".into(),
172        },
173        PaletteCommand {
174            label: "Move To Label".into(),
175            shortcut: "v".into(),
176            action: Action::MoveToLabel,
177            category: "Mail".into(),
178        },
179        PaletteCommand {
180            label: "Snooze".into(),
181            shortcut: "Z".into(),
182            action: Action::Snooze,
183            category: "Mail".into(),
184        },
185        PaletteCommand {
186            label: "Unsubscribe".into(),
187            shortcut: "D".into(),
188            action: Action::Unsubscribe,
189            category: "Mail".into(),
190        },
191        PaletteCommand {
192            label: "Attachments".into(),
193            shortcut: "A".into(),
194            action: Action::AttachmentList,
195            category: "Mail".into(),
196        },
197        PaletteCommand {
198            label: "Open Links".into(),
199            shortcut: "L".into(),
200            action: Action::OpenLinks,
201            category: "Mail".into(),
202        },
203        PaletteCommand {
204            label: "Open In Browser".into(),
205            shortcut: "O".into(),
206            action: Action::OpenInBrowser,
207            category: "Mail".into(),
208        },
209        PaletteCommand {
210            label: "Toggle Reader Mode".into(),
211            shortcut: "R".into(),
212            action: Action::ToggleReaderMode,
213            category: "View".into(),
214        },
215        PaletteCommand {
216            label: "Export Thread".into(),
217            shortcut: "E".into(),
218            action: Action::ExportThread,
219            category: "Mail".into(),
220        },
221        PaletteCommand {
222            label: "Clear Selection".into(),
223            shortcut: "Esc".into(),
224            action: Action::ClearSelection,
225            category: "Selection".into(),
226        },
227        PaletteCommand {
228            label: "Toggle Select".into(),
229            shortcut: "x".into(),
230            action: Action::ToggleSelect,
231            category: "Selection".into(),
232        },
233        PaletteCommand {
234            label: "Visual Select".into(),
235            shortcut: "V".into(),
236            action: Action::VisualLineMode,
237            category: "Selection".into(),
238        },
239        PaletteCommand {
240            label: "Go to Inbox".into(),
241            shortcut: "gi".into(),
242            action: Action::GoToInbox,
243            category: "Navigation".into(),
244        },
245        PaletteCommand {
246            label: "Go to Starred".into(),
247            shortcut: "gs".into(),
248            action: Action::GoToStarred,
249            category: "Navigation".into(),
250        },
251        PaletteCommand {
252            label: "Go to Sent".into(),
253            shortcut: "gt".into(),
254            action: Action::GoToSent,
255            category: "Navigation".into(),
256        },
257        PaletteCommand {
258            label: "Go to Drafts".into(),
259            shortcut: "gd".into(),
260            action: Action::GoToDrafts,
261            category: "Navigation".into(),
262        },
263        PaletteCommand {
264            label: "Go to All Mail".into(),
265            shortcut: "ga".into(),
266            action: Action::GoToAllMail,
267            category: "Navigation".into(),
268        },
269        PaletteCommand {
270            label: "Search".into(),
271            shortcut: "/".into(),
272            action: Action::OpenSearch,
273            category: "Search".into(),
274        },
275        PaletteCommand {
276            label: "Switch Pane".into(),
277            shortcut: "Tab".into(),
278            action: Action::SwitchPane,
279            category: "Navigation".into(),
280        },
281        PaletteCommand {
282            label: "Open Mailbox".into(),
283            shortcut: "".into(),
284            action: Action::OpenMailboxScreen,
285            category: "Navigation".into(),
286        },
287        PaletteCommand {
288            label: "Open Search Page".into(),
289            shortcut: "".into(),
290            action: Action::OpenSearchScreen,
291            category: "Navigation".into(),
292        },
293        PaletteCommand {
294            label: "Open Rules Page".into(),
295            shortcut: "".into(),
296            action: Action::OpenRulesScreen,
297            category: "Navigation".into(),
298        },
299        PaletteCommand {
300            label: "Open Diagnostics Page".into(),
301            shortcut: "".into(),
302            action: Action::OpenDiagnosticsScreen,
303            category: "Navigation".into(),
304        },
305        PaletteCommand {
306            label: "Open Accounts Page".into(),
307            shortcut: "".into(),
308            action: Action::OpenAccountsScreen,
309            category: "Navigation".into(),
310        },
311        PaletteCommand {
312            label: "Refresh Rules".into(),
313            shortcut: "".into(),
314            action: Action::RefreshRules,
315            category: "Rules".into(),
316        },
317        PaletteCommand {
318            label: "New Rule".into(),
319            shortcut: "".into(),
320            action: Action::OpenRuleFormNew,
321            category: "Rules".into(),
322        },
323        PaletteCommand {
324            label: "Edit Rule".into(),
325            shortcut: "".into(),
326            action: Action::OpenRuleFormEdit,
327            category: "Rules".into(),
328        },
329        PaletteCommand {
330            label: "Toggle Rule Enabled".into(),
331            shortcut: "".into(),
332            action: Action::ToggleRuleEnabled,
333            category: "Rules".into(),
334        },
335        PaletteCommand {
336            label: "Rule Dry Run".into(),
337            shortcut: "".into(),
338            action: Action::ShowRuleDryRun,
339            category: "Rules".into(),
340        },
341        PaletteCommand {
342            label: "Rule History".into(),
343            shortcut: "".into(),
344            action: Action::ShowRuleHistory,
345            category: "Rules".into(),
346        },
347        PaletteCommand {
348            label: "Delete Rule".into(),
349            shortcut: "".into(),
350            action: Action::DeleteRule,
351            category: "Rules".into(),
352        },
353        PaletteCommand {
354            label: "Refresh Diagnostics".into(),
355            shortcut: "".into(),
356            action: Action::RefreshDiagnostics,
357            category: "Diagnostics".into(),
358        },
359        PaletteCommand {
360            label: "Generate Bug Report".into(),
361            shortcut: "".into(),
362            action: Action::GenerateBugReport,
363            category: "Diagnostics".into(),
364        },
365        PaletteCommand {
366            label: "Refresh Accounts".into(),
367            shortcut: "".into(),
368            action: Action::RefreshAccounts,
369            category: "Accounts".into(),
370        },
371        PaletteCommand {
372            label: "New IMAP/SMTP Account".into(),
373            shortcut: "".into(),
374            action: Action::OpenAccountFormNew,
375            category: "Accounts".into(),
376        },
377        PaletteCommand {
378            label: "Test Account".into(),
379            shortcut: "".into(),
380            action: Action::TestAccountForm,
381            category: "Accounts".into(),
382        },
383        PaletteCommand {
384            label: "Set Default Account".into(),
385            shortcut: "".into(),
386            action: Action::SetDefaultAccount,
387            category: "Accounts".into(),
388        },
389        PaletteCommand {
390            label: "Toggle Thread/Message List".into(),
391            shortcut: "".into(),
392            action: Action::ToggleMailListMode,
393            category: "View".into(),
394        },
395        PaletteCommand {
396            label: "Toggle Fullscreen".into(),
397            shortcut: "F".into(),
398            action: Action::ToggleFullscreen,
399            category: "View".into(),
400        },
401        PaletteCommand {
402            label: "Sync now".into(),
403            shortcut: "".into(),
404            action: Action::SyncNow,
405            category: "Sync".into(),
406        },
407        PaletteCommand {
408            label: "Help".into(),
409            shortcut: "?".into(),
410            action: Action::Help,
411            category: "Navigation".into(),
412        },
413        PaletteCommand {
414            label: "Quit".into(),
415            shortcut: "q".into(),
416            action: Action::QuitView,
417            category: "Navigation".into(),
418        },
419    ]
420}
421
422pub fn draw(frame: &mut Frame, area: Rect, palette: &CommandPalette, theme: &crate::theme::Theme) {
423    if !palette.visible {
424        return;
425    }
426
427    let width = (area.width as u32 * 68 / 100).min(92) as u16;
428    let height = (palette.filtered.len() as u16 + 8)
429        .min(area.height.saturating_sub(4))
430        .max(10);
431    let x = area.x + (area.width.saturating_sub(width)) / 2;
432    let y = area.y + (area.height.saturating_sub(height)) / 2;
433    let popup_area = Rect::new(x, y, width, height);
434
435    frame.render_widget(Clear, popup_area);
436
437    let block = Block::bordered()
438        .title(" Command Palette ")
439        .title_style(Style::default().fg(theme.accent).bold())
440        .border_type(BorderType::Rounded)
441        .border_style(Style::default().fg(theme.warning))
442        .style(Style::default().bg(theme.modal_bg));
443
444    let inner = block.inner(popup_area);
445    frame.render_widget(block, popup_area);
446
447    if inner.height < 4 {
448        return;
449    }
450
451    let chunks = Layout::default()
452        .direction(Direction::Vertical)
453        .constraints([
454            Constraint::Length(3),
455            Constraint::Min(3),
456            Constraint::Length(3),
457        ])
458        .split(inner);
459
460    let selected_command = palette
461        .filtered
462        .get(palette.selected)
463        .and_then(|&idx| palette.commands.get(idx));
464
465    let query_text = if palette.input.is_empty() {
466        "type a command or shortcut".to_string()
467    } else {
468        palette.input.clone()
469    };
470    let input_block = Block::bordered()
471        .title(format!(" Query  {} matches ", palette.filtered.len()))
472        .border_type(BorderType::Rounded)
473        .border_style(Style::default().fg(theme.border_unfocused))
474        .style(Style::default().bg(theme.hint_bar_bg));
475    let input = Paragraph::new(Line::from(vec![
476        Span::styled("> ", Style::default().fg(theme.accent).bold()),
477        Span::styled(
478            query_text,
479            Style::default().fg(if palette.input.is_empty() {
480                theme.text_muted
481            } else {
482                theme.text_primary
483            }),
484        ),
485    ]))
486    .block(input_block);
487    frame.render_widget(input, chunks[0]);
488
489    let list_area = chunks[1];
490
491    let visible_len = list_area.height as usize;
492    let start = if visible_len == 0 {
493        0
494    } else {
495        palette
496            .selected
497            .saturating_sub(visible_len.saturating_sub(1) / 2)
498    };
499    let rows: Vec<Row> = palette
500        .filtered
501        .iter()
502        .enumerate()
503        .skip(start)
504        .take(visible_len)
505        .map(|(i, &cmd_idx)| {
506            let cmd = &palette.commands[cmd_idx];
507            let style = if i + start == palette.selected {
508                theme.highlight_style()
509            } else {
510                Style::default().fg(theme.text_secondary)
511            };
512            let (icon, category_color) = category_style(&cmd.category, theme);
513            let shortcut = if cmd.shortcut.is_empty() {
514                Span::styled("palette", Style::default().fg(theme.text_muted))
515            } else {
516                Span::styled(
517                    cmd.shortcut.clone(),
518                    Style::default().fg(theme.text_primary).bold(),
519                )
520            };
521            Row::new(vec![
522                Cell::from(Span::styled(
523                    icon,
524                    Style::default().fg(category_color).bold(),
525                )),
526                Cell::from(Line::from(vec![
527                    Span::styled(
528                        format!(" {} ", cmd.category),
529                        Style::default().bg(category_color).fg(Color::Black).bold(),
530                    ),
531                    Span::raw(" "),
532                    Span::styled(&cmd.label, Style::default().fg(theme.text_primary)),
533                ])),
534                Cell::from(shortcut),
535            ])
536            .style(style)
537        })
538        .collect();
539
540    let table = Table::new(
541        rows,
542        [
543            Constraint::Length(3),
544            Constraint::Fill(1),
545            Constraint::Length(10),
546        ],
547    )
548    .column_spacing(1)
549    .block(
550        Block::bordered()
551            .title(" Commands ")
552            .border_type(BorderType::Rounded)
553            .border_style(Style::default().fg(theme.border_unfocused)),
554    );
555    frame.render_widget(table, list_area);
556
557    let mut scrollbar_state =
558        ScrollbarState::new(palette.filtered.len().saturating_sub(visible_len)).position(start);
559
560    frame.render_stateful_widget(
561        Scrollbar::default()
562            .orientation(ScrollbarOrientation::VerticalRight)
563            .thumb_style(Style::default().fg(theme.warning)),
564        list_area,
565        &mut scrollbar_state,
566    );
567
568    let footer_text = selected_command
569        .map(|cmd| {
570            let shortcut = if cmd.shortcut.is_empty() {
571                "palette".to_string()
572            } else {
573                cmd.shortcut.clone()
574            };
575            Line::from(vec![
576                Span::styled("enter ", Style::default().fg(theme.accent).bold()),
577                Span::styled("run", Style::default().fg(theme.text_secondary)),
578                Span::raw("   "),
579                Span::styled("↑↓ ", Style::default().fg(theme.accent).bold()),
580                Span::styled("move", Style::default().fg(theme.text_secondary)),
581                Span::raw("   "),
582                Span::styled("esc ", Style::default().fg(theme.accent).bold()),
583                Span::styled("close", Style::default().fg(theme.text_secondary)),
584                Span::raw("   "),
585                Span::styled("selected ", Style::default().fg(theme.text_muted)),
586                Span::styled(&cmd.label, Style::default().fg(theme.text_primary).bold()),
587                Span::styled(" · ", Style::default().fg(theme.text_muted)),
588                Span::styled(shortcut, Style::default().fg(theme.accent)),
589            ])
590        })
591        .unwrap_or_else(|| {
592            Line::from(Span::styled(
593                "No matching commands",
594                Style::default().fg(theme.text_muted),
595            ))
596        });
597    let footer = Paragraph::new(footer_text).block(
598        Block::bordered()
599            .border_type(BorderType::Rounded)
600            .border_style(Style::default().fg(theme.border_unfocused)),
601    );
602    frame.render_widget(footer, chunks[2]);
603}
604
605fn category_style(category: &str, theme: &crate::theme::Theme) -> (&'static str, Color) {
606    match category {
607        "Mail" => ("@", theme.warning),
608        "Navigation" => (">", theme.accent),
609        "Search" => ("/", theme.link_fg),
610        "Selection" => ("+", theme.success),
611        "View" => ("~", theme.text_secondary),
612        "Rules" => ("#", theme.error),
613        "Diagnostics" => ("!", theme.warning),
614        "Accounts" => ("=", theme.accent_dim),
615        "Sync" => ("*", theme.success),
616        _ => ("?", theme.text_muted),
617    }
618}