Skip to main content

egui_cha_ds/molecules/
command_palette.rs

1//! Command Palette - Keyboard-driven command launcher
2//!
3//! A "Raycast-like" overlay for quick command access with fuzzy search,
4//! keyboard navigation, and grouping support.
5//!
6//! # Features
7//!
8//! - **Keyboard Navigation**: Arrow keys, Enter to execute, Escape to close
9//! - **Search**: Filter commands by typing (fuzzy matching optional via feature)
10//! - **Grouping**: Organize commands with groups and separators
11//! - **Shortcuts**: Display keyboard shortcuts for each command
12//! - **Icons**: Phosphor icons support
13//!
14//! # Example
15//!
16//! ```ignore
17//! // In your Model
18//! struct Model {
19//!     command_palette: CommandPaletteState,
20//! }
21//!
22//! // In your view
23//! if model.command_palette.is_open {
24//!     CommandPalette::new()
25//!         .placeholder("Type a command...")
26//!         .item(icons::GEAR, "Settings", Msg::OpenSettings)
27//!         .item_with_shortcut(icons::FLOPPY_DISK, "Save", Msg::Save, "⌘S")
28//!         .separator()
29//!         .group("File", |p| {
30//!             p.item(icons::FILE, "New File", Msg::NewFile)
31//!              .item(icons::FOLDER_SIMPLE, "Open Folder", Msg::OpenFolder)
32//!         })
33//!         .show(ctx, &mut model.command_palette, Msg::CommandPaletteClose);
34//! }
35//! ```
36
37use egui::{Key, Ui};
38use egui_cha::ViewCtx;
39
40use crate::Theme;
41
42/// State for CommandPalette (store in your Model)
43#[derive(Debug, Clone, Default)]
44pub struct CommandPaletteState {
45    /// Whether the palette is open
46    pub is_open: bool,
47    /// Current search query
48    pub query: String,
49    /// Currently selected item index (flattened)
50    pub selected_index: usize,
51}
52
53impl CommandPaletteState {
54    /// Create a new closed state
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Open the palette and reset state
60    pub fn open(&mut self) {
61        self.is_open = true;
62        self.query.clear();
63        self.selected_index = 0;
64    }
65
66    /// Close the palette
67    pub fn close(&mut self) {
68        self.is_open = false;
69        self.query.clear();
70        self.selected_index = 0;
71    }
72
73    /// Toggle open/close
74    pub fn toggle(&mut self) {
75        if self.is_open {
76            self.close();
77        } else {
78            self.open();
79        }
80    }
81}
82
83/// A single command item
84#[derive(Clone)]
85pub struct CommandItem<Msg> {
86    icon: Option<&'static str>,
87    label: String,
88    description: Option<String>,
89    shortcut: Option<String>,
90    msg: Msg,
91}
92
93impl<Msg: Clone> CommandItem<Msg> {
94    /// Create a new command item
95    pub fn new(label: impl Into<String>, msg: Msg) -> Self {
96        Self {
97            icon: None,
98            label: label.into(),
99            description: None,
100            shortcut: None,
101            msg,
102        }
103    }
104
105    /// Add an icon
106    pub fn icon(mut self, icon: &'static str) -> Self {
107        self.icon = Some(icon);
108        self
109    }
110
111    /// Add a description
112    pub fn description(mut self, desc: impl Into<String>) -> Self {
113        self.description = Some(desc.into());
114        self
115    }
116
117    /// Add a keyboard shortcut display
118    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
119        self.shortcut = Some(shortcut.into());
120        self
121    }
122}
123
124/// An entry in the command palette (item, separator, or group)
125#[derive(Clone)]
126pub enum CommandEntry<Msg> {
127    /// A command item
128    Item(CommandItem<Msg>),
129    /// A visual separator
130    Separator,
131    /// A group of commands with a label
132    Group {
133        label: String,
134        items: Vec<CommandEntry<Msg>>,
135    },
136}
137
138/// Command Palette UI component
139pub struct CommandPalette<Msg> {
140    placeholder: String,
141    entries: Vec<CommandEntry<Msg>>,
142    width: f32,
143    max_height: f32,
144    show_icons: bool,
145}
146
147impl<Msg: Clone> CommandPalette<Msg> {
148    /// Create a new command palette
149    pub fn new() -> Self {
150        Self {
151            placeholder: "Type a command...".to_string(),
152            entries: Vec::new(),
153            width: 500.0,
154            max_height: 400.0,
155            show_icons: true,
156        }
157    }
158
159    /// Set placeholder text for the search input
160    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
161        self.placeholder = text.into();
162        self
163    }
164
165    /// Set the width of the palette
166    pub fn width(mut self, width: f32) -> Self {
167        self.width = width;
168        self
169    }
170
171    /// Set the maximum height of the palette
172    pub fn max_height(mut self, height: f32) -> Self {
173        self.max_height = height;
174        self
175    }
176
177    /// Hide icons
178    pub fn hide_icons(mut self) -> Self {
179        self.show_icons = false;
180        self
181    }
182
183    /// Add a simple command item
184    pub fn item(mut self, icon: &'static str, label: impl Into<String>, msg: Msg) -> Self {
185        self.entries
186            .push(CommandEntry::Item(CommandItem::new(label, msg).icon(icon)));
187        self
188    }
189
190    /// Add a command item without icon
191    pub fn item_plain(mut self, label: impl Into<String>, msg: Msg) -> Self {
192        self.entries
193            .push(CommandEntry::Item(CommandItem::new(label, msg)));
194        self
195    }
196
197    /// Add a command item with shortcut
198    pub fn item_with_shortcut(
199        mut self,
200        icon: &'static str,
201        label: impl Into<String>,
202        msg: Msg,
203        shortcut: impl Into<String>,
204    ) -> Self {
205        self.entries.push(CommandEntry::Item(
206            CommandItem::new(label, msg).icon(icon).shortcut(shortcut),
207        ));
208        self
209    }
210
211    /// Add a command item with description
212    pub fn item_with_description(
213        mut self,
214        icon: &'static str,
215        label: impl Into<String>,
216        msg: Msg,
217        description: impl Into<String>,
218    ) -> Self {
219        self.entries.push(CommandEntry::Item(
220            CommandItem::new(label, msg)
221                .icon(icon)
222                .description(description),
223        ));
224        self
225    }
226
227    /// Add a full command item
228    pub fn item_full(mut self, item: CommandItem<Msg>) -> Self {
229        self.entries.push(CommandEntry::Item(item));
230        self
231    }
232
233    /// Add a separator
234    pub fn separator(mut self) -> Self {
235        self.entries.push(CommandEntry::Separator);
236        self
237    }
238
239    /// Add a group of commands
240    pub fn group(mut self, label: impl Into<String>, build: impl FnOnce(Self) -> Self) -> Self {
241        let builder = Self::new();
242        let built = build(builder);
243        self.entries.push(CommandEntry::Group {
244            label: label.into(),
245            items: built.entries,
246        });
247        self
248    }
249
250    /// Show the command palette
251    ///
252    /// Returns the message to emit when a command is selected
253    pub fn show(self, ctx: &mut ViewCtx<'_, Msg>, state: &mut CommandPaletteState, on_close: Msg) {
254        if !state.is_open {
255            return;
256        }
257
258        let theme = Theme::current(ctx.ui.ctx());
259
260        // Handle keyboard navigation (before rendering, using previous frame's state)
261        let mut should_close = false;
262        let mut selected_msg: Option<Msg> = None;
263
264        ctx.ui.input(|input| {
265            if input.key_pressed(Key::Escape) {
266                should_close = true;
267            }
268        });
269
270        // Store query for later keyboard handling
271        let query_before = state.query.clone();
272
273        // Pre-calculate items to determine height
274        let row_height = theme.spacing_lg + theme.spacing_sm;
275        let header_height = 60.0; // input + separator area
276        let flat_items_for_height = self.flatten_items(&query_before);
277        let content_height = flat_items_for_height.len() as f32 * row_height + header_height;
278        let actual_height = content_height.min(self.max_height);
279
280        // Render overlay
281        egui::Area::new(egui::Id::new("command_palette_area"))
282            .anchor(egui::Align2::CENTER_TOP, [0.0, 100.0])
283            .order(egui::Order::Foreground)
284            .show(ctx.ui.ctx(), |ui| {
285                egui::Frame::popup(ui.style())
286                    .fill(theme.bg_primary)
287                    .stroke(egui::Stroke::new(theme.border_width, theme.border))
288                    .rounding(theme.radius_md)
289                    .shadow(egui::Shadow {
290                        spread: 8,
291                        blur: 16,
292                        color: egui::Color32::from_black_alpha(60),
293                        offset: [0, 4],
294                    })
295                    .show(ui, |ui| {
296                        ui.set_width(self.width);
297                        ui.set_min_height(actual_height);
298
299                        // Search input
300                        ui.add_space(theme.spacing_sm);
301                        let response = ui.add(
302                            egui::TextEdit::singleline(&mut state.query)
303                                .hint_text(&self.placeholder)
304                                .desired_width(f32::INFINITY)
305                                .frame(false)
306                                .font(egui::TextStyle::Body)
307                                .margin(egui::vec2(theme.spacing_sm, 0.0)),
308                        );
309                        // Auto-focus
310                        response.request_focus();
311                        ui.add_space(theme.spacing_xs);
312
313                        ui.separator();
314
315                        // Flatten entries AFTER TextEdit has updated query
316                        let flat_items = self.flatten_items(&state.query);
317                        let item_count = flat_items.len();
318
319                        // Clamp selected_index to valid range
320                        if item_count > 0 && state.selected_index >= item_count {
321                            state.selected_index = item_count - 1;
322                        }
323
324                        // Reset selected_index when query changes
325                        if state.query != query_before {
326                            state.selected_index = 0;
327                        }
328
329                        // Handle arrow keys and enter
330                        ui.input(|input| {
331                            if input.key_pressed(Key::ArrowDown) && item_count > 0 {
332                                state.selected_index = (state.selected_index + 1) % item_count;
333                            }
334                            if input.key_pressed(Key::ArrowUp) && item_count > 0 {
335                                state.selected_index = state
336                                    .selected_index
337                                    .checked_sub(1)
338                                    .unwrap_or(item_count - 1);
339                            }
340                            if input.key_pressed(Key::Enter) && !flat_items.is_empty() {
341                                if let Some(item) = flat_items.get(state.selected_index) {
342                                    selected_msg = Some(item.msg.clone());
343                                    should_close = true;
344                                }
345                            }
346                        });
347
348                        // Results list - height is min of content and max_height
349                        let row_height = theme.spacing_lg + theme.spacing_sm;
350                        let content_height = flat_items.len() as f32 * row_height;
351                        let scroll_max = content_height.min(self.max_height - 60.0);
352
353                        egui::ScrollArea::vertical()
354                            .id_salt("command_palette_scroll")
355                            .max_height(scroll_max)
356                            .show(ui, |ui| {
357                                self.render_entries(
358                                    ui,
359                                    &self.entries,
360                                    &state.query,
361                                    state.selected_index,
362                                    &mut 0,
363                                    &theme,
364                                    &mut selected_msg,
365                                    &mut should_close,
366                                );
367                            });
368                    });
369            });
370
371        // Handle close
372        if should_close {
373            state.close();
374            ctx.emit(on_close);
375        }
376
377        // Emit selected command
378        if let Some(msg) = selected_msg {
379            ctx.emit(msg);
380        }
381    }
382
383    /// Show without ViewCtx (returns selected index or None)
384    pub fn show_raw(self, ui: &mut Ui, state: &mut CommandPaletteState) -> Option<usize> {
385        if !state.is_open {
386            return None;
387        }
388
389        let theme = Theme::current(ui.ctx());
390
391        // Handle escape key
392        let mut should_close = false;
393        let mut selected_index: Option<usize> = None;
394
395        ui.input(|input| {
396            if input.key_pressed(Key::Escape) {
397                should_close = true;
398            }
399        });
400
401        // Store query for change detection
402        let query_before = state.query.clone();
403
404        // Pre-calculate items to determine height
405        let row_height = theme.spacing_lg + theme.spacing_sm;
406        let header_height = 60.0; // input + separator area
407        let flat_items_for_height = self.flatten_items(&query_before);
408        let content_height = flat_items_for_height.len() as f32 * row_height + header_height;
409        let actual_height = content_height.min(self.max_height);
410
411        // Render
412        egui::Area::new(egui::Id::new("command_palette_area"))
413            .anchor(egui::Align2::CENTER_TOP, [0.0, 100.0])
414            .order(egui::Order::Foreground)
415            .show(ui.ctx(), |ui| {
416                egui::Frame::popup(ui.style())
417                    .fill(theme.bg_primary)
418                    .stroke(egui::Stroke::new(theme.border_width, theme.border))
419                    .rounding(theme.radius_md)
420                    .show(ui, |ui| {
421                        ui.set_width(self.width);
422                        ui.set_min_height(actual_height);
423
424                        ui.add_space(theme.spacing_sm);
425                        let response = ui.add(
426                            egui::TextEdit::singleline(&mut state.query)
427                                .hint_text(&self.placeholder)
428                                .desired_width(f32::INFINITY)
429                                .frame(false)
430                                .margin(egui::vec2(theme.spacing_sm, 0.0)),
431                        );
432                        response.request_focus();
433                        ui.add_space(theme.spacing_xs);
434                        ui.separator();
435
436                        // Flatten entries AFTER TextEdit has updated query
437                        let flat_items = self.flatten_items(&state.query);
438                        let item_count = flat_items.len();
439
440                        // Clamp selected_index to valid range
441                        if item_count > 0 && state.selected_index >= item_count {
442                            state.selected_index = item_count - 1;
443                        }
444
445                        // Reset selected_index when query changes
446                        if state.query != query_before {
447                            state.selected_index = 0;
448                        }
449
450                        // Handle arrow keys and enter
451                        ui.input(|input| {
452                            if input.key_pressed(Key::ArrowDown) && item_count > 0 {
453                                state.selected_index = (state.selected_index + 1) % item_count;
454                            }
455                            if input.key_pressed(Key::ArrowUp) && item_count > 0 {
456                                state.selected_index = state
457                                    .selected_index
458                                    .checked_sub(1)
459                                    .unwrap_or(item_count - 1);
460                            }
461                            if input.key_pressed(Key::Enter) && !flat_items.is_empty() {
462                                selected_index = Some(state.selected_index);
463                                should_close = true;
464                            }
465                        });
466
467                        // Results list - height is min of content and max_height
468                        let row_height = theme.spacing_lg + theme.spacing_sm;
469                        let content_height = flat_items.len() as f32 * row_height;
470                        let scroll_max = content_height.min(self.max_height - 60.0);
471
472                        let mut dummy_msg: Option<Msg> = None;
473                        let mut dummy_close = false;
474
475                        egui::ScrollArea::vertical()
476                            .id_salt("command_palette_scroll_raw")
477                            .max_height(scroll_max)
478                            .show(ui, |ui| {
479                                self.render_entries(
480                                    ui,
481                                    &self.entries,
482                                    &state.query,
483                                    state.selected_index,
484                                    &mut 0,
485                                    &theme,
486                                    &mut dummy_msg,
487                                    &mut dummy_close,
488                                );
489                            });
490
491                        if dummy_close {
492                            should_close = true;
493                        }
494                    });
495            });
496
497        if should_close {
498            state.close();
499        }
500
501        selected_index
502    }
503
504    /// Flatten items for indexing (filtered by query)
505    fn flatten_items(&self, query: &str) -> Vec<&CommandItem<Msg>> {
506        let mut items = Vec::new();
507        self.collect_items(&self.entries, query, &mut items);
508        items
509    }
510
511    fn collect_items<'a>(
512        &'a self,
513        entries: &'a [CommandEntry<Msg>],
514        query: &str,
515        out: &mut Vec<&'a CommandItem<Msg>>,
516    ) {
517        let query_lower = query.to_lowercase();
518
519        for entry in entries {
520            match entry {
521                CommandEntry::Item(item) => {
522                    if query.is_empty() || self.matches_query(item, &query_lower) {
523                        out.push(item);
524                    }
525                }
526                CommandEntry::Group { items, .. } => {
527                    self.collect_items(items, query, out);
528                }
529                CommandEntry::Separator => {}
530            }
531        }
532    }
533
534    fn matches_query(&self, item: &CommandItem<Msg>, query: &str) -> bool {
535        let label_lower = item.label.to_lowercase();
536
537        #[cfg(feature = "fuzzy")]
538        {
539            // Fuzzy matching with nucleo or similar
540            fuzzy_match(&label_lower, query)
541        }
542
543        #[cfg(not(feature = "fuzzy"))]
544        {
545            // Simple substring match
546            label_lower.contains(query)
547                || item
548                    .description
549                    .as_ref()
550                    .map(|d| d.to_lowercase().contains(query))
551                    .unwrap_or(false)
552        }
553    }
554
555    fn render_entries(
556        &self,
557        ui: &mut Ui,
558        entries: &[CommandEntry<Msg>],
559        query: &str,
560        selected_index: usize,
561        current_index: &mut usize,
562        theme: &Theme,
563        selected_msg: &mut Option<Msg>,
564        should_close: &mut bool,
565    ) {
566        let query_lower = query.to_lowercase();
567
568        for entry in entries {
569            match entry {
570                CommandEntry::Item(item) => {
571                    if !query.is_empty() && !self.matches_query(item, &query_lower) {
572                        continue;
573                    }
574
575                    let is_selected = *current_index == selected_index;
576                    let clicked = self.render_item(ui, item, is_selected, theme);
577
578                    if clicked {
579                        *selected_msg = Some(item.msg.clone());
580                        *should_close = true;
581                    }
582
583                    *current_index += 1;
584                }
585                CommandEntry::Separator => {
586                    // Hide separator when searching
587                    if query.is_empty() {
588                        ui.add_space(theme.spacing_xs);
589                        ui.separator();
590                        ui.add_space(theme.spacing_xs);
591                    }
592                }
593                CommandEntry::Group { label, items } => {
594                    // Check if any items in group match
595                    let has_matches = query.is_empty()
596                        || items.iter().any(|e| {
597                            if let CommandEntry::Item(item) = e {
598                                self.matches_query(item, &query_lower)
599                            } else {
600                                false
601                            }
602                        });
603
604                    if has_matches {
605                        // Group label
606                        ui.add_space(theme.spacing_sm);
607                        ui.label(
608                            egui::RichText::new(label.to_uppercase())
609                                .size(theme.font_size_xs)
610                                .color(theme.text_muted),
611                        );
612                        ui.add_space(theme.spacing_xs);
613
614                        self.render_entries(
615                            ui,
616                            items,
617                            query,
618                            selected_index,
619                            current_index,
620                            theme,
621                            selected_msg,
622                            should_close,
623                        );
624                    }
625                }
626            }
627        }
628    }
629
630    fn render_item(
631        &self,
632        ui: &mut Ui,
633        item: &CommandItem<Msg>,
634        is_selected: bool,
635        theme: &Theme,
636    ) -> bool {
637        // Colors based on state
638        let (text_color, icon_color) = if is_selected {
639            (theme.text_primary, theme.text_primary)
640        } else {
641            (theme.text_secondary, theme.text_muted)
642        };
643
644        let hover_color = theme.bg_tertiary;
645        let selected_color = theme.bg_secondary;
646
647        // Fixed row height
648        let row_height = theme.spacing_lg + theme.spacing_sm;
649        let available_width = ui.available_width();
650        let padding = theme.spacing_sm;
651
652        let (rect, response) = ui.allocate_exact_size(
653            egui::vec2(available_width, row_height),
654            egui::Sense::click(),
655        );
656
657        if ui.is_rect_visible(rect) {
658            let painter = ui.painter();
659
660            // Background
661            let bg = if is_selected {
662                Some(selected_color)
663            } else if response.hovered() {
664                Some(hover_color)
665            } else {
666                None
667            };
668
669            if let Some(color) = bg {
670                painter.rect_filled(rect, theme.radius_sm, color);
671            }
672
673            // Content layout
674            let mut x = rect.min.x + padding;
675            let center_y = rect.center().y;
676
677            // Icon
678            if self.show_icons {
679                if let Some(icon) = item.icon {
680                    let galley = painter.layout_no_wrap(
681                        icon.to_string(),
682                        egui::FontId::new(
683                            theme.font_size_md,
684                            egui::FontFamily::Name("icons".into()),
685                        ),
686                        icon_color,
687                    );
688                    let icon_pos = egui::pos2(x, center_y - galley.size().y / 2.0);
689                    painter.galley(icon_pos, galley, icon_color);
690                    x += theme.font_size_md + theme.spacing_sm;
691                }
692            }
693
694            // Label
695            let label_galley = painter.layout_no_wrap(
696                item.label.clone(),
697                egui::FontId::proportional(theme.font_size_sm),
698                text_color,
699            );
700            let label_pos = egui::pos2(x, center_y - label_galley.size().y / 2.0);
701            let label_width = label_galley.size().x;
702            painter.galley(label_pos, label_galley, text_color);
703
704            // Description (after label, smaller and muted)
705            if let Some(desc) = &item.description {
706                let desc_x = x + label_width + theme.spacing_sm;
707                let desc_galley = painter.layout_no_wrap(
708                    desc.clone(),
709                    egui::FontId::proportional(theme.font_size_xs),
710                    theme.text_muted,
711                );
712                let desc_pos = egui::pos2(desc_x, center_y - desc_galley.size().y / 2.0);
713                painter.galley(desc_pos, desc_galley, theme.text_muted);
714            }
715
716            // Shortcut (right-aligned)
717            if let Some(shortcut) = &item.shortcut {
718                let galley = painter.layout_no_wrap(
719                    shortcut.clone(),
720                    egui::FontId::proportional(theme.font_size_xs),
721                    theme.text_muted,
722                );
723                let shortcut_x = rect.max.x - padding - galley.size().x;
724                let shortcut_pos = egui::pos2(shortcut_x, center_y - galley.size().y / 2.0);
725                painter.galley(shortcut_pos, galley, theme.text_muted);
726            }
727        }
728
729        // Cursor
730        if response.hovered() {
731            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
732        }
733
734        // Scroll into view if selected
735        if is_selected {
736            ui.scroll_to_rect(rect, Some(egui::Align::Center));
737        }
738
739        response.clicked()
740    }
741}
742
743impl<Msg: Clone> Default for CommandPalette<Msg> {
744    fn default() -> Self {
745        Self::new()
746    }
747}
748
749#[cfg(feature = "fuzzy")]
750fn fuzzy_match(text: &str, pattern: &str) -> bool {
751    // Simple fuzzy: all chars in pattern appear in order in text
752    let mut pattern_chars = pattern.chars().peekable();
753    for c in text.chars() {
754        if pattern_chars.peek() == Some(&c) {
755            pattern_chars.next();
756        }
757    }
758    pattern_chars.peek().is_none()
759}