1use 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
11const 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#[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
43struct 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
52pub struct CommandItemBuilder<'a> {
54 items: &'a mut Vec<CommandItem>,
55 index: usize,
56}
57
58impl CommandItemBuilder<'_> {
59 #[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 #[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
85pub struct CommandBuilder<'a> {
87 items: &'a mut Vec<CommandItem>,
88}
89
90impl CommandBuilder<'_> {
91 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 pub fn group(&mut self, heading: &str) {
108 self.items.push(CommandItem::Group {
109 heading: heading.to_string(),
110 });
111 }
112
113 pub fn separator(&mut self) {
115 self.items.push(CommandItem::Separator);
116 }
117}
118
119pub struct CommandResponse {
121 pub response: egui::Response,
123 pub executed: Option<String>,
125 pub is_open: bool,
127 pub changed: bool,
129}
130
131pub 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 is_open: bool,
160 search: String,
161 selected: usize,
162 animation: Animation<f32>,
163}
164
165impl Command {
166 #[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 #[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 #[must_use]
192 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
193 self.placeholder = placeholder.into();
194 self
195 }
196
197 #[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 #[must_use]
207 pub const fn width(mut self, width: f32) -> Self {
208 self.width = width;
209 self
210 }
211
212 #[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 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 self.load_state(&ctx, id);
231 let was_open = self.is_open;
232
233 let mut items = Vec::new();
235 let mut builder = CommandBuilder { items: &mut items };
236 content(&mut builder);
237
238 self.handle_trigger(&ctx);
240
241 let mut executed = None;
242
243 if self.is_open {
244 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 let filtered = self.filter_items(&items);
253
254 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 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 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 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 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 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 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 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 self.draw_input(ui, id, theme);
454
455 self.draw_separator(ui, theme, self.width);
457
458 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 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 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 if is_selected || hovered {
638 ui.painter().rect_filled(rect, ITEM_RADIUS, theme.accent());
639 }
640
641 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 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 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 if let Some(shortcut_text) = shortcut {
679 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}