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 = 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#[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 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 self.draw_input(ui, id, theme);
447
448 self.draw_separator(ui, theme, self.width);
450
451 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 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 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 if is_selected || hovered {
631 ui.painter().rect_filled(rect, ITEM_RADIUS, theme.accent());
632 }
633
634 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 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 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 if let Some(shortcut_text) = shortcut {
672 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}