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 let mut best: Option<(u32, usize)> = None;
566 for (index, group) in self.groups.iter().enumerate() {
567 let mut scored: Vec<(u32, usize)> = group
568 .items
569 .iter()
570 .enumerate()
571 .filter_map(|(index, item)| item.score(&self.query).map(|score| (score, index)))
572 .collect();
573 scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
574 for (score, item) in scored {
575 let enabled = group.items.get(item).is_some_and(|item| !item.disabled);
576 if enabled && best.is_none_or(|(top, _)| score > top) {
577 best = Some((score, entries.len()));
578 }
579 entries.push(Entry {
580 remote: false,
581 group: index,
582 item,
583 });
584 }
585 }
586 let best = best
589 .filter(|_| !self.query.trim().is_empty())
590 .map(|(_, position)| position);
591 for (index, group) in self.remote.iter().enumerate() {
592 entries.extend((0..group.items.len()).map(|item| Entry {
593 remote: true,
594 group: index,
595 item,
596 }));
597 }
598 self.entries = entries;
599
600 let kept = kept.and_then(|entry| self.entries.iter().position(|e| *e == entry));
601 if kept.is_none() {
602 self.offset = 0;
603 }
604 let first = kept
605 .or(best)
606 .or_else(|| (0..self.entries.len()).find(|&p| self.is_enabled(p)));
607 self.highlight(first);
608 }
609
610 fn step(&mut self, forward: bool) {
612 let len = self.entries.len();
613 if len == 0 {
614 return;
615 }
616 let start = self
617 .highlighted
618 .unwrap_or(if forward { len - 1 } else { 0 });
619 let offset = if forward { 1 } else { len - 1 };
620 let target = (1..=len)
621 .map(|n| (start + offset * n) % len)
622 .find(|&position| self.is_enabled(position));
623 if target.is_some() {
624 self.highlight(target);
625 }
626 }
627
628 fn highlight(&mut self, position: Option<usize>) {
629 self.highlighted = position;
630 self.reveal();
631 }
632
633 fn reveal(&mut self) {
635 if let Some(position) = self.highlighted {
636 if position < self.offset {
637 self.offset = position;
638 } else if position >= self.offset + self.rows {
639 self.offset = position + 1 - self.rows;
640 }
641 }
642 self.offset = self
643 .offset
644 .min(self.entries.len().saturating_sub(self.rows));
645 }
646
647 pub fn visible(&self) -> std::ops::Range<usize> {
649 let end = (self.offset + self.rows).min(self.entries.len());
650 self.offset.min(end)..end
651 }
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
656pub enum Action {
657 Next,
658 Previous,
659 First,
660 Last,
661 Activate,
662 Close,
663}
664
665impl Action {
666 pub fn event<Id>(self, state: &State<Id>) -> Option<Event<Id>> {
669 let movable = state.has_enabled();
670 match self {
671 Action::Next => movable.then_some(Event::Next),
672 Action::Previous => movable.then_some(Event::Previous),
673 Action::First => movable.then_some(Event::First),
674 Action::Last => movable.then_some(Event::Last),
675 Action::Activate => state
676 .highlighted
677 .is_some()
678 .then_some(Event::ActivateHighlighted),
679 Action::Close => Some(Event::Close),
680 }
681 }
682}
683
684impl keys::Action for Action {
685 const ALL: &'static [Self] = &[
686 Action::Next,
687 Action::Previous,
688 Action::First,
689 Action::Last,
690 Action::Activate,
691 Action::Close,
692 ];
693
694 fn defaults() -> Keymap<Self> {
695 default_keymap()
696 }
697
698 fn name(self) -> &'static str {
699 match self {
700 Action::Next => "Next",
701 Action::Previous => "Previous",
702 Action::First => "First",
703 Action::Last => "Last",
704 Action::Activate => "Activate",
705 Action::Close => "Close",
706 }
707 }
708
709 fn description(self) -> &'static str {
710 match self {
711 Action::Next => "Highlights the next enabled result, wrapping at the end.",
712 Action::Previous => "Highlights the previous enabled result, wrapping at the start.",
713 Action::First => "Highlights the first enabled result.",
714 Action::Last => "Highlights the last enabled result.",
715 Action::Activate => "Activates the highlighted result.",
716 Action::Close => "Clears the query, or closes the list when the query is empty.",
717 }
718 }
719}
720
721pub fn default_keymap() -> Keymap<Action> {
738 Keymap::new()
739 .bind(Chord::named(Named::ArrowDown), Action::Next)
740 .bind(Chord::named(Named::ArrowUp), Action::Previous)
741 .bind(Chord::named(Named::Home), Action::First)
742 .bind(Chord::named(Named::End), Action::Last)
743 .bind(Chord::named(Named::Enter), Action::Activate)
744 .bind(Chord::named(Named::Escape), Action::Close)
745}
746
747pub fn subscription<Id: Send + 'static>() -> Subscription<Event<Id>> {
749 Subscription::run(stream::<Id>)
750}
751
752pub fn stream<Id: Send + 'static>() -> impl Stream<Item = Event<Id>> {
754 let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
755 stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
756}
757
758pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
760 receiver.ready_chunks(BATCH_SIZE)
761}
762
763pub struct Command<'a, Id, Message> {
768 state: &'a State<Id>,
769 placeholder: String,
770 empty: String,
771 loading: bool,
772 width: Length,
773 height: Length,
774 max_height: f32,
775 id: Option<widget::Id>,
776 keymap: Keymap<Action>,
777 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
778}
779
780pub fn command<'a, Id, Message>(state: &'a State<Id>) -> Command<'a, Id, Message> {
782 Command {
783 state,
784 placeholder: "Type a command or search...".to_owned(),
785 empty: DEFAULT_EMPTY.to_owned(),
786 loading: false,
787 width: Length::Fill,
788 height: Length::Shrink,
789 max_height: f32::INFINITY,
790 id: None,
791 keymap: default_keymap(),
792 on_event: None,
793 }
794}
795
796impl<Id: fmt::Debug, Message> fmt::Debug for Command<'_, Id, Message> {
797 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
798 f.debug_struct("Command")
799 .field("state", self.state)
800 .field("placeholder", &self.placeholder)
801 .field("empty", &self.empty)
802 .field("loading", &self.loading)
803 .field("enabled", &self.on_event.is_some())
804 .finish_non_exhaustive()
805 }
806}
807
808impl<'a, Id, Message> Command<'a, Id, Message> {
809 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
810 self.placeholder = placeholder.into();
811 self
812 }
813
814 pub fn empty(mut self, empty: impl Into<String>) -> Self {
816 self.empty = empty.into();
817 self
818 }
819
820 pub fn loading(mut self, loading: bool) -> Self {
822 self.loading = loading;
823 self
824 }
825
826 pub fn width(mut self, width: impl Into<Length>) -> Self {
828 self.width = width.into();
829 self
830 }
831
832 pub fn height(mut self, height: impl Into<Length>) -> Self {
835 self.height = height.into();
836 self
837 }
838
839 pub fn max_height(mut self, max_height: f32) -> Self {
843 self.max_height = max_height;
844 self
845 }
846
847 pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
850 self.id = Some(id.into());
851 self
852 }
853
854 pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
856 self.keymap = keymap;
857 self
858 }
859
860 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
861 self.on_event = Some(Box::new(on_event));
862 self
863 }
864
865 pub fn is_enabled(&self) -> bool {
866 self.on_event.is_some()
867 }
868}
869
870impl<'a, Id, Message> From<Command<'a, Id, Message>> for Element<'a, Message>
871where
872 Id: 'a,
873 Message: Clone + 'a,
874{
875 fn from(command: Command<'a, Id, Message>) -> Self {
876 let Command {
877 state,
878 placeholder,
879 empty,
880 loading,
881 width,
882 height,
883 max_height,
884 id,
885 keymap,
886 on_event,
887 } = command;
888
889 let on_event: Option<Rc<dyn Fn(Event<Id>) -> Message + 'a>> = on_event.map(Rc::from);
890 let list = list(state, &empty, loading, on_event.as_deref());
891 let list = container(list).height(if height == Length::Shrink {
892 Length::Shrink
893 } else {
894 Length::Fill
895 });
896 let on_input = on_event.clone().map(|on_event| {
897 Box::new(move |query| on_event(Event::Input(query)))
898 as Box<dyn Fn(String) -> Message + 'a>
899 });
900
901 let surface = container(column![
902 search_field(&placeholder, &state.query, id, on_input),
903 container(widget::space())
904 .width(Length::Fill)
905 .height(1)
906 .style(|theme| divider_style(&Tokens::of(theme))),
907 list,
908 ])
909 .width(width)
910 .height(height)
911 .max_height(max_height)
912 .clip(true)
913 .style(|theme| surface_style(&Tokens::of(theme)));
914
915 let Some(on_event) = on_event else {
916 return surface.into();
917 };
918 let on_focus = on_event.clone();
919 anchored(surface)
920 .dismiss_keys([])
921 .on_focus(move |focused| on_focus(Event::Focus(focused)))
922 .on_key(move |key| {
923 keymap
924 .resolve_event(key)
925 .and_then(|action| action.event(state))
926 .map(&*on_event)
927 })
928 .into()
929 }
930}
931
932fn search_field<'a, Message: Clone + 'a>(
933 placeholder: &str,
934 value: &str,
935 id: Option<widget::Id>,
936 on_input: Option<impl Fn(String) -> Message + 'a>,
937) -> Element<'a, Message> {
938 let enabled = on_input.is_some();
939 let vertical = (SEARCH_HEIGHT - text_size::SM * LINE_HEIGHT) / 2.0;
940 let mut input = widget::text_input(placeholder, value)
941 .size(text_size::SM)
942 .line_height(LineHeight::Relative(LINE_HEIGHT))
943 .padding(Padding {
944 top: vertical,
945 bottom: vertical,
946 left: space::MD + ICON_SIZE + space::SM,
947 right: space::MD,
948 })
949 .width(Length::Fill)
950 .style(|theme, status| search_style(&Tokens::of(theme), status));
951 if let Some(id) = id {
952 input = input.id(id);
953 }
954 if let Some(on_input) = on_input {
955 input = input.on_input(on_input);
956 }
957
958 let icon = themed(
959 crate::lucide!(Search),
960 menu::ICON_SIZE,
961 opacity(enabled),
962 |theme| Tokens::of(theme).muted_foreground,
963 );
964
965 stack![
966 input,
967 container(icon)
968 .height(Length::Fill)
969 .align_y(Alignment::Center)
970 .padding(Padding::ZERO.left(space::MD)),
971 ]
972 .into()
973}
974
975fn list<'a, Id, Message: Clone + 'a>(
976 state: &'a State<Id>,
977 empty: &str,
978 loading: bool,
979 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
980) -> Element<'a, Message> {
981 let mut children: Vec<Element<'a, Message>> = Vec::new();
982 let mut previous: Option<(bool, usize)> = None;
983
984 for position in state.visible() {
985 let Some(&entry) = state.entries.get(position) else {
986 continue;
987 };
988 let Some(group) = state.group(entry) else {
989 continue;
990 };
991 let Some(item) = group.items.get(entry.item) else {
992 continue;
993 };
994
995 let key = (entry.remote, entry.group);
996 if previous != Some(key) {
997 if previous.is_some() {
998 children.push(menu::separator());
999 }
1000 if !group.label.is_empty() {
1001 children.push(menu::group_label(&group.label, false));
1002 }
1003 previous = Some(key);
1004 }
1005
1006 let enabled = on_event.is_some() && !item.disabled;
1007 let status = if !enabled {
1008 RowStatus::Disabled
1009 } else if state.shows_highlight() && state.highlighted == Some(position) {
1010 RowStatus::Highlighted
1011 } else {
1012 RowStatus::Idle
1013 };
1014 let messages = on_event.filter(|_| enabled).map(|on_event| {
1015 (
1016 on_event(Event::Highlight(position)),
1017 on_event(Event::Activate(position)),
1018 )
1019 });
1020 let parts = Parts {
1021 icon: item.icon,
1022 hint: item.shortcut.as_deref(),
1023 ..Parts::label(&item.label)
1024 };
1025 children.push(menu::item(parts, status, item.destructive, messages));
1026 }
1027
1028 if children.is_empty() {
1029 return menu::empty(if loading { SEARCHING } else { empty });
1030 }
1031 if loading {
1032 children.push(menu::inset(
1033 container(muted(SEARCHING.to_owned(), text_size::SM)).padding(ROW_PADDING),
1034 ));
1035 }
1036
1037 let rows = Column::from_vec(children)
1038 .padding([space::XS, 0.0])
1039 .width(Length::Fill);
1040 let Some(on_event) = on_event else {
1041 return rows.into();
1042 };
1043 let down = on_event(Event::Scroll(1));
1044 let up = on_event(Event::Scroll(-1));
1045 mouse_area(rows)
1046 .on_scroll(move |delta| match delta {
1047 ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } if y < 0.0 => down.clone(),
1048 _ => up.clone(),
1049 })
1050 .into()
1051}
1052
1053fn muted<'a, Message: 'a>(content: String, size: f32) -> Element<'a, Message> {
1054 text(content)
1055 .size(size)
1056 .line_height(LineHeight::Absolute(menu::LINE_HEIGHT.into()))
1057 .style(|theme: &Theme| text::Style {
1058 color: Some(Tokens::of(theme).muted_foreground),
1059 })
1060 .into()
1061}
1062
1063pub fn search_style(tokens: &Tokens, status: Status) -> text_input::Style {
1065 let style = text_input::Style {
1066 background: Background::Color(Color::TRANSPARENT),
1067 border: Border::default(),
1068 icon: tokens.muted_foreground,
1069 placeholder: tokens.muted_foreground,
1070 value: tokens.foreground,
1071 selection: fade(tokens.primary, 0.25),
1072 };
1073 if status != Status::Disabled {
1074 return style;
1075 }
1076 text_input::Style {
1077 placeholder: fade(style.placeholder, 0.5),
1078 value: fade(style.value, 0.5),
1079 icon: fade(style.icon, 0.5),
1080 selection: Color::TRANSPARENT,
1081 ..style
1082 }
1083}
1084
1085pub fn divider_style(tokens: &Tokens) -> container::Style {
1087 container::Style {
1088 background: Some(Background::Color(tokens.border)),
1089 ..container::Style::default()
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use crate::theme::{dark, light};
1097 use futures::SinkExt;
1098 use futures::executor::block_on;
1099
1100 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1101 let chord: Chord = chord.parse().unwrap();
1102 keymap.resolve(chord.key(), chord.modifiers())
1103 }
1104
1105 fn sample() -> State<&'static str> {
1106 State::new([
1107 group(
1108 "Suggestions",
1109 [
1110 item("calendar", "Calendar"),
1111 item("emoji", "Search emoji").keywords(["smiley"]),
1112 item("calculator", "Calculator").disabled(true),
1113 ],
1114 ),
1115 group(
1116 "Settings",
1117 [
1118 item("profile", "Profile").shortcut("Ctrl+P"),
1119 item("billing", "Billing"),
1120 item("settings", "Settings"),
1121 ],
1122 ),
1123 ])
1124 }
1125
1126 fn ids(state: &State<&'static str>) -> Vec<&'static str> {
1127 state.results().map(|(_, item)| item.id).collect()
1128 }
1129
1130 fn highlighted(state: &State<&'static str>) -> Option<&'static str> {
1131 state.highlighted().map(|item| item.id)
1132 }
1133
1134 #[test]
1135 fn empty_query_matches_everything_equally() {
1136 assert_eq!(score("", "Anything"), Some(0));
1137 assert_eq!(score(" ", "Anything"), Some(0));
1138 }
1139
1140 #[test]
1141 fn matching_ignores_case() {
1142 assert_eq!(score("SET", "settings"), score("set", "Settings"));
1143 assert!(score("cAl", "CALENDAR").is_some());
1144 }
1145
1146 #[test]
1147 fn match_kinds_rank_in_order() {
1148 let exact = score("set", "Set");
1149 let prefix = score("set", "Settings");
1150 let word = score("set", "Reset settings");
1151 let contains = score("set", "Asset");
1152 let fuzzy = score("set", "Sheet editor");
1153 assert!(exact > prefix, "{exact:?} {prefix:?}");
1154 assert!(prefix > word, "{prefix:?} {word:?}");
1155 assert!(word > contains, "{word:?} {contains:?}");
1156 assert!(contains > fuzzy, "{contains:?} {fuzzy:?}");
1157 assert!(fuzzy.is_some());
1158 }
1159
1160 #[test]
1161 fn letters_must_appear_in_order() {
1162 assert!(score("tes", "Settings").is_none());
1163 assert!(score("xyz", "Settings").is_none());
1164 assert!(score("settings!", "Settings").is_none());
1165 }
1166
1167 #[test]
1168 fn shorter_texts_win_within_a_kind() {
1169 assert!(score("set", "Settings") > score("set", "Settings and privacy"));
1170 }
1171
1172 #[test]
1173 fn fuzzy_prefers_word_starts_and_runs() {
1174 assert!(score("ts", "Toggle sidebar") > score("ts", "Tabs list"));
1175 assert!(score("nf", "newFile") > score("nf", "notify"));
1176 assert!(score("sba", "Show bar") > score("sba", "Toggle sidebar"));
1177 assert!(score("ogl", "toggle") > score("ogl", "tobgaly"));
1178 }
1179
1180 #[test]
1181 fn keywords_find_items_but_rank_below_labels() {
1182 let emoji = item("emoji", "Search emoji").keywords(["smiley"]);
1183 assert!(emoji.score("smil").is_some());
1184 let smiley = item("smiley", "Smiley");
1185 assert!(smiley.score("smil") > emoji.score("smil"));
1186 assert!(item("x", "Plain").score("smil").is_none());
1187 }
1188
1189 #[test]
1190 fn new_state_lists_every_item_and_highlights_the_first() {
1191 let state = sample();
1192 assert_eq!(state.len(), 6);
1193 assert_eq!(highlighted(&state), Some("calendar"));
1194 assert_eq!(state.query(), "");
1195 assert!(State::<u8>::new([]).is_empty());
1196 assert!(State::<u8>::new([]).highlighted().is_none());
1197 }
1198
1199 #[test]
1200 fn the_highlight_shows_only_while_focused_or_after_navigating() {
1201 let mut state = sample();
1202 assert!(!state.shows_highlight());
1203 assert!(!state.is_focused());
1204
1205 let _ = state.update(Event::Focus(true));
1206 assert!(state.shows_highlight() && state.is_focused());
1207 let _ = state.update(Event::Focus(false));
1208 assert!(!state.shows_highlight());
1209
1210 for event in [
1211 Event::Next,
1212 Event::Previous,
1213 Event::First,
1214 Event::Last,
1215 Event::Highlight(0),
1216 ] {
1217 let mut state = sample();
1218 let _ = state.update(event);
1219 assert!(state.shows_highlight());
1220 let _ = state.update(Event::Focus(true));
1221 let _ = state.update(Event::Focus(false));
1222 assert!(!state.shows_highlight(), "leaving the field hides it again");
1223 }
1224 }
1225
1226 #[test]
1227 fn typing_or_scrolling_alone_does_not_show_the_highlight() {
1228 let mut state = sample();
1229 let _ = state.update(Event::Input("set".into()));
1230 let _ = state.update(Event::Scroll(1));
1231 assert!(!state.shows_highlight());
1232 assert!(state.highlighted().is_some(), "Enter still has a target");
1233 }
1234
1235 #[test]
1236 fn typing_filters_ranks_and_asks_for_a_search() {
1237 let mut state = sample();
1238 let output = state.update(Event::Input("cal".into()));
1239 assert!(matches!(output, Some(Output::Search(query)) if query == "cal"));
1240 assert_eq!(ids(&state), ["calendar", "calculator"]);
1241 assert_eq!(highlighted(&state), Some("calendar"));
1242
1243 let _ = state.update(Event::Input("calc".into()));
1245 assert_eq!(ids(&state), ["calculator"]);
1246 assert!(state.highlighted().is_none());
1247
1248 let _ = state.update(Event::Input("set".into()));
1249 assert_eq!(ids(&state), ["settings"]);
1250 let groups: Vec<_> = state.results().map(|(group, _)| group).collect();
1251 assert_eq!(groups, ["Settings"]);
1252
1253 assert!(state.update(Event::Input("set".into())).is_none());
1254 }
1255
1256 #[test]
1257 fn nothing_matching_leaves_nothing_highlighted() {
1258 let mut state = sample();
1259 let _ = state.update(Event::Input("zzz".into()));
1260 assert!(state.is_empty());
1261 assert!(state.highlighted().is_none());
1262 assert!(state.update(Event::ActivateHighlighted).is_none());
1263 let _ = state.update(Event::Next);
1264 assert!(state.highlighted().is_none());
1265 }
1266
1267 #[test]
1268 fn arrows_wrap_and_skip_disabled_items() {
1269 let mut state = sample();
1270 let _ = state.update(Event::Next);
1271 assert_eq!(highlighted(&state), Some("emoji"));
1272 let _ = state.update(Event::Next);
1273 assert_eq!(highlighted(&state), Some("profile"));
1274 let _ = state.update(Event::Previous);
1275 let _ = state.update(Event::Previous);
1276 let _ = state.update(Event::Previous);
1277 assert_eq!(highlighted(&state), Some("settings"));
1278 let _ = state.update(Event::Next);
1279 assert_eq!(highlighted(&state), Some("calendar"));
1280 }
1281
1282 #[test]
1283 fn typing_highlights_the_best_match_in_any_group() {
1284 let mut state = State::new([
1285 group(
1286 "Actions",
1287 [
1288 item("retry", "Retry failed jobs"),
1289 item("clear", "Clear finished jobs").destructive(true),
1290 ],
1291 ),
1292 group(
1293 "Open job",
1294 [
1295 item("ledger", "ledger-reconcile"),
1296 item("lead", "lead-import").disabled(true),
1297 ],
1298 ),
1299 ]);
1300 let _ = state.update(Event::Input("led".into()));
1301 assert_eq!(
1302 ids(&state)[..2],
1303 ["retry", "clear"],
1304 "groups keep their order"
1305 );
1306 assert_eq!(highlighted(&state), Some("ledger"));
1307
1308 let _ = state.update(Event::Input(String::new()));
1309 assert_eq!(highlighted(&state), Some("retry"), "no query, first row");
1310 }
1311
1312 #[test]
1313 fn home_and_end_jump_to_enabled_ends() {
1314 let mut state = State::new([group(
1315 "",
1316 [
1317 item(1, "One").disabled(true),
1318 item(2, "Two"),
1319 item(3, "Three"),
1320 item(4, "Four").disabled(true),
1321 ],
1322 )]);
1323 let _ = state.update(Event::Last);
1324 assert_eq!(state.highlighted().map(|i| i.id), Some(3));
1325 let _ = state.update(Event::First);
1326 assert_eq!(state.highlighted().map(|i| i.id), Some(2));
1327 }
1328
1329 #[test]
1330 fn run_and_activate_return_the_id() {
1331 let mut state = sample();
1332 let _ = state.update(Event::Next);
1333 assert!(matches!(
1334 state.update(Event::ActivateHighlighted),
1335 Some(Output::Activated("emoji"))
1336 ));
1337
1338 assert!(matches!(
1339 state.update(Event::Activate(4)),
1340 Some(Output::Activated("billing"))
1341 ));
1342 assert_eq!(highlighted(&state), Some("billing"));
1343 assert!(state.update(Event::Activate(2)).is_none());
1344 assert!(state.update(Event::Activate(99)).is_none());
1345 }
1346
1347 #[test]
1348 fn hovering_a_disabled_row_keeps_the_highlight() {
1349 let mut state = sample();
1350 let _ = state.update(Event::Highlight(2));
1351 assert_eq!(highlighted(&state), Some("calendar"));
1352 let _ = state.update(Event::Highlight(3));
1353 assert_eq!(highlighted(&state), Some("profile"));
1354 }
1355
1356 #[test]
1357 fn clear_empties_the_query_then_dismisses() {
1358 let mut state = sample();
1359 let _ = state.update(Event::Input("bill".into()));
1360 let output = state.update(Event::Close);
1361 assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
1362 assert_eq!(state.len(), 6);
1363 assert!(matches!(state.update(Event::Close), Some(Output::Closed)));
1364 }
1365
1366 #[test]
1367 fn results_for_the_current_query_join_a_remote_group() {
1368 let mut state = sample();
1369 let _ = state.update(Event::Input("rep".into()));
1370 assert!(state.is_empty());
1371
1372 let _ = state.update(Event::Received(vec![
1373 results("rep", "Files", [item("a", "report.pdf")]),
1374 results("rep", "Files", [item("b", "reply.txt")]),
1375 ]));
1376 assert_eq!(ids(&state), ["a", "b"]);
1377 assert!(state.results().all(|(group, _)| group == "Files"));
1378 assert_eq!(highlighted(&state), Some("a"));
1379 }
1380
1381 #[test]
1382 fn stale_results_are_dropped() {
1383 let mut state = sample();
1384 let _ = state.update(Event::Input("re".into()));
1385 let _ = state.update(Event::Input("rep".into()));
1386 let _ = state.update(Event::Received(vec![results(
1387 "re",
1388 "Files",
1389 [item("old", "readme.md")],
1390 )]));
1391 assert!(state.is_empty());
1392 }
1393
1394 #[test]
1395 fn a_new_query_clears_remote_results() {
1396 let mut state = sample();
1397 let files = |state: &State<&'static str>| {
1398 state
1399 .results()
1400 .filter(|(group, _)| *group == "Files")
1401 .count()
1402 };
1403 let _ = state.update(Event::Input("re".into()));
1404 let _ = state.update(Event::Received(vec![results(
1405 "re",
1406 "Files",
1407 [item("old", "readme.md")],
1408 )]));
1409 assert_eq!(files(&state), 1);
1410 let _ = state.update(Event::Input("rea".into()));
1411 assert_eq!(files(&state), 0);
1412 }
1413
1414 #[test]
1415 fn arriving_results_keep_the_highlight() {
1416 let mut state = sample();
1417 let _ = state.update(Event::Input("s".into()));
1418 let _ = state.update(Event::Next);
1419 let before = highlighted(&state);
1420 let _ = state.update(Event::Received(vec![results(
1421 "s",
1422 "Files",
1423 [item("f", "styles.css")],
1424 )]));
1425 assert_eq!(highlighted(&state), before);
1426 assert_eq!(state.results().last().map(|(_, item)| item.id), Some("f"));
1427 }
1428
1429 #[test]
1430 fn remote_results_are_not_filtered_again() {
1431 let mut state = sample();
1432 let _ = state.update(Event::Input("q".into()));
1433 let _ = state.update(Event::Received(vec![results(
1434 "q",
1435 "Files",
1436 [item("x", "unrelated.txt")],
1437 )]));
1438 assert_eq!(ids(&state), ["x"]);
1439 }
1440
1441 #[test]
1442 fn the_window_follows_the_highlight_and_scrolls() {
1443 let mut state = sample().with_visible_rows(3);
1444 assert_eq!(state.visible(), 0..3);
1445 let _ = state.update(Event::Last);
1446 assert_eq!(state.visible(), 3..6);
1447 let _ = state.update(Event::Next);
1448 assert_eq!(state.visible(), 0..3);
1449 let _ = state.update(Event::Scroll(10));
1450 assert_eq!(state.visible(), 3..6);
1451 let _ = state.update(Event::Scroll(-1));
1452 assert_eq!(state.visible(), 2..5);
1453 assert_eq!(State::<u8>::new([]).with_visible_rows(0).rows, 1);
1454 assert_eq!(State::<u8>::new([]).visible(), 0..0);
1455 }
1456
1457 #[test]
1458 fn ready_hands_the_sender_back() {
1459 let (sender, _receiver) = mpsc::channel(1);
1460 let output = sample().update(Event::Ready(sender));
1461 assert!(matches!(output, Some(Output::Ready(_))));
1462 }
1463
1464 #[test]
1465 fn default_keymap_and_overrides() {
1466 let keymap = default_keymap();
1467 assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1468 assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1469 assert_eq!(press(&keymap, "Home"), Some(Action::First));
1470 assert_eq!(press(&keymap, "End"), Some(Action::Last));
1471 assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1472 assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1473
1474 let custom = keymap
1475 .bind("Ctrl+N".parse().unwrap(), Action::Next)
1476 .unbind(&Chord::named(Named::Home));
1477 assert_eq!(press(&custom, "ctrl+n"), Some(Action::Next));
1478 assert_eq!(press(&custom, "Home"), None);
1479 assert!(default_keymap().clear().is_empty());
1480 }
1481
1482 #[test]
1483 fn actions_map_to_events_only_when_they_apply() {
1484 let mut state = sample();
1485 for action in [Action::Next, Action::Previous, Action::First, Action::Last] {
1486 assert!(action.event(&state).is_some(), "{action:?}");
1487 }
1488 assert!(matches!(
1489 Action::Activate.event(&state),
1490 Some(Event::ActivateHighlighted)
1491 ));
1492 assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1493
1494 let _ = state.update(Event::Input("zzz".into()));
1495 for action in [
1496 Action::Next,
1497 Action::Previous,
1498 Action::First,
1499 Action::Last,
1500 Action::Activate,
1501 ] {
1502 assert!(action.event(&state).is_none(), "{action:?}");
1503 }
1504 assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1505 }
1506
1507 #[test]
1508 fn stream_emits_ready_first_then_batches() {
1509 let mut events = Box::pin(stream::<&'static str>());
1510 let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
1511 panic!("first event must be Ready");
1512 };
1513 block_on(async {
1514 for name in ["a", "b", "c"] {
1515 sender
1516 .send(results("q", "Files", [item(name, name)]))
1517 .await
1518 .ok();
1519 }
1520 });
1521 let Some(Event::Received(batch)) = block_on(events.next()) else {
1522 panic!("expected a batch");
1523 };
1524 let names: Vec<_> = batch.iter().map(|r| r.items[0].id).collect();
1525 assert_eq!(names, ["a", "b", "c"]);
1526 }
1527
1528 #[test]
1529 fn batches_split_bursts_at_batch_size() {
1530 let items = stream::iter(0..BATCH_SIZE + 3);
1531 let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
1532 assert_eq!(sizes, [BATCH_SIZE, 3]);
1533 }
1534
1535 #[test]
1536 fn default_builder_is_disabled() {
1537 let state = sample();
1538 let c: Command<'_, &str, ()> = command(&state);
1539 assert!(!c.is_enabled());
1540 assert_eq!(c.empty, DEFAULT_EMPTY);
1541 assert!(!c.loading);
1542 assert_eq!((c.width, c.height), (Length::Fill, Length::Shrink));
1543 assert_eq!(c.max_height, f32::INFINITY);
1544 let c = c
1545 .loading(true)
1546 .height(320)
1547 .max_height(400.0)
1548 .on_event(|_| ());
1549 assert!(c.is_enabled() && c.loading);
1550 assert_eq!(c.height, Length::Fixed(320.0));
1551 assert_eq!(c.max_height, 400.0);
1552 }
1553
1554 const STATES: [Status; 5] = [
1555 Status::Active,
1556 Status::Hovered,
1557 Status::Focused { is_hovered: false },
1558 Status::Focused { is_hovered: true },
1559 Status::Disabled,
1560 ];
1561
1562 #[test]
1563 fn search_field_is_borderless_and_fades_when_disabled() {
1564 for theme in [light(), dark()] {
1565 let tokens = Tokens::of(&theme);
1566 for status in STATES {
1567 let style = search_style(&tokens, status);
1568 assert_eq!(style.border.width, 0.0);
1569 assert_eq!(style.background, Background::Color(Color::TRANSPARENT));
1570 let expected = if status == Status::Disabled {
1571 fade(tokens.foreground, 0.5)
1572 } else {
1573 tokens.foreground
1574 };
1575 assert_eq!(style.value, expected, "{status:?}");
1576 }
1577 }
1578 }
1579
1580 #[test]
1581 fn dividers_use_the_border_token() {
1582 for theme in [light(), dark()] {
1583 let tokens = Tokens::of(&theme);
1584 assert_eq!(
1585 divider_style(&tokens).background,
1586 Some(Background::Color(tokens.border))
1587 );
1588 }
1589 }
1590}