1use std::{cell::Cell, rc::Rc};
26
27use gpui::{
28 App, Axis, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, KeyBinding, Pixels,
29 Point, ScrollHandle, SharedString, Window, actions, div, prelude::*, px,
30};
31use theme::{TextStyle, Theme, Typeset};
32
33use crate::{
34 icons,
35 input::{self, FieldEvent, TextField},
36 popover, scroll, search,
37 widgets::{ButtonStyle, Buttons, Content},
38};
39
40actions!(
41 bezel_multi_select,
42 [SelectNext, SelectPrevious, Confirm, Dismiss]
43);
44
45pub const KEY_CONTEXT: &str = "MultiSelect";
48
49const WIDTH: f32 = 280.0;
51const SEARCH: &str = "Search…";
52const SEARCH_OR_CREATE: &str = "Search or create…";
53
54pub fn init(cx: &mut App) {
57 cx.bind_keys(bindings());
58}
59
60pub fn bindings() -> Vec<KeyBinding> {
63 let ctx = Some(KEY_CONTEXT);
64 vec![
65 KeyBinding::new("down", SelectNext, ctx),
66 KeyBinding::new("up", SelectPrevious, ctx),
67 KeyBinding::new("enter", Confirm, ctx),
68 KeyBinding::new("escape", Dismiss, ctx),
69 KeyBinding::new("ctrl-n", SelectNext, ctx),
70 KeyBinding::new("ctrl-p", SelectPrevious, ctx),
71 ]
72}
73
74pub fn tint(theme: &Theme, name: &str) -> Hsla {
77 let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
78 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
79 });
80 theme.categorical(hash as usize)
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum Check {
86 Off,
87 On,
88 Mixed,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct Choice {
95 pub name: SharedString,
96 pub check: Check,
97 pub count: Option<usize>,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
103pub enum MultiSelectEvent {
104 Toggled { name: SharedString, on: bool },
106 Created(SharedString),
109 Renamed {
111 from: SharedString,
112 to: SharedString,
113 },
114 Deleted(SharedString),
116 Dismissed,
118 Searched(SharedString),
122}
123
124#[derive(Clone, Debug, PartialEq, Eq)]
126enum Row {
127 Create(SharedString),
128 Choice(usize),
130}
131
132struct Managing {
134 name: SharedString,
135 field: Entity<TextField>,
136}
137
138type Normalize = dyn Fn(&str) -> Option<SharedString>;
139type Tint = dyn Fn(&str, &Theme) -> Hsla;
140
141pub struct MultiSelect {
146 choices: Vec<Choice>,
147 query: Entity<TextField>,
148 text: SharedString,
149 rows: Vec<Row>,
150 active: Option<usize>,
152 create: Option<Rc<Normalize>>,
153 placeholder: Option<SharedString>,
154 plain: bool,
157 searched: bool,
159 manage: bool,
160 tint: Rc<Tint>,
161 width: (Pixels, Pixels),
163 managing: Option<Managing>,
164 refocus: bool,
167 scroll: ScrollHandle,
168 scrolled: Cell<Option<usize>>,
170 focus_handle: FocusHandle,
171}
172
173impl EventEmitter<MultiSelectEvent> for MultiSelect {}
174
175impl MultiSelect {
176 pub fn new(choices: Vec<Choice>, cx: &mut Context<Self>) -> Self {
177 let query = cx.new(|cx| search::query_field(SEARCH, cx));
178 cx.subscribe(&query, |view, query, event: &FieldEvent, cx| {
179 if matches!(event, FieldEvent::Changed(_)) {
180 let text = query.read(cx).content().clone();
181 if view.text != text {
182 view.text = text.clone();
183 view.refilter();
184 if view.searched {
185 cx.emit(MultiSelectEvent::Searched(text));
186 }
187 cx.notify();
188 }
189 }
190 })
191 .detach();
192 let mut view = Self {
193 choices,
194 query,
195 text: "".into(),
196 rows: Vec::new(),
197 active: None,
198 create: None,
199 placeholder: None,
200 plain: false,
201 searched: false,
202 manage: false,
203 tint: Rc::new(|name, theme| tint(theme, name)),
204 width: (px(WIDTH), px(WIDTH)),
205 managing: None,
206 refocus: false,
207 scroll: ScrollHandle::new(),
208 scrolled: Cell::new(None),
209 focus_handle: cx.focus_handle(),
210 };
211 view.refilter();
212 view
213 }
214
215 pub fn with_create(
220 mut self,
221 normalize: impl Fn(&str) -> Option<SharedString> + 'static,
222 ) -> Self {
223 self.create = Some(Rc::new(normalize));
224 self.refilter();
225 self
226 }
227
228 pub fn with_plain_rows(mut self) -> Self {
231 self.plain = true;
232 self
233 }
234
235 pub fn with_search(mut self) -> Self {
240 self.searched = true;
241 self.refilter();
242 self
243 }
244
245 pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
248 self.placeholder = Some(placeholder.into());
249 self
250 }
251
252 pub fn with_manage(mut self) -> Self {
254 self.manage = true;
255 self
256 }
257
258 pub fn with_tint(mut self, tint: impl Fn(&str, &Theme) -> Hsla + 'static) -> Self {
260 self.tint = Rc::new(tint);
261 self
262 }
263
264 pub fn with_width(mut self, min: Pixels, max: Pixels) -> Self {
267 self.width = (min, max);
268 self
269 }
270
271 pub fn choices(&self) -> &[Choice] {
272 &self.choices
273 }
274
275 pub fn set_choices(&mut self, choices: Vec<Choice>, cx: &mut Context<Self>) {
278 self.choices = choices;
279 if let Some(managing) = &self.managing
280 && !self
281 .choices
282 .iter()
283 .any(|choice| choice.name == managing.name)
284 {
285 self.managing = None;
286 self.refocus = true;
287 }
288 self.refilter();
289 cx.notify();
290 }
291
292 pub fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
296 self.managing = None;
297 let placeholder = self.placeholder.clone().unwrap_or(match self.create {
298 Some(_) => SEARCH_OR_CREATE.into(),
299 None => SEARCH.into(),
300 });
301 self.query
302 .update(cx, |query, cx| query.set_placeholder(placeholder, cx));
303 self.clear_query(cx);
304 window.focus(&self.query.focus_handle(cx), cx);
305 cx.notify();
306 }
307
308 fn clear_query(&mut self, cx: &mut Context<Self>) {
309 self.query.update(cx, |query, cx| query.clear(cx));
310 if self.searched && !self.text.is_empty() {
311 cx.emit(MultiSelectEvent::Searched("".into()));
312 }
313 self.text = "".into();
314 self.refilter();
315 }
316
317 fn refilter(&mut self) {
318 let names: Vec<&str> = self
319 .choices
320 .iter()
321 .map(|choice| choice.name.as_ref())
322 .collect();
323 let created = self
324 .create
325 .as_ref()
326 .and_then(|normalize| normalize(&self.text))
327 .filter(|name| !self.choices.iter().any(|choice| choice.name == *name));
328 self.rows = created
329 .map(Row::Create)
330 .into_iter()
331 .chain(
332 match self.searched {
333 true => (0..self.choices.len())
334 .filter(|&ix| !self.text.is_empty() && self.choices[ix].check != Check::On)
335 .collect(),
336 false => popover::filter_indices(&self.text, &names),
337 }
338 .into_iter()
339 .map(Row::Choice),
340 )
341 .collect();
342 self.active = (!self.rows.is_empty()).then_some(0);
343 self.scroll.set_offset(Point::default());
344 self.scrolled.set(None);
345 }
346
347 fn toggle(&mut self, choice: usize, cx: &mut Context<Self>) {
348 let Some(choice) = self.choices.get(choice) else {
349 return;
350 };
351 cx.emit(MultiSelectEvent::Toggled {
352 name: choice.name.clone(),
353 on: choice.check != Check::On,
354 });
355 }
356
357 fn take(&mut self, row: usize, cx: &mut Context<Self>) {
358 match self.rows.get(row).cloned() {
359 Some(Row::Create(name)) => cx.emit(MultiSelectEvent::Created(name)),
360 Some(Row::Choice(choice)) => self.toggle(choice, cx),
361 None => return,
362 }
363 self.clear_query(cx);
364 cx.notify();
365 }
366
367 fn manage_choice(&mut self, choice: usize, window: &mut Window, cx: &mut Context<Self>) {
368 let Some(name) = self.choices.get(choice).map(|choice| choice.name.clone()) else {
369 return;
370 };
371 let field = cx.new(|cx| {
372 let mut field = TextField::new(cx);
373 field.set_content(name.clone(), cx);
374 field.select(0..name.len(), cx);
375 field
376 });
377 window.focus(&field.focus_handle(cx), cx);
378 self.managing = Some(Managing { name, field });
379 cx.notify();
380 }
381
382 fn leave_managing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
383 self.managing = None;
384 window.focus(&self.query.focus_handle(cx), cx);
385 cx.notify();
386 }
387
388 fn rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
389 let Some(managing) = &self.managing else {
390 return;
391 };
392 let text = managing.field.read(cx).content().clone();
393 let to = match &self.create {
394 Some(normalize) => normalize(&text),
395 None => Some(SharedString::from(text.trim().to_owned())).filter(|to| !to.is_empty()),
396 };
397 if let Some(to) = to.filter(|to| *to != managing.name) {
398 cx.emit(MultiSelectEvent::Renamed {
399 from: managing.name.clone(),
400 to,
401 });
402 }
403 self.leave_managing(window, cx);
404 }
405
406 fn delete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
407 if let Some(managing) = &self.managing {
408 cx.emit(MultiSelectEvent::Deleted(managing.name.clone()));
409 }
410 self.leave_managing(window, cx);
411 }
412
413 fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
414 if self.managing.is_none() {
415 self.active = popover::menu_step(self.active, self.rows.len(), 1);
416 cx.notify();
417 }
418 }
419
420 fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
421 if self.managing.is_none() {
422 self.active = popover::menu_step(self.active, self.rows.len(), -1);
423 cx.notify();
424 }
425 }
426
427 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
428 if self.managing.is_some() {
429 self.rename(window, cx);
430 } else if let Some(active) = self.active {
431 self.take(active, cx);
432 }
433 }
434
435 fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
436 if self.managing.is_some() {
437 self.leave_managing(window, cx);
438 } else {
439 cx.emit(MultiSelectEvent::Dismissed);
440 }
441 }
442
443 fn backspace(&mut self, _: &input::Backspace, _: &mut Window, cx: &mut Context<Self>) {
446 if self.managing.is_some() || !self.text.is_empty() {
447 return;
448 }
449 if let Some(last) = self
450 .choices
451 .iter()
452 .rposition(|choice| choice.check == Check::On)
453 {
454 self.toggle(last, cx);
455 cx.stop_propagation();
456 }
457 }
458
459 fn query_line(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::Div {
462 let chips = self
463 .choices
464 .iter()
465 .enumerate()
466 .filter(|(_, choice)| choice.check == Check::On)
467 .map(|(index, choice)| {
468 theme
469 .chip(choice.name.clone(), (self.tint)(&choice.name, theme))
470 .max_w_full()
471 .child(
472 div()
473 .id(("multi-select-remove", index))
474 .flex_none()
475 .cursor_pointer()
476 .on_click(cx.listener(move |view, _, _, cx| view.toggle(index, cx)))
477 .child(
478 icons::icon(icons::glyph::X)
479 .size(px(10.0))
480 .text_color(theme.text_muted),
481 ),
482 )
483 });
484 let chips: Vec<_> = chips.collect();
485 div()
486 .flex()
487 .flex_col()
488 .when(!chips.is_empty(), |line| {
489 line.child(
490 div()
491 .flex()
492 .flex_row()
493 .flex_wrap()
494 .items_center()
495 .gap(px(4.0))
496 .px(px(popover::MENU_ROW_INSET))
497 .py(px(4.0))
498 .children(chips),
499 )
500 })
501 .child(popover::search_line(
502 theme,
503 self.query.clone().into_any_element(),
504 ))
505 }
506
507 fn list(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
508 if self.rows.is_empty() && self.searched && self.text.is_empty() {
509 return div().into_any_element();
510 }
511 if self.rows.is_empty() {
512 return div()
513 .px(px(popover::MENU_ROW_INSET))
514 .py(px(8.0))
515 .text_style(TextStyle::Body)
516 .text_color(theme.text_muted)
517 .child("No matches")
518 .into_any_element();
519 }
520 let rows = self.rows.iter().enumerate().map(|(position, row)| {
521 let active = Some(position) == self.active;
522 let base = popover::menu_row(theme, active, None).on_mouse_move(cx.listener(
523 move |view, _, _, cx| {
524 if view.active != Some(position) {
525 view.active = Some(position);
526 cx.notify();
527 }
528 },
529 ));
530 match row {
531 Row::Create(name) => base
532 .id("multi-select-create")
533 .on_click(cx.listener(move |view, _, _, cx| view.take(position, cx)))
534 .child(
535 icons::icon(icons::glyph::Plus)
536 .size(px(13.0))
537 .text_color(theme.text_muted),
538 )
539 .child("Create")
540 .child(
541 theme
542 .chip(name.clone(), (self.tint)(name, theme))
543 .max_w_full(),
544 )
545 .into_any_element(),
546 Row::Choice(index) => {
547 let choice = &self.choices[*index];
548 let index = *index;
549 let mark = match choice.check {
550 Check::On => Some(icons::glyph::Check),
551 Check::Mixed => Some(icons::glyph::Minus),
552 Check::Off => None,
553 };
554 base.id(("multi-select-row", index))
555 .on_click(cx.listener(move |view, _, _, cx| view.take(position, cx)))
556 .child(
557 div().flex_1().min_w_0().flex().child(match self.plain {
558 true => div()
559 .min_w_0()
560 .truncate()
561 .child(choice.name.clone())
562 .into_any_element(),
563 false => theme
564 .chip(choice.name.clone(), (self.tint)(&choice.name, theme))
565 .flex_shrink_1()
566 .min_w_0()
567 .into_any_element(),
568 }),
569 )
570 .when_some(choice.count, |row, count| {
571 row.child(
572 div()
573 .flex_none()
574 .text_style(TextStyle::Callout)
575 .text_color(theme.text_muted)
576 .child(SharedString::from(count.to_string())),
577 )
578 })
579 .child(
580 div()
581 .flex_none()
582 .size(px(13.0))
583 .when_some(mark, |slot, mark| {
584 slot.child(
585 icons::icon(mark).size(px(13.0)).text_color(theme.text),
586 )
587 }),
588 )
589 .when(self.manage, |row| {
590 row.child(
591 theme
592 .icon_button(icons::glyph::Ellipsis, ButtonStyle::Ghost, None)
593 .id(("multi-select-manage", index))
594 .when(!active, |button| button.invisible())
595 .on_click(cx.listener(move |view, _, window, cx| {
596 cx.stop_propagation();
597 view.manage_choice(index, window, cx);
598 })),
599 )
600 })
601 .into_any_element()
602 }
603 }
604 });
605 if self.scrolled.get() != self.active {
607 self.scrolled.set(self.active);
608 if let Some(active) = self.active {
609 self.scroll.scroll_to_item(active);
610 }
611 }
612 scroll::Viewport::new(
613 "multi-select-rows",
614 div()
615 .id("multi-select-rows-inner")
616 .max_h(px(search::MAX_ROWS * popover::menu_row_height()))
617 .flex()
618 .flex_col()
619 .children(rows),
620 Axis::Vertical,
621 )
622 .track_scroll(&self.scroll)
623 .into_any_element()
624 }
625
626 fn managing_pane(
627 &self,
628 managing: &Managing,
629 theme: &Theme,
630 cx: &mut Context<Self>,
631 ) -> gpui::Div {
632 div()
633 .flex()
634 .flex_col()
635 .child(
636 div()
637 .flex()
638 .flex_row()
639 .items_center()
640 .gap(px(4.0))
641 .p(px(popover::MENU_PAD))
642 .child(
643 theme
644 .icon_button(icons::glyph::ChevronLeft, ButtonStyle::Ghost, None)
645 .id("multi-select-back")
646 .on_click(
647 cx.listener(|view, _, window, cx| view.leave_managing(window, cx)),
648 ),
649 )
650 .child(div().flex_1().min_w_0().child(managing.field.clone())),
651 )
652 .child(popover::divider(theme))
653 .child(
654 popover::menu_row(theme, false, None)
655 .id("multi-select-delete")
656 .text_color(theme.danger)
657 .on_click(cx.listener(|view, _, window, cx| view.delete(window, cx)))
658 .child("Delete"),
659 )
660 }
661}
662
663impl Focusable for MultiSelect {
664 fn focus_handle(&self, _: &App) -> FocusHandle {
667 self.focus_handle.clone()
668 }
669}
670
671impl Render for MultiSelect {
672 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
673 if std::mem::take(&mut self.refocus) {
674 window.focus(&self.query.focus_handle(cx), cx);
675 }
676 let theme = Theme::of(cx).clone();
677 let body = match &self.managing {
678 Some(managing) => self.managing_pane(managing, &theme, cx).into_any_element(),
679 None => div()
680 .flex()
681 .flex_col()
682 .child(self.query_line(&theme, cx))
683 .child(self.list(&theme, cx))
684 .into_any_element(),
685 };
686 popover::popover_card(&theme)
687 .key_context(KEY_CONTEXT)
688 .track_focus(&self.focus_handle)
689 .min_w(self.width.0)
690 .max_w(self.width.1)
691 .on_action(cx.listener(Self::select_next))
692 .on_action(cx.listener(Self::select_previous))
693 .on_action(cx.listener(Self::confirm))
694 .on_action(cx.listener(Self::dismiss))
695 .capture_action(cx.listener(Self::backspace))
696 .on_mouse_down_out(cx.listener(|_, _, _, cx| {
697 cx.emit(MultiSelectEvent::Dismissed);
698 cx.stop_propagation();
699 }))
700 .child(body)
701 }
702}