1use std::fmt;
62use std::rc::Rc;
63
64use iced::futures::channel::mpsc;
65use iced::futures::{Stream, StreamExt, stream};
66use iced::keyboard::key::Named;
67use iced::mouse::ScrollDelta;
68use iced::widget::text::LineHeight;
69use iced::widget::text_input::{self, Status};
70use iced::widget::{self, Column, column, container, mouse_area, stack, text};
71use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Theme};
72
73use crate::icon::{Glyph, opacity, themed};
74use crate::keys::{self, Chord, Keymap};
75use crate::overlay::anchored::{anchored, surface_style};
76use crate::overlay::menu::{self, Parts, ROW_PADDING, RowStatus};
77use crate::theme::{Tokens, fade, space, text_size};
78
79pub const CHANNEL_CAPACITY: usize = 64;
81pub const BATCH_SIZE: usize = 32;
83pub const VISIBLE_ROWS: usize = 8;
85pub const DEFAULT_EMPTY: &str = "No results found.";
87pub const SEARCHING: &str = "Searching...";
89
90const SEARCH_HEIGHT: f32 = 44.0;
91const ICON_SIZE: f32 = 16.0;
92const LINE_HEIGHT: f32 = 1.3;
93
94const EXACT: u32 = 5000;
95const PREFIX: u32 = 4000;
96const WORD: u32 = 3000;
97const CONTAINS: u32 = 2000;
98const FUZZY: u32 = 1000;
99const WORD_START: u32 = 10;
100const CONSECUTIVE: u32 = 5;
101const KEYWORD_PENALTY: u32 = 500;
103
104pub fn score(query: &str, text: &str) -> Option<u32> {
121 let query: Vec<char> = query.trim().chars().flat_map(char::to_lowercase).collect();
122 if query.is_empty() {
123 return Some(0);
124 }
125 let folded = fold(text);
126 let chars: Vec<char> = folded.iter().map(|(c, _)| *c).collect();
127 let extra = chars.len().checked_sub(query.len())?;
128 let closeness = 999 - extra.min(999) as u32;
129
130 if extra == 0 && chars == query {
131 return Some(EXACT);
132 }
133 if chars.starts_with(&query) {
134 return Some(PREFIX + closeness);
135 }
136 let starts: Vec<usize> = chars
137 .windows(query.len())
138 .enumerate()
139 .filter(|(_, window)| *window == query.as_slice())
140 .map(|(start, _)| start)
141 .collect();
142 if starts.iter().any(|&start| folded[start].1) {
143 return Some(WORD + closeness);
144 }
145 if !starts.is_empty() {
146 return Some(CONTAINS + closeness);
147 }
148 fuzzy(&query, &folded).map(|bonus| FUZZY + bonus.min(999))
149}
150
151fn fold(text: &str) -> Vec<(char, bool)> {
153 let mut folded = Vec::with_capacity(text.len());
154 let mut previous: Option<char> = None;
155 for c in text.chars() {
156 let starts_word = match previous {
157 None => true,
158 Some(p) => {
159 !p.is_alphanumeric() && c.is_alphanumeric() || p.is_lowercase() && c.is_uppercase()
160 }
161 };
162 for (n, lower) in c.to_lowercase().enumerate() {
163 folded.push((lower, starts_word && n == 0));
164 }
165 previous = Some(c);
166 }
167 folded
168}
169
170fn fuzzy(query: &[char], text: &[(char, bool)]) -> Option<u32> {
174 let mut previous: Vec<Option<u32>> = Vec::new();
175 for (i, &wanted) in query.iter().enumerate() {
176 let mut current = vec![None; text.len()];
177 let mut best_before: Option<u32> = None;
178 for (j, &(c, starts_word)) in text.iter().enumerate() {
179 if i > 0 && j >= 2 {
180 best_before = best_before.max(previous[j - 2]);
181 }
182 if c != wanted {
183 continue;
184 }
185 let letter = if starts_word { WORD_START } else { 1 };
186 let before = if i == 0 {
187 Some(0)
188 } else {
189 let adjacent = j
190 .checked_sub(1)
191 .and_then(|k| previous[k])
192 .map(|score| score + CONSECUTIVE);
193 adjacent.max(best_before)
194 };
195 current[j] = before.map(|score| score + letter);
196 }
197 previous = current;
198 }
199 previous.into_iter().flatten().max()
200}
201
202#[derive(Debug, Clone, PartialEq)]
204pub struct Item<Id> {
205 pub id: Id,
206 pub label: String,
207 pub icon: Option<Glyph>,
208 pub shortcut: Option<String>,
210 pub keywords: Vec<String>,
212 pub disabled: bool,
213 pub destructive: bool,
214}
215
216pub fn item<Id>(id: Id, label: impl Into<String>) -> Item<Id> {
218 Item {
219 id,
220 label: label.into(),
221 icon: None,
222 shortcut: None,
223 keywords: Vec::new(),
224 disabled: false,
225 destructive: false,
226 }
227}
228
229impl<Id> Item<Id> {
230 pub fn icon(mut self, glyph: Glyph) -> Self {
231 self.icon = Some(glyph);
232 self
233 }
234
235 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
236 self.shortcut = Some(shortcut.into());
237 self
238 }
239
240 pub fn keywords<S: Into<String>>(mut self, keywords: impl IntoIterator<Item = S>) -> Self {
241 self.keywords = keywords.into_iter().map(Into::into).collect();
242 self
243 }
244
245 pub fn disabled(mut self, disabled: bool) -> Self {
247 self.disabled = disabled;
248 self
249 }
250
251 pub fn destructive(mut self, destructive: bool) -> Self {
253 self.destructive = destructive;
254 self
255 }
256
257 pub fn score(&self, query: &str) -> Option<u32> {
260 let keyword = self
261 .keywords
262 .iter()
263 .filter_map(|keyword| score(query, keyword))
264 .max()
265 .map(|score| score.saturating_sub(KEYWORD_PENALTY));
266 score(query, &self.label).max(keyword)
267 }
268}
269
270#[derive(Debug, Clone, PartialEq)]
272pub struct Group<Id> {
273 pub label: String,
274 pub items: Vec<Item<Id>>,
275}
276
277pub fn group<Id>(label: impl Into<String>, items: impl IntoIterator<Item = Item<Id>>) -> Group<Id> {
278 Group {
279 label: label.into(),
280 items: items.into_iter().collect(),
281 }
282}
283
284#[derive(Debug, Clone, PartialEq)]
287pub struct Results<Id> {
288 pub query: String,
289 pub group: String,
290 pub items: Vec<Item<Id>>,
291}
292
293pub fn results<Id>(
294 query: impl Into<String>,
295 group: impl Into<String>,
296 items: impl IntoIterator<Item = Item<Id>>,
297) -> Results<Id> {
298 Results {
299 query: query.into(),
300 group: group.into(),
301 items: items.into_iter().collect(),
302 }
303}
304
305pub type Sender<Id> = mpsc::Sender<Results<Id>>;
307
308#[derive(Debug, Clone)]
310pub enum Event<Id> {
311 Ready(Sender<Id>),
313 Received(Vec<Results<Id>>),
315 Input(String),
317 Next,
319 Previous,
321 First,
322 Last,
323 ActivateHighlighted,
325 Close,
327 Highlight(usize),
329 Activate(usize),
331 Scroll(i32),
333 Focus(bool),
335}
336
337#[derive(Debug, Clone)]
339pub enum Output<Id> {
340 Ready(Sender<Id>),
341 Search(String),
343 Activated(Id),
345 Closed,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351struct Entry {
352 remote: bool,
353 group: usize,
354 item: usize,
355}
356
357#[derive(Debug, Clone, PartialEq)]
359pub struct State<Id> {
360 groups: Vec<Group<Id>>,
361 remote: Vec<Group<Id>>,
362 query: String,
363 entries: Vec<Entry>,
364 highlighted: Option<usize>,
365 offset: usize,
366 rows: usize,
367 focused: bool,
368 navigated: bool,
369}
370
371impl<Id> State<Id> {
372 pub fn new(groups: impl IntoIterator<Item = Group<Id>>) -> Self {
375 let mut state = Self {
376 groups: groups.into_iter().collect(),
377 remote: Vec::new(),
378 query: String::new(),
379 entries: Vec::new(),
380 highlighted: None,
381 offset: 0,
382 rows: VISIBLE_ROWS,
383 focused: false,
384 navigated: false,
385 };
386 state.rank(false);
387 state
388 }
389
390 pub fn with_visible_rows(mut self, rows: usize) -> Self {
392 self.rows = rows.max(1);
393 self.reveal();
394 self
395 }
396
397 pub fn query(&self) -> &str {
398 &self.query
399 }
400
401 pub fn groups(&self) -> &[Group<Id>] {
402 &self.groups
403 }
404
405 pub fn results(&self) -> impl Iterator<Item = (&str, &Item<Id>)> {
407 self.entries.iter().filter_map(|&entry| {
408 let group = self.group(entry)?;
409 Some((group.label.as_str(), group.items.get(entry.item)?))
410 })
411 }
412
413 pub fn len(&self) -> usize {
414 self.entries.len()
415 }
416
417 pub fn is_empty(&self) -> bool {
418 self.entries.is_empty()
419 }
420
421 pub fn highlighted(&self) -> Option<&Item<Id>> {
423 self.highlighted.and_then(|position| self.item_at(position))
424 }
425
426 pub fn highlighted_position(&self) -> Option<usize> {
428 self.highlighted
429 }
430
431 pub fn shows_highlight(&self) -> bool {
435 self.focused || self.navigated
436 }
437
438 pub fn is_focused(&self) -> bool {
440 self.focused
441 }
442
443 fn group(&self, entry: Entry) -> Option<&Group<Id>> {
444 let groups = if entry.remote {
445 &self.remote
446 } else {
447 &self.groups
448 };
449 groups.get(entry.group)
450 }
451
452 fn item_at(&self, position: usize) -> Option<&Item<Id>> {
453 let entry = *self.entries.get(position)?;
454 self.group(entry)?.items.get(entry.item)
455 }
456
457 fn is_enabled(&self, position: usize) -> bool {
458 self.item_at(position).is_some_and(|item| !item.disabled)
459 }
460
461 fn has_enabled(&self) -> bool {
462 (0..self.entries.len()).any(|position| self.is_enabled(position))
463 }
464
465 pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>>
467 where
468 Id: Clone,
469 {
470 if matches!(
471 event,
472 Event::Next
473 | Event::Previous
474 | Event::First
475 | Event::Last
476 | Event::Highlight(_)
477 | Event::Activate(_)
478 ) {
479 self.navigated = true;
480 }
481 match event {
482 Event::Ready(sender) => return Some(Output::Ready(sender)),
483 Event::Received(batch) => self.receive(batch),
484 Event::Input(query) => return self.search(query),
485 Event::Next => self.step(true),
486 Event::Previous => self.step(false),
487 Event::First => {
488 let first = (0..self.entries.len()).find(|&p| self.is_enabled(p));
489 self.highlight(first);
490 }
491 Event::Last => {
492 let last = (0..self.entries.len()).rev().find(|&p| self.is_enabled(p));
493 self.highlight(last);
494 }
495 Event::Focus(focused) => {
496 self.focused = focused;
497 if !focused {
498 self.navigated = false;
499 }
500 }
501 Event::ActivateHighlighted => {
502 let id = self.highlighted()?.id.clone();
503 return Some(Output::Activated(id));
504 }
505 Event::Close if self.query.is_empty() => return Some(Output::Closed),
506 Event::Close => return self.search(String::new()),
507 Event::Highlight(position) => {
508 if self.is_enabled(position) {
509 self.highlighted = Some(position);
510 }
511 }
512 Event::Activate(position) => {
513 if !self.is_enabled(position) {
514 return None;
515 }
516 self.highlighted = Some(position);
517 let id = self.item_at(position)?.id.clone();
518 return Some(Output::Activated(id));
519 }
520 Event::Scroll(rows) => {
521 let last = self.entries.len().saturating_sub(self.rows);
522 let offset = (self.offset as i64).saturating_add(rows.into());
523 self.offset = offset.clamp(0, last as i64) as usize;
524 }
525 }
526 None
527 }
528
529 fn search(&mut self, query: String) -> Option<Output<Id>> {
530 if query == self.query {
531 return None;
532 }
533 self.query = query;
534 self.remote.clear();
535 self.rank(false);
536 Some(Output::Search(self.query.clone()))
537 }
538
539 fn receive(&mut self, batch: Vec<Results<Id>>) {
540 let mut changed = false;
541 for results in batch {
542 if results.query != self.query || results.items.is_empty() {
543 continue;
544 }
545 changed = true;
546 match self.remote.iter_mut().find(|g| g.label == results.group) {
547 Some(group) => group.items.extend(results.items),
548 None => self.remote.push(group(results.group, results.items)),
549 }
550 }
551 if changed {
552 self.rank(true);
553 }
554 }
555
556 fn rank(&mut self, keep_highlight: bool) {
559 let kept = keep_highlight
560 .then(|| self.highlighted.and_then(|p| self.entries.get(p).copied()))
561 .flatten();
562
563 let mut entries = Vec::new();
564 for (index, group) in self.groups.iter().enumerate() {
565 let mut scored: Vec<(u32, usize)> = group
566 .items
567 .iter()
568 .enumerate()
569 .filter_map(|(index, item)| item.score(&self.query).map(|score| (score, index)))
570 .collect();
571 scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
572 entries.extend(scored.into_iter().map(|(_, item)| Entry {
573 remote: false,
574 group: index,
575 item,
576 }));
577 }
578 for (index, group) in self.remote.iter().enumerate() {
579 entries.extend((0..group.items.len()).map(|item| Entry {
580 remote: true,
581 group: index,
582 item,
583 }));
584 }
585 self.entries = entries;
586
587 let kept = kept.and_then(|entry| self.entries.iter().position(|e| *e == entry));
588 if kept.is_none() {
589 self.offset = 0;
590 }
591 let first = kept.or_else(|| (0..self.entries.len()).find(|&p| self.is_enabled(p)));
592 self.highlight(first);
593 }
594
595 fn step(&mut self, forward: bool) {
597 let len = self.entries.len();
598 if len == 0 {
599 return;
600 }
601 let start = self
602 .highlighted
603 .unwrap_or(if forward { len - 1 } else { 0 });
604 let offset = if forward { 1 } else { len - 1 };
605 let target = (1..=len)
606 .map(|n| (start + offset * n) % len)
607 .find(|&position| self.is_enabled(position));
608 if target.is_some() {
609 self.highlight(target);
610 }
611 }
612
613 fn highlight(&mut self, position: Option<usize>) {
614 self.highlighted = position;
615 self.reveal();
616 }
617
618 fn reveal(&mut self) {
620 if let Some(position) = self.highlighted {
621 if position < self.offset {
622 self.offset = position;
623 } else if position >= self.offset + self.rows {
624 self.offset = position + 1 - self.rows;
625 }
626 }
627 self.offset = self
628 .offset
629 .min(self.entries.len().saturating_sub(self.rows));
630 }
631
632 pub fn visible(&self) -> std::ops::Range<usize> {
634 let end = (self.offset + self.rows).min(self.entries.len());
635 self.offset.min(end)..end
636 }
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
641pub enum Action {
642 Next,
643 Previous,
644 First,
645 Last,
646 Activate,
647 Close,
648}
649
650impl Action {
651 pub fn event<Id>(self, state: &State<Id>) -> Option<Event<Id>> {
654 let movable = state.has_enabled();
655 match self {
656 Action::Next => movable.then_some(Event::Next),
657 Action::Previous => movable.then_some(Event::Previous),
658 Action::First => movable.then_some(Event::First),
659 Action::Last => movable.then_some(Event::Last),
660 Action::Activate => state
661 .highlighted
662 .is_some()
663 .then_some(Event::ActivateHighlighted),
664 Action::Close => Some(Event::Close),
665 }
666 }
667}
668
669impl keys::Action for Action {
670 const ALL: &'static [Self] = &[
671 Action::Next,
672 Action::Previous,
673 Action::First,
674 Action::Last,
675 Action::Activate,
676 Action::Close,
677 ];
678
679 fn defaults() -> Keymap<Self> {
680 default_keymap()
681 }
682
683 fn name(self) -> &'static str {
684 match self {
685 Action::Next => "Next",
686 Action::Previous => "Previous",
687 Action::First => "First",
688 Action::Last => "Last",
689 Action::Activate => "Activate",
690 Action::Close => "Close",
691 }
692 }
693
694 fn description(self) -> &'static str {
695 match self {
696 Action::Next => "Highlights the next enabled result, wrapping at the end.",
697 Action::Previous => "Highlights the previous enabled result, wrapping at the start.",
698 Action::First => "Highlights the first enabled result.",
699 Action::Last => "Highlights the last enabled result.",
700 Action::Activate => "Activates the highlighted result.",
701 Action::Close => "Clears the query, or closes the list when the query is empty.",
702 }
703 }
704}
705
706pub fn default_keymap() -> Keymap<Action> {
723 Keymap::new()
724 .bind(Chord::named(Named::ArrowDown), Action::Next)
725 .bind(Chord::named(Named::ArrowUp), Action::Previous)
726 .bind(Chord::named(Named::Home), Action::First)
727 .bind(Chord::named(Named::End), Action::Last)
728 .bind(Chord::named(Named::Enter), Action::Activate)
729 .bind(Chord::named(Named::Escape), Action::Close)
730}
731
732pub fn subscription<Id: Send + 'static>() -> Subscription<Event<Id>> {
734 Subscription::run(stream::<Id>)
735}
736
737pub fn stream<Id: Send + 'static>() -> impl Stream<Item = Event<Id>> {
739 let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
740 stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
741}
742
743pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
745 receiver.ready_chunks(BATCH_SIZE)
746}
747
748pub struct Command<'a, Id, Message> {
753 state: &'a State<Id>,
754 placeholder: String,
755 empty: String,
756 loading: bool,
757 width: Length,
758 height: Length,
759 max_height: f32,
760 id: Option<widget::Id>,
761 keymap: Keymap<Action>,
762 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
763}
764
765pub fn command<'a, Id, Message>(state: &'a State<Id>) -> Command<'a, Id, Message> {
767 Command {
768 state,
769 placeholder: "Type a command or search...".to_owned(),
770 empty: DEFAULT_EMPTY.to_owned(),
771 loading: false,
772 width: Length::Fill,
773 height: Length::Shrink,
774 max_height: f32::INFINITY,
775 id: None,
776 keymap: default_keymap(),
777 on_event: None,
778 }
779}
780
781impl<Id: fmt::Debug, Message> fmt::Debug for Command<'_, Id, Message> {
782 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
783 f.debug_struct("Command")
784 .field("state", self.state)
785 .field("placeholder", &self.placeholder)
786 .field("empty", &self.empty)
787 .field("loading", &self.loading)
788 .field("enabled", &self.on_event.is_some())
789 .finish_non_exhaustive()
790 }
791}
792
793impl<'a, Id, Message> Command<'a, Id, Message> {
794 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
795 self.placeholder = placeholder.into();
796 self
797 }
798
799 pub fn empty(mut self, empty: impl Into<String>) -> Self {
801 self.empty = empty.into();
802 self
803 }
804
805 pub fn loading(mut self, loading: bool) -> Self {
807 self.loading = loading;
808 self
809 }
810
811 pub fn width(mut self, width: impl Into<Length>) -> Self {
813 self.width = width.into();
814 self
815 }
816
817 pub fn height(mut self, height: impl Into<Length>) -> Self {
820 self.height = height.into();
821 self
822 }
823
824 pub fn max_height(mut self, max_height: f32) -> Self {
828 self.max_height = max_height;
829 self
830 }
831
832 pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
835 self.id = Some(id.into());
836 self
837 }
838
839 pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
841 self.keymap = keymap;
842 self
843 }
844
845 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
846 self.on_event = Some(Box::new(on_event));
847 self
848 }
849
850 pub fn is_enabled(&self) -> bool {
851 self.on_event.is_some()
852 }
853}
854
855impl<'a, Id, Message> From<Command<'a, Id, Message>> for Element<'a, Message>
856where
857 Id: 'a,
858 Message: Clone + 'a,
859{
860 fn from(command: Command<'a, Id, Message>) -> Self {
861 let Command {
862 state,
863 placeholder,
864 empty,
865 loading,
866 width,
867 height,
868 max_height,
869 id,
870 keymap,
871 on_event,
872 } = command;
873
874 let on_event: Option<Rc<dyn Fn(Event<Id>) -> Message + 'a>> = on_event.map(Rc::from);
875 let list = list(state, &empty, loading, on_event.as_deref());
876 let list = container(list).height(if height == Length::Shrink {
877 Length::Shrink
878 } else {
879 Length::Fill
880 });
881 let on_input = on_event.clone().map(|on_event| {
882 Box::new(move |query| on_event(Event::Input(query)))
883 as Box<dyn Fn(String) -> Message + 'a>
884 });
885
886 let surface = container(column![
887 search_field(&placeholder, &state.query, id, on_input),
888 container(widget::space())
889 .width(Length::Fill)
890 .height(1)
891 .style(|theme| divider_style(&Tokens::of(theme))),
892 list,
893 ])
894 .width(width)
895 .height(height)
896 .max_height(max_height)
897 .clip(true)
898 .style(|theme| surface_style(&Tokens::of(theme)));
899
900 let Some(on_event) = on_event else {
901 return surface.into();
902 };
903 let on_focus = on_event.clone();
904 anchored(surface)
905 .dismiss_keys([])
906 .on_focus(move |focused| on_focus(Event::Focus(focused)))
907 .on_key(move |key| {
908 keymap
909 .resolve_event(key)
910 .and_then(|action| action.event(state))
911 .map(&*on_event)
912 })
913 .into()
914 }
915}
916
917fn search_field<'a, Message: Clone + 'a>(
918 placeholder: &str,
919 value: &str,
920 id: Option<widget::Id>,
921 on_input: Option<impl Fn(String) -> Message + 'a>,
922) -> Element<'a, Message> {
923 let enabled = on_input.is_some();
924 let vertical = (SEARCH_HEIGHT - text_size::SM * LINE_HEIGHT) / 2.0;
925 let mut input = widget::text_input(placeholder, value)
926 .size(text_size::SM)
927 .line_height(LineHeight::Relative(LINE_HEIGHT))
928 .padding(Padding {
929 top: vertical,
930 bottom: vertical,
931 left: space::MD + ICON_SIZE + space::SM,
932 right: space::MD,
933 })
934 .width(Length::Fill)
935 .style(|theme, status| search_style(&Tokens::of(theme), status));
936 if let Some(id) = id {
937 input = input.id(id);
938 }
939 if let Some(on_input) = on_input {
940 input = input.on_input(on_input);
941 }
942
943 let icon = themed(
944 crate::lucide!(Search),
945 menu::ICON_SIZE,
946 opacity(enabled),
947 |theme| Tokens::of(theme).muted_foreground,
948 );
949
950 stack![
951 input,
952 container(icon)
953 .height(Length::Fill)
954 .align_y(Alignment::Center)
955 .padding(Padding::ZERO.left(space::MD)),
956 ]
957 .into()
958}
959
960fn list<'a, Id, Message: Clone + 'a>(
961 state: &'a State<Id>,
962 empty: &str,
963 loading: bool,
964 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
965) -> Element<'a, Message> {
966 let mut children: Vec<Element<'a, Message>> = Vec::new();
967 let mut previous: Option<(bool, usize)> = None;
968
969 for position in state.visible() {
970 let Some(&entry) = state.entries.get(position) else {
971 continue;
972 };
973 let Some(group) = state.group(entry) else {
974 continue;
975 };
976 let Some(item) = group.items.get(entry.item) else {
977 continue;
978 };
979
980 let key = (entry.remote, entry.group);
981 if previous != Some(key) {
982 if previous.is_some() {
983 children.push(menu::separator());
984 }
985 if !group.label.is_empty() {
986 children.push(menu::group_label(&group.label, false));
987 }
988 previous = Some(key);
989 }
990
991 let enabled = on_event.is_some() && !item.disabled;
992 let status = if !enabled {
993 RowStatus::Disabled
994 } else if state.shows_highlight() && state.highlighted == Some(position) {
995 RowStatus::Highlighted
996 } else {
997 RowStatus::Idle
998 };
999 let messages = on_event.filter(|_| enabled).map(|on_event| {
1000 (
1001 on_event(Event::Highlight(position)),
1002 on_event(Event::Activate(position)),
1003 )
1004 });
1005 let parts = Parts {
1006 icon: item.icon,
1007 hint: item.shortcut.as_deref(),
1008 ..Parts::label(&item.label)
1009 };
1010 children.push(menu::item(parts, status, item.destructive, messages));
1011 }
1012
1013 if children.is_empty() {
1014 return menu::empty(if loading { SEARCHING } else { empty });
1015 }
1016 if loading {
1017 children.push(menu::inset(
1018 container(muted(SEARCHING.to_owned(), text_size::SM)).padding(ROW_PADDING),
1019 ));
1020 }
1021
1022 let rows = Column::from_vec(children)
1023 .padding([space::XS, 0.0])
1024 .width(Length::Fill);
1025 let Some(on_event) = on_event else {
1026 return rows.into();
1027 };
1028 let down = on_event(Event::Scroll(1));
1029 let up = on_event(Event::Scroll(-1));
1030 mouse_area(rows)
1031 .on_scroll(move |delta| match delta {
1032 ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } if y < 0.0 => down.clone(),
1033 _ => up.clone(),
1034 })
1035 .into()
1036}
1037
1038fn muted<'a, Message: 'a>(content: String, size: f32) -> Element<'a, Message> {
1039 text(content)
1040 .size(size)
1041 .line_height(LineHeight::Absolute(menu::LINE_HEIGHT.into()))
1042 .style(|theme: &Theme| text::Style {
1043 color: Some(Tokens::of(theme).muted_foreground),
1044 })
1045 .into()
1046}
1047
1048pub fn search_style(tokens: &Tokens, status: Status) -> text_input::Style {
1050 let style = text_input::Style {
1051 background: Background::Color(Color::TRANSPARENT),
1052 border: Border::default(),
1053 icon: tokens.muted_foreground,
1054 placeholder: tokens.muted_foreground,
1055 value: tokens.foreground,
1056 selection: fade(tokens.primary, 0.25),
1057 };
1058 if status != Status::Disabled {
1059 return style;
1060 }
1061 text_input::Style {
1062 placeholder: fade(style.placeholder, 0.5),
1063 value: fade(style.value, 0.5),
1064 icon: fade(style.icon, 0.5),
1065 selection: Color::TRANSPARENT,
1066 ..style
1067 }
1068}
1069
1070pub fn divider_style(tokens: &Tokens) -> container::Style {
1072 container::Style {
1073 background: Some(Background::Color(tokens.border)),
1074 ..container::Style::default()
1075 }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081 use crate::theme::{dark, light};
1082 use futures::SinkExt;
1083 use futures::executor::block_on;
1084
1085 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1086 let chord: Chord = chord.parse().unwrap();
1087 keymap.resolve(chord.key(), chord.modifiers())
1088 }
1089
1090 fn sample() -> State<&'static str> {
1091 State::new([
1092 group(
1093 "Suggestions",
1094 [
1095 item("calendar", "Calendar"),
1096 item("emoji", "Search emoji").keywords(["smiley"]),
1097 item("calculator", "Calculator").disabled(true),
1098 ],
1099 ),
1100 group(
1101 "Settings",
1102 [
1103 item("profile", "Profile").shortcut("Ctrl+P"),
1104 item("billing", "Billing"),
1105 item("settings", "Settings"),
1106 ],
1107 ),
1108 ])
1109 }
1110
1111 fn ids(state: &State<&'static str>) -> Vec<&'static str> {
1112 state.results().map(|(_, item)| item.id).collect()
1113 }
1114
1115 fn highlighted(state: &State<&'static str>) -> Option<&'static str> {
1116 state.highlighted().map(|item| item.id)
1117 }
1118
1119 #[test]
1120 fn empty_query_matches_everything_equally() {
1121 assert_eq!(score("", "Anything"), Some(0));
1122 assert_eq!(score(" ", "Anything"), Some(0));
1123 }
1124
1125 #[test]
1126 fn matching_ignores_case() {
1127 assert_eq!(score("SET", "settings"), score("set", "Settings"));
1128 assert!(score("cAl", "CALENDAR").is_some());
1129 }
1130
1131 #[test]
1132 fn match_kinds_rank_in_order() {
1133 let exact = score("set", "Set");
1134 let prefix = score("set", "Settings");
1135 let word = score("set", "Reset settings");
1136 let contains = score("set", "Asset");
1137 let fuzzy = score("set", "Sheet editor");
1138 assert!(exact > prefix, "{exact:?} {prefix:?}");
1139 assert!(prefix > word, "{prefix:?} {word:?}");
1140 assert!(word > contains, "{word:?} {contains:?}");
1141 assert!(contains > fuzzy, "{contains:?} {fuzzy:?}");
1142 assert!(fuzzy.is_some());
1143 }
1144
1145 #[test]
1146 fn letters_must_appear_in_order() {
1147 assert!(score("tes", "Settings").is_none());
1148 assert!(score("xyz", "Settings").is_none());
1149 assert!(score("settings!", "Settings").is_none());
1150 }
1151
1152 #[test]
1153 fn shorter_texts_win_within_a_kind() {
1154 assert!(score("set", "Settings") > score("set", "Settings and privacy"));
1155 }
1156
1157 #[test]
1158 fn fuzzy_prefers_word_starts_and_runs() {
1159 assert!(score("ts", "Toggle sidebar") > score("ts", "Tabs list"));
1160 assert!(score("nf", "newFile") > score("nf", "notify"));
1161 assert!(score("sba", "Show bar") > score("sba", "Toggle sidebar"));
1162 assert!(score("ogl", "toggle") > score("ogl", "tobgaly"));
1163 }
1164
1165 #[test]
1166 fn keywords_find_items_but_rank_below_labels() {
1167 let emoji = item("emoji", "Search emoji").keywords(["smiley"]);
1168 assert!(emoji.score("smil").is_some());
1169 let smiley = item("smiley", "Smiley");
1170 assert!(smiley.score("smil") > emoji.score("smil"));
1171 assert!(item("x", "Plain").score("smil").is_none());
1172 }
1173
1174 #[test]
1175 fn new_state_lists_every_item_and_highlights_the_first() {
1176 let state = sample();
1177 assert_eq!(state.len(), 6);
1178 assert_eq!(highlighted(&state), Some("calendar"));
1179 assert_eq!(state.query(), "");
1180 assert!(State::<u8>::new([]).is_empty());
1181 assert!(State::<u8>::new([]).highlighted().is_none());
1182 }
1183
1184 #[test]
1185 fn the_highlight_shows_only_while_focused_or_after_navigating() {
1186 let mut state = sample();
1187 assert!(!state.shows_highlight());
1188 assert!(!state.is_focused());
1189
1190 let _ = state.update(Event::Focus(true));
1191 assert!(state.shows_highlight() && state.is_focused());
1192 let _ = state.update(Event::Focus(false));
1193 assert!(!state.shows_highlight());
1194
1195 for event in [
1196 Event::Next,
1197 Event::Previous,
1198 Event::First,
1199 Event::Last,
1200 Event::Highlight(0),
1201 ] {
1202 let mut state = sample();
1203 let _ = state.update(event);
1204 assert!(state.shows_highlight());
1205 let _ = state.update(Event::Focus(true));
1206 let _ = state.update(Event::Focus(false));
1207 assert!(!state.shows_highlight(), "leaving the field hides it again");
1208 }
1209 }
1210
1211 #[test]
1212 fn typing_or_scrolling_alone_does_not_show_the_highlight() {
1213 let mut state = sample();
1214 let _ = state.update(Event::Input("set".into()));
1215 let _ = state.update(Event::Scroll(1));
1216 assert!(!state.shows_highlight());
1217 assert!(state.highlighted().is_some(), "Enter still has a target");
1218 }
1219
1220 #[test]
1221 fn typing_filters_ranks_and_asks_for_a_search() {
1222 let mut state = sample();
1223 let output = state.update(Event::Input("cal".into()));
1224 assert!(matches!(output, Some(Output::Search(query)) if query == "cal"));
1225 assert_eq!(ids(&state), ["calendar", "calculator"]);
1226 assert_eq!(highlighted(&state), Some("calendar"));
1227
1228 let _ = state.update(Event::Input("calc".into()));
1230 assert_eq!(ids(&state), ["calculator"]);
1231 assert!(state.highlighted().is_none());
1232
1233 let _ = state.update(Event::Input("set".into()));
1234 assert_eq!(ids(&state), ["settings"]);
1235 let groups: Vec<_> = state.results().map(|(group, _)| group).collect();
1236 assert_eq!(groups, ["Settings"]);
1237
1238 assert!(state.update(Event::Input("set".into())).is_none());
1239 }
1240
1241 #[test]
1242 fn nothing_matching_leaves_nothing_highlighted() {
1243 let mut state = sample();
1244 let _ = state.update(Event::Input("zzz".into()));
1245 assert!(state.is_empty());
1246 assert!(state.highlighted().is_none());
1247 assert!(state.update(Event::ActivateHighlighted).is_none());
1248 let _ = state.update(Event::Next);
1249 assert!(state.highlighted().is_none());
1250 }
1251
1252 #[test]
1253 fn arrows_wrap_and_skip_disabled_items() {
1254 let mut state = sample();
1255 let _ = state.update(Event::Next);
1256 assert_eq!(highlighted(&state), Some("emoji"));
1257 let _ = state.update(Event::Next);
1258 assert_eq!(highlighted(&state), Some("profile"));
1259 let _ = state.update(Event::Previous);
1260 let _ = state.update(Event::Previous);
1261 let _ = state.update(Event::Previous);
1262 assert_eq!(highlighted(&state), Some("settings"));
1263 let _ = state.update(Event::Next);
1264 assert_eq!(highlighted(&state), Some("calendar"));
1265 }
1266
1267 #[test]
1268 fn home_and_end_jump_to_enabled_ends() {
1269 let mut state = State::new([group(
1270 "",
1271 [
1272 item(1, "One").disabled(true),
1273 item(2, "Two"),
1274 item(3, "Three"),
1275 item(4, "Four").disabled(true),
1276 ],
1277 )]);
1278 let _ = state.update(Event::Last);
1279 assert_eq!(state.highlighted().map(|i| i.id), Some(3));
1280 let _ = state.update(Event::First);
1281 assert_eq!(state.highlighted().map(|i| i.id), Some(2));
1282 }
1283
1284 #[test]
1285 fn run_and_activate_return_the_id() {
1286 let mut state = sample();
1287 let _ = state.update(Event::Next);
1288 assert!(matches!(
1289 state.update(Event::ActivateHighlighted),
1290 Some(Output::Activated("emoji"))
1291 ));
1292
1293 assert!(matches!(
1294 state.update(Event::Activate(4)),
1295 Some(Output::Activated("billing"))
1296 ));
1297 assert_eq!(highlighted(&state), Some("billing"));
1298 assert!(state.update(Event::Activate(2)).is_none());
1299 assert!(state.update(Event::Activate(99)).is_none());
1300 }
1301
1302 #[test]
1303 fn hovering_a_disabled_row_keeps_the_highlight() {
1304 let mut state = sample();
1305 let _ = state.update(Event::Highlight(2));
1306 assert_eq!(highlighted(&state), Some("calendar"));
1307 let _ = state.update(Event::Highlight(3));
1308 assert_eq!(highlighted(&state), Some("profile"));
1309 }
1310
1311 #[test]
1312 fn clear_empties_the_query_then_dismisses() {
1313 let mut state = sample();
1314 let _ = state.update(Event::Input("bill".into()));
1315 let output = state.update(Event::Close);
1316 assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
1317 assert_eq!(state.len(), 6);
1318 assert!(matches!(state.update(Event::Close), Some(Output::Closed)));
1319 }
1320
1321 #[test]
1322 fn results_for_the_current_query_join_a_remote_group() {
1323 let mut state = sample();
1324 let _ = state.update(Event::Input("rep".into()));
1325 assert!(state.is_empty());
1326
1327 let _ = state.update(Event::Received(vec![
1328 results("rep", "Files", [item("a", "report.pdf")]),
1329 results("rep", "Files", [item("b", "reply.txt")]),
1330 ]));
1331 assert_eq!(ids(&state), ["a", "b"]);
1332 assert!(state.results().all(|(group, _)| group == "Files"));
1333 assert_eq!(highlighted(&state), Some("a"));
1334 }
1335
1336 #[test]
1337 fn stale_results_are_dropped() {
1338 let mut state = sample();
1339 let _ = state.update(Event::Input("re".into()));
1340 let _ = state.update(Event::Input("rep".into()));
1341 let _ = state.update(Event::Received(vec![results(
1342 "re",
1343 "Files",
1344 [item("old", "readme.md")],
1345 )]));
1346 assert!(state.is_empty());
1347 }
1348
1349 #[test]
1350 fn a_new_query_clears_remote_results() {
1351 let mut state = sample();
1352 let files = |state: &State<&'static str>| {
1353 state
1354 .results()
1355 .filter(|(group, _)| *group == "Files")
1356 .count()
1357 };
1358 let _ = state.update(Event::Input("re".into()));
1359 let _ = state.update(Event::Received(vec![results(
1360 "re",
1361 "Files",
1362 [item("old", "readme.md")],
1363 )]));
1364 assert_eq!(files(&state), 1);
1365 let _ = state.update(Event::Input("rea".into()));
1366 assert_eq!(files(&state), 0);
1367 }
1368
1369 #[test]
1370 fn arriving_results_keep_the_highlight() {
1371 let mut state = sample();
1372 let _ = state.update(Event::Input("s".into()));
1373 let _ = state.update(Event::Next);
1374 let before = highlighted(&state);
1375 let _ = state.update(Event::Received(vec![results(
1376 "s",
1377 "Files",
1378 [item("f", "styles.css")],
1379 )]));
1380 assert_eq!(highlighted(&state), before);
1381 assert_eq!(state.results().last().map(|(_, item)| item.id), Some("f"));
1382 }
1383
1384 #[test]
1385 fn remote_results_are_not_filtered_again() {
1386 let mut state = sample();
1387 let _ = state.update(Event::Input("q".into()));
1388 let _ = state.update(Event::Received(vec![results(
1389 "q",
1390 "Files",
1391 [item("x", "unrelated.txt")],
1392 )]));
1393 assert_eq!(ids(&state), ["x"]);
1394 }
1395
1396 #[test]
1397 fn the_window_follows_the_highlight_and_scrolls() {
1398 let mut state = sample().with_visible_rows(3);
1399 assert_eq!(state.visible(), 0..3);
1400 let _ = state.update(Event::Last);
1401 assert_eq!(state.visible(), 3..6);
1402 let _ = state.update(Event::Next);
1403 assert_eq!(state.visible(), 0..3);
1404 let _ = state.update(Event::Scroll(10));
1405 assert_eq!(state.visible(), 3..6);
1406 let _ = state.update(Event::Scroll(-1));
1407 assert_eq!(state.visible(), 2..5);
1408 assert_eq!(State::<u8>::new([]).with_visible_rows(0).rows, 1);
1409 assert_eq!(State::<u8>::new([]).visible(), 0..0);
1410 }
1411
1412 #[test]
1413 fn ready_hands_the_sender_back() {
1414 let (sender, _receiver) = mpsc::channel(1);
1415 let output = sample().update(Event::Ready(sender));
1416 assert!(matches!(output, Some(Output::Ready(_))));
1417 }
1418
1419 #[test]
1420 fn default_keymap_and_overrides() {
1421 let keymap = default_keymap();
1422 assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1423 assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1424 assert_eq!(press(&keymap, "Home"), Some(Action::First));
1425 assert_eq!(press(&keymap, "End"), Some(Action::Last));
1426 assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1427 assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1428
1429 let custom = keymap
1430 .bind("Ctrl+N".parse().unwrap(), Action::Next)
1431 .unbind(&Chord::named(Named::Home));
1432 assert_eq!(press(&custom, "ctrl+n"), Some(Action::Next));
1433 assert_eq!(press(&custom, "Home"), None);
1434 assert!(default_keymap().clear().is_empty());
1435 }
1436
1437 #[test]
1438 fn actions_map_to_events_only_when_they_apply() {
1439 let mut state = sample();
1440 for action in [Action::Next, Action::Previous, Action::First, Action::Last] {
1441 assert!(action.event(&state).is_some(), "{action:?}");
1442 }
1443 assert!(matches!(
1444 Action::Activate.event(&state),
1445 Some(Event::ActivateHighlighted)
1446 ));
1447 assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1448
1449 let _ = state.update(Event::Input("zzz".into()));
1450 for action in [
1451 Action::Next,
1452 Action::Previous,
1453 Action::First,
1454 Action::Last,
1455 Action::Activate,
1456 ] {
1457 assert!(action.event(&state).is_none(), "{action:?}");
1458 }
1459 assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1460 }
1461
1462 #[test]
1463 fn stream_emits_ready_first_then_batches() {
1464 let mut events = Box::pin(stream::<&'static str>());
1465 let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
1466 panic!("first event must be Ready");
1467 };
1468 block_on(async {
1469 for name in ["a", "b", "c"] {
1470 sender
1471 .send(results("q", "Files", [item(name, name)]))
1472 .await
1473 .ok();
1474 }
1475 });
1476 let Some(Event::Received(batch)) = block_on(events.next()) else {
1477 panic!("expected a batch");
1478 };
1479 let names: Vec<_> = batch.iter().map(|r| r.items[0].id).collect();
1480 assert_eq!(names, ["a", "b", "c"]);
1481 }
1482
1483 #[test]
1484 fn batches_split_bursts_at_batch_size() {
1485 let items = stream::iter(0..BATCH_SIZE + 3);
1486 let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
1487 assert_eq!(sizes, [BATCH_SIZE, 3]);
1488 }
1489
1490 #[test]
1491 fn default_builder_is_disabled() {
1492 let state = sample();
1493 let c: Command<'_, &str, ()> = command(&state);
1494 assert!(!c.is_enabled());
1495 assert_eq!(c.empty, DEFAULT_EMPTY);
1496 assert!(!c.loading);
1497 assert_eq!((c.width, c.height), (Length::Fill, Length::Shrink));
1498 assert_eq!(c.max_height, f32::INFINITY);
1499 let c = c
1500 .loading(true)
1501 .height(320)
1502 .max_height(400.0)
1503 .on_event(|_| ());
1504 assert!(c.is_enabled() && c.loading);
1505 assert_eq!(c.height, Length::Fixed(320.0));
1506 assert_eq!(c.max_height, 400.0);
1507 }
1508
1509 const STATES: [Status; 5] = [
1510 Status::Active,
1511 Status::Hovered,
1512 Status::Focused { is_hovered: false },
1513 Status::Focused { is_hovered: true },
1514 Status::Disabled,
1515 ];
1516
1517 #[test]
1518 fn search_field_is_borderless_and_fades_when_disabled() {
1519 for theme in [light(), dark()] {
1520 let tokens = Tokens::of(&theme);
1521 for status in STATES {
1522 let style = search_style(&tokens, status);
1523 assert_eq!(style.border.width, 0.0);
1524 assert_eq!(style.background, Background::Color(Color::TRANSPARENT));
1525 let expected = if status == Status::Disabled {
1526 fade(tokens.foreground, 0.5)
1527 } else {
1528 tokens.foreground
1529 };
1530 assert_eq!(style.value, expected, "{status:?}");
1531 }
1532 }
1533 }
1534
1535 #[test]
1536 fn dividers_use_the_border_token() {
1537 for theme in [light(), dark()] {
1538 let tokens = Tokens::of(&theme);
1539 assert_eq!(
1540 divider_style(&tokens).background,
1541 Some(Background::Color(tokens.border))
1542 );
1543 }
1544 }
1545}