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