1use crate::ui::shortcuts;
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
57pub enum PaletteMatchMode {
58 #[default]
59 All,
60 Exact,
61 Prefix,
62 WordStart,
63 Substring,
64 Fuzzy,
65}
66
67impl PaletteMatchMode {
68 pub fn cycle(self) -> Self {
70 match self {
71 Self::All => Self::Exact,
72 Self::Exact => Self::Prefix,
73 Self::Prefix => Self::WordStart,
74 Self::WordStart => Self::Substring,
75 Self::Substring => Self::Fuzzy,
76 Self::Fuzzy => Self::All,
77 }
78 }
79
80 pub fn label(self) -> &'static str {
82 match self {
83 Self::All => "All",
84 Self::Exact => "Exact",
85 Self::Prefix => "Prefix",
86 Self::WordStart => "WordStart",
87 Self::Substring => "Substr",
88 Self::Fuzzy => "Fuzzy",
89 }
90 }
91}
92
93fn fuzzy_match(text: &str, pattern: &str) -> bool {
95 let mut chars = text.chars();
96 for p in pattern.chars() {
97 if !chars.any(|c| c == p) {
98 return false;
99 }
100 }
101 true
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
107pub enum PaletteGroup {
108 Chrome,
110 Filter,
112 View,
114 Analytics,
116 Export,
118 Recording,
120 Sources,
122}
123
124impl PaletteGroup {
125 pub fn label(&self) -> &'static str {
127 match self {
128 Self::Chrome => "Chrome",
129 Self::Filter => "Filters",
130 Self::View => "Views",
131 Self::Analytics => "Analytics",
132 Self::Export => "Export",
133 Self::Recording => "Recording",
134 Self::Sources => "Sources",
135 }
136 }
137
138 pub const ALL: &'static [PaletteGroup] = &[
140 PaletteGroup::Chrome,
141 PaletteGroup::Filter,
142 PaletteGroup::View,
143 PaletteGroup::Analytics,
144 PaletteGroup::Export,
145 PaletteGroup::Recording,
146 PaletteGroup::Sources,
147 ];
148}
149
150#[derive(Clone, Debug)]
152pub enum PaletteAction {
153 ToggleTheme,
154 ToggleDensity,
155 ToggleHelpStrip,
156 OpenUpdateBanner,
157 FilterAgent,
158 FilterWorkspace,
159 FilterToday,
160 FilterWeek,
161 FilterCustomDate,
162 OpenSavedViews,
163 SaveViewSlot(u8),
164 LoadViewSlot(u8),
165 OpenBulkActions,
166 ReloadIndex,
167 AnalyticsDashboard,
169 AnalyticsExplorer,
170 AnalyticsHeatmap,
171 AnalyticsBreakdowns,
172 AnalyticsTools,
173 AnalyticsPlans,
174 AnalyticsCoverage,
175 ScreenshotHtml,
177 ScreenshotSvg,
178 ScreenshotText,
179 MacroRecordingToggle,
181 Sources,
183}
184
185impl PaletteAction {
186 pub fn group(&self) -> PaletteGroup {
188 match self {
189 Self::ToggleTheme
190 | Self::ToggleDensity
191 | Self::ToggleHelpStrip
192 | Self::OpenUpdateBanner => PaletteGroup::Chrome,
193 Self::FilterAgent
194 | Self::FilterWorkspace
195 | Self::FilterToday
196 | Self::FilterWeek
197 | Self::FilterCustomDate => PaletteGroup::Filter,
198 Self::OpenSavedViews
199 | Self::SaveViewSlot(_)
200 | Self::LoadViewSlot(_)
201 | Self::OpenBulkActions
202 | Self::ReloadIndex => PaletteGroup::View,
203 Self::AnalyticsDashboard
204 | Self::AnalyticsExplorer
205 | Self::AnalyticsHeatmap
206 | Self::AnalyticsBreakdowns
207 | Self::AnalyticsTools
208 | Self::AnalyticsPlans
209 | Self::AnalyticsCoverage => PaletteGroup::Analytics,
210 Self::ScreenshotHtml | Self::ScreenshotSvg | Self::ScreenshotText => {
211 PaletteGroup::Export
212 }
213 Self::MacroRecordingToggle => PaletteGroup::Recording,
214 Self::Sources => PaletteGroup::Sources,
215 }
216 }
217
218 pub fn target_msg_name(&self) -> &'static str {
224 match self {
225 Self::ToggleTheme => "ThemeToggled",
227 Self::ToggleDensity => "DensityModeCycled",
228 Self::ToggleHelpStrip => "HelpPinToggled",
229 Self::OpenUpdateBanner => "update_info inline (no CassMsg)",
230 Self::FilterAgent => "InputModeEntered(Agent)",
232 Self::FilterWorkspace => "InputModeEntered(Workspace)",
233 Self::FilterToday => "FilterTimeSet { from: start_of_day }",
234 Self::FilterWeek => "FilterTimeSet { from: week_ago }",
235 Self::FilterCustomDate => "InputModeEntered(CreatedFrom)",
236 Self::OpenSavedViews => "SavedViewsOpened",
238 Self::SaveViewSlot(_) => "ViewSaved(slot)",
239 Self::LoadViewSlot(_) => "ViewLoaded(slot)",
240 Self::OpenBulkActions => "BulkActionsOpened",
241 Self::ReloadIndex => "IndexRefreshRequested",
242 Self::AnalyticsDashboard => "batch[AnalyticsEntered, AnalyticsViewChanged(Dashboard)]",
244 Self::AnalyticsExplorer => "batch[AnalyticsEntered, AnalyticsViewChanged(Explorer)]",
245 Self::AnalyticsHeatmap => "batch[AnalyticsEntered, AnalyticsViewChanged(Heatmap)]",
246 Self::AnalyticsBreakdowns => {
247 "batch[AnalyticsEntered, AnalyticsViewChanged(Breakdowns)]"
248 }
249 Self::AnalyticsTools => "batch[AnalyticsEntered, AnalyticsViewChanged(Tools)]",
250 Self::AnalyticsPlans => "batch[AnalyticsEntered, AnalyticsViewChanged(Plans)]",
251 Self::AnalyticsCoverage => "batch[AnalyticsEntered, AnalyticsViewChanged(Coverage)]",
252 Self::ScreenshotHtml => "ScreenshotRequested(Html)",
254 Self::ScreenshotSvg => "ScreenshotRequested(Svg)",
255 Self::ScreenshotText => "ScreenshotRequested(Text)",
256 Self::MacroRecordingToggle => "MacroRecordingToggled",
258 Self::Sources => "SourcesEntered",
260 }
261 }
262}
263
264#[derive(Clone, Debug, PartialEq)]
270pub enum PaletteResult {
271 ToggleTheme,
273 CycleDensity,
275 ToggleHelpStrip,
277 OpenUpdateBanner,
279 EnterInputMode(InputModeTarget),
281 SetTimeFilter { from: TimeFilterPreset },
283 OpenSavedViews,
285 SaveViewSlot(u8),
287 LoadViewSlot(u8),
289 OpenBulkActions,
291 ReloadIndex,
293 OpenAnalyticsView(AnalyticsTarget),
295 Screenshot(ScreenshotTarget),
297 ToggleMacroRecording,
299 OpenSources,
301 Noop,
303}
304
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
307pub enum InputModeTarget {
308 Agent,
309 Workspace,
310 CreatedFrom,
311}
312
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum TimeFilterPreset {
316 Today,
317 LastWeek,
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322pub enum AnalyticsTarget {
323 Dashboard,
324 Explorer,
325 Heatmap,
326 Breakdowns,
327 Tools,
328 Plans,
329 Coverage,
330}
331
332#[derive(Clone, Copy, Debug, PartialEq, Eq)]
334pub enum ScreenshotTarget {
335 Html,
336 Svg,
337 Text,
338}
339
340impl PaletteAction {
341 pub fn dispatch(&self) -> PaletteResult {
347 match self {
348 Self::ToggleTheme => PaletteResult::ToggleTheme,
350 Self::ToggleDensity => PaletteResult::CycleDensity,
351 Self::ToggleHelpStrip => PaletteResult::ToggleHelpStrip,
352 Self::OpenUpdateBanner => PaletteResult::OpenUpdateBanner,
353 Self::FilterAgent => PaletteResult::EnterInputMode(InputModeTarget::Agent),
355 Self::FilterWorkspace => PaletteResult::EnterInputMode(InputModeTarget::Workspace),
356 Self::FilterToday => PaletteResult::SetTimeFilter {
357 from: TimeFilterPreset::Today,
358 },
359 Self::FilterWeek => PaletteResult::SetTimeFilter {
360 from: TimeFilterPreset::LastWeek,
361 },
362 Self::FilterCustomDate => PaletteResult::EnterInputMode(InputModeTarget::CreatedFrom),
363 Self::OpenSavedViews => PaletteResult::OpenSavedViews,
365 Self::SaveViewSlot(slot) => PaletteResult::SaveViewSlot(*slot),
366 Self::LoadViewSlot(slot) => PaletteResult::LoadViewSlot(*slot),
367 Self::OpenBulkActions => PaletteResult::OpenBulkActions,
368 Self::ReloadIndex => PaletteResult::ReloadIndex,
369 Self::AnalyticsDashboard => {
371 PaletteResult::OpenAnalyticsView(AnalyticsTarget::Dashboard)
372 }
373 Self::AnalyticsExplorer => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Explorer),
374 Self::AnalyticsHeatmap => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Heatmap),
375 Self::AnalyticsBreakdowns => {
376 PaletteResult::OpenAnalyticsView(AnalyticsTarget::Breakdowns)
377 }
378 Self::AnalyticsTools => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Tools),
379 Self::AnalyticsPlans => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Plans),
380 Self::AnalyticsCoverage => PaletteResult::OpenAnalyticsView(AnalyticsTarget::Coverage),
381 Self::ScreenshotHtml => PaletteResult::Screenshot(ScreenshotTarget::Html),
383 Self::ScreenshotSvg => PaletteResult::Screenshot(ScreenshotTarget::Svg),
384 Self::ScreenshotText => PaletteResult::Screenshot(ScreenshotTarget::Text),
385 Self::MacroRecordingToggle => PaletteResult::ToggleMacroRecording,
387 Self::Sources => PaletteResult::OpenSources,
389 }
390 }
391}
392
393pub fn execute_selected(state: &PaletteState) -> PaletteResult {
397 state
398 .filtered
399 .get(state.selected)
400 .map(|item| item.action.dispatch())
401 .unwrap_or(PaletteResult::Noop)
402}
403
404pub fn action_id(action: &PaletteAction) -> String {
409 format!("{action:?}")
410}
411
412pub fn action_by_id(items: &[PaletteItem], id: &str) -> Option<PaletteAction> {
414 items
415 .iter()
416 .find(|item| action_id(&item.action) == id)
417 .map(|item| item.action.clone())
418}
419
420#[derive(Clone, Debug)]
422pub struct PaletteItem {
423 pub action: PaletteAction,
424 pub label: String,
425 pub hint: String,
426}
427
428#[derive(Clone, Debug)]
429pub struct PaletteState {
430 pub open: bool,
431 pub query: String,
432 pub filtered: Vec<PaletteItem>,
433 pub all_actions: Vec<PaletteItem>,
434 pub selected: usize,
435 pub match_mode: PaletteMatchMode,
436}
437
438impl PaletteState {
439 pub fn new(actions: Vec<PaletteItem>) -> Self {
440 let filtered = actions.clone();
441 Self {
442 open: false,
443 query: String::new(),
444 filtered,
445 all_actions: actions,
446 selected: 0,
447 match_mode: PaletteMatchMode::default(),
448 }
449 }
450
451 pub fn refilter(&mut self) {
453 if self.query.trim().is_empty() {
454 self.filtered = self.all_actions.clone();
455 } else {
456 let q = self.query.to_lowercase();
457 let matches = |text: &str| -> bool {
458 let t = text.to_lowercase();
459 match self.match_mode {
460 PaletteMatchMode::All | PaletteMatchMode::Substring => t.contains(&q),
461 PaletteMatchMode::Exact => t == q,
462 PaletteMatchMode::Prefix => t.starts_with(&q),
463 PaletteMatchMode::WordStart => {
464 t.split_whitespace().any(|word| word.starts_with(&q))
465 }
466 PaletteMatchMode::Fuzzy => fuzzy_match(&t, &q),
467 }
468 };
469 self.filtered = self
470 .all_actions
471 .iter()
472 .filter(|a| matches(&a.label) || matches(&a.hint))
473 .cloned()
474 .collect();
475 }
476 if self.selected >= self.filtered.len() {
477 self.selected = self.filtered.len().saturating_sub(1);
478 }
479 }
480
481 pub fn move_selection(&mut self, delta: isize) {
482 if self.filtered.is_empty() {
483 self.selected = 0;
484 return;
485 }
486 let len = self.filtered.len() as isize;
487 let idx = (self.selected as isize + delta).rem_euclid(len);
488 self.selected = idx as usize;
489 }
490}
491
492pub fn default_actions() -> Vec<PaletteItem> {
494 let mut items = vec![
495 item(
496 PaletteAction::OpenSavedViews,
497 "Saved views",
498 "List saved slots",
499 ),
500 item(
501 PaletteAction::ToggleDensity,
502 "Toggle density",
503 shortcuts::DENSITY,
504 ),
505 item(PaletteAction::ToggleTheme, "Toggle theme", shortcuts::THEME),
506 item(
507 PaletteAction::ToggleHelpStrip,
508 "Toggle help strip",
509 shortcuts::HELP,
510 ),
511 item(
512 PaletteAction::OpenUpdateBanner,
513 "Check updates",
514 "Show update assistant",
515 ),
516 item(
517 PaletteAction::FilterAgent,
518 "Filter: agent",
519 shortcuts::FILTER_AGENT,
520 ),
521 item(
522 PaletteAction::FilterWorkspace,
523 "Filter: workspace",
524 shortcuts::FILTER_WORKSPACE,
525 ),
526 item(
527 PaletteAction::FilterToday,
528 "Filter: today",
529 "Restrict to today",
530 ),
531 item(
532 PaletteAction::FilterWeek,
533 "Filter: last 7 days",
534 "Restrict to week",
535 ),
536 item(
537 PaletteAction::FilterCustomDate,
538 "Filter: date range",
539 shortcuts::FILTER_DATE_FROM,
540 ),
541 item(
542 PaletteAction::OpenBulkActions,
543 "Bulk actions",
544 shortcuts::BULK_MENU,
545 ),
546 item(
547 PaletteAction::ReloadIndex,
548 "Reload index/view",
549 shortcuts::REFRESH,
550 ),
551 ];
552 items.push(item(
554 PaletteAction::AnalyticsDashboard,
555 "Analytics: Dashboard",
556 "KPI overview",
557 ));
558 items.push(item(
559 PaletteAction::AnalyticsExplorer,
560 "Analytics: Explorer",
561 "Time-series explorer",
562 ));
563 items.push(item(
564 PaletteAction::AnalyticsHeatmap,
565 "Analytics: Heatmap",
566 "Calendar heatmap",
567 ));
568 items.push(item(
569 PaletteAction::AnalyticsBreakdowns,
570 "Analytics: Breakdowns",
571 "Agents/workspaces/sources",
572 ));
573 items.push(item(
574 PaletteAction::AnalyticsTools,
575 "Analytics: Tools",
576 "Per-tool usage",
577 ));
578 items.push(item(
579 PaletteAction::AnalyticsPlans,
580 "Analytics: Plans",
581 "Plan frequency + token share",
582 ));
583 items.push(item(
584 PaletteAction::AnalyticsCoverage,
585 "Analytics: Coverage",
586 "Token coverage diagnostics",
587 ));
588 items.push(item(
590 PaletteAction::ScreenshotHtml,
591 "Screenshot: HTML",
592 "Capture TUI as HTML",
593 ));
594 items.push(item(
595 PaletteAction::ScreenshotSvg,
596 "Screenshot: SVG",
597 "Capture TUI as SVG",
598 ));
599 items.push(item(
600 PaletteAction::ScreenshotText,
601 "Screenshot: Text",
602 "Capture TUI as plain text",
603 ));
604 items.push(item(
606 PaletteAction::MacroRecordingToggle,
607 "Toggle macro recording",
608 "Alt+M",
609 ));
610 items.push(item(
612 PaletteAction::Sources,
613 "Sources management",
614 "Ctrl+Shift+S",
615 ));
616 for slot in 1..=9 {
618 items.push(item(
619 PaletteAction::SaveViewSlot(slot),
620 format!("Save view to slot {slot}"),
621 format!("Ctrl+{slot}"),
622 ));
623 items.push(item(
624 PaletteAction::LoadViewSlot(slot),
625 format!("Load view from slot {slot}"),
626 format!("Shift+{slot}"),
627 ));
628 }
629 items
630}
631
632fn item(action: PaletteAction, label: impl Into<String>, hint: impl Into<String>) -> PaletteItem {
633 PaletteItem {
634 action,
635 label: label.into(),
636 hint: hint.into(),
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
647 fn test_palette_action_clone() {
648 let action = PaletteAction::ToggleTheme;
649 let cloned = action.clone();
650 assert!(matches!(cloned, PaletteAction::ToggleTheme));
651 }
652
653 #[test]
654 fn test_palette_action_debug() {
655 let action = PaletteAction::FilterAgent;
656 let debug_str = format!("{:?}", action);
657 assert!(debug_str.contains("FilterAgent"));
658 }
659
660 #[test]
661 fn test_palette_action_slot_variants() {
662 let save = PaletteAction::SaveViewSlot(5);
663 let load = PaletteAction::LoadViewSlot(3);
664
665 let save_debug = format!("{:?}", save);
666 let load_debug = format!("{:?}", load);
667
668 assert!(save_debug.contains("SaveViewSlot"));
669 assert!(save_debug.contains("5"));
670 assert!(load_debug.contains("LoadViewSlot"));
671 assert!(load_debug.contains("3"));
672 }
673
674 #[test]
677 fn test_palette_item_creation() {
678 let item = PaletteItem {
679 action: PaletteAction::ToggleTheme,
680 label: "Toggle theme".to_string(),
681 hint: "Switch light/dark".to_string(),
682 };
683
684 assert_eq!(item.label, "Toggle theme");
685 assert_eq!(item.hint, "Switch light/dark");
686 }
687
688 #[test]
689 fn test_palette_item_clone() {
690 let item = PaletteItem {
691 action: PaletteAction::ReloadIndex,
692 label: "Reload".to_string(),
693 hint: "Refresh".to_string(),
694 };
695
696 let cloned = item.clone();
697 assert_eq!(cloned.label, item.label);
698 assert_eq!(cloned.hint, item.hint);
699 }
700
701 #[test]
702 fn test_palette_item_debug() {
703 let item = PaletteItem {
704 action: PaletteAction::FilterToday,
705 label: "Today".to_string(),
706 hint: "Show today".to_string(),
707 };
708
709 let debug_str = format!("{:?}", item);
710 assert!(debug_str.contains("PaletteItem"));
711 assert!(debug_str.contains("Today"));
712 }
713
714 #[test]
717 fn test_palette_state_new_empty() {
718 let state = PaletteState::new(vec![]);
719
720 assert!(!state.open);
721 assert!(state.query.is_empty());
722 assert!(state.filtered.is_empty());
723 assert!(state.all_actions.is_empty());
724 assert_eq!(state.selected, 0);
725 }
726
727 #[test]
728 fn test_palette_state_new_with_items() {
729 let items = vec![
730 item(PaletteAction::ToggleTheme, "Theme", "Switch"),
731 item(PaletteAction::ToggleDensity, "Density", "Change"),
732 ];
733 let state = PaletteState::new(items);
734
735 assert!(!state.open);
736 assert!(state.query.is_empty());
737 assert_eq!(state.filtered.len(), 2);
738 assert_eq!(state.all_actions.len(), 2);
739 assert_eq!(state.selected, 0);
740 }
741
742 #[test]
743 fn test_palette_state_filtered_matches_all_initially() {
744 let items = vec![
745 item(PaletteAction::FilterAgent, "Agent", "Set agent"),
746 item(PaletteAction::FilterWorkspace, "Workspace", "Set ws"),
747 item(PaletteAction::FilterToday, "Today", "Restrict"),
748 ];
749 let state = PaletteState::new(items);
750
751 assert_eq!(state.filtered.len(), state.all_actions.len());
752 }
753
754 #[test]
757 fn test_refilter_empty_query_shows_all() {
758 let items = vec![
759 item(PaletteAction::ToggleTheme, "Theme", "Switch"),
760 item(PaletteAction::ToggleDensity, "Density", "Change"),
761 ];
762 let mut state = PaletteState::new(items);
763 state.query = "".to_string();
764 state.refilter();
765
766 assert_eq!(state.filtered.len(), 2);
767 }
768
769 #[test]
770 fn test_refilter_whitespace_query_shows_all() {
771 let items = vec![
772 item(PaletteAction::ToggleTheme, "Theme", "Switch"),
773 item(PaletteAction::ToggleDensity, "Density", "Change"),
774 ];
775 let mut state = PaletteState::new(items);
776 state.query = " ".to_string();
777 state.refilter();
778
779 assert_eq!(state.filtered.len(), 2);
780 }
781
782 #[test]
783 fn test_refilter_matches_label() {
784 let items = vec![
785 item(PaletteAction::ToggleTheme, "Toggle theme", "Switch"),
786 item(PaletteAction::FilterAgent, "Filter agent", "Set"),
787 ];
788 let mut state = PaletteState::new(items);
789 state.query = "theme".to_string();
790 state.refilter();
791
792 assert_eq!(state.filtered.len(), 1);
793 assert_eq!(state.filtered[0].label, "Toggle theme");
794 }
795
796 #[test]
797 fn test_refilter_matches_hint() {
798 let items = vec![
799 item(PaletteAction::ToggleTheme, "Theme", "Switch light/dark"),
800 item(PaletteAction::FilterAgent, "Agent", "Set filter"),
801 ];
802 let mut state = PaletteState::new(items);
803 state.query = "light".to_string();
804 state.refilter();
805
806 assert_eq!(state.filtered.len(), 1);
807 assert_eq!(state.filtered[0].label, "Theme");
808 }
809
810 #[test]
811 fn test_refilter_case_insensitive() {
812 let items = vec![
813 item(PaletteAction::ToggleTheme, "Toggle Theme", "Switch"),
814 item(PaletteAction::FilterAgent, "Filter Agent", "Set"),
815 ];
816 let mut state = PaletteState::new(items);
817 state.query = "THEME".to_string();
818 state.refilter();
819
820 assert_eq!(state.filtered.len(), 1);
821 assert_eq!(state.filtered[0].label, "Toggle Theme");
822 }
823
824 #[test]
825 fn test_refilter_no_matches() {
826 let items = vec![
827 item(PaletteAction::ToggleTheme, "Theme", "Switch"),
828 item(PaletteAction::FilterAgent, "Agent", "Set"),
829 ];
830 let mut state = PaletteState::new(items);
831 state.query = "xyz".to_string();
832 state.refilter();
833
834 assert!(state.filtered.is_empty());
835 }
836
837 #[test]
838 fn test_refilter_adjusts_selection_when_out_of_bounds() {
839 let items = vec![
840 item(PaletteAction::ToggleTheme, "Theme", "Switch"),
841 item(PaletteAction::FilterAgent, "Agent", "Set"),
842 item(PaletteAction::FilterWorkspace, "Workspace", "Set"),
843 ];
844 let mut state = PaletteState::new(items);
845 state.selected = 2;
846 state.query = "theme".to_string();
847 state.refilter();
848
849 assert!(state.selected < state.filtered.len() || state.filtered.is_empty());
850 }
851
852 #[test]
853 fn test_refilter_selection_stays_zero_when_empty() {
854 let items = vec![item(PaletteAction::ToggleTheme, "Theme", "Switch")];
855 let mut state = PaletteState::new(items);
856 state.selected = 0;
857 state.query = "nomatch".to_string();
858 state.refilter();
859
860 assert!(state.filtered.is_empty());
861 assert_eq!(state.selected, 0);
862 }
863
864 #[test]
867 fn test_move_selection_down() {
868 let items = vec![
869 item(PaletteAction::ToggleTheme, "Theme", "A"),
870 item(PaletteAction::FilterAgent, "Agent", "B"),
871 item(PaletteAction::FilterWorkspace, "Workspace", "C"),
872 ];
873 let mut state = PaletteState::new(items);
874 assert_eq!(state.selected, 0);
875
876 state.move_selection(1);
877 assert_eq!(state.selected, 1);
878
879 state.move_selection(1);
880 assert_eq!(state.selected, 2);
881 }
882
883 #[test]
884 fn test_move_selection_up() {
885 let items = vec![
886 item(PaletteAction::ToggleTheme, "Theme", "A"),
887 item(PaletteAction::FilterAgent, "Agent", "B"),
888 item(PaletteAction::FilterWorkspace, "Workspace", "C"),
889 ];
890 let mut state = PaletteState::new(items);
891 state.selected = 2;
892
893 state.move_selection(-1);
894 assert_eq!(state.selected, 1);
895
896 state.move_selection(-1);
897 assert_eq!(state.selected, 0);
898 }
899
900 #[test]
901 fn test_move_selection_wraps_forward() {
902 let items = vec![
903 item(PaletteAction::ToggleTheme, "Theme", "A"),
904 item(PaletteAction::FilterAgent, "Agent", "B"),
905 ];
906 let mut state = PaletteState::new(items);
907 state.selected = 1;
908
909 state.move_selection(1);
910 assert_eq!(state.selected, 0);
911 }
912
913 #[test]
914 fn test_move_selection_wraps_backward() {
915 let items = vec![
916 item(PaletteAction::ToggleTheme, "Theme", "A"),
917 item(PaletteAction::FilterAgent, "Agent", "B"),
918 ];
919 let mut state = PaletteState::new(items);
920 state.selected = 0;
921
922 state.move_selection(-1);
923 assert_eq!(state.selected, 1);
924 }
925
926 #[test]
927 fn test_move_selection_empty_list() {
928 let mut state = PaletteState::new(vec![]);
929
930 state.move_selection(1);
931 assert_eq!(state.selected, 0);
932
933 state.move_selection(-1);
934 assert_eq!(state.selected, 0);
935 }
936
937 #[test]
938 fn test_move_selection_large_delta() {
939 let items = vec![
940 item(PaletteAction::ToggleTheme, "A", ""),
941 item(PaletteAction::FilterAgent, "B", ""),
942 item(PaletteAction::FilterWorkspace, "C", ""),
943 ];
944 let mut state = PaletteState::new(items);
945 state.selected = 0;
946
947 state.move_selection(5);
948 assert_eq!(state.selected, 2); state.move_selection(-7);
951 assert_eq!(state.selected, 1);
953 }
954
955 #[test]
958 fn test_default_actions_not_empty() {
959 let actions = default_actions();
960 assert!(!actions.is_empty());
961 }
962
963 #[test]
964 fn test_default_actions_has_basic_items() {
965 let actions = default_actions();
966 let labels: Vec<&str> = actions.iter().map(|a| a.label.as_str()).collect();
967
968 assert!(labels.contains(&"Toggle theme"));
969 assert!(labels.contains(&"Toggle density"));
970 assert!(labels.contains(&"Filter: agent"));
971 assert!(labels.contains(&"Reload index/view"));
972 }
973
974 #[test]
975 fn test_default_actions_has_view_slots() {
976 let actions = default_actions();
977
978 for slot in 1..=9 {
979 let save_label = format!("Save view to slot {slot}");
980 let load_label = format!("Load view from slot {slot}");
981
982 assert!(
983 actions.iter().any(|a| a.label == save_label),
984 "Missing save slot {slot}"
985 );
986 assert!(
987 actions.iter().any(|a| a.label == load_label),
988 "Missing load slot {slot}"
989 );
990 }
991 }
992
993 #[test]
994 fn test_default_actions_all_have_labels_and_hints() {
995 let actions = default_actions();
996
997 for action in &actions {
998 assert!(!action.label.is_empty(), "Action has empty label");
999 assert!(!action.hint.is_empty(), "Action has empty hint");
1000 }
1001 }
1002
1003 #[test]
1006 fn test_item_helper_function() {
1007 let result = item(PaletteAction::ToggleTheme, "Label", "Hint");
1008
1009 assert_eq!(result.label, "Label");
1010 assert_eq!(result.hint, "Hint");
1011 assert!(matches!(result.action, PaletteAction::ToggleTheme));
1012 }
1013
1014 #[test]
1015 fn test_item_helper_with_string() {
1016 let result = item(
1017 PaletteAction::FilterAgent,
1018 String::from("My Label"),
1019 String::from("My Hint"),
1020 );
1021
1022 assert_eq!(result.label, "My Label");
1023 assert_eq!(result.hint, "My Hint");
1024 }
1025
1026 #[test]
1029 fn group_all_contains_seven_groups() {
1030 assert_eq!(PaletteGroup::ALL.len(), 7);
1031 }
1032
1033 #[test]
1034 fn group_labels_are_nonempty() {
1035 for g in PaletteGroup::ALL {
1036 assert!(!g.label().is_empty(), "{:?} has empty label", g);
1037 }
1038 }
1039
1040 #[test]
1041 fn every_action_has_a_group() {
1042 let all: Vec<PaletteAction> = vec![
1044 PaletteAction::ToggleTheme,
1045 PaletteAction::ToggleDensity,
1046 PaletteAction::ToggleHelpStrip,
1047 PaletteAction::OpenUpdateBanner,
1048 PaletteAction::FilterAgent,
1049 PaletteAction::FilterWorkspace,
1050 PaletteAction::FilterToday,
1051 PaletteAction::FilterWeek,
1052 PaletteAction::FilterCustomDate,
1053 PaletteAction::OpenSavedViews,
1054 PaletteAction::SaveViewSlot(1),
1055 PaletteAction::LoadViewSlot(1),
1056 PaletteAction::OpenBulkActions,
1057 PaletteAction::ReloadIndex,
1058 PaletteAction::AnalyticsDashboard,
1059 PaletteAction::AnalyticsExplorer,
1060 PaletteAction::AnalyticsHeatmap,
1061 PaletteAction::AnalyticsBreakdowns,
1062 PaletteAction::AnalyticsTools,
1063 PaletteAction::AnalyticsPlans,
1064 PaletteAction::AnalyticsCoverage,
1065 PaletteAction::ScreenshotHtml,
1066 PaletteAction::ScreenshotSvg,
1067 PaletteAction::ScreenshotText,
1068 PaletteAction::MacroRecordingToggle,
1069 PaletteAction::Sources,
1070 ];
1071 for action in &all {
1072 let _ = action.group(); }
1074 }
1075
1076 #[test]
1077 fn every_action_has_a_target_msg() {
1078 let all: Vec<PaletteAction> = vec![
1079 PaletteAction::ToggleTheme,
1080 PaletteAction::ToggleDensity,
1081 PaletteAction::ToggleHelpStrip,
1082 PaletteAction::OpenUpdateBanner,
1083 PaletteAction::FilterAgent,
1084 PaletteAction::FilterWorkspace,
1085 PaletteAction::FilterToday,
1086 PaletteAction::FilterWeek,
1087 PaletteAction::FilterCustomDate,
1088 PaletteAction::OpenSavedViews,
1089 PaletteAction::SaveViewSlot(1),
1090 PaletteAction::LoadViewSlot(1),
1091 PaletteAction::OpenBulkActions,
1092 PaletteAction::ReloadIndex,
1093 PaletteAction::AnalyticsDashboard,
1094 PaletteAction::AnalyticsExplorer,
1095 PaletteAction::AnalyticsHeatmap,
1096 PaletteAction::AnalyticsBreakdowns,
1097 PaletteAction::AnalyticsTools,
1098 PaletteAction::AnalyticsPlans,
1099 PaletteAction::AnalyticsCoverage,
1100 PaletteAction::ScreenshotHtml,
1101 PaletteAction::ScreenshotSvg,
1102 PaletteAction::ScreenshotText,
1103 PaletteAction::MacroRecordingToggle,
1104 PaletteAction::Sources,
1105 ];
1106 for action in &all {
1107 let target = action.target_msg_name();
1108 assert!(!target.is_empty(), "{:?} has empty target_msg_name", action);
1109 }
1110 }
1111
1112 #[test]
1113 fn chrome_group_contains_expected_actions() {
1114 assert_eq!(PaletteAction::ToggleTheme.group(), PaletteGroup::Chrome);
1115 assert_eq!(PaletteAction::ToggleDensity.group(), PaletteGroup::Chrome);
1116 assert_eq!(PaletteAction::ToggleHelpStrip.group(), PaletteGroup::Chrome);
1117 assert_eq!(
1118 PaletteAction::OpenUpdateBanner.group(),
1119 PaletteGroup::Chrome
1120 );
1121 }
1122
1123 #[test]
1124 fn filter_group_contains_expected_actions() {
1125 assert_eq!(PaletteAction::FilterAgent.group(), PaletteGroup::Filter);
1126 assert_eq!(PaletteAction::FilterWorkspace.group(), PaletteGroup::Filter);
1127 assert_eq!(PaletteAction::FilterToday.group(), PaletteGroup::Filter);
1128 assert_eq!(PaletteAction::FilterWeek.group(), PaletteGroup::Filter);
1129 assert_eq!(
1130 PaletteAction::FilterCustomDate.group(),
1131 PaletteGroup::Filter
1132 );
1133 }
1134
1135 #[test]
1136 fn analytics_group_has_seven_variants() {
1137 let analytics: Vec<PaletteAction> = vec![
1138 PaletteAction::AnalyticsDashboard,
1139 PaletteAction::AnalyticsExplorer,
1140 PaletteAction::AnalyticsHeatmap,
1141 PaletteAction::AnalyticsBreakdowns,
1142 PaletteAction::AnalyticsTools,
1143 PaletteAction::AnalyticsPlans,
1144 PaletteAction::AnalyticsCoverage,
1145 ];
1146 assert_eq!(analytics.len(), 7);
1147 for a in &analytics {
1148 assert_eq!(a.group(), PaletteGroup::Analytics);
1149 }
1150 }
1151
1152 #[test]
1153 fn view_group_contains_expected_actions() {
1154 assert_eq!(PaletteAction::OpenSavedViews.group(), PaletteGroup::View);
1155 assert_eq!(PaletteAction::SaveViewSlot(3).group(), PaletteGroup::View);
1156 assert_eq!(PaletteAction::LoadViewSlot(5).group(), PaletteGroup::View);
1157 assert_eq!(PaletteAction::OpenBulkActions.group(), PaletteGroup::View);
1158 assert_eq!(PaletteAction::ReloadIndex.group(), PaletteGroup::View);
1159 }
1160
1161 #[test]
1162 fn export_group_contains_expected_actions() {
1163 assert_eq!(PaletteAction::ScreenshotHtml.group(), PaletteGroup::Export);
1164 assert_eq!(PaletteAction::ScreenshotSvg.group(), PaletteGroup::Export);
1165 assert_eq!(PaletteAction::ScreenshotText.group(), PaletteGroup::Export);
1166 }
1167
1168 #[test]
1169 fn default_actions_cover_all_groups() {
1170 let actions = default_actions();
1171 let mut groups_seen = std::collections::HashSet::new();
1172 for a in &actions {
1173 groups_seen.insert(a.action.group());
1174 }
1175 for g in PaletteGroup::ALL {
1176 assert!(
1177 groups_seen.contains(g),
1178 "Group {:?} not represented in default_actions()",
1179 g
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn target_msg_names_are_distinct_per_non_slot_action() {
1186 let non_slot: Vec<PaletteAction> = vec![
1188 PaletteAction::ToggleTheme,
1189 PaletteAction::ToggleDensity,
1190 PaletteAction::ToggleHelpStrip,
1191 PaletteAction::OpenUpdateBanner,
1192 PaletteAction::FilterAgent,
1193 PaletteAction::FilterWorkspace,
1194 PaletteAction::FilterToday,
1195 PaletteAction::FilterWeek,
1196 PaletteAction::FilterCustomDate,
1197 PaletteAction::OpenSavedViews,
1198 PaletteAction::OpenBulkActions,
1199 PaletteAction::ReloadIndex,
1200 PaletteAction::AnalyticsDashboard,
1201 PaletteAction::AnalyticsExplorer,
1202 PaletteAction::AnalyticsHeatmap,
1203 PaletteAction::AnalyticsBreakdowns,
1204 PaletteAction::AnalyticsTools,
1205 PaletteAction::AnalyticsPlans,
1206 PaletteAction::AnalyticsCoverage,
1207 PaletteAction::ScreenshotHtml,
1208 PaletteAction::ScreenshotSvg,
1209 PaletteAction::ScreenshotText,
1210 PaletteAction::MacroRecordingToggle,
1211 PaletteAction::Sources,
1212 ];
1213 let mut seen = std::collections::HashSet::new();
1214 for a in &non_slot {
1215 let name = a.target_msg_name();
1216 assert!(
1217 seen.insert(name),
1218 "Duplicate target_msg_name {:?} for {:?}",
1219 name,
1220 a
1221 );
1222 }
1223 }
1224
1225 #[test]
1228 fn palette_result_clone_and_eq() {
1229 let r = PaletteResult::ToggleTheme;
1230 assert_eq!(r.clone(), PaletteResult::ToggleTheme);
1231 }
1232
1233 #[test]
1234 fn palette_result_debug_format() {
1235 let r = PaletteResult::EnterInputMode(InputModeTarget::Agent);
1236 let s = format!("{:?}", r);
1237 assert!(s.contains("EnterInputMode"));
1238 assert!(s.contains("Agent"));
1239 }
1240
1241 #[test]
1242 fn palette_result_noop_variant() {
1243 let r = PaletteResult::Noop;
1244 assert_eq!(r, PaletteResult::Noop);
1245 }
1246
1247 #[test]
1250 fn dispatch_chrome_actions() {
1251 assert_eq!(
1252 PaletteAction::ToggleTheme.dispatch(),
1253 PaletteResult::ToggleTheme
1254 );
1255 assert_eq!(
1256 PaletteAction::ToggleDensity.dispatch(),
1257 PaletteResult::CycleDensity
1258 );
1259 assert_eq!(
1260 PaletteAction::ToggleHelpStrip.dispatch(),
1261 PaletteResult::ToggleHelpStrip
1262 );
1263 assert_eq!(
1264 PaletteAction::OpenUpdateBanner.dispatch(),
1265 PaletteResult::OpenUpdateBanner
1266 );
1267 }
1268
1269 #[test]
1270 fn dispatch_filter_actions() {
1271 assert_eq!(
1272 PaletteAction::FilterAgent.dispatch(),
1273 PaletteResult::EnterInputMode(InputModeTarget::Agent)
1274 );
1275 assert_eq!(
1276 PaletteAction::FilterWorkspace.dispatch(),
1277 PaletteResult::EnterInputMode(InputModeTarget::Workspace)
1278 );
1279 assert_eq!(
1280 PaletteAction::FilterToday.dispatch(),
1281 PaletteResult::SetTimeFilter {
1282 from: TimeFilterPreset::Today
1283 }
1284 );
1285 assert_eq!(
1286 PaletteAction::FilterWeek.dispatch(),
1287 PaletteResult::SetTimeFilter {
1288 from: TimeFilterPreset::LastWeek
1289 }
1290 );
1291 assert_eq!(
1292 PaletteAction::FilterCustomDate.dispatch(),
1293 PaletteResult::EnterInputMode(InputModeTarget::CreatedFrom)
1294 );
1295 }
1296
1297 #[test]
1298 fn dispatch_view_actions() {
1299 assert_eq!(
1300 PaletteAction::OpenSavedViews.dispatch(),
1301 PaletteResult::OpenSavedViews
1302 );
1303 assert_eq!(
1304 PaletteAction::OpenBulkActions.dispatch(),
1305 PaletteResult::OpenBulkActions
1306 );
1307 assert_eq!(
1308 PaletteAction::ReloadIndex.dispatch(),
1309 PaletteResult::ReloadIndex
1310 );
1311 }
1312
1313 #[test]
1314 fn dispatch_slot_actions_preserve_slot_number() {
1315 for slot in 1..=9u8 {
1316 assert_eq!(
1317 PaletteAction::SaveViewSlot(slot).dispatch(),
1318 PaletteResult::SaveViewSlot(slot)
1319 );
1320 assert_eq!(
1321 PaletteAction::LoadViewSlot(slot).dispatch(),
1322 PaletteResult::LoadViewSlot(slot)
1323 );
1324 }
1325 }
1326
1327 #[test]
1328 fn dispatch_analytics_actions() {
1329 let cases = vec![
1330 (
1331 PaletteAction::AnalyticsDashboard,
1332 AnalyticsTarget::Dashboard,
1333 ),
1334 (PaletteAction::AnalyticsExplorer, AnalyticsTarget::Explorer),
1335 (PaletteAction::AnalyticsHeatmap, AnalyticsTarget::Heatmap),
1336 (
1337 PaletteAction::AnalyticsBreakdowns,
1338 AnalyticsTarget::Breakdowns,
1339 ),
1340 (PaletteAction::AnalyticsTools, AnalyticsTarget::Tools),
1341 (PaletteAction::AnalyticsPlans, AnalyticsTarget::Plans),
1342 (PaletteAction::AnalyticsCoverage, AnalyticsTarget::Coverage),
1343 ];
1344 for (action, expected_target) in cases {
1345 assert_eq!(
1346 action.dispatch(),
1347 PaletteResult::OpenAnalyticsView(expected_target),
1348 "dispatch mismatch for {:?}",
1349 expected_target
1350 );
1351 }
1352 }
1353
1354 #[test]
1355 fn dispatch_export_actions() {
1356 assert_eq!(
1357 PaletteAction::ScreenshotHtml.dispatch(),
1358 PaletteResult::Screenshot(ScreenshotTarget::Html)
1359 );
1360 assert_eq!(
1361 PaletteAction::ScreenshotSvg.dispatch(),
1362 PaletteResult::Screenshot(ScreenshotTarget::Svg)
1363 );
1364 assert_eq!(
1365 PaletteAction::ScreenshotText.dispatch(),
1366 PaletteResult::Screenshot(ScreenshotTarget::Text)
1367 );
1368 }
1369
1370 #[test]
1371 fn dispatch_recording_and_sources() {
1372 assert_eq!(
1373 PaletteAction::MacroRecordingToggle.dispatch(),
1374 PaletteResult::ToggleMacroRecording
1375 );
1376 assert_eq!(
1377 PaletteAction::Sources.dispatch(),
1378 PaletteResult::OpenSources
1379 );
1380 }
1381
1382 #[test]
1383 fn dispatch_exhaustive_all_26_actions() {
1384 let all: Vec<PaletteAction> = vec![
1386 PaletteAction::ToggleTheme,
1387 PaletteAction::ToggleDensity,
1388 PaletteAction::ToggleHelpStrip,
1389 PaletteAction::OpenUpdateBanner,
1390 PaletteAction::FilterAgent,
1391 PaletteAction::FilterWorkspace,
1392 PaletteAction::FilterToday,
1393 PaletteAction::FilterWeek,
1394 PaletteAction::FilterCustomDate,
1395 PaletteAction::OpenSavedViews,
1396 PaletteAction::SaveViewSlot(1),
1397 PaletteAction::LoadViewSlot(1),
1398 PaletteAction::OpenBulkActions,
1399 PaletteAction::ReloadIndex,
1400 PaletteAction::AnalyticsDashboard,
1401 PaletteAction::AnalyticsExplorer,
1402 PaletteAction::AnalyticsHeatmap,
1403 PaletteAction::AnalyticsBreakdowns,
1404 PaletteAction::AnalyticsTools,
1405 PaletteAction::AnalyticsPlans,
1406 PaletteAction::AnalyticsCoverage,
1407 PaletteAction::ScreenshotHtml,
1408 PaletteAction::ScreenshotSvg,
1409 PaletteAction::ScreenshotText,
1410 PaletteAction::MacroRecordingToggle,
1411 PaletteAction::Sources,
1412 ];
1413 for action in &all {
1414 let result = action.dispatch();
1415 assert_ne!(
1416 result,
1417 PaletteResult::Noop,
1418 "{:?} dispatched to Noop",
1419 action
1420 );
1421 }
1422 }
1423
1424 #[test]
1427 fn execute_selected_returns_noop_on_empty_state() {
1428 let state = PaletteState::new(vec![]);
1429 assert_eq!(execute_selected(&state), PaletteResult::Noop);
1430 }
1431
1432 #[test]
1433 fn execute_selected_returns_noop_on_out_of_bounds() {
1434 let items = vec![item(PaletteAction::ToggleTheme, "Theme", "Toggle")];
1435 let mut state = PaletteState::new(items);
1436 state.selected = 5; assert_eq!(execute_selected(&state), PaletteResult::Noop);
1438 }
1439
1440 #[test]
1441 fn execute_selected_dispatches_first_item() {
1442 let items = vec![
1443 item(PaletteAction::ToggleTheme, "Theme", "Toggle"),
1444 item(PaletteAction::ReloadIndex, "Reload", "Refresh"),
1445 ];
1446 let state = PaletteState::new(items);
1447 assert_eq!(execute_selected(&state), PaletteResult::ToggleTheme);
1448 }
1449
1450 #[test]
1451 fn execute_selected_dispatches_second_item() {
1452 let items = vec![
1453 item(PaletteAction::ToggleTheme, "Theme", "Toggle"),
1454 item(PaletteAction::ReloadIndex, "Reload", "Refresh"),
1455 ];
1456 let mut state = PaletteState::new(items);
1457 state.selected = 1;
1458 assert_eq!(execute_selected(&state), PaletteResult::ReloadIndex);
1459 }
1460
1461 #[test]
1462 fn execute_selected_respects_filter() {
1463 let items = vec![
1464 item(PaletteAction::ToggleTheme, "Theme", "Toggle"),
1465 item(PaletteAction::ReloadIndex, "Reload", "Refresh"),
1466 ];
1467 let mut state = PaletteState::new(items);
1468 state.query = "reload".to_string();
1469 state.refilter();
1470 assert_eq!(execute_selected(&state), PaletteResult::ReloadIndex);
1472 }
1473
1474 #[test]
1475 fn execute_selected_noop_after_no_match_filter() {
1476 let items = vec![item(PaletteAction::ToggleTheme, "Theme", "Toggle")];
1477 let mut state = PaletteState::new(items);
1478 state.query = "zzz_no_match".to_string();
1479 state.refilter();
1480 assert_eq!(execute_selected(&state), PaletteResult::Noop);
1481 }
1482
1483 #[test]
1484 fn execute_selected_slot_preserves_value() {
1485 let items = vec![item(PaletteAction::SaveViewSlot(7), "Save 7", "Ctrl+7")];
1486 let state = PaletteState::new(items);
1487 assert_eq!(execute_selected(&state), PaletteResult::SaveViewSlot(7));
1488 }
1489
1490 #[test]
1493 fn supporting_enums_derive_traits() {
1494 let imt = InputModeTarget::Agent;
1496 assert_eq!(imt, imt);
1497 let _ = format!("{:?}", imt);
1498
1499 let tfp = TimeFilterPreset::Today;
1500 assert_eq!(tfp, tfp);
1501 let _ = format!("{:?}", tfp);
1502
1503 let at = AnalyticsTarget::Dashboard;
1504 assert_eq!(at, at);
1505 let _ = format!("{:?}", at);
1506
1507 let st = ScreenshotTarget::Html;
1508 assert_eq!(st, st);
1509 let _ = format!("{:?}", st);
1510 }
1511}