Skip to main content

egui_charts/ui_kit/command_palette/
palette.rs

1//! Command palette widget
2
3use super::command::Command;
4use super::fuzzy::{FuzzyMatch, fuzzy_match};
5use crate::icons::icons;
6use crate::localization as l10n;
7use crate::theme::ThemeExt;
8use crate::tokens::DESIGN_TOKENS;
9use egui::text::{LayoutJob, TextWrapping};
10use egui::{Color32, Context, Key, Rect, RichText, ScrollArea, Sense, TextEdit, Vec2};
11
12/// Command palette widget for quick command access
13pub struct CommandPalette {
14    /// All registered commands
15    commands: Vec<Command>,
16    /// Current search query
17    search_query: String,
18    /// Currently selected index in the filtered results
19    selected_index: usize,
20    /// Cached filtered results (command index, match)
21    filtered: Vec<(usize, FuzzyMatch)>,
22    focus_search: bool,
23    context: Option<Context>,
24}
25
26impl CommandPalette {
27    /// Create a new command palette with the given commands
28    pub fn new(commands: Vec<Command>) -> Self {
29        Self {
30            commands,
31            search_query: String::new(),
32            selected_index: 0,
33            filtered: Vec::new(),
34            focus_search: true,
35            context: None,
36        }
37    }
38
39    /// Add a command to the palette
40    pub fn add_command(&mut self, command: Command) {
41        self.edit_commands(|commands| commands.push(command));
42    }
43
44    /// Remove a command by ID
45    pub fn remove_command(&mut self, id: &str) {
46        self.edit_commands(|commands| commands.retain(|c| c.id != id));
47    }
48
49    /// Get the current search query
50    pub fn query(&self) -> &str {
51        &self.search_query
52    }
53
54    /// Clear the search query and reset selection
55    pub fn clear(&mut self) {
56        self.search_query.clear();
57        self.selected_index = 0;
58        self.filtered.clear();
59        self.focus_search = true;
60    }
61
62    /// Update filtered results based on current query
63    fn update_filter(&mut self) {
64        self.filtered.clear();
65
66        // An empty query lists nothing, so no command is highlighted before the user types.
67        if !self.search_query.is_empty() {
68            for (idx, cmd) in self.commands.iter().enumerate() {
69                let mut searchable = cmd.searchable_text();
70                if let Some(ctx) = &self.context {
71                    for text in std::iter::once(&cmd.label)
72                        .chain(cmd.description.iter())
73                        .chain(cmd.category.iter())
74                    {
75                        searchable.push(' ');
76                        searchable.push_str(&l10n::text(ctx, text));
77                    }
78                }
79                if let Some(m) = fuzzy_match(&self.search_query, &searchable) {
80                    self.filtered.push((idx, m));
81                }
82            }
83
84            // Sort by score (descending)
85            self.filtered.sort_by_key(|m| std::cmp::Reverse(m.1.score));
86        }
87
88        // Reset selection if out of bounds
89        if self.selected_index >= self.filtered.len() {
90            self.selected_index = 0;
91        }
92    }
93
94    /// Applies `change` to the commands and rebuilds the rows, keeping the highlight on the command
95    /// it was on while that command is still listed.
96    fn edit_commands(&mut self, change: impl FnOnce(&mut Vec<Command>)) {
97        let highlighted = self
98            .filtered
99            .get(self.selected_index)
100            .map(|&(i, _)| self.commands[i].id.clone());
101        change(&mut self.commands);
102        self.update_filter();
103        if let Some(row) = highlighted.and_then(|id| {
104            self.filtered
105                .iter()
106                .position(|&(i, _)| self.commands[i].id == id)
107        }) {
108            self.selected_index = row;
109        }
110    }
111
112    /// Show the command palette modal
113    ///
114    /// The list stays empty, under an instruction to type, until the query matches commands; the
115    /// best match is then highlighted. Returns the ID of the executed command if any. Keyboard
116    /// activation consumes its key so the host can open another dialog in the same frame.
117    pub fn show(&mut self, ctx: &Context, open: &mut bool) -> Option<String> {
118        if !*open {
119            return None;
120        }
121        self.context = Some(ctx.clone());
122        self.update_filter();
123
124        let mut executed_command = None;
125
126        let search_id = egui::Id::unique("command_palette_search");
127        let focus_search = std::mem::take(&mut self.focus_search);
128        let search_focused = ctx.memory(|m| m.has_focus(search_id));
129        // Handle keyboard navigation
130        let (move_up, move_down, execute, close) = ctx.input(|i| {
131            (
132                search_focused && i.key_pressed(Key::ArrowUp),
133                search_focused && i.key_pressed(Key::ArrowDown),
134                search_focused && i.key_pressed(Key::Enter),
135                i.key_pressed(Key::Escape),
136            )
137        });
138
139        if close {
140            *open = false;
141            self.clear();
142            return None;
143        }
144
145        if move_up && self.selected_index > 0 {
146            self.selected_index -= 1;
147        }
148        if move_down && self.selected_index + 1 < self.filtered.len() {
149            self.selected_index += 1;
150        }
151
152        if execute && !self.filtered.is_empty() {
153            ctx.input_mut(|i| i.consume_key(i.modifiers, Key::Enter));
154            let (cmd_idx, _) = self.filtered[self.selected_index];
155            executed_command = Some(self.commands[cmd_idx].id.clone());
156            *open = false;
157            self.clear();
158            return executed_command;
159        }
160
161        crate::ui_kit::modal::Modal::new("command_palette", "Search actions")
162            .dim_background(false)
163            .width(480.0)
164            .height(680.0)
165            .rounding(ctx.active_theme().radius.lg)
166            .show(ctx, open, |ui| {
167                ui.add_space(12.0);
168                ui.separator();
169                // The separators and item spacing add 14 px to the 41 px search row.
170                ui.allocate_ui_with_layout(
171                    Vec2::new(ui.available_width(), 27.0),
172                    egui::Layout::left_to_right(egui::Align::Center),
173                    |ui| {
174                        let theme = ui.active_theme();
175                        ui.add(icons::QUICK_SEARCH.as_image_tinted(
176                            Vec2::splat(18.0),
177                            theme.ui.panel_bg.lerp_to_gamma(theme.ui.text, 0.2),
178                        ));
179                        if focus_search {
180                            ui.memory_mut(|memory| memory.request_focus(search_id));
181                        }
182                        let response = ui
183                            .add(
184                                TextEdit::singleline(&mut self.search_query)
185                                    .id(search_id)
186                                    // Enter runs a match; without one the query stays focused.
187                                    .return_key(None)
188                                    .frame(egui::Frame::NONE)
189                                    .desired_width(ui.available_width())
190                                    .font(egui::FontId::proportional(14.0)),
191                            )
192                            .accessible_name(l10n::text(ui.ctx(), "Search actions"));
193                        // Sizing passes clear invisible widgets' focus while building the field.
194                        if focus_search {
195                            response.request_focus();
196                        }
197                        if response.changed() {
198                            self.update_filter();
199                        }
200                    },
201                );
202                ui.separator();
203                ui.add_space(12.0);
204                if self.filtered.is_empty() {
205                    let message = if self.search_query.is_empty() {
206                        "Type to search for actions"
207                    } else {
208                        "No matching actions"
209                    };
210                    ui.centered_and_justified(|ui| {
211                        ui.label(
212                            RichText::new(l10n::text(ui.ctx(), message))
213                                .size(ui.active_theme().text.heading),
214                        );
215                    });
216                } else {
217                    ScrollArea::vertical()
218                        .max_height(ui.available_height())
219                        .show(ui, |ui| {
220                            for (list_idx, (cmd_idx, _)) in self.filtered.iter().enumerate() {
221                                let cmd = &self.commands[*cmd_idx];
222                                if command_row(ui, cmd, list_idx == self.selected_index).clicked() {
223                                    executed_command = Some(cmd.id.clone());
224                                }
225                            }
226                        });
227                }
228            });
229
230        if executed_command.is_some() || !*open {
231            ctx.input_mut(|i| {
232                i.consume_key(i.modifiers, Key::Enter);
233                i.consume_key(i.modifiers, Key::Space);
234            });
235            *open = false;
236            self.clear();
237        }
238
239        executed_command
240    }
241}
242
243/// Height of a row that shows a description under its label.
244const ROW_HEIGHT_WITH_DESCRIPTION: f32 = 40.0;
245
246/// Paint one result row: icon slot, label with optional description, then category and
247/// shortcut at the right.
248fn command_row(ui: &mut egui::Ui, cmd: &Command, is_selected: bool) -> egui::Response {
249    let theme = ui.active_theme();
250    let height = if cmd.description.is_some() {
251        ROW_HEIGHT_WITH_DESCRIPTION
252    } else {
253        DESIGN_TOKENS.sizing.list_item_height
254    };
255    let (rect, response) =
256        ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::click());
257    let translated_label = l10n::text(ui.ctx(), &cmd.label);
258    response.widget_info(|| {
259        egui::WidgetInfo::selected(
260            egui::Role::Button,
261            ui.is_enabled(),
262            is_selected,
263            &translated_label,
264        )
265    });
266    if !ui.is_rect_visible(rect) {
267        return response;
268    }
269
270    let visuals = ui.visuals();
271    let (fill, text, icon_tint, secondary) = if is_selected {
272        let fg = visuals.selection.stroke.color;
273        (visuals.selection.bg_fill, fg, fg, fg)
274    } else {
275        let fill = if response.hovered() {
276            visuals.widgets.hovered.bg_fill
277        } else {
278            Color32::TRANSPARENT
279        };
280        let tint = visuals.widgets.noninteractive.fg_stroke.color;
281        (fill, visuals.text_color(), tint, visuals.weak_text_color())
282    };
283    let painter = ui.painter();
284    painter.rect_filled(
285        rect.shrink2(Vec2::new(theme.spacing.sm, 0.0)),
286        ui.active_theme().radius.sm,
287        fill,
288    );
289    crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.sm);
290
291    // Every row reserves the icon slot, so labels line up.
292    let icon_size = DESIGN_TOKENS.sizing.icon_sm;
293    let icon_x = rect.min.x + theme.spacing.md;
294    if let Some(icon) = cmd.icon {
295        let icon_rect = Rect::from_min_size(
296            egui::pos2(icon_x, rect.center().y - icon_size / 2.0),
297            Vec2::splat(icon_size),
298        );
299        icon.as_image_tinted(icon_rect.size(), icon_tint)
300            .paint_at(ui, icon_rect);
301    }
302
303    let text_x = icon_x + icon_size + theme.spacing.lg;
304    let small = egui::FontId::proportional(theme.text.small);
305    // Shortcut at the right edge, the category to its left.
306    let mut right = rect.max.x - theme.spacing.md;
307    // Leave at least half the text space for the command and its description.
308    let metadata_left = (text_x + right) / 2.0;
309    let shortcut = cmd
310        .shortcut
311        .map(|shortcut| ui.ctx().format_shortcut(&shortcut));
312    for label in [shortcut.as_ref(), cmd.category.as_ref()]
313        .into_iter()
314        .flatten()
315    {
316        if right <= metadata_left {
317            break;
318        }
319        let mut job = LayoutJob::simple_singleline(
320            l10n::text(ui.ctx(), label.clone()),
321            small.clone(),
322            secondary,
323        );
324        job.wrap = TextWrapping::truncate_at_width(right - metadata_left);
325        let galley = painter.layout_job(job);
326        let pos = egui::pos2(
327            right - galley.size().x,
328            rect.center().y - galley.size().y / 2.0,
329        );
330        painter.galley(pos, galley, secondary);
331        right = pos.x - theme.spacing.lg;
332    }
333
334    // A description goes under the label; both meet at the row's middle.
335    for (label, font, color, align) in [
336        (
337            Some(&cmd.label),
338            egui::FontId::proportional(14.0),
339            text,
340            if cmd.description.is_some() { 1.0 } else { 0.5 },
341        ),
342        (cmd.description.as_ref(), small, secondary, 0.0),
343    ] {
344        if let Some(label) = label {
345            let mut job =
346                LayoutJob::simple_singleline(l10n::text(ui.ctx(), label.clone()), font, color);
347            job.wrap = TextWrapping::truncate_at_width((right - text_x).max(0.0));
348            let galley = painter.layout_job(job);
349            let pos = egui::pos2(text_x, rect.center().y - galley.size().y * align);
350            painter.galley(pos, galley, color);
351        }
352    }
353
354    response
355}
356
357/// Handler for managing command palette state
358pub struct CommandPaletteHandler {
359    palette: CommandPalette,
360    open: bool,
361}
362
363impl CommandPaletteHandler {
364    /// Create a new command palette handler
365    pub fn new(commands: Vec<Command>) -> Self {
366        Self {
367            palette: CommandPalette::new(commands),
368            open: false,
369        }
370    }
371
372    /// Open the command palette
373    pub fn open(&mut self) {
374        self.open = true;
375        self.palette.clear();
376    }
377
378    /// Close the command palette
379    pub fn close(&mut self) {
380        self.open = false;
381        self.palette.clear();
382    }
383
384    /// Check if the palette is open
385    pub fn is_open(&self) -> bool {
386        self.open
387    }
388
389    /// Add a command
390    pub fn add_command(&mut self, command: Command) {
391        self.palette.add_command(command);
392    }
393
394    /// Remove a command by ID
395    pub fn remove_command(&mut self, id: &str) {
396        self.palette.remove_command(id);
397    }
398
399    /// Update the command palette each frame
400    ///
401    /// Handles Ctrl+K / Cmd+K to open, and returns the executed command ID if any
402    pub fn update(&mut self, ctx: &Context) -> Option<String> {
403        // Check for Ctrl+K / Cmd+K to open
404        let should_open = ctx.input(|i| {
405            let ctrl_or_cmd = if cfg!(target_os = "macos") {
406                i.modifiers.mac_cmd
407            } else {
408                i.modifiers.ctrl
409            };
410            ctrl_or_cmd && i.key_pressed(Key::K)
411        });
412
413        if should_open && !self.open {
414            self.open();
415        }
416
417        self.show(ctx)
418    }
419
420    /// Show the palette when a host dispatcher owns its opening shortcut.
421    pub fn show(&mut self, ctx: &Context) -> Option<String> {
422        self.palette.show(ctx, &mut self.open)
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use egui::{Event, Modifiers, RawInput};
430
431    fn frame(ctx: &Context, palette: &mut CommandPalette, key: Option<Key>) -> Option<String> {
432        let events = key
433            .map(|key| Event::Key {
434                key,
435                physical_key: None,
436                pressed: true,
437                repeat: false,
438                modifiers: Modifiers::NONE,
439            })
440            .into_iter()
441            .collect();
442        let (mut open, mut executed) = (true, None);
443        ctx.run_ui(
444            RawInput {
445                events,
446                ..Default::default()
447            },
448            |ui| executed = palette.show(ui.ctx(), &mut open),
449        )
450        .drop_without_applying_deltas();
451        executed
452    }
453
454    #[test]
455    fn empty_palette_has_an_undimmed_backdrop_and_a_spacious_search_row() {
456        for (_, theme) in crate::theme::presets() {
457            let ctx = Context::default();
458            crate::theme::apply_to_egui(&ctx, &theme);
459            let mut palette = CommandPalette::new(vec![]);
460            frame(&ctx, &mut palette, None);
461            let mut open = true;
462            let output = ctx.run_ui(
463                RawInput {
464                    screen_rect: Some(Rect::from_min_size(
465                        egui::Pos2::ZERO,
466                        Vec2::new(1440.0, 900.0),
467                    )),
468                    ..Default::default()
469                },
470                |ui| {
471                    palette.show(ui.ctx(), &mut open);
472                },
473            );
474            let rules: Vec<_> = output
475                .shapes
476                .iter()
477                .filter_map(|shape| match &shape.shape {
478                    egui::Shape::LineSegment { points, .. } if points[0].y == points[1].y => {
479                        Some(points[0].y)
480                    }
481                    _ => None,
482                })
483                .collect();
484            assert_eq!(rules.len(), 2, "{rules:?}");
485            assert_eq!(rules[1] - rules[0], 41.0);
486            for shape in &output.shapes {
487                match &shape.shape {
488                    egui::Shape::Rect(rect) if rect.rect.contains(egui::pos2(10.0, 10.0)) => {
489                        assert_eq!(
490                            rect.fill.a(),
491                            0,
492                            "the backdrop must leave the plot undimmed"
493                        );
494                    }
495                    egui::Shape::Text(text)
496                        if text.galley.text() == "Type to search for actions" =>
497                    {
498                        assert_eq!(
499                            text.galley.job.sections[0].format.font_id.size,
500                            theme.text.heading
501                        );
502                    }
503                    _ => {}
504                }
505            }
506            output.drop_without_applying_deltas();
507        }
508    }
509
510    #[test]
511    fn search_and_focused_rows_consume_their_activation_key() {
512        for (_, theme) in crate::theme::presets() {
513            for (focus_row, key) in [(false, Key::Enter), (true, Key::Enter), (true, Key::Space)] {
514                let ctx = Context::default();
515                crate::theme::apply_to_egui(&ctx, &theme);
516                let mut palette = CommandPalette::new(vec![
517                    Command::new("a", "Action one"),
518                    Command::new("b", "Action two"),
519                ]);
520                frame(&ctx, &mut palette, None);
521                assert_eq!(
522                    frame(&ctx, &mut palette, Some(key)),
523                    None,
524                    "nothing is listed or run before a query"
525                );
526                palette.search_query = "action".into();
527                frame(&ctx, &mut palette, None);
528                frame(&ctx, &mut palette, None);
529                if focus_row {
530                    let search = ctx.memory(|m| m.focused());
531                    frame(&ctx, &mut palette, Some(Key::Tab));
532                    assert_ne!(ctx.memory(|m| m.focused()), search);
533                    frame(&ctx, &mut palette, Some(Key::Tab));
534                }
535                assert_eq!(
536                    frame(&ctx, &mut palette, Some(key)).as_deref(),
537                    Some(if focus_row { "b" } else { "a" })
538                );
539                assert!(!ctx.input(|i| i.key_pressed(key)));
540            }
541        }
542    }
543
544    #[test]
545    fn row_text_stays_inside_its_columns_after_narrow_resizing() {
546        for (_, theme) in crate::theme::presets() {
547            let ctx = Context::default();
548            egui_extras::install_image_loaders(&ctx);
549            crate::theme::apply_to_egui(&ctx, &theme);
550            let commands = [
551                Command::new("study", "Add Moving Average Convergence Divergence")
552                    .description("Add this study to the current chart in a separate pane")
553                    .icon(&icons::RAY)
554                    .category("Studies")
555                    .shortcut(egui::KeyboardShortcut::new(Modifiers::CTRL, Key::I)),
556                Command::new("settings", "Open settings")
557                    .category("Chart appearance and scale settings"),
558            ];
559            for width in [480.0, 238.0, 278.0, 480.0] {
560                for selected in [false, true] {
561                    for command in &commands {
562                        let mut row = Rect::NOTHING;
563                        let output = ctx.run_ui(
564                            RawInput {
565                                screen_rect: Some(Rect::from_min_size(
566                                    egui::Pos2::ZERO,
567                                    Vec2::new(width, 200.0),
568                                )),
569                                ..Default::default()
570                            },
571                            |ui| row = command_row(ui, command, selected).rect,
572                        );
573                        let text: Vec<_> = output
574                            .shapes
575                            .iter()
576                            .filter_map(|shape| {
577                                if let egui::Shape::Text(text) = &shape.shape {
578                                    Some(Rect::from_min_size(text.pos, text.galley.size()))
579                                } else {
580                                    None
581                                }
582                            })
583                            .collect();
584                        output.drop_without_applying_deltas();
585                        assert_eq!(
586                            text.len(),
587                            if command.description.is_some() { 4 } else { 2 }
588                        );
589                        for (i, rect) in text.iter().enumerate() {
590                            assert!(
591                                row.contains_rect(*rect),
592                                "text outside row: {rect:?} {row:?}"
593                            );
594                            for other in &text[..i] {
595                                let overlap = rect.intersect(*other);
596                                assert!(
597                                    overlap.width() <= 0.0 || overlap.height() <= 0.0,
598                                    "text columns overlap at width {width}: {rect:?} {other:?}"
599                                );
600                            }
601                        }
602                    }
603                }
604            }
605        }
606    }
607
608    #[test]
609    fn changing_the_commands_keeps_the_rows_on_listed_commands() {
610        let palette = || {
611            CommandPalette::new(
612                ["a", "b", "c"]
613                    .into_iter()
614                    .map(|id| Command::new(id, id).category("Actions"))
615                    .collect(),
616            )
617        };
618
619        // A removal leaves no row past the end of the list.
620        let (ctx, mut p) = (Context::default(), palette());
621        p.search_query = "actions".into();
622        frame(&ctx, &mut p, None);
623        p.remove_command("b");
624        assert_eq!(frame(&ctx, &mut p, None), None);
625
626        // A removal above the highlighted command keeps the highlight on it.
627        let (ctx, mut p) = (Context::default(), palette());
628        p.search_query = "actions".into();
629        frame(&ctx, &mut p, None);
630        frame(&ctx, &mut p, Some(Key::ArrowDown));
631        p.remove_command("a");
632        assert_eq!(frame(&ctx, &mut p, Some(Key::Enter)).as_deref(), Some("b"));
633
634        for (query, clear_translation, expected) in [
635            ("zweite", false, Some("b")),
636            ("b", false, Some("b")),
637            ("zweite", true, None),
638        ] {
639            let (ctx, mut p) = (Context::default(), palette());
640            l10n::set_translator(&ctx, |english| (english == "b").then(|| "zweite".into()));
641            p.search_query = query.into();
642            frame(&ctx, &mut p, None);
643            if clear_translation {
644                l10n::clear_translator(&ctx);
645            }
646            assert_eq!(
647                frame(&ctx, &mut p, Some(Key::Enter)).as_deref(),
648                expected,
649                "{query}, clear translation: {clear_translation}"
650            );
651        }
652
653        let (ctx, mut p) = (Context::default(), palette());
654        l10n::set_translator(&ctx, |english| Some(format!("Aktion {english}")));
655        p.search_query = "Aktion".into();
656        frame(&ctx, &mut p, None);
657        frame(&ctx, &mut p, None);
658        frame(&ctx, &mut p, Some(Key::ArrowDown));
659        p.remove_command("c");
660        assert_eq!(frame(&ctx, &mut p, Some(Key::Enter)).as_deref(), Some("b"));
661    }
662}