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 = 6.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
434                ui.painter()
435                    .rect_filled(panel_rect, CORNER_RADIUS, theme.popover());
436                ui.painter().rect_stroke(
437                    panel_rect,
438                    CORNER_RADIUS,
439                    egui::Stroke::new(1.0, theme.border()),
440                    egui::StrokeKind::Inside,
441                );
442
443                ui.scope_builder(egui::UiBuilder::new().max_rect(panel_rect), |ui| {
444                    ui.vertical(|ui| {
445                        // Input - we need to handle this specially
446                        self.draw_input(ui, id, theme);
447
448                        // Separator
449                        self.draw_separator(ui, theme, self.width);
450
451                        // List
452                        let (exec, sel) = self.draw_list(ui, theme, filtered, should_close);
453                        executed = exec;
454                        self.selected = sel;
455                    });
456                });
457            });
458
459        executed
460    }
461
462    fn draw_input(&mut self, ui: &mut Ui, _id: egui::Id, theme: &Theme) {
463        let input_rect = Rect::from_min_size(ui.cursor().min, vec2(self.width, INPUT_HEIGHT));
464
465        // Search icon
466        let icon_x = input_rect.left() + INPUT_PADDING_X;
467        ui.painter().text(
468            Pos2::new(icon_x, input_rect.center().y),
469            Align2::LEFT_CENTER,
470            "🔍",
471            egui::FontId::proportional(ICON_SIZE),
472            theme.muted_foreground(),
473        );
474
475        // Text input - positioned after icon with gap
476        let text_left = icon_x + ICON_SIZE + INPUT_GAP;
477        let text_rect = Rect::from_min_max(
478            Pos2::new(text_left, input_rect.top()),
479            Pos2::new(input_rect.right() - INPUT_PADDING_X, input_rect.bottom()),
480        );
481
482        ui.scope_builder(egui::UiBuilder::new().max_rect(text_rect), |ui| {
483            ui.centered_and_justified(|ui| {
484                let response = ui.add(
485                    egui::TextEdit::singleline(&mut self.search)
486                        .frame(false)
487                        .hint_text(&self.placeholder)
488                        .font(egui::FontId::proportional(theme.typography.base))
489                        .vertical_align(egui::Align::Center),
490                );
491                response.request_focus();
492            });
493        });
494
495        ui.advance_cursor_after_rect(input_rect);
496    }
497
498    fn draw_separator(&self, ui: &mut Ui, theme: &Theme, width: f32) {
499        let y = ui.cursor().top();
500        ui.painter().hline(
501            ui.cursor().left()..=ui.cursor().left() + width,
502            y,
503            egui::Stroke::new(1.0, theme.border()),
504        );
505        ui.add_space(1.0);
506    }
507
508    fn draw_list(
509        &self,
510        ui: &mut Ui,
511        theme: &Theme,
512        items: &[&CommandItem],
513        should_close: &mut bool,
514    ) -> (Option<String>, usize) {
515        let mut executed = None;
516        let mut new_selected = self.selected;
517        let mut action_index = 0;
518
519        egui::ScrollArea::vertical()
520            .max_height(LIST_MAX_HEIGHT)
521            .show(ui, |ui| {
522                ui.add_space(LIST_PADDING);
523
524                ui.horizontal(|ui| {
525                    ui.add_space(LIST_PADDING);
526                    ui.vertical(|ui| {
527                        if items.is_empty() {
528                            self.draw_empty(ui, theme);
529                        } else {
530                            for item in items {
531                                match item {
532                                    CommandItem::Action {
533                                        id,
534                                        label,
535                                        icon,
536                                        shortcut,
537                                    } => {
538                                        let is_selected = action_index == self.selected;
539                                        let result = self.draw_item(
540                                            ui,
541                                            theme,
542                                            &ItemDrawParams {
543                                                id,
544                                                label,
545                                                icon: icon.as_deref(),
546                                                shortcut: shortcut.as_deref(),
547                                                is_selected,
548                                            },
549                                        );
550
551                                        if let Some(clicked_id) = result.0 {
552                                            executed = Some(clicked_id);
553                                            *should_close = true;
554                                        }
555                                        if result.1 {
556                                            new_selected = action_index;
557                                        }
558                                        action_index += 1;
559                                    }
560                                    CommandItem::Group { heading } => {
561                                        self.draw_group_heading(ui, theme, heading);
562                                    }
563                                    CommandItem::Separator => {
564                                        ui.add_space(LIST_PADDING);
565                                        self.draw_separator(
566                                            ui,
567                                            theme,
568                                            self.width - LIST_PADDING * 4.0,
569                                        );
570                                        ui.add_space(LIST_PADDING);
571                                    }
572                                }
573                            }
574                        }
575                    });
576                    ui.add_space(LIST_PADDING);
577                });
578
579                ui.add_space(LIST_PADDING);
580            });
581
582        (executed, new_selected)
583    }
584
585    fn draw_empty(&self, ui: &mut Ui, theme: &Theme) {
586        ui.add_space(24.0);
587        ui.centered_and_justified(|ui| {
588            ui.label(
589                egui::RichText::new("No results found.")
590                    .color(theme.muted_foreground())
591                    .size(theme.typography.base),
592            );
593        });
594        ui.add_space(24.0);
595    }
596
597    fn draw_group_heading(&self, ui: &mut Ui, theme: &Theme, heading: &str) {
598        ui.add_space(GROUP_PADDING_Y);
599        ui.horizontal(|ui| {
600            ui.add_space(GROUP_PADDING_X);
601            ui.label(
602                egui::RichText::new(heading)
603                    .color(theme.muted_foreground())
604                    .size(theme.typography.sm)
605                    .strong(),
606            );
607        });
608        ui.add_space(GROUP_PADDING_Y);
609    }
610
611    fn draw_item(
612        &self,
613        ui: &mut Ui,
614        theme: &Theme,
615        params: &ItemDrawParams,
616    ) -> (Option<String>, bool) {
617        let id = params.id;
618        let label = params.label;
619        let icon = params.icon;
620        let shortcut = params.shortcut;
621        let is_selected = params.is_selected;
622        let available_width = ui.available_width() - LIST_PADDING;
623        let (rect, response) =
624            ui.allocate_exact_size(vec2(available_width, ITEM_HEIGHT), Sense::click());
625
626        let hovered = response.hovered();
627        let clicked = response.clicked();
628
629        // Background
630        if is_selected || hovered {
631            ui.painter().rect_filled(rect, ITEM_RADIUS, theme.accent());
632        }
633
634        // Text color based on state
635        let text_color = if is_selected || hovered {
636            theme.accent_foreground()
637        } else {
638            theme.popover_foreground()
639        };
640
641        let icon_color = if is_selected || hovered {
642            theme.accent_foreground()
643        } else {
644            theme.muted_foreground()
645        };
646
647        let mut x = rect.left() + ITEM_PADDING_X;
648
649        // Icon
650        if let Some(icon_text) = icon {
651            ui.painter().text(
652                Pos2::new(x, rect.center().y),
653                Align2::LEFT_CENTER,
654                icon_text,
655                egui::FontId::proportional(ICON_SIZE),
656                icon_color,
657            );
658            x += ICON_SIZE + ITEM_GAP;
659        }
660
661        // Label
662        ui.painter().text(
663            Pos2::new(x, rect.center().y),
664            Align2::LEFT_CENTER,
665            label,
666            egui::FontId::proportional(theme.typography.base),
667            text_color,
668        );
669
670        // Shortcut using Kbd component
671        if let Some(shortcut_text) = shortcut {
672            // Position the Kbd at the right side
673            let kbd_rect = Rect::from_min_max(
674                Pos2::new(rect.right() - 100.0, rect.top()),
675                Pos2::new(rect.right() - ITEM_PADDING_X, rect.bottom()),
676            );
677            ui.scope_builder(egui::UiBuilder::new().max_rect(kbd_rect), |ui| {
678                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
679                    Kbd::new(shortcut_text).show(ui);
680                });
681            });
682        }
683
684        let executed = if clicked { Some(id.to_string()) } else { None };
685        (executed, hovered)
686    }
687}
688
689impl Default for Command {
690    fn default() -> Self {
691        Self::new()
692    }
693}