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}