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: "Edit Config".into(),
271 shortcut: "gc".into(),
272 action: Action::EditConfig,
273 category: "System".into(),
274 },
275 PaletteCommand {
276 label: "Open Logs".into(),
277 shortcut: "gL".into(),
278 action: Action::OpenLogs,
279 category: "System".into(),
280 },
281 PaletteCommand {
282 label: "Search".into(),
283 shortcut: "/".into(),
284 action: Action::OpenSearch,
285 category: "Search".into(),
286 },
287 PaletteCommand {
288 label: "Switch Pane".into(),
289 shortcut: "Tab".into(),
290 action: Action::SwitchPane,
291 category: "Navigation".into(),
292 },
293 PaletteCommand {
294 label: "Open Mailbox".into(),
295 shortcut: "".into(),
296 action: Action::OpenMailboxScreen,
297 category: "Navigation".into(),
298 },
299 PaletteCommand {
300 label: "Open Search Page".into(),
301 shortcut: "".into(),
302 action: Action::OpenSearchScreen,
303 category: "Navigation".into(),
304 },
305 PaletteCommand {
306 label: "Open Rules Page".into(),
307 shortcut: "".into(),
308 action: Action::OpenRulesScreen,
309 category: "Navigation".into(),
310 },
311 PaletteCommand {
312 label: "Open Diagnostics Page".into(),
313 shortcut: "".into(),
314 action: Action::OpenDiagnosticsScreen,
315 category: "Navigation".into(),
316 },
317 PaletteCommand {
318 label: "Open Accounts Page".into(),
319 shortcut: "".into(),
320 action: Action::OpenAccountsScreen,
321 category: "Navigation".into(),
322 },
323 PaletteCommand {
324 label: "Refresh Rules".into(),
325 shortcut: "".into(),
326 action: Action::RefreshRules,
327 category: "Rules".into(),
328 },
329 PaletteCommand {
330 label: "New Rule".into(),
331 shortcut: "".into(),
332 action: Action::OpenRuleFormNew,
333 category: "Rules".into(),
334 },
335 PaletteCommand {
336 label: "Edit Rule".into(),
337 shortcut: "".into(),
338 action: Action::OpenRuleFormEdit,
339 category: "Rules".into(),
340 },
341 PaletteCommand {
342 label: "Toggle Rule Enabled".into(),
343 shortcut: "".into(),
344 action: Action::ToggleRuleEnabled,
345 category: "Rules".into(),
346 },
347 PaletteCommand {
348 label: "Rule Dry Run".into(),
349 shortcut: "".into(),
350 action: Action::ShowRuleDryRun,
351 category: "Rules".into(),
352 },
353 PaletteCommand {
354 label: "Rule History".into(),
355 shortcut: "".into(),
356 action: Action::ShowRuleHistory,
357 category: "Rules".into(),
358 },
359 PaletteCommand {
360 label: "Delete Rule".into(),
361 shortcut: "".into(),
362 action: Action::DeleteRule,
363 category: "Rules".into(),
364 },
365 PaletteCommand {
366 label: "Refresh Diagnostics".into(),
367 shortcut: "".into(),
368 action: Action::RefreshDiagnostics,
369 category: "Diagnostics".into(),
370 },
371 PaletteCommand {
372 label: "Generate Bug Report".into(),
373 shortcut: "".into(),
374 action: Action::GenerateBugReport,
375 category: "Diagnostics".into(),
376 },
377 PaletteCommand {
378 label: "Open Diagnostics Details".into(),
379 shortcut: "d".into(),
380 action: Action::OpenDiagnosticsPaneDetails,
381 category: "Diagnostics".into(),
382 },
383 PaletteCommand {
384 label: "Refresh Accounts".into(),
385 shortcut: "".into(),
386 action: Action::RefreshAccounts,
387 category: "Accounts".into(),
388 },
389 PaletteCommand {
390 label: "New IMAP/SMTP Account".into(),
391 shortcut: "".into(),
392 action: Action::OpenAccountFormNew,
393 category: "Accounts".into(),
394 },
395 PaletteCommand {
396 label: "Test Account".into(),
397 shortcut: "".into(),
398 action: Action::TestAccountForm,
399 category: "Accounts".into(),
400 },
401 PaletteCommand {
402 label: "Set Default Account".into(),
403 shortcut: "".into(),
404 action: Action::SetDefaultAccount,
405 category: "Accounts".into(),
406 },
407 PaletteCommand {
408 label: "Toggle Thread/Message List".into(),
409 shortcut: "".into(),
410 action: Action::ToggleMailListMode,
411 category: "View".into(),
412 },
413 PaletteCommand {
414 label: "Toggle Fullscreen".into(),
415 shortcut: "F".into(),
416 action: Action::ToggleFullscreen,
417 category: "View".into(),
418 },
419 PaletteCommand {
420 label: "Sync now".into(),
421 shortcut: "".into(),
422 action: Action::SyncNow,
423 category: "Sync".into(),
424 },
425 PaletteCommand {
426 label: "Help".into(),
427 shortcut: "?".into(),
428 action: Action::Help,
429 category: "Navigation".into(),
430 },
431 PaletteCommand {
432 label: "Quit".into(),
433 shortcut: "q".into(),
434 action: Action::QuitView,
435 category: "Navigation".into(),
436 },
437 ]
438}
439
440pub fn draw(frame: &mut Frame, area: Rect, palette: &CommandPalette, theme: &crate::theme::Theme) {
441 if !palette.visible {
442 return;
443 }
444
445 let width = (area.width as u32 * 68 / 100).min(92) as u16;
446 let height = (palette.filtered.len() as u16 + 8)
447 .min(area.height.saturating_sub(4))
448 .max(10);
449 let x = area.x + (area.width.saturating_sub(width)) / 2;
450 let y = area.y + (area.height.saturating_sub(height)) / 2;
451 let popup_area = Rect::new(x, y, width, height);
452
453 frame.render_widget(Clear, popup_area);
454
455 let block = Block::bordered()
456 .title(" Command Palette ")
457 .title_style(Style::default().fg(theme.accent).bold())
458 .border_type(BorderType::Rounded)
459 .border_style(Style::default().fg(theme.warning))
460 .style(Style::default().bg(theme.modal_bg));
461
462 let inner = block.inner(popup_area);
463 frame.render_widget(block, popup_area);
464
465 if inner.height < 4 {
466 return;
467 }
468
469 let chunks = Layout::default()
470 .direction(Direction::Vertical)
471 .constraints([
472 Constraint::Length(3),
473 Constraint::Min(3),
474 Constraint::Length(3),
475 ])
476 .split(inner);
477
478 let selected_command = palette
479 .filtered
480 .get(palette.selected)
481 .and_then(|&idx| palette.commands.get(idx));
482
483 let query_text = if palette.input.is_empty() {
484 "type a command or shortcut".to_string()
485 } else {
486 palette.input.clone()
487 };
488 let input_block = Block::bordered()
489 .title(format!(" Query {} matches ", palette.filtered.len()))
490 .border_type(BorderType::Rounded)
491 .border_style(Style::default().fg(theme.border_unfocused))
492 .style(Style::default().bg(theme.hint_bar_bg));
493 let input = Paragraph::new(Line::from(vec![
494 Span::styled("> ", Style::default().fg(theme.accent).bold()),
495 Span::styled(
496 query_text,
497 Style::default().fg(if palette.input.is_empty() {
498 theme.text_muted
499 } else {
500 theme.text_primary
501 }),
502 ),
503 ]))
504 .block(input_block);
505 frame.render_widget(input, chunks[0]);
506
507 let list_area = chunks[1];
508
509 let visible_len = list_area.height as usize;
510 let start = if visible_len == 0 {
511 0
512 } else {
513 palette
514 .selected
515 .saturating_sub(visible_len.saturating_sub(1) / 2)
516 };
517 let rows: Vec<Row> = palette
518 .filtered
519 .iter()
520 .enumerate()
521 .skip(start)
522 .take(visible_len)
523 .map(|(i, &cmd_idx)| {
524 let cmd = &palette.commands[cmd_idx];
525 let style = if i + start == palette.selected {
526 theme.highlight_style()
527 } else {
528 Style::default().fg(theme.text_secondary)
529 };
530 let (icon, category_color) = category_style(&cmd.category, theme);
531 let shortcut = if cmd.shortcut.is_empty() {
532 Span::styled("palette", Style::default().fg(theme.text_muted))
533 } else {
534 Span::styled(
535 cmd.shortcut.clone(),
536 Style::default().fg(theme.text_primary).bold(),
537 )
538 };
539 Row::new(vec![
540 Cell::from(Span::styled(
541 icon,
542 Style::default().fg(category_color).bold(),
543 )),
544 Cell::from(Line::from(vec![
545 Span::styled(
546 format!(" {} ", cmd.category),
547 Style::default().bg(category_color).fg(Color::Black).bold(),
548 ),
549 Span::raw(" "),
550 Span::styled(&cmd.label, Style::default().fg(theme.text_primary)),
551 ])),
552 Cell::from(shortcut),
553 ])
554 .style(style)
555 })
556 .collect();
557
558 let table = Table::new(
559 rows,
560 [
561 Constraint::Length(3),
562 Constraint::Fill(1),
563 Constraint::Length(10),
564 ],
565 )
566 .column_spacing(1)
567 .block(
568 Block::bordered()
569 .title(" Commands ")
570 .border_type(BorderType::Rounded)
571 .border_style(Style::default().fg(theme.border_unfocused)),
572 );
573 frame.render_widget(table, list_area);
574
575 let mut scrollbar_state =
576 ScrollbarState::new(palette.filtered.len().saturating_sub(visible_len)).position(start);
577
578 frame.render_stateful_widget(
579 Scrollbar::default()
580 .orientation(ScrollbarOrientation::VerticalRight)
581 .thumb_style(Style::default().fg(theme.warning)),
582 list_area,
583 &mut scrollbar_state,
584 );
585
586 let footer_text = selected_command
587 .map(|cmd| {
588 let shortcut = if cmd.shortcut.is_empty() {
589 "palette".to_string()
590 } else {
591 cmd.shortcut.clone()
592 };
593 Line::from(vec![
594 Span::styled("enter ", Style::default().fg(theme.accent).bold()),
595 Span::styled("run", Style::default().fg(theme.text_secondary)),
596 Span::raw(" "),
597 Span::styled("↑↓ ", Style::default().fg(theme.accent).bold()),
598 Span::styled("move", Style::default().fg(theme.text_secondary)),
599 Span::raw(" "),
600 Span::styled("esc ", Style::default().fg(theme.accent).bold()),
601 Span::styled("close", Style::default().fg(theme.text_secondary)),
602 Span::raw(" "),
603 Span::styled("selected ", Style::default().fg(theme.text_muted)),
604 Span::styled(&cmd.label, Style::default().fg(theme.text_primary).bold()),
605 Span::styled(" · ", Style::default().fg(theme.text_muted)),
606 Span::styled(shortcut, Style::default().fg(theme.accent)),
607 ])
608 })
609 .unwrap_or_else(|| {
610 Line::from(Span::styled(
611 "No matching commands",
612 Style::default().fg(theme.text_muted),
613 ))
614 });
615 let footer = Paragraph::new(footer_text).block(
616 Block::bordered()
617 .border_type(BorderType::Rounded)
618 .border_style(Style::default().fg(theme.border_unfocused)),
619 );
620 frame.render_widget(footer, chunks[2]);
621}
622
623fn category_style(category: &str, theme: &crate::theme::Theme) -> (&'static str, Color) {
624 match category {
625 "Mail" => ("@", theme.warning),
626 "Navigation" => (">", theme.accent),
627 "Search" => ("/", theme.link_fg),
628 "Selection" => ("+", theme.success),
629 "View" => ("~", theme.text_secondary),
630 "Rules" => ("#", theme.error),
631 "Diagnostics" => ("!", theme.warning),
632 "Accounts" => ("=", theme.accent_dim),
633 "Sync" => ("*", theme.success),
634 _ => ("?", theme.text_muted),
635 }
636}