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: "Open Diagnostics Details".into(),
367 shortcut: "d".into(),
368 action: Action::OpenDiagnosticsPaneDetails,
369 category: "Diagnostics".into(),
370 },
371 PaletteCommand {
372 label: "Open Logs".into(),
373 shortcut: "L".into(),
374 action: Action::OpenLogs,
375 category: "Diagnostics".into(),
376 },
377 PaletteCommand {
378 label: "Refresh Accounts".into(),
379 shortcut: "".into(),
380 action: Action::RefreshAccounts,
381 category: "Accounts".into(),
382 },
383 PaletteCommand {
384 label: "New IMAP/SMTP Account".into(),
385 shortcut: "".into(),
386 action: Action::OpenAccountFormNew,
387 category: "Accounts".into(),
388 },
389 PaletteCommand {
390 label: "Test Account".into(),
391 shortcut: "".into(),
392 action: Action::TestAccountForm,
393 category: "Accounts".into(),
394 },
395 PaletteCommand {
396 label: "Set Default Account".into(),
397 shortcut: "".into(),
398 action: Action::SetDefaultAccount,
399 category: "Accounts".into(),
400 },
401 PaletteCommand {
402 label: "Toggle Thread/Message List".into(),
403 shortcut: "".into(),
404 action: Action::ToggleMailListMode,
405 category: "View".into(),
406 },
407 PaletteCommand {
408 label: "Toggle Fullscreen".into(),
409 shortcut: "F".into(),
410 action: Action::ToggleFullscreen,
411 category: "View".into(),
412 },
413 PaletteCommand {
414 label: "Sync now".into(),
415 shortcut: "".into(),
416 action: Action::SyncNow,
417 category: "Sync".into(),
418 },
419 PaletteCommand {
420 label: "Help".into(),
421 shortcut: "?".into(),
422 action: Action::Help,
423 category: "Navigation".into(),
424 },
425 PaletteCommand {
426 label: "Quit".into(),
427 shortcut: "q".into(),
428 action: Action::QuitView,
429 category: "Navigation".into(),
430 },
431 ]
432}
433
434pub fn draw(frame: &mut Frame, area: Rect, palette: &CommandPalette, theme: &crate::theme::Theme) {
435 if !palette.visible {
436 return;
437 }
438
439 let width = (area.width as u32 * 68 / 100).min(92) as u16;
440 let height = (palette.filtered.len() as u16 + 8)
441 .min(area.height.saturating_sub(4))
442 .max(10);
443 let x = area.x + (area.width.saturating_sub(width)) / 2;
444 let y = area.y + (area.height.saturating_sub(height)) / 2;
445 let popup_area = Rect::new(x, y, width, height);
446
447 frame.render_widget(Clear, popup_area);
448
449 let block = Block::bordered()
450 .title(" Command Palette ")
451 .title_style(Style::default().fg(theme.accent).bold())
452 .border_type(BorderType::Rounded)
453 .border_style(Style::default().fg(theme.warning))
454 .style(Style::default().bg(theme.modal_bg));
455
456 let inner = block.inner(popup_area);
457 frame.render_widget(block, popup_area);
458
459 if inner.height < 4 {
460 return;
461 }
462
463 let chunks = Layout::default()
464 .direction(Direction::Vertical)
465 .constraints([
466 Constraint::Length(3),
467 Constraint::Min(3),
468 Constraint::Length(3),
469 ])
470 .split(inner);
471
472 let selected_command = palette
473 .filtered
474 .get(palette.selected)
475 .and_then(|&idx| palette.commands.get(idx));
476
477 let query_text = if palette.input.is_empty() {
478 "type a command or shortcut".to_string()
479 } else {
480 palette.input.clone()
481 };
482 let input_block = Block::bordered()
483 .title(format!(" Query {} matches ", palette.filtered.len()))
484 .border_type(BorderType::Rounded)
485 .border_style(Style::default().fg(theme.border_unfocused))
486 .style(Style::default().bg(theme.hint_bar_bg));
487 let input = Paragraph::new(Line::from(vec![
488 Span::styled("> ", Style::default().fg(theme.accent).bold()),
489 Span::styled(
490 query_text,
491 Style::default().fg(if palette.input.is_empty() {
492 theme.text_muted
493 } else {
494 theme.text_primary
495 }),
496 ),
497 ]))
498 .block(input_block);
499 frame.render_widget(input, chunks[0]);
500
501 let list_area = chunks[1];
502
503 let visible_len = list_area.height as usize;
504 let start = if visible_len == 0 {
505 0
506 } else {
507 palette
508 .selected
509 .saturating_sub(visible_len.saturating_sub(1) / 2)
510 };
511 let rows: Vec<Row> = palette
512 .filtered
513 .iter()
514 .enumerate()
515 .skip(start)
516 .take(visible_len)
517 .map(|(i, &cmd_idx)| {
518 let cmd = &palette.commands[cmd_idx];
519 let style = if i + start == palette.selected {
520 theme.highlight_style()
521 } else {
522 Style::default().fg(theme.text_secondary)
523 };
524 let (icon, category_color) = category_style(&cmd.category, theme);
525 let shortcut = if cmd.shortcut.is_empty() {
526 Span::styled("palette", Style::default().fg(theme.text_muted))
527 } else {
528 Span::styled(
529 cmd.shortcut.clone(),
530 Style::default().fg(theme.text_primary).bold(),
531 )
532 };
533 Row::new(vec![
534 Cell::from(Span::styled(
535 icon,
536 Style::default().fg(category_color).bold(),
537 )),
538 Cell::from(Line::from(vec![
539 Span::styled(
540 format!(" {} ", cmd.category),
541 Style::default().bg(category_color).fg(Color::Black).bold(),
542 ),
543 Span::raw(" "),
544 Span::styled(&cmd.label, Style::default().fg(theme.text_primary)),
545 ])),
546 Cell::from(shortcut),
547 ])
548 .style(style)
549 })
550 .collect();
551
552 let table = Table::new(
553 rows,
554 [
555 Constraint::Length(3),
556 Constraint::Fill(1),
557 Constraint::Length(10),
558 ],
559 )
560 .column_spacing(1)
561 .block(
562 Block::bordered()
563 .title(" Commands ")
564 .border_type(BorderType::Rounded)
565 .border_style(Style::default().fg(theme.border_unfocused)),
566 );
567 frame.render_widget(table, list_area);
568
569 let mut scrollbar_state =
570 ScrollbarState::new(palette.filtered.len().saturating_sub(visible_len)).position(start);
571
572 frame.render_stateful_widget(
573 Scrollbar::default()
574 .orientation(ScrollbarOrientation::VerticalRight)
575 .thumb_style(Style::default().fg(theme.warning)),
576 list_area,
577 &mut scrollbar_state,
578 );
579
580 let footer_text = selected_command
581 .map(|cmd| {
582 let shortcut = if cmd.shortcut.is_empty() {
583 "palette".to_string()
584 } else {
585 cmd.shortcut.clone()
586 };
587 Line::from(vec![
588 Span::styled("enter ", Style::default().fg(theme.accent).bold()),
589 Span::styled("run", Style::default().fg(theme.text_secondary)),
590 Span::raw(" "),
591 Span::styled("↑↓ ", Style::default().fg(theme.accent).bold()),
592 Span::styled("move", Style::default().fg(theme.text_secondary)),
593 Span::raw(" "),
594 Span::styled("esc ", Style::default().fg(theme.accent).bold()),
595 Span::styled("close", Style::default().fg(theme.text_secondary)),
596 Span::raw(" "),
597 Span::styled("selected ", Style::default().fg(theme.text_muted)),
598 Span::styled(&cmd.label, Style::default().fg(theme.text_primary).bold()),
599 Span::styled(" · ", Style::default().fg(theme.text_muted)),
600 Span::styled(shortcut, Style::default().fg(theme.accent)),
601 ])
602 })
603 .unwrap_or_else(|| {
604 Line::from(Span::styled(
605 "No matching commands",
606 Style::default().fg(theme.text_muted),
607 ))
608 });
609 let footer = Paragraph::new(footer_text).block(
610 Block::bordered()
611 .border_type(BorderType::Rounded)
612 .border_style(Style::default().fg(theme.border_unfocused)),
613 );
614 frame.render_widget(footer, chunks[2]);
615}
616
617fn category_style(category: &str, theme: &crate::theme::Theme) -> (&'static str, Color) {
618 match category {
619 "Mail" => ("@", theme.warning),
620 "Navigation" => (">", theme.accent),
621 "Search" => ("/", theme.link_fg),
622 "Selection" => ("+", theme.success),
623 "View" => ("~", theme.text_secondary),
624 "Rules" => ("#", theme.error),
625 "Diagnostics" => ("!", theme.warning),
626 "Accounts" => ("=", theme.accent_dim),
627 "Sync" => ("*", theme.success),
628 _ => ("?", theme.text_muted),
629 }
630}