1use egui::{Key, Ui};
38use egui_cha::ViewCtx;
39
40use crate::Theme;
41
42#[derive(Debug, Clone, Default)]
44pub struct CommandPaletteState {
45 pub is_open: bool,
47 pub query: String,
49 pub selected_index: usize,
51}
52
53impl CommandPaletteState {
54 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn open(&mut self) {
61 self.is_open = true;
62 self.query.clear();
63 self.selected_index = 0;
64 }
65
66 pub fn close(&mut self) {
68 self.is_open = false;
69 self.query.clear();
70 self.selected_index = 0;
71 }
72
73 pub fn toggle(&mut self) {
75 if self.is_open {
76 self.close();
77 } else {
78 self.open();
79 }
80 }
81}
82
83#[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 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 pub fn icon(mut self, icon: &'static str) -> Self {
107 self.icon = Some(icon);
108 self
109 }
110
111 pub fn description(mut self, desc: impl Into<String>) -> Self {
113 self.description = Some(desc.into());
114 self
115 }
116
117 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
119 self.shortcut = Some(shortcut.into());
120 self
121 }
122}
123
124#[derive(Clone)]
126pub enum CommandEntry<Msg> {
127 Item(CommandItem<Msg>),
129 Separator,
131 Group {
133 label: String,
134 items: Vec<CommandEntry<Msg>>,
135 },
136}
137
138pub 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 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 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
161 self.placeholder = text.into();
162 self
163 }
164
165 pub fn width(mut self, width: f32) -> Self {
167 self.width = width;
168 self
169 }
170
171 pub fn max_height(mut self, height: f32) -> Self {
173 self.max_height = height;
174 self
175 }
176
177 pub fn hide_icons(mut self) -> Self {
179 self.show_icons = false;
180 self
181 }
182
183 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 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 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 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 pub fn item_full(mut self, item: CommandItem<Msg>) -> Self {
229 self.entries.push(CommandEntry::Item(item));
230 self
231 }
232
233 pub fn separator(mut self) -> Self {
235 self.entries.push(CommandEntry::Separator);
236 self
237 }
238
239 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 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 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 let query_before = state.query.clone();
272
273 let row_height = theme.spacing_lg + theme.spacing_sm;
275 let header_height = 60.0; 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 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 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 response.request_focus();
311 ui.add_space(theme.spacing_xs);
312
313 ui.separator();
314
315 let flat_items = self.flatten_items(&state.query);
317 let item_count = flat_items.len();
318
319 if item_count > 0 && state.selected_index >= item_count {
321 state.selected_index = item_count - 1;
322 }
323
324 if state.query != query_before {
326 state.selected_index = 0;
327 }
328
329 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 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 if should_close {
373 state.close();
374 ctx.emit(on_close);
375 }
376
377 if let Some(msg) = selected_msg {
379 ctx.emit(msg);
380 }
381 }
382
383 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 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 let query_before = state.query.clone();
403
404 let row_height = theme.spacing_lg + theme.spacing_sm;
406 let header_height = 60.0; 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 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 let flat_items = self.flatten_items(&state.query);
438 let item_count = flat_items.len();
439
440 if item_count > 0 && state.selected_index >= item_count {
442 state.selected_index = item_count - 1;
443 }
444
445 if state.query != query_before {
447 state.selected_index = 0;
448 }
449
450 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 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 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_match(&label_lower, query)
541 }
542
543 #[cfg(not(feature = "fuzzy"))]
544 {
545 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 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 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 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 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 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 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 let mut x = rect.min.x + padding;
675 let center_y = rect.center().y;
676
677 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 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 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 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 if response.hovered() {
731 ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
732 }
733
734 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 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}