Skip to main content

egui_shadcn/
command.rs

1//! Command component - command palette with search and grouped actions.
2//!
3//! Make sure the Lucide font is loaded if you want the icon glyphs to render properly.
4
5use crate::dialog::{DialogProps, dialog};
6use crate::theme::Theme;
7use crate::tokens::DEFAULT_RADIUS;
8use egui::{
9    Align, Color32, CornerRadius, Frame, Id, Key, Layout, Margin, Response, RichText, ScrollArea,
10    Sense, Stroke, Ui, Vec2, WidgetText, vec2,
11};
12use lucide_icons::Icon;
13use std::fmt::{self, Debug};
14use std::hash::Hash;
15
16// =============================================================================
17// Props and context
18// =============================================================================
19
20pub struct OnCommandSelect<'a>(pub Box<dyn FnMut() + 'a>);
21
22impl<'a> Debug for OnCommandSelect<'a> {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.debug_struct("OnCommandSelect").finish()
25    }
26}
27
28#[derive(Debug)]
29pub struct CommandProps {
30    pub id_source: Id,
31    pub min_width: Option<f32>,
32    pub show_border: bool,
33    pub show_shadow: bool,
34}
35
36impl CommandProps {
37    pub fn new(id_source: Id) -> Self {
38        Self {
39            id_source,
40            min_width: None,
41            show_border: true,
42            show_shadow: true,
43        }
44    }
45
46    pub fn min_width(mut self, width: f32) -> Self {
47        self.min_width = Some(width);
48        self
49    }
50
51    pub fn show_border(mut self, show: bool) -> Self {
52        self.show_border = show;
53        self
54    }
55
56    pub fn show_shadow(mut self, show: bool) -> Self {
57        self.show_shadow = show;
58        self
59    }
60}
61
62#[derive(Clone, Debug)]
63pub struct CommandInputProps {
64    pub placeholder: String,
65}
66
67impl CommandInputProps {
68    pub fn new(placeholder: impl Into<String>) -> Self {
69        Self {
70            placeholder: placeholder.into(),
71        }
72    }
73}
74
75#[derive(Clone, Debug)]
76pub struct CommandListProps {
77    pub max_height: f32,
78}
79
80impl Default for CommandListProps {
81    fn default() -> Self {
82        Self { max_height: 300.0 }
83    }
84}
85
86#[derive(Clone, Debug)]
87pub struct CommandGroupProps {
88    pub heading: Option<String>,
89}
90
91impl CommandGroupProps {
92    pub fn new(heading: impl Into<String>) -> Self {
93        Self {
94            heading: Some(heading.into()),
95        }
96    }
97}
98
99#[derive(Debug)]
100pub struct CommandItemProps<'a, IdSource> {
101    pub id_source: IdSource,
102    pub label: WidgetText,
103    pub keywords: Vec<String>,
104    pub icon: Option<Icon>,
105    pub shortcut: Option<String>,
106    pub disabled: bool,
107    pub on_select: Option<OnCommandSelect<'a>>,
108}
109
110impl<'a, IdSource: Hash> CommandItemProps<'a, IdSource> {
111    pub fn new(id_source: IdSource, label: impl Into<WidgetText>) -> Self {
112        Self {
113            id_source,
114            label: label.into(),
115            keywords: Vec::new(),
116            icon: None,
117            shortcut: None,
118            disabled: false,
119            on_select: None,
120        }
121    }
122
123    pub fn keywords(mut self, keywords: &[&str]) -> Self {
124        self.keywords = keywords.iter().map(|k| k.to_string()).collect();
125        self
126    }
127
128    pub fn icon(mut self, icon: Icon) -> Self {
129        self.icon = Some(icon);
130        self
131    }
132
133    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
134        self.shortcut = Some(shortcut.into());
135        self
136    }
137
138    pub fn disabled(mut self, disabled: bool) -> Self {
139        self.disabled = disabled;
140        self
141    }
142
143    pub fn on_select(mut self, callback: impl FnMut() + 'a) -> Self {
144        self.on_select = Some(OnCommandSelect(Box::new(callback)));
145        self
146    }
147}
148
149#[derive(Clone, Debug, Default)]
150struct CommandState {
151    query: String,
152    selected_index: usize,
153    selectable_count: usize,
154}
155
156#[derive(Clone, Debug, Default)]
157struct CommandRenderState {
158    visible_count: usize,
159    selectable_count: usize,
160    empty_text: Option<String>,
161    enter_pressed: bool,
162}
163
164#[derive(Clone, Copy, Debug)]
165struct CommandTokens {
166    bg: Color32,
167    text: Color32,
168    muted: Color32,
169    border: Color32,
170    accent: Color32,
171    accent_text: Color32,
172}
173
174#[derive(Clone, Copy, Debug)]
175struct CommandMetrics {
176    input_height: f32,
177    item_height: f32,
178    item_padding: Margin,
179    group_padding: Margin,
180    separator_margin: f32,
181}
182
183fn command_tokens(theme: &Theme) -> CommandTokens {
184    CommandTokens {
185        bg: theme.palette.popover,
186        text: theme.palette.popover_foreground,
187        muted: theme.palette.muted_foreground,
188        border: theme.palette.border,
189        accent: theme.palette.accent,
190        accent_text: theme.palette.accent_foreground,
191    }
192}
193
194fn command_metrics() -> CommandMetrics {
195    CommandMetrics {
196        input_height: 48.0,
197        item_height: 36.0,
198        item_padding: Margin::symmetric(8, 6),
199        group_padding: Margin::symmetric(8, 4),
200        separator_margin: 6.0,
201    }
202}
203
204pub struct CommandContext<'a> {
205    state: &'a mut CommandState,
206    render: &'a mut CommandRenderState,
207    tokens: CommandTokens,
208    metrics: CommandMetrics,
209}
210
211// =============================================================================
212// Root
213// =============================================================================
214
215pub fn command<R>(
216    ui: &mut Ui,
217    theme: &Theme,
218    props: CommandProps,
219    add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
220) -> R {
221    let state_id = ui.make_persistent_id(props.id_source);
222    let mut state = ui
223        .ctx()
224        .data(|data| data.get_temp::<CommandState>(state_id))
225        .unwrap_or_default();
226
227    let (up, down, enter) = ui.input(|i| {
228        (
229            i.key_pressed(Key::ArrowUp),
230            i.key_pressed(Key::ArrowDown),
231            i.key_pressed(Key::Enter),
232        )
233    });
234
235    if state.selectable_count > 0 {
236        if down {
237            state.selected_index = (state.selected_index + 1) % state.selectable_count;
238        } else if up {
239            state.selected_index = if state.selected_index == 0 {
240                state.selectable_count - 1
241            } else {
242                state.selected_index - 1
243            };
244        }
245    } else {
246        state.selected_index = 0;
247    }
248
249    let tokens = command_tokens(theme);
250    let metrics = command_metrics();
251    let rounding = CornerRadius::same(DEFAULT_RADIUS.r3 as u8);
252    let shadow = if props.show_shadow {
253        ui.style().visuals.popup_shadow
254    } else {
255        egui::Shadow::NONE
256    };
257    let stroke = if props.show_border {
258        Stroke::new(1.0, tokens.border)
259    } else {
260        Stroke::NONE
261    };
262
263    let mut render = CommandRenderState {
264        enter_pressed: enter,
265        ..Default::default()
266    };
267
268    let inner = Frame::NONE
269        .fill(tokens.bg)
270        .stroke(stroke)
271        .corner_radius(rounding)
272        .shadow(shadow)
273        .show(ui, |command_ui| {
274            if let Some(min_width) = props.min_width {
275                command_ui.set_min_width(min_width);
276            }
277            command_ui.visuals_mut().override_text_color = Some(tokens.text);
278            command_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
279            let mut ctx = CommandContext {
280                state: &mut state,
281                render: &mut render,
282                tokens,
283                metrics,
284            };
285            add_contents(command_ui, &mut ctx)
286        })
287        .inner;
288
289    state.selectable_count = render.selectable_count;
290    if state.selectable_count == 0 {
291        state.selected_index = 0;
292    } else if state.selected_index >= state.selectable_count {
293        state.selected_index = state.selectable_count - 1;
294    }
295
296    ui.ctx().data_mut(|data| data.insert_temp(state_id, state));
297
298    inner
299}
300
301// =============================================================================
302// Input
303// =============================================================================
304
305pub fn command_input(ui: &mut Ui, ctx: &mut CommandContext, props: CommandInputProps) -> Response {
306    let desired = vec2(ui.available_width(), ctx.metrics.input_height);
307    let inner = ui.allocate_ui_with_layout(desired, Layout::left_to_right(Align::Center), |row| {
308        row.spacing_mut().item_spacing = vec2(8.0, 0.0);
309        row.visuals_mut().override_text_color = Some(ctx.tokens.muted);
310
311        let icon_text = RichText::new(Icon::Search.unicode()).size(14.0);
312        row.label(icon_text);
313        row.visuals_mut().override_text_color = Some(ctx.tokens.text);
314
315        let mut edit = egui::TextEdit::singleline(&mut ctx.state.query)
316            .hint_text(props.placeholder)
317            .frame(false);
318        edit = edit.desired_width(f32::INFINITY);
319        let response = row.add(edit);
320
321        if response.changed() {
322            ctx.state.selected_index = 0;
323        }
324
325        response
326    });
327
328    let response = inner.inner;
329    let rect = inner.response.rect;
330    let stroke = Stroke::new(1.0, ctx.tokens.border);
331    ui.painter()
332        .line_segment([rect.left_bottom(), rect.right_bottom()], stroke);
333
334    response
335}
336
337// =============================================================================
338// List and groups
339// =============================================================================
340
341pub fn command_list<R>(
342    ui: &mut Ui,
343    ctx: &mut CommandContext,
344    props: CommandListProps,
345    add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
346) -> R {
347    ctx.render.visible_count = 0;
348    ctx.render.selectable_count = 0;
349    ctx.render.empty_text = None;
350
351    ScrollArea::vertical()
352        .max_height(props.max_height)
353        .show(ui, |list_ui| {
354            list_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
355            let inner = add_contents(list_ui, ctx);
356            if ctx.render.visible_count == 0
357                && let Some(text) = ctx.render.empty_text.take()
358            {
359                list_ui.add_space(8.0);
360                list_ui.with_layout(Layout::top_down(Align::Center), |ui| {
361                    ui.label(RichText::new(text).color(ctx.tokens.muted).size(12.0));
362                });
363                list_ui.add_space(8.0);
364            }
365            inner
366        })
367        .inner
368}
369
370pub fn command_empty(ui: &mut Ui, ctx: &mut CommandContext, text: &str) -> Response {
371    ctx.render.empty_text = Some(text.to_string());
372    ui.allocate_response(Vec2::ZERO, Sense::hover())
373}
374
375pub fn command_group<R>(
376    ui: &mut Ui,
377    ctx: &mut CommandContext,
378    props: CommandGroupProps,
379    add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
380) -> R {
381    Frame::NONE
382        .inner_margin(ctx.metrics.group_padding)
383        .show(ui, |group_ui| {
384            group_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
385            if let Some(heading) = props.heading {
386                group_ui.label(
387                    RichText::new(heading)
388                        .size(11.0)
389                        .color(ctx.tokens.muted)
390                        .strong(),
391                );
392            }
393            add_contents(group_ui, ctx)
394        })
395        .inner
396}
397
398pub fn command_separator(ui: &mut Ui, ctx: &mut CommandContext) -> Response {
399    ui.add_space(ctx.metrics.separator_margin);
400    let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
401    ui.painter().line_segment(
402        [rect.left_center(), rect.right_center()],
403        Stroke::new(1.0, ctx.tokens.border),
404    );
405    ui.add_space(ctx.metrics.separator_margin);
406    response
407}
408
409// =============================================================================
410// Items
411// =============================================================================
412
413pub fn command_item<'a, IdSource: Hash>(
414    ui: &mut Ui,
415    ctx: &mut CommandContext,
416    mut props: CommandItemProps<'a, IdSource>,
417) -> Option<Response> {
418    let query = ctx.state.query.trim();
419    let label_text = props.label.text().to_string();
420    if !command_matches(query, &label_text, &props.keywords) {
421        return None;
422    }
423
424    ctx.render.visible_count += 1;
425    let selectable_index = if props.disabled {
426        None
427    } else {
428        let index = ctx.render.selectable_count;
429        ctx.render.selectable_count += 1;
430        Some(index)
431    };
432
433    let is_selected = selectable_index == Some(ctx.state.selected_index);
434    let rounding = CornerRadius::same(4);
435    let desired = vec2(ui.available_width(), ctx.metrics.item_height);
436    let item_id = ui.make_persistent_id(&props.id_source);
437
438    let inner = ui.allocate_ui_with_layout(desired, Layout::left_to_right(Align::Center), |row| {
439        row.spacing_mut().item_spacing = vec2(8.0, 0.0);
440        let rect = row.max_rect();
441        let response = row.interact(rect, item_id, Sense::click());
442        let hovered = response.hovered();
443
444        let fill = if is_selected {
445            ctx.tokens.accent
446        } else if hovered {
447            ctx.tokens.accent.gamma_multiply(0.35)
448        } else {
449            Color32::TRANSPARENT
450        };
451
452        if fill != Color32::TRANSPARENT {
453            row.painter().rect_filled(rect, rounding, fill);
454        }
455
456        Frame::NONE
457            .inner_margin(ctx.metrics.item_padding)
458            .show(row, |content| {
459                let mut text_color = ctx.tokens.text;
460                if props.disabled {
461                    text_color = ctx.tokens.muted;
462                } else if is_selected {
463                    text_color = ctx.tokens.accent_text;
464                }
465
466                if let Some(icon) = props.icon {
467                    content.label(RichText::new(icon.unicode()).size(16.0).color(text_color));
468                }
469
470                content.label(RichText::new(label_text.as_str()).color(text_color));
471
472                if let Some(shortcut) = props.shortcut.take() {
473                    content.with_layout(Layout::right_to_left(Align::Center), |ui| {
474                        command_shortcut(ui, ctx, &shortcut);
475                    });
476                }
477            });
478
479        response
480    });
481
482    let response = inner.inner;
483    if let Some(index) = selectable_index {
484        if response.hovered() && !props.disabled {
485            ctx.state.selected_index = index;
486        }
487        if (response.clicked() || ctx.render.enter_pressed && is_selected)
488            && !props.disabled
489            && let Some(callback) = props.on_select.as_mut()
490        {
491            (callback.0)();
492        }
493    }
494
495    Some(response)
496}
497
498pub fn command_shortcut(ui: &mut Ui, ctx: &CommandContext, text: &str) -> Response {
499    ui.label(
500        RichText::new(text)
501            .size(10.0)
502            .color(ctx.tokens.muted)
503            .monospace(),
504    )
505}
506
507fn command_matches(query: &str, label: &str, keywords: &[String]) -> bool {
508    if query.is_empty() {
509        return true;
510    }
511    if fuzzy_match(query, label) {
512        return true;
513    }
514    keywords.iter().any(|kw| fuzzy_match(query, kw))
515}
516
517fn fuzzy_match(query: &str, text: &str) -> bool {
518    let query_lower = query.to_lowercase();
519    let mut q = query_lower.chars();
520    let mut q_next = q.next();
521    if q_next.is_none() {
522        return true;
523    }
524    for ch in text.to_lowercase().chars() {
525        if Some(ch) == q_next {
526            q_next = q.next();
527            if q_next.is_none() {
528                return true;
529            }
530        }
531    }
532    false
533}
534
535// =============================================================================
536// Command dialog
537// =============================================================================
538
539#[derive(Debug)]
540pub struct CommandDialogProps<'a> {
541    pub id_source: Id,
542    pub open: &'a mut bool,
543    pub title: String,
544    pub description: String,
545    pub show_close_button: bool,
546}
547
548impl<'a> CommandDialogProps<'a> {
549    pub fn new(id_source: Id, open: &'a mut bool) -> Self {
550        Self {
551            id_source,
552            open,
553            title: "Command Palette".to_string(),
554            description: "Search for a command to run...".to_string(),
555            show_close_button: true,
556        }
557    }
558
559    pub fn title(mut self, title: impl Into<String>) -> Self {
560        self.title = title.into();
561        self
562    }
563
564    pub fn description(mut self, description: impl Into<String>) -> Self {
565        self.description = description.into();
566        self
567    }
568
569    pub fn show_close_button(mut self, show: bool) -> Self {
570        self.show_close_button = show;
571        self
572    }
573}
574
575pub fn command_dialog<R>(
576    ui: &mut Ui,
577    theme: &Theme,
578    props: CommandDialogProps<'_>,
579    add_contents: impl FnOnce(&mut Ui, &mut CommandContext) -> R,
580) -> Option<R> {
581    let dialog_props = DialogProps::new(props.id_source, props.open)
582        .title(props.title)
583        .description(props.description)
584        .scrollable(false)
585        .show_close_button(props.show_close_button)
586        .size(vec2(520.0, 0.0));
587
588    dialog(ui, theme, dialog_props, |dialog_ui| {
589        command(
590            dialog_ui,
591            theme,
592            CommandProps::new(props.id_source.with("command"))
593                .show_border(false)
594                .show_shadow(false),
595            add_contents,
596        )
597    })
598}