Skip to main content

armas_basic/components/
command.rs

1//! Command Component
2//!
3//! A command palette for search and quick actions. Styled to match shadcn/ui command.
4
5use crate::animation::{Animation, EasingFunction};
6use crate::components::Kbd;
7use crate::ext::ArmasContextExt;
8use crate::Theme;
9use egui::{vec2, Align2, Color32, Key, Modifiers, Pos2, Rect, Sense, Ui};
10
11// Constants matching shadcn styling
12const DEFAULT_PANEL_WIDTH: f32 = 512.0;
13const DEFAULT_PANEL_MAX_HEIGHT: f32 = 384.0;
14const CORNER_RADIUS: f32 = 8.0;
15const INPUT_HEIGHT: f32 = 44.0;
16const INPUT_PADDING_X: f32 = 12.0;
17const INPUT_GAP: f32 = 8.0;
18const LIST_MAX_HEIGHT: f32 = 300.0;
19const LIST_PADDING: f32 = 4.0;
20const GROUP_PADDING_X: f32 = 8.0;
21const GROUP_PADDING_Y: f32 = 6.0;
22const ITEM_HEIGHT: f32 = 32.0;
23const ITEM_RADIUS: f32 = 2.0;
24const ITEM_PADDING_X: f32 = 8.0;
25const ITEM_GAP: f32 = 8.0;
26const ICON_SIZE: f32 = 16.0;
27
28/// Internal representation of a command item
29#[derive(Clone)]
30enum CommandItem {
31    Action {
32        id: String,
33        label: String,
34        icon: Option<String>,
35        shortcut: Option<String>,
36    },
37    Group {
38        heading: String,
39    },
40    Separator,
41}
42
43/// Parameters for drawing a command palette item
44struct ItemDrawParams<'a> {
45    id: &'a str,
46    label: &'a str,
47    icon: Option<&'a str>,
48    shortcut: Option<&'a str>,
49    is_selected: bool,
50}
51
52/// Builder for configuring individual commands
53pub struct CommandItemBuilder<'a> {
54    items: &'a mut Vec<CommandItem>,
55    index: usize,
56}
57
58impl CommandItemBuilder<'_> {
59    /// Set command icon
60    #[must_use]
61    pub fn icon(self, icon: impl Into<String>) -> Self {
62        if let Some(CommandItem::Action {
63            icon: ref mut i, ..
64        }) = self.items.get_mut(self.index)
65        {
66            *i = Some(icon.into());
67        }
68        self
69    }
70
71    /// Set keyboard shortcut display (use format like "⌘+K" or "Ctrl+Shift+P")
72    #[must_use]
73    pub fn shortcut(self, shortcut: impl Into<String>) -> Self {
74        if let Some(CommandItem::Action {
75            shortcut: ref mut s,
76            ..
77        }) = self.items.get_mut(self.index)
78        {
79            *s = Some(shortcut.into());
80        }
81        self
82    }
83}
84
85/// Builder for adding commands to the menu
86pub struct CommandBuilder<'a> {
87    items: &'a mut Vec<CommandItem>,
88}
89
90impl CommandBuilder<'_> {
91    /// Add a command item
92    pub fn item(&mut self, id: &str, label: &str) -> CommandItemBuilder<'_> {
93        self.items.push(CommandItem::Action {
94            id: id.to_string(),
95            label: label.to_string(),
96            icon: None,
97            shortcut: None,
98        });
99        let index = self.items.len() - 1;
100        CommandItemBuilder {
101            items: self.items,
102            index,
103        }
104    }
105
106    /// Add a group heading
107    pub fn group(&mut self, heading: &str) {
108        self.items.push(CommandItem::Group {
109            heading: heading.to_string(),
110        });
111    }
112
113    /// Add a separator
114    pub fn separator(&mut self) {
115        self.items.push(CommandItem::Separator);
116    }
117}
118
119/// Response from command
120pub struct CommandResponse {
121    /// The UI response
122    pub response: egui::Response,
123    /// ID of executed command, if any
124    pub executed: Option<String>,
125    /// Whether the command palette is currently open
126    pub is_open: bool,
127    /// Whether the open state changed this frame (opened or closed)
128    pub changed: bool,
129}
130
131/// Command palette component
132///
133/// # Example
134///
135/// ```rust,no_run
136/// # use egui::Ui;
137/// # fn example(ui: &mut Ui) {
138/// use armas_basic::components::Command;
139///
140/// let mut cmd = Command::new().placeholder("Type a command...");
141/// let response = cmd.show(ui, |builder| {
142///     builder.item("open", "Open File");
143///     builder.item("save", "Save");
144///     builder.item("quit", "Quit");
145/// });
146/// if let Some(id) = response.executed {
147///     // handle executed command id
148/// }
149/// # }
150/// ```
151pub struct Command {
152    id: Option<egui::Id>,
153    placeholder: String,
154    trigger_key: Key,
155    trigger_modifiers: Modifiers,
156    width: f32,
157    max_height: f32,
158    // Internal state
159    is_open: bool,
160    search: String,
161    selected: usize,
162    animation: Animation<f32>,
163}
164
165impl Command {
166    /// Create a new command palette
167    #[must_use]
168    pub fn new() -> Self {
169        Self {
170            id: None,
171            placeholder: "Type a command or search...".to_string(),
172            trigger_key: Key::K,
173            trigger_modifiers: Modifiers::COMMAND,
174            width: DEFAULT_PANEL_WIDTH,
175            max_height: DEFAULT_PANEL_MAX_HEIGHT,
176            is_open: false,
177            search: String::new(),
178            selected: 0,
179            animation: Animation::new(0.0, 1.0, 0.15).easing(EasingFunction::CubicOut),
180        }
181    }
182
183    /// Set a custom ID for state persistence
184    #[must_use]
185    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
186        self.id = Some(id.into());
187        self
188    }
189
190    /// Set placeholder text for the search input
191    #[must_use]
192    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
193        self.placeholder = placeholder.into();
194        self
195    }
196
197    /// Set the keyboard shortcut to trigger the command palette
198    #[must_use]
199    pub const fn trigger(mut self, key: Key, modifiers: Modifiers) -> Self {
200        self.trigger_key = key;
201        self.trigger_modifiers = modifiers;
202        self
203    }
204
205    /// Set the panel width
206    #[must_use]
207    pub const fn width(mut self, width: f32) -> Self {
208        self.width = width;
209        self
210    }
211
212    /// Set the panel max height
213    #[must_use]
214    pub const fn max_height(mut self, max_height: f32) -> Self {
215        self.max_height = max_height;
216        self
217    }
218
219    /// Show the command palette
220    pub fn show<R>(
221        &mut self,
222        ui: &mut Ui,
223        content: impl FnOnce(&mut CommandBuilder) -> R,
224    ) -> CommandResponse {
225        let theme = ui.ctx().armas_theme();
226        let ctx = ui.ctx().clone();
227        let id = self.id.unwrap_or_else(|| ui.id().with("command"));
228
229        // Load state
230        self.load_state(&ctx, id);
231        let was_open = self.is_open;
232
233        // Collect items
234        let mut items = Vec::new();
235        let mut builder = CommandBuilder { items: &mut items };
236        content(&mut builder);
237
238        // Handle trigger key
239        self.handle_trigger(&ctx);
240
241        let mut executed = None;
242
243        if self.is_open {
244            // Update animation
245            let dt = ctx.input(|i| i.unstable_dt);
246            self.animation.update(dt);
247            if self.animation.is_running() {
248                ctx.request_repaint();
249            }
250
251            // Filter items
252            let filtered = self.filter_items(&items);
253
254            // Draw UI
255            let mut should_close = false;
256            self.draw_backdrop(ui, id, &mut should_close);
257            executed = self.draw_panel(ui, id, &theme, &filtered, &mut should_close);
258
259            // Handle keyboard
260            self.handle_keyboard(&ctx, &filtered, &mut should_close, &mut executed);
261
262            if should_close {
263                self.is_open = false;
264                self.search.clear();
265                self.selected = 0;
266                self.animation.reset();
267            }
268        }
269
270        let changed = was_open != self.is_open;
271
272        // Save state
273        self.save_state(&ctx, id);
274
275        let response = ui.interact(ui.min_rect(), id, Sense::hover());
276
277        CommandResponse {
278            response,
279            executed,
280            is_open: self.is_open,
281            changed,
282        }
283    }
284
285    // ========================================================================
286    // State persistence
287    // ========================================================================
288
289    fn load_state(&mut self, ctx: &egui::Context, id: egui::Id) {
290        let state_id = id.with("state");
291        let stored: Option<(bool, String, usize, Animation<f32>)> =
292            ctx.data_mut(|d| d.get_temp(state_id));
293        if let Some((is_open, search, selected, animation)) = stored {
294            self.is_open = is_open;
295            self.search = search;
296            self.selected = selected;
297            self.animation = animation;
298        }
299    }
300
301    fn save_state(&self, ctx: &egui::Context, id: egui::Id) {
302        let state_id = id.with("state");
303        ctx.data_mut(|d| {
304            d.insert_temp(
305                state_id,
306                (
307                    self.is_open,
308                    self.search.clone(),
309                    self.selected,
310                    self.animation.clone(),
311                ),
312            );
313        });
314    }
315
316    // ========================================================================
317    // Event handlers
318    // ========================================================================
319
320    fn handle_trigger(&mut self, ctx: &egui::Context) {
321        ctx.input(|i| {
322            if i.key_pressed(self.trigger_key) && i.modifiers.matches_exact(self.trigger_modifiers)
323            {
324                self.is_open = !self.is_open;
325                if self.is_open {
326                    self.search.clear();
327                    self.selected = 0;
328                    self.animation.start();
329                } else {
330                    self.animation.reset();
331                }
332            }
333        });
334    }
335
336    fn handle_keyboard(
337        &mut self,
338        ctx: &egui::Context,
339        items: &[&CommandItem],
340        should_close: &mut bool,
341        executed: &mut Option<String>,
342    ) {
343        let action_count = items
344            .iter()
345            .filter(|i| matches!(i, CommandItem::Action { .. }))
346            .count();
347
348        ctx.input(|i| {
349            if i.key_pressed(Key::Escape) {
350                *should_close = true;
351            }
352
353            if i.key_pressed(Key::ArrowDown) && self.selected < action_count.saturating_sub(1) {
354                self.selected += 1;
355            }
356
357            if i.key_pressed(Key::ArrowUp) && self.selected > 0 {
358                self.selected -= 1;
359            }
360
361            if i.key_pressed(Key::Enter) && action_count > 0 {
362                let mut idx = 0;
363                for item in items {
364                    if let CommandItem::Action { id, .. } = item {
365                        if idx == self.selected {
366                            *executed = Some(id.clone());
367                            *should_close = true;
368                            break;
369                        }
370                        idx += 1;
371                    }
372                }
373            }
374        });
375    }
376
377    // ========================================================================
378    // Filtering
379    // ========================================================================
380
381    fn filter_items<'a>(&self, items: &'a [CommandItem]) -> Vec<&'a CommandItem> {
382        let query = self.search.to_lowercase();
383        items
384            .iter()
385            .filter(|item| match item {
386                CommandItem::Action { label, .. } => {
387                    query.is_empty() || label.to_lowercase().contains(&query)
388                }
389                CommandItem::Group { .. } | CommandItem::Separator => query.is_empty(),
390            })
391            .collect()
392    }
393
394    // ========================================================================
395    // Drawing
396    // ========================================================================
397
398    fn draw_backdrop(&self, ui: &mut Ui, id: egui::Id, should_close: &mut bool) {
399        let screen = ui.ctx().viewport_rect();
400        let alpha = (self.animation.value() * 128.0) as u8;
401
402        egui::Area::new(id.with("backdrop"))
403            .order(egui::Order::Foreground)
404            .anchor(Align2::LEFT_TOP, vec2(0.0, 0.0))
405            .show(ui.ctx(), |ui| {
406                let response = ui.allocate_response(screen.size(), Sense::click());
407                ui.painter()
408                    .rect_filled(screen, 0.0, Color32::from_black_alpha(alpha));
409                if response.clicked() {
410                    *should_close = true;
411                }
412            });
413    }
414
415    fn draw_panel(
416        &mut self,
417        ui: &mut Ui,
418        id: egui::Id,
419        theme: &Theme,
420        filtered: &[&CommandItem],
421        should_close: &mut bool,
422    ) -> Option<String> {
423        let screen = ui.ctx().viewport_rect();
424        let mut executed = None;
425
426        egui::Area::new(id.with("panel"))
427            .order(egui::Order::Foreground)
428            .anchor(Align2::CENTER_TOP, vec2(0.0, screen.height() * 0.2))
429            .show(ui.ctx(), |ui| {
430                let panel_rect =
431                    Rect::from_min_size(ui.cursor().min, vec2(self.width, self.max_height));
432
433                // Panel background — match Dialog styling
434                let shadow = egui::epaint::Shadow {
435                    offset: [0, 4],
436                    blur: 16,
437                    spread: 0,
438                    color: Color32::from_black_alpha(60),
439                };
440                ui.painter().add(shadow.as_shape(panel_rect, CORNER_RADIUS));
441                ui.painter()
442                    .rect_filled(panel_rect, CORNER_RADIUS, theme.background());
443                ui.painter().rect_stroke(
444                    panel_rect,
445                    CORNER_RADIUS,
446                    egui::Stroke::new(1.0, theme.border()),
447                    egui::StrokeKind::Inside,
448                );
449
450                ui.scope_builder(egui::UiBuilder::new().max_rect(panel_rect), |ui| {
451                    ui.vertical(|ui| {
452                        // Input - we need to handle this specially
453                        self.draw_input(ui, id, theme);
454
455                        // Separator
456                        self.draw_separator(ui, theme, self.width);
457
458                        // List
459                        let (exec, sel) = self.draw_list(ui, theme, filtered, should_close);
460                        executed = exec;
461                        self.selected = sel;
462                    });
463                });
464            });
465
466        executed
467    }
468
469    fn draw_input(&mut self, ui: &mut Ui, _id: egui::Id, theme: &Theme) {
470        let input_rect = Rect::from_min_size(ui.cursor().min, vec2(self.width, INPUT_HEIGHT));
471
472        // Search icon
473        let icon_x = input_rect.left() + INPUT_PADDING_X;
474        ui.painter().text(
475            Pos2::new(icon_x, input_rect.center().y),
476            Align2::LEFT_CENTER,
477            "🔍",
478            egui::FontId::proportional(ICON_SIZE),
479            theme.muted_foreground(),
480        );
481
482        // Text input - positioned after icon with gap
483        let text_left = icon_x + ICON_SIZE + INPUT_GAP;
484        let text_rect = Rect::from_min_max(
485            Pos2::new(text_left, input_rect.top()),
486            Pos2::new(input_rect.right() - INPUT_PADDING_X, input_rect.bottom()),
487        );
488
489        ui.scope_builder(egui::UiBuilder::new().max_rect(text_rect), |ui| {
490            ui.centered_and_justified(|ui| {
491                let response = ui.add(
492                    egui::TextEdit::singleline(&mut self.search)
493                        .frame(false)
494                        .hint_text(&self.placeholder)
495                        .font(egui::FontId::proportional(theme.typography.base))
496                        .vertical_align(egui::Align::Center),
497                );
498                response.request_focus();
499            });
500        });
501
502        ui.advance_cursor_after_rect(input_rect);
503    }
504
505    fn draw_separator(&self, ui: &mut Ui, theme: &Theme, width: f32) {
506        let y = ui.cursor().top();
507        ui.painter().hline(
508            ui.cursor().left()..=ui.cursor().left() + width,
509            y,
510            egui::Stroke::new(1.0, theme.border()),
511        );
512        ui.add_space(1.0);
513    }
514
515    fn draw_list(
516        &self,
517        ui: &mut Ui,
518        theme: &Theme,
519        items: &[&CommandItem],
520        should_close: &mut bool,
521    ) -> (Option<String>, usize) {
522        let mut executed = None;
523        let mut new_selected = self.selected;
524        let mut action_index = 0;
525
526        egui::ScrollArea::vertical()
527            .max_height(LIST_MAX_HEIGHT)
528            .show(ui, |ui| {
529                ui.add_space(LIST_PADDING);
530
531                ui.horizontal(|ui| {
532                    ui.add_space(LIST_PADDING);
533                    ui.vertical(|ui| {
534                        if items.is_empty() {
535                            self.draw_empty(ui, theme);
536                        } else {
537                            for item in items {
538                                match item {
539                                    CommandItem::Action {
540                                        id,
541                                        label,
542                                        icon,
543                                        shortcut,
544                                    } => {
545                                        let is_selected = action_index == self.selected;
546                                        let result = self.draw_item(
547                                            ui,
548                                            theme,
549                                            &ItemDrawParams {
550                                                id,
551                                                label,
552                                                icon: icon.as_deref(),
553                                                shortcut: shortcut.as_deref(),
554                                                is_selected,
555                                            },
556                                        );
557
558                                        if let Some(clicked_id) = result.0 {
559                                            executed = Some(clicked_id);
560                                            *should_close = true;
561                                        }
562                                        if result.1 {
563                                            new_selected = action_index;
564                                        }
565                                        action_index += 1;
566                                    }
567                                    CommandItem::Group { heading } => {
568                                        self.draw_group_heading(ui, theme, heading);
569                                    }
570                                    CommandItem::Separator => {
571                                        ui.add_space(LIST_PADDING);
572                                        self.draw_separator(
573                                            ui,
574                                            theme,
575                                            self.width - LIST_PADDING * 4.0,
576                                        );
577                                        ui.add_space(LIST_PADDING);
578                                    }
579                                }
580                            }
581                        }
582                    });
583                    ui.add_space(LIST_PADDING);
584                });
585
586                ui.add_space(LIST_PADDING);
587            });
588
589        (executed, new_selected)
590    }
591
592    fn draw_empty(&self, ui: &mut Ui, theme: &Theme) {
593        ui.add_space(24.0);
594        ui.centered_and_justified(|ui| {
595            ui.label(
596                egui::RichText::new("No results found.")
597                    .color(theme.muted_foreground())
598                    .size(theme.typography.base),
599            );
600        });
601        ui.add_space(24.0);
602    }
603
604    fn draw_group_heading(&self, ui: &mut Ui, theme: &Theme, heading: &str) {
605        ui.add_space(GROUP_PADDING_Y);
606        ui.horizontal(|ui| {
607            ui.add_space(GROUP_PADDING_X);
608            ui.label(
609                egui::RichText::new(heading)
610                    .color(theme.muted_foreground())
611                    .size(theme.typography.sm)
612                    .strong(),
613            );
614        });
615        ui.add_space(GROUP_PADDING_Y);
616    }
617
618    fn draw_item(
619        &self,
620        ui: &mut Ui,
621        theme: &Theme,
622        params: &ItemDrawParams,
623    ) -> (Option<String>, bool) {
624        let id = params.id;
625        let label = params.label;
626        let icon = params.icon;
627        let shortcut = params.shortcut;
628        let is_selected = params.is_selected;
629        let available_width = ui.available_width() - LIST_PADDING;
630        let (rect, response) =
631            ui.allocate_exact_size(vec2(available_width, ITEM_HEIGHT), Sense::click());
632
633        let hovered = response.hovered();
634        let clicked = response.clicked();
635
636        // Background
637        if is_selected || hovered {
638            ui.painter().rect_filled(rect, ITEM_RADIUS, theme.accent());
639        }
640
641        // Text color based on state
642        let text_color = if is_selected || hovered {
643            theme.accent_foreground()
644        } else {
645            theme.popover_foreground()
646        };
647
648        let icon_color = if is_selected || hovered {
649            theme.accent_foreground()
650        } else {
651            theme.muted_foreground()
652        };
653
654        let mut x = rect.left() + ITEM_PADDING_X;
655
656        // Icon
657        if let Some(icon_text) = icon {
658            ui.painter().text(
659                Pos2::new(x, rect.center().y),
660                Align2::LEFT_CENTER,
661                icon_text,
662                egui::FontId::proportional(ICON_SIZE),
663                icon_color,
664            );
665            x += ICON_SIZE + ITEM_GAP;
666        }
667
668        // Label
669        ui.painter().text(
670            Pos2::new(x, rect.center().y),
671            Align2::LEFT_CENTER,
672            label,
673            egui::FontId::proportional(theme.typography.base),
674            text_color,
675        );
676
677        // Shortcut using Kbd component
678        if let Some(shortcut_text) = shortcut {
679            // Position the Kbd at the right side
680            let kbd_rect = Rect::from_min_max(
681                Pos2::new(rect.right() - 100.0, rect.top()),
682                Pos2::new(rect.right() - ITEM_PADDING_X, rect.bottom()),
683            );
684            ui.scope_builder(egui::UiBuilder::new().max_rect(kbd_rect), |ui| {
685                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
686                    Kbd::new(shortcut_text).show(ui);
687                });
688            });
689        }
690
691        let executed = if clicked { Some(id.to_string()) } else { None };
692        (executed, hovered)
693    }
694}
695
696impl Default for Command {
697    fn default() -> Self {
698        Self::new()
699    }
700}