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
19pub 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
37pub 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}