Skip to main content

iced_shadcn/
command.rs

1use std::borrow::Cow;
2
3use iced::border::Border;
4use iced::widget::Id;
5use iced::widget::{column, container, row, text};
6use iced::{Alignment, Background, Element, Length, Shadow};
7
8use crate::button::{ButtonProps, ButtonSize, ButtonVariant, button_content};
9use crate::dialog::{DialogProps, dialog};
10use crate::input::{InputProps, InputSize, InputVariant, input};
11use crate::kbd::{KbdProps, KbdSize, kbd, kbd_shortcut};
12use crate::scroll_area::{
13    ScrollAreaProps, ScrollAreaScrollbarVisibility, ScrollAreaScrollbars, scroll_area,
14};
15use crate::separator::{SeparatorProps, SeparatorSize, separator};
16use crate::spinner::{Spinner, SpinnerSize, spinner};
17use crate::theme::Theme;
18
19/// Filter callback used by the command palette when `should_filter` is enabled.
20pub type CommandFilter = fn(value: &str, search: &str, keywords: &[String]) -> f32;
21
22#[derive(Clone, Copy, Debug)]
23struct CommandTokens {
24    bg: iced::Color,
25    text: iced::Color,
26    muted: iced::Color,
27}
28
29fn command_tokens(theme: &Theme) -> CommandTokens {
30    CommandTokens {
31        bg: theme.palette.popover,
32        text: theme.palette.popover_foreground,
33        muted: theme.palette.muted_foreground,
34    }
35}
36
37/// Root `Command` configuration.
38pub struct CommandProps<'a, Message> {
39    pub id_source: Id,
40    pub query: &'a str,
41    pub on_query_change: Option<Box<dyn Fn(String) -> Message + 'a>>,
42    pub input: CommandInputProps<'a>,
43    pub list: CommandListProps<'a, Message>,
44    pub empty: Option<CommandEmptyProps<'a>>,
45    pub min_width: Option<f32>,
46    pub show_border: bool,
47    pub show_shadow: bool,
48    pub show_item_border: bool,
49    pub should_filter: bool,
50    pub filter: CommandFilter,
51}
52
53impl<'a, Message> CommandProps<'a, Message> {
54    pub fn new(id_source: Id, query: &'a str, list: CommandListProps<'a, Message>) -> Self {
55        Self {
56            id_source,
57            query,
58            on_query_change: None,
59            input: CommandInputProps::default(),
60            list,
61            empty: None,
62            min_width: None,
63            show_border: true,
64            show_shadow: true,
65            show_item_border: false,
66            should_filter: true,
67            filter: default_command_filter,
68        }
69    }
70
71    pub fn on_query_change(mut self, callback: impl Fn(String) -> Message + 'a) -> Self {
72        self.on_query_change = Some(Box::new(callback));
73        self
74    }
75
76    pub fn input(mut self, input: CommandInputProps<'a>) -> Self {
77        self.input = input;
78        self
79    }
80
81    pub fn empty(mut self, empty: CommandEmptyProps<'a>) -> Self {
82        self.empty = Some(empty);
83        self
84    }
85
86    pub fn min_width(mut self, width: f32) -> Self {
87        self.min_width = Some(width.max(1.0));
88        self
89    }
90
91    pub fn show_border(mut self, show: bool) -> Self {
92        self.show_border = show;
93        self
94    }
95
96    pub fn show_container_border(mut self, show: bool) -> Self {
97        self.show_border = show;
98        self
99    }
100
101    pub fn show_shadow(mut self, show: bool) -> Self {
102        self.show_shadow = show;
103        self
104    }
105
106    pub fn show_item_border(mut self, show: bool) -> Self {
107        self.show_item_border = show;
108        self
109    }
110
111    pub fn should_filter(mut self, should_filter: bool) -> Self {
112        self.should_filter = should_filter;
113        self
114    }
115
116    pub fn filter(mut self, filter: CommandFilter) -> Self {
117        self.filter = filter;
118        self
119    }
120}
121
122#[derive(Clone, Debug)]
123pub struct CommandInputProps<'a> {
124    pub placeholder: &'a str,
125}
126
127impl<'a> CommandInputProps<'a> {
128    pub fn new(placeholder: &'a str) -> Self {
129        Self { placeholder }
130    }
131}
132
133impl Default for CommandInputProps<'_> {
134    fn default() -> Self {
135        Self::new("Type a command or search...")
136    }
137}
138
139pub struct CommandListProps<'a, Message> {
140    pub max_height: f32,
141    pub entries: Vec<CommandListEntry<'a, Message>>,
142}
143
144impl<'a, Message> CommandListProps<'a, Message> {
145    pub fn new(entries: Vec<CommandListEntry<'a, Message>>) -> Self {
146        Self {
147            max_height: crate::theme::ThemeStyles::default().command.list_max_height,
148            entries,
149        }
150    }
151
152    pub fn max_height(mut self, max_height: f32) -> Self {
153        self.max_height = max_height.max(1.0);
154        self
155    }
156}
157
158pub enum CommandListEntry<'a, Message> {
159    Group(CommandGroupProps<'a, Message>),
160    Item(CommandItemProps<'a, Message>),
161    LinkItem(CommandLinkItemProps<'a, Message>),
162    Separator(CommandSeparatorProps),
163    Loading(CommandLoadingProps<'a>),
164}
165
166pub struct CommandGroupProps<'a, Message> {
167    pub heading: Option<Cow<'a, str>>,
168    pub value: Option<Cow<'a, str>>,
169    pub force_mount: bool,
170    pub entries: Vec<CommandListEntry<'a, Message>>,
171}
172
173impl<'a, Message> CommandGroupProps<'a, Message> {
174    pub fn new(entries: Vec<CommandListEntry<'a, Message>>) -> Self {
175        Self {
176            heading: None,
177            value: None,
178            force_mount: false,
179            entries,
180        }
181    }
182
183    pub fn heading(mut self, heading: impl Into<Cow<'a, str>>) -> Self {
184        self.heading = Some(heading.into());
185        self
186    }
187
188    pub fn value(mut self, value: impl Into<Cow<'a, str>>) -> Self {
189        self.value = Some(value.into());
190        self
191    }
192
193    pub fn force_mount(mut self, force_mount: bool) -> Self {
194        self.force_mount = force_mount;
195        self
196    }
197}
198
199#[derive(Clone, Debug)]
200pub struct CommandItemProps<'a, Message> {
201    pub value: Cow<'a, str>,
202    pub label: Cow<'a, str>,
203    pub keywords: Vec<String>,
204    pub icon: Option<Cow<'a, str>>,
205    pub shortcut: Option<Cow<'a, str>>,
206    pub disabled: bool,
207    pub force_mount: bool,
208    pub on_select: Option<Message>,
209}
210
211impl<'a, Message> CommandItemProps<'a, Message> {
212    pub fn new(value: impl Into<Cow<'a, str>>, label: impl Into<Cow<'a, str>>) -> Self {
213        Self {
214            value: value.into(),
215            label: label.into(),
216            keywords: Vec::new(),
217            icon: None,
218            shortcut: None,
219            disabled: false,
220            force_mount: false,
221            on_select: None,
222        }
223    }
224
225    pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
226        self.keywords = keywords.into_iter().map(Into::into).collect();
227        self
228    }
229
230    pub fn icon(mut self, icon: impl Into<Cow<'a, str>>) -> Self {
231        self.icon = Some(icon.into());
232        self
233    }
234
235    pub fn shortcut(mut self, shortcut: impl Into<Cow<'a, str>>) -> Self {
236        self.shortcut = Some(shortcut.into());
237        self
238    }
239
240    pub fn disabled(mut self, disabled: bool) -> Self {
241        self.disabled = disabled;
242        self
243    }
244
245    pub fn force_mount(mut self, force_mount: bool) -> Self {
246        self.force_mount = force_mount;
247        self
248    }
249
250    pub fn on_select(mut self, on_select: Message) -> Self {
251        self.on_select = Some(on_select);
252        self
253    }
254}
255
256#[derive(Clone, Debug)]
257pub struct CommandLinkItemProps<'a, Message> {
258    pub value: Cow<'a, str>,
259    pub label: Cow<'a, str>,
260    pub href: Cow<'a, str>,
261    pub keywords: Vec<String>,
262    pub icon: Option<Cow<'a, str>>,
263    pub shortcut: Option<Cow<'a, str>>,
264    pub disabled: bool,
265    pub force_mount: bool,
266    pub on_select: Option<Message>,
267}
268
269impl<'a, Message> CommandLinkItemProps<'a, Message> {
270    pub fn new(
271        value: impl Into<Cow<'a, str>>,
272        label: impl Into<Cow<'a, str>>,
273        href: impl Into<Cow<'a, str>>,
274    ) -> Self {
275        Self {
276            value: value.into(),
277            label: label.into(),
278            href: href.into(),
279            keywords: Vec::new(),
280            icon: None,
281            shortcut: None,
282            disabled: false,
283            force_mount: false,
284            on_select: None,
285        }
286    }
287
288    pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
289        self.keywords = keywords.into_iter().map(Into::into).collect();
290        self
291    }
292
293    pub fn icon(mut self, icon: impl Into<Cow<'a, str>>) -> Self {
294        self.icon = Some(icon.into());
295        self
296    }
297
298    pub fn shortcut(mut self, shortcut: impl Into<Cow<'a, str>>) -> Self {
299        self.shortcut = Some(shortcut.into());
300        self
301    }
302
303    pub fn disabled(mut self, disabled: bool) -> Self {
304        self.disabled = disabled;
305        self
306    }
307
308    pub fn force_mount(mut self, force_mount: bool) -> Self {
309        self.force_mount = force_mount;
310        self
311    }
312
313    pub fn on_select(mut self, on_select: Message) -> Self {
314        self.on_select = Some(on_select);
315        self
316    }
317}
318
319#[derive(Clone, Copy, Debug, Default)]
320pub struct CommandSeparatorProps {
321    pub force_mount: bool,
322}
323
324impl CommandSeparatorProps {
325    pub fn force_mount(mut self, force_mount: bool) -> Self {
326        self.force_mount = force_mount;
327        self
328    }
329}
330
331#[derive(Clone, Debug)]
332pub struct CommandLoadingProps<'a> {
333    pub label: Cow<'a, str>,
334    pub progress: Option<f32>,
335}
336
337impl<'a> CommandLoadingProps<'a> {
338    pub fn new(label: impl Into<Cow<'a, str>>) -> Self {
339        Self {
340            label: label.into(),
341            progress: None,
342        }
343    }
344
345    pub fn progress(mut self, progress: f32) -> Self {
346        self.progress = Some(progress.clamp(0.0, 1.0));
347        self
348    }
349}
350
351#[derive(Clone, Debug)]
352pub struct CommandEmptyProps<'a> {
353    pub text: Cow<'a, str>,
354    pub force_mount: bool,
355}
356
357impl<'a> CommandEmptyProps<'a> {
358    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
359        Self {
360            text: text.into(),
361            force_mount: false,
362        }
363    }
364
365    pub fn force_mount(mut self, force_mount: bool) -> Self {
366        self.force_mount = force_mount;
367        self
368    }
369}
370
371pub fn command<'a, Message: Clone + 'a>(
372    props: CommandProps<'a, Message>,
373    theme: &'a Theme,
374) -> Element<'a, Message> {
375    let CommandProps {
376        id_source,
377        query,
378        on_query_change,
379        input,
380        list,
381        empty,
382        min_width,
383        show_border,
384        show_shadow,
385        show_item_border,
386        should_filter,
387        filter,
388    } = props;
389
390    let tokens = command_tokens(theme);
391    let min_width = min_width.unwrap_or(theme.styles.command.min_width);
392    let radius_md = theme.radius.md;
393    let menu_shadow = theme.styles.menu.shadow;
394    let container_border_color = theme.palette.border;
395
396    let input = command_input(query, on_query_change, input, tokens, theme);
397
398    let rendered = render_entries(
399        list.entries,
400        query,
401        should_filter,
402        show_item_border,
403        filter,
404        tokens,
405        theme,
406    );
407    let visible_item_count = rendered.visible_items;
408    let list_column = column(rendered.elements)
409        .spacing(theme.styles.command.list_item_gap)
410        .width(Length::Fill);
411    let list = scroll_area(
412        container(list_column).width(Length::Fill),
413        ScrollAreaProps::new()
414            .bordered(false)
415            .scrollbars(ScrollAreaScrollbars::Vertical)
416            .scrollbar_visibility(ScrollAreaScrollbarVisibility::Auto),
417        theme,
418    )
419    .height(Length::Fixed(list.max_height))
420    .width(Length::Fill);
421
422    let mut body = column![input, list].spacing(6);
423    if let Some(empty) = empty
424        && (empty.force_mount || visible_item_count == 0)
425    {
426        body = body.push(command_empty(empty, tokens));
427    }
428    let _ = id_source;
429
430    let content_radius = (radius_md - if show_border { 1.0 } else { 0.0 }).max(0.0);
431    let content = container(body)
432        .width(Length::Fixed(min_width))
433        .style(move |_t| iced::widget::container::Style {
434            background: Some(Background::Color(tokens.bg)),
435            text_color: Some(tokens.text),
436            border: Border {
437                radius: content_radius.into(),
438                width: 0.0,
439                color: iced::Color::TRANSPARENT,
440            },
441            ..Default::default()
442        });
443
444    let shell_shadow = if show_shadow {
445        Shadow {
446            color: iced::Color {
447                a: menu_shadow.opacity,
448                ..iced::Color::BLACK
449            },
450            offset: iced::Vector::new(0.0, menu_shadow.offset_y),
451            blur_radius: menu_shadow.blur_radius,
452        }
453    } else {
454        Shadow::default()
455    };
456
457    if show_border {
458        container(content)
459            .padding(1.0)
460            .style(move |_t| iced::widget::container::Style {
461                background: Some(Background::Color(container_border_color)),
462                border: Border {
463                    radius: radius_md.into(),
464                    width: 0.0,
465                    color: iced::Color::TRANSPARENT,
466                },
467                shadow: shell_shadow,
468                ..Default::default()
469            })
470            .into()
471    } else {
472        container(content)
473            .style(move |_t| iced::widget::container::Style {
474                border: Border {
475                    radius: radius_md.into(),
476                    width: 0.0,
477                    color: iced::Color::TRANSPARENT,
478                },
479                shadow: shell_shadow,
480                ..Default::default()
481            })
482            .into()
483    }
484}
485
486fn command_input<'a, Message: Clone + 'a>(
487    query: &'a str,
488    on_query_change: Option<Box<dyn Fn(String) -> Message + 'a>>,
489    props: CommandInputProps<'a>,
490    tokens: CommandTokens,
491    theme: &Theme,
492) -> Element<'a, Message> {
493    let search_icon = text(char::from(lucide_icons::Icon::Search).to_string())
494        .font(iced::Font::with_name("lucide"))
495        .size(13)
496        .style(move |_t| iced::widget::text::Style {
497            color: Some(tokens.muted),
498        });
499
500    let field = input(
501        query,
502        props.placeholder,
503        on_query_change,
504        InputProps::new()
505            .size(InputSize::Size1)
506            .variant(InputVariant::Ghost),
507        theme,
508    )
509    .width(Length::Fill);
510
511    let line = separator(
512        SeparatorProps::new()
513            .size(SeparatorSize::Size4)
514            .thickness(1.0)
515            .gap(0.0),
516        theme,
517    )
518    .width(Length::Fill);
519
520    column![
521        container(
522            row![search_icon, field]
523                .spacing(10)
524                .align_y(Alignment::Center)
525                .width(Length::Fill),
526        )
527        .padding([4.0, 10.0]),
528        line
529    ]
530    .spacing(0)
531    .width(Length::Fill)
532    .into()
533}
534
535struct RenderedEntries<'a, Message> {
536    elements: Vec<Element<'a, Message>>,
537    visible_items: usize,
538}
539
540fn render_entries<'a, Message: Clone + 'a>(
541    entries: Vec<CommandListEntry<'a, Message>>,
542    query: &str,
543    should_filter: bool,
544    show_item_border: bool,
545    filter: CommandFilter,
546    tokens: CommandTokens,
547    theme: &'a Theme,
548) -> RenderedEntries<'a, Message> {
549    let mut elements = Vec::new();
550    let mut visible_items = 0usize;
551
552    for entry in entries {
553        match entry {
554            CommandListEntry::Group(group) => {
555                let nested = render_entries(
556                    group.entries,
557                    query,
558                    should_filter,
559                    show_item_border,
560                    filter,
561                    tokens,
562                    theme,
563                );
564                let group_visible = group.force_mount || !nested.elements.is_empty();
565                if group_visible {
566                    elements.push(command_group(group.heading, nested.elements, tokens, theme));
567                    visible_items += nested.visible_items;
568                }
569            }
570            CommandListEntry::Item(item) => {
571                let visible = item.force_mount
572                    || !should_filter
573                    || command_matches(query, &item.value, &item.keywords, filter) > 0.0;
574                if visible {
575                    elements.push(command_item(
576                        CommandItemRenderProps {
577                            label: item.label,
578                            icon: item.icon,
579                            shortcut: item.shortcut,
580                            disabled: item.disabled,
581                            on_select: item.on_select,
582                        },
583                        show_item_border,
584                        tokens,
585                        theme,
586                    ));
587                    visible_items += 1;
588                }
589            }
590            CommandListEntry::LinkItem(item) => {
591                let visible = item.force_mount
592                    || !should_filter
593                    || command_matches(query, &item.value, &item.keywords, filter) > 0.0;
594                if visible {
595                    elements.push(command_link_item(
596                        LinkRenderProps {
597                            label: item.label,
598                            href: item.href,
599                            icon: item.icon,
600                            shortcut: item.shortcut,
601                            disabled: item.disabled,
602                            on_select: item.on_select,
603                        },
604                        show_item_border,
605                        tokens,
606                        theme,
607                    ));
608                    visible_items += 1;
609                }
610            }
611            CommandListEntry::Separator(separator) => {
612                let visible = !should_filter || query.trim().is_empty() || separator.force_mount;
613                if visible {
614                    elements.push(command_separator(theme));
615                }
616            }
617            CommandListEntry::Loading(loading) => {
618                elements.push(command_loading(loading, tokens, theme));
619            }
620        }
621    }
622
623    RenderedEntries {
624        elements,
625        visible_items,
626    }
627}
628
629fn command_group<'a, Message: Clone + 'a>(
630    heading: Option<Cow<'a, str>>,
631    items: Vec<Element<'a, Message>>,
632    tokens: CommandTokens,
633    theme: &Theme,
634) -> Element<'a, Message> {
635    let mut group = column!().spacing(theme.styles.command.group_item_gap);
636    if let Some(heading) = heading {
637        group = group.push(
638            text(heading)
639                .size(11)
640                .style(move |_t| iced::widget::text::Style {
641                    color: Some(tokens.muted),
642                }),
643        );
644    }
645    for item in items {
646        group = group.push(item);
647    }
648    container(group).padding(6).into()
649}
650
651fn command_item<'a, Message: Clone + 'a>(
652    props: CommandItemRenderProps<'a, Message>,
653    show_item_border: bool,
654    _tokens: CommandTokens,
655    theme: &'a Theme,
656) -> Element<'a, Message> {
657    let mut content = row!().align_y(Alignment::Center).spacing(8);
658    if let Some(icon) = props.icon {
659        content = content.push(text(icon).font(iced::Font::with_name("lucide")).size(13));
660    }
661    content = content
662        .push(text(props.label).size(13))
663        .push(iced::widget::space().width(Length::Fill));
664
665    if let Some(shortcut) = props.shortcut {
666        content = content.push(command_shortcut(shortcut, theme));
667    }
668
669    let item = button_content(
670        content,
671        props.on_select.filter(|_| !props.disabled),
672        ButtonProps::new()
673            .variant(ButtonVariant::Ghost)
674            .size(ButtonSize::Size1)
675            .disabled(props.disabled),
676        theme,
677    )
678    .width(Length::Fill);
679
680    if show_item_border {
681        let item_border_radius = theme.radius.sm;
682        let item_border_color = theme.palette.border;
683        container(item)
684            .width(Length::Fill)
685            .style(move |_t| iced::widget::container::Style {
686                border: Border {
687                    radius: item_border_radius.into(),
688                    width: 1.0,
689                    color: item_border_color,
690                },
691                ..Default::default()
692            })
693            .into()
694    } else {
695        item.into()
696    }
697}
698
699struct CommandItemRenderProps<'a, Message> {
700    label: Cow<'a, str>,
701    icon: Option<Cow<'a, str>>,
702    shortcut: Option<Cow<'a, str>>,
703    disabled: bool,
704    on_select: Option<Message>,
705}
706
707struct LinkRenderProps<'a, Message> {
708    label: Cow<'a, str>,
709    href: Cow<'a, str>,
710    icon: Option<Cow<'a, str>>,
711    shortcut: Option<Cow<'a, str>>,
712    disabled: bool,
713    on_select: Option<Message>,
714}
715
716fn command_link_item<'a, Message: Clone + 'a>(
717    props: LinkRenderProps<'a, Message>,
718    show_item_border: bool,
719    tokens: CommandTokens,
720    theme: &'a Theme,
721) -> Element<'a, Message> {
722    let mut content = row!().align_y(Alignment::Center).spacing(8);
723    if let Some(icon) = props.icon {
724        content = content.push(text(icon).font(iced::Font::with_name("lucide")).size(13));
725    }
726    content = content
727        .push(text(props.label).size(13))
728        .push(iced::widget::space().width(Length::Fill))
729        .push(
730            text(props.href)
731                .size(10)
732                .style(move |_t| iced::widget::text::Style {
733                    color: Some(tokens.muted),
734                }),
735        );
736
737    if let Some(shortcut) = props.shortcut {
738        content = content.push(command_shortcut(shortcut, theme));
739    }
740
741    let item = button_content(
742        content,
743        props.on_select.filter(|_| !props.disabled),
744        ButtonProps::new()
745            .variant(ButtonVariant::Ghost)
746            .size(ButtonSize::Size1)
747            .disabled(props.disabled),
748        theme,
749    )
750    .width(Length::Fill);
751
752    if show_item_border {
753        let item_border_radius = theme.radius.sm;
754        let item_border_color = theme.palette.border;
755        container(item)
756            .width(Length::Fill)
757            .style(move |_t| iced::widget::container::Style {
758                border: Border {
759                    radius: item_border_radius.into(),
760                    width: 1.0,
761                    color: item_border_color,
762                },
763                ..Default::default()
764            })
765            .into()
766    } else {
767        item.into()
768    }
769}
770
771fn command_loading<'a, Message: Clone + 'a>(
772    loading: CommandLoadingProps<'a>,
773    tokens: CommandTokens,
774    theme: &Theme,
775) -> Element<'a, Message> {
776    let indicator = spinner(
777        Spinner::new(theme)
778            .size(SpinnerSize::Size1)
779            .progress(loading.progress.unwrap_or(0.0))
780            .color(tokens.muted),
781    );
782    row![
783        indicator,
784        text(loading.label)
785            .size(12)
786            .style(move |_t| iced::widget::text::Style {
787                color: Some(tokens.muted),
788            })
789    ]
790    .align_y(Alignment::Center)
791    .spacing(8)
792    .into()
793}
794
795fn command_separator<'a, Message: Clone + 'a>(theme: &Theme) -> Element<'a, Message> {
796    separator(
797        SeparatorProps::new()
798            .size(SeparatorSize::Size4)
799            .thickness(1.0)
800            .gap(0.0),
801        theme,
802    )
803    .into()
804}
805
806fn command_empty<'a, Message: Clone + 'a>(
807    empty: CommandEmptyProps<'a>,
808    tokens: CommandTokens,
809) -> Element<'a, Message> {
810    container(
811        text(empty.text)
812            .size(12)
813            .style(move |_t| iced::widget::text::Style {
814                color: Some(tokens.muted),
815            }),
816    )
817    .width(Length::Fill)
818    .padding(8)
819    .into()
820}
821
822fn command_shortcut<'a, Message: Clone + 'a>(
823    shortcut: Cow<'a, str>,
824    theme: &'a Theme,
825) -> Element<'a, Message> {
826    let value = shortcut.trim();
827    let props = KbdProps::new().size(KbdSize::Size1).shadow(false);
828
829    if value.contains('+') {
830        let labels: Vec<&str> = value
831            .split('+')
832            .map(str::trim)
833            .filter(|part| !part.is_empty())
834            .collect();
835        if labels.len() > 1 {
836            return kbd_shortcut(labels, props, theme);
837        }
838    }
839
840    kbd(value.to_string(), props, theme)
841}
842
843fn command_matches(query: &str, value: &str, keywords: &[String], filter: CommandFilter) -> f32 {
844    if query.trim().is_empty() {
845        return 1.0;
846    }
847    filter(value, query, keywords)
848}
849
850fn default_command_filter(value: &str, search: &str, keywords: &[String]) -> f32 {
851    let mut best = fuzzy_score(search, value);
852    for keyword in keywords {
853        best = best.max(fuzzy_score(search, keyword));
854    }
855    best
856}
857
858fn fuzzy_score(query: &str, text: &str) -> f32 {
859    let query = query.trim().to_lowercase();
860    let text = text.to_lowercase();
861    if query.is_empty() {
862        return 1.0;
863    }
864    if text.is_empty() {
865        return 0.0;
866    }
867
868    let mut matched = 0usize;
869    let mut query_chars = query.chars();
870    let mut target = query_chars.next();
871    for ch in text.chars() {
872        if Some(ch) == target {
873            matched += 1;
874            target = query_chars.next();
875            if target.is_none() {
876                break;
877            }
878        }
879    }
880
881    if matched == 0 {
882        return 0.0;
883    }
884
885    let ratio = matched as f32 / query.chars().count() as f32;
886    if target.is_none() { ratio } else { ratio * 0.5 }
887}
888
889pub struct CommandDialogProps<'a, Message> {
890    pub open: bool,
891    pub on_close: Message,
892    pub title: String,
893    pub description: String,
894    pub show_close_button: bool,
895    pub dialog_props: DialogProps,
896    pub command: CommandProps<'a, Message>,
897}
898
899impl<'a, Message> CommandDialogProps<'a, Message> {
900    pub fn new(open: bool, on_close: Message, command: CommandProps<'a, Message>) -> Self {
901        Self {
902            open,
903            on_close,
904            title: "Command Palette".to_string(),
905            description: "Search for a command to run...".to_string(),
906            show_close_button: true,
907            dialog_props: DialogProps::new().padding(0),
908            command,
909        }
910    }
911
912    pub fn title(mut self, title: impl Into<String>) -> Self {
913        self.title = title.into();
914        self
915    }
916
917    pub fn description(mut self, description: impl Into<String>) -> Self {
918        self.description = description.into();
919        self
920    }
921
922    pub fn show_close_button(mut self, show: bool) -> Self {
923        self.show_close_button = show;
924        self
925    }
926
927    pub fn dialog_props(mut self, props: DialogProps) -> Self {
928        self.dialog_props = props;
929        self
930    }
931}
932
933pub fn command_dialog<'a, Message: Clone + 'a>(
934    base: impl Into<Element<'a, Message>>,
935    props: CommandDialogProps<'a, Message>,
936    theme: &'a Theme,
937) -> Element<'a, Message> {
938    let content = command(props.command, theme);
939    dialog(
940        base,
941        props.open,
942        content,
943        props.on_close,
944        props.dialog_props,
945        theme,
946    )
947}
948
949#[cfg(test)]
950mod tests {
951    use super::{default_command_filter, fuzzy_score};
952
953    #[test]
954    fn fuzzy_score_matches_subsequence() {
955        let score = fuzzy_score("set", "settings");
956        assert!(score > 0.0);
957    }
958
959    #[test]
960    fn fuzzy_score_zero_when_no_match() {
961        let score = fuzzy_score("zzz", "settings");
962        assert_eq!(score, 0.0);
963    }
964
965    #[test]
966    fn default_filter_uses_keywords() {
967        let score = default_command_filter("Billing", "pay", &["payments".to_string()]);
968        assert!(score > 0.0);
969    }
970}