1use std::sync::Arc;
5
6use gpui_kit::{AnyElement, App, IntoElement as _, SharedString, Window};
7
8use crate::Icon;
9
10type LeadingBuilder = Arc<dyn Fn(&mut Window, &mut App) -> AnyElement + Send + Sync>;
13
14#[derive(Clone)]
16enum Leading {
17 Icon(Box<Icon>),
20 Element(LeadingBuilder),
22}
23
24pub trait SelectValue: Clone + PartialEq + 'static {
50 fn key(&self) -> SharedString;
52}
53
54impl SelectValue for SharedString {
55 fn key(&self) -> SharedString {
56 self.clone()
57 }
58}
59
60impl SelectValue for String {
61 fn key(&self) -> SharedString {
62 self.clone().into()
63 }
64}
65
66impl SelectValue for &'static str {
67 fn key(&self) -> SharedString {
68 (*self).into()
69 }
70}
71
72macro_rules! integer_values {
73 ($($int:ty),*) => {
74 $(
75 impl SelectValue for $int {
76 fn key(&self) -> SharedString {
77 self.to_string().into()
78 }
79 }
80 )*
81 };
82}
83
84integer_values!(usize, u32, u64, i32, i64);
85
86#[derive(Clone)]
104pub struct SelectItem<V> {
105 value: V,
106 label: SharedString,
107 description: Option<SharedString>,
108 leading: Option<Leading>,
109 keywords: Vec<SharedString>,
111 disabled: bool,
112}
113
114impl<V: SelectValue> std::fmt::Debug for SelectItem<V> {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("SelectItem")
117 .field("key", &self.value.key())
118 .field("label", &self.label)
119 .field("description", &self.description)
120 .field("disabled", &self.disabled)
121 .finish_non_exhaustive()
122 }
123}
124
125impl<V: SelectValue> SelectItem<V> {
126 pub fn new(value: V, label: impl Into<SharedString>) -> Self {
128 let label = label.into();
129 Self {
130 value,
131 keywords: Vec::new(),
132 label,
133 description: None,
134 leading: None,
135 disabled: false,
136 }
137 }
138
139 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
141 self.description = Some(description.into());
142 self
143 }
144
145 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
148 self.leading = Some(Leading::Icon(Box::new(icon.into())));
149 self
150 }
151
152 pub fn leading<E: gpui_kit::IntoElement>(
157 mut self,
158 build: impl Fn(&mut Window, &mut App) -> E + Send + Sync + 'static,
159 ) -> Self {
160 self.leading = Some(Leading::Element(Arc::new(move |window, cx| {
161 build(window, cx).into_any_element()
162 })));
163 self
164 }
165
166 pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
169 for keyword in keywords {
170 self.keywords.push(keyword.into());
171 }
172 self
173 }
174
175 pub fn disabled(mut self, disabled: bool) -> Self {
178 self.disabled = disabled;
179 self
180 }
181
182 pub fn value(&self) -> &V {
184 &self.value
185 }
186
187 pub fn key(&self) -> SharedString {
189 self.value.key()
190 }
191
192 pub fn label(&self) -> &SharedString {
194 &self.label
195 }
196
197 pub fn description_text(&self) -> Option<&SharedString> {
199 self.description.as_ref()
200 }
201
202 pub fn icon_source(&self) -> Option<&Icon> {
204 match &self.leading {
205 Some(Leading::Icon(icon)) => Some(icon),
206 _ => None,
207 }
208 }
209
210 pub fn is_disabled(&self) -> bool {
212 self.disabled
213 }
214
215 pub(crate) fn render_leading(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
217 match self.leading.as_ref()? {
218 Leading::Icon(icon) => Some((**icon).clone().into_any_element()),
219 Leading::Element(build) => Some(build(window, cx)),
220 }
221 }
222
223 pub fn matches(&self, query: &str) -> bool {
226 self.match_score(query).is_some()
227 }
228
229 pub fn match_score(&self, query: &str) -> Option<u32> {
233 let label = crate::fuzzy::score_of(query, &self.label);
234 let keyword = self
235 .keywords
236 .iter()
237 .filter_map(|keyword| crate::fuzzy::score_of(query, keyword))
238 .max()
239 .map(|score| score / 2);
240 label.max(keyword)
241 }
242}
243
244#[derive(Clone, Debug)]
246pub enum SelectEntry<V: SelectValue> {
247 Item(SelectItem<V>),
249 Label(SharedString),
251 Separator,
253}
254
255impl<V: SelectValue> SelectEntry<V> {
256 pub fn label(text: impl Into<SharedString>) -> Self {
258 Self::Label(text.into())
259 }
260
261 pub fn item(&self) -> Option<&SelectItem<V>> {
263 match self {
264 Self::Item(item) => Some(item),
265 _ => None,
266 }
267 }
268}
269
270impl<V: SelectValue> From<SelectItem<V>> for SelectEntry<V> {
271 fn from(item: SelectItem<V>) -> Self {
272 Self::Item(item)
273 }
274}
275
276pub(crate) fn visible_entries<V: SelectValue>(
280 entries: &[SelectEntry<V>],
281 query: &str,
282) -> Vec<usize> {
283 let query = query.trim();
284 let mut visible = Vec::with_capacity(entries.len());
285 let mut pending_label: Option<usize> = None;
286 let mut pending_separator: Option<usize> = None;
287 let mut group: Vec<(usize, u32)> = Vec::new();
288 let flush = |group: &mut Vec<(usize, u32)>,
291 visible: &mut Vec<usize>,
292 pending_separator: &mut Option<usize>,
293 pending_label: &mut Option<usize>| {
294 if group.is_empty() {
295 return;
296 }
297 if let Some(separator) = pending_separator.take()
298 && !visible.is_empty()
299 {
300 visible.push(separator);
301 }
302 if let Some(label) = pending_label.take() {
303 visible.push(label);
304 }
305 group.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
306 visible.extend(group.drain(..).map(|(index, _)| index));
307 };
308 for (index, entry) in entries.iter().enumerate() {
309 match entry {
310 SelectEntry::Item(item) => {
311 if let Some(score) = item.match_score(query) {
312 group.push((index, score));
313 }
314 }
315 SelectEntry::Label(_) => {
316 flush(
317 &mut group,
318 &mut visible,
319 &mut pending_separator,
320 &mut pending_label,
321 );
322 pending_label = Some(index);
323 }
324 SelectEntry::Separator => {
325 flush(
326 &mut group,
327 &mut visible,
328 &mut pending_separator,
329 &mut pending_label,
330 );
331 pending_label = None;
332 pending_separator = Some(index);
333 }
334 }
335 }
336 flush(
337 &mut group,
338 &mut visible,
339 &mut pending_separator,
340 &mut pending_label,
341 );
342 visible
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 fn entries() -> Vec<SelectEntry<&'static str>> {
350 vec![
351 SelectEntry::label("Type"),
352 SelectItem::new("all", "All chats").into(),
353 SelectItem::new("local", "Local").into(),
354 SelectEntry::Separator,
355 SelectEntry::label("Sort by"),
356 SelectItem::new("updated", "Updated").into(),
357 SelectItem::new("created", "Created")
358 .keywords(["time"])
359 .into(),
360 ]
361 }
362
363 #[test]
364 fn matching_is_fuzzy_ignores_case_and_reads_keywords() {
365 let item = SelectItem::new("de", "Deutsch").keywords(["German"]);
366 assert!(item.matches(""));
367 assert!(item.matches(" "));
368 assert!(item.matches("deu"));
369 assert!(item.matches("GERM"));
370 assert!(item.matches("dtsh"));
371 assert!(item.matches("grmn"));
372 assert!(!item.matches("french"));
373 }
374
375 #[test]
376 fn a_query_ranks_the_rows_of_a_group_best_first() {
377 let entries: Vec<SelectEntry<&'static str>> = vec![
378 SelectEntry::label("Language"),
379 SelectItem::new("de", "Deutsch").keywords(["German"]).into(),
380 SelectItem::new("fr", "French").into(),
381 SelectItem::new("en", "English").into(),
382 SelectEntry::Separator,
383 SelectEntry::label("Other"),
384 SelectItem::new("x", "Zen").into(),
385 SelectItem::new("y", "Enigma").into(),
386 ];
387 let labels = |query: &str| -> Vec<String> {
388 visible_entries(&entries, query)
389 .into_iter()
390 .map(|index| match &entries[index] {
391 SelectEntry::Item(item) => item.label().to_string(),
392 SelectEntry::Label(text) => format!("# {text}"),
393 SelectEntry::Separator => "--".to_string(),
394 })
395 .collect()
396 };
397 assert_eq!(
398 labels("en"),
399 [
400 "# Language",
401 "English",
402 "French",
403 "Deutsch",
404 "--",
405 "# Other",
406 "Enigma",
407 "Zen"
408 ],
409 "label hits beat the keyword hit, groups stay"
410 );
411 assert_eq!(
412 labels("").len(),
413 entries.len(),
414 "an empty query keeps the order"
415 );
416 assert_eq!(
417 visible_entries(&entries, ""),
418 (0..entries.len()).collect::<Vec<_>>()
419 );
420 }
421
422 #[test]
423 fn an_empty_query_shows_every_row() {
424 assert_eq!(visible_entries(&entries(), ""), vec![0, 1, 2, 3, 4, 5, 6]);
425 }
426
427 #[test]
428 fn a_query_keeps_only_the_labels_and_separators_that_still_frame_an_item() {
429 let entries = entries();
430 assert_eq!(visible_entries(&entries, "local"), vec![0, 2]);
431 assert_eq!(visible_entries(&entries, "time"), vec![4, 6]);
432 assert_eq!(visible_entries(&entries, "ated"), vec![4, 5, 6]);
433 assert_eq!(
434 visible_entries(&entries, "l"),
435 vec![0, 2, 1],
436 "Local starts with the letter, so it ranks above All chats"
437 );
438 assert_eq!(
439 visible_entries(&entries, " LOC "),
440 vec![0, 2],
441 "case and space"
442 );
443 assert_eq!(visible_entries(&entries, "zzz"), Vec::<usize>::new());
444 }
445
446 #[test]
447 fn a_separator_never_leads_or_trails() {
448 let entries: Vec<SelectEntry<&'static str>> = vec![
449 SelectEntry::Separator,
450 SelectItem::new("a", "A").into(),
451 SelectEntry::Separator,
452 SelectEntry::Separator,
453 SelectItem::new("b", "B").into(),
454 SelectEntry::Separator,
455 ];
456 assert_eq!(visible_entries(&entries, ""), vec![1, 3, 4]);
457 }
458
459 #[test]
460 fn rows_can_cross_threads() {
461 fn assert_send<T: Send>(_: &T) {}
462 let entries: Vec<SelectEntry<usize>> = vec![
463 SelectItem::new(1, "One")
464 .leading(|_, _| gpui_kit::div())
465 .into(),
466 SelectEntry::Separator,
467 ];
468 assert_send(&entries);
469 }
470
471 #[test]
472 fn readers_read_back() {
473 let item = SelectItem::new("v", "Label")
474 .description("More")
475 .disabled(true);
476 assert_eq!(*item.value(), "v");
477 assert_eq!(item.key(), "v");
478 assert_eq!(item.label(), "Label");
479 assert_eq!(item.description_text().map(|d| d.as_ref()), Some("More"));
480 assert!(item.is_disabled());
481 assert!(item.icon_source().is_none());
482 assert!(SelectEntry::from(item.clone()).item().is_some());
483 assert!(SelectEntry::<&str>::Separator.item().is_none());
484 assert!(format!("{item:?}").contains("Label"));
485 assert_eq!(7usize.key(), "7");
486 assert_eq!(String::from("s").key(), "s");
487 }
488}