1use 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
12pub struct CommandPalette {
14 commands: Vec<Command>,
16 search_query: String,
18 selected_index: usize,
20 filtered: Vec<(usize, FuzzyMatch)>,
22 focus_search: bool,
23 context: Option<Context>,
24}
25
26impl CommandPalette {
27 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 pub fn add_command(&mut self, command: Command) {
41 self.edit_commands(|commands| commands.push(command));
42 }
43
44 pub fn remove_command(&mut self, id: &str) {
46 self.edit_commands(|commands| commands.retain(|c| c.id != id));
47 }
48
49 pub fn query(&self) -> &str {
51 &self.search_query
52 }
53
54 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 fn update_filter(&mut self) {
64 self.filtered.clear();
65
66 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 self.filtered.sort_by_key(|m| std::cmp::Reverse(m.1.score));
86 }
87
88 if self.selected_index >= self.filtered.len() {
90 self.selected_index = 0;
91 }
92 }
93
94 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 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 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 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 .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 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
243const ROW_HEIGHT_WITH_DESCRIPTION: f32 = 40.0;
245
246fn 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 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 let mut right = rect.max.x - theme.spacing.md;
307 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 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
357pub struct CommandPaletteHandler {
359 palette: CommandPalette,
360 open: bool,
361}
362
363impl CommandPaletteHandler {
364 pub fn new(commands: Vec<Command>) -> Self {
366 Self {
367 palette: CommandPalette::new(commands),
368 open: false,
369 }
370 }
371
372 pub fn open(&mut self) {
374 self.open = true;
375 self.palette.clear();
376 }
377
378 pub fn close(&mut self) {
380 self.open = false;
381 self.palette.clear();
382 }
383
384 pub fn is_open(&self) -> bool {
386 self.open
387 }
388
389 pub fn add_command(&mut self, command: Command) {
391 self.palette.add_command(command);
392 }
393
394 pub fn remove_command(&mut self, id: &str) {
396 self.palette.remove_command(id);
397 }
398
399 pub fn update(&mut self, ctx: &Context) -> Option<String> {
403 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 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 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 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}