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 {
142 choices: Vec<Choice>,
143 query: Entity<TextField>,
144 text: SharedString,
145 rows: Vec<Row>,
146 active: Option<usize>,
148 create: Option<Rc<Normalize>>,
149 placeholder: Option<SharedString>,
150 plain: bool,
153 searched: bool,
155 manage: bool,
156 tint: Rc<Tint>,
157 width: (Pixels, Pixels),
159 managing: Option<Managing>,
160 refocus: bool,
163 scroll: ScrollHandle,
164 scrolled: Cell<Option<usize>>,
166 focus_handle: FocusHandle,
167}
168
169impl EventEmitter<MultiSelectEvent> for MultiSelect {}
170
171impl MultiSelect {
172 pub fn new(choices: Vec<Choice>, cx: &mut Context<Self>) -> Self {
173 let query = cx.new(|cx| search::query_field(SEARCH, cx));
174 cx.subscribe(&query, |view, query, event: &FieldEvent, cx| {
175 if matches!(event, FieldEvent::Changed(_)) {
176 let text = query.read(cx).content().clone();
177 if view.text != text {
178 view.text = text.clone();
179 view.refilter();
180 if view.searched {
181 cx.emit(MultiSelectEvent::Searched(text));
182 }
183 cx.notify();
184 }
185 }
186 })
187 .detach();
188 let mut view = Self {
189 choices,
190 query,
191 text: "".into(),
192 rows: Vec::new(),
193 active: None,
194 create: None,
195 placeholder: None,
196 plain: false,
197 searched: false,
198 manage: false,
199 tint: Rc::new(|name, theme| tint(theme, name)),
200 width: (px(WIDTH), px(WIDTH)),
201 managing: None,
202 refocus: false,
203 scroll: ScrollHandle::new(),
204 scrolled: Cell::new(None),
205 focus_handle: cx.focus_handle(),
206 };
207 view.refilter();
208 view
209 }
210
211 pub fn with_create(
216 mut self,
217 normalize: impl Fn(&str) -> Option<SharedString> + 'static,
218 ) -> Self {
219 self.create = Some(Rc::new(normalize));
220 self.refilter();
221 self
222 }
223
224 pub fn with_plain_rows(mut self) -> Self {
227 self.plain = true;
228 self
229 }
230
231 pub fn with_search(mut self) -> Self {
236 self.searched = true;
237 self.refilter();
238 self
239 }
240
241 pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
244 self.placeholder = Some(placeholder.into());
245 self
246 }
247
248 pub fn with_manage(mut self) -> Self {
250 self.manage = true;
251 self
252 }
253
254 pub fn with_tint(mut self, tint: impl Fn(&str, &Theme) -> Hsla + 'static) -> Self {
256 self.tint = Rc::new(tint);
257 self
258 }
259
260 pub fn with_width(mut self, min: Pixels, max: Pixels) -> Self {
263 self.width = (min, max);
264 self
265 }
266
267 pub fn choices(&self) -> &[Choice] {
268 &self.choices
269 }
270
271 pub fn set_choices(&mut self, choices: Vec<Choice>, cx: &mut Context<Self>) {
274 self.choices = choices;
275 if let Some(managing) = &self.managing
276 && !self
277 .choices
278 .iter()
279 .any(|choice| choice.name == managing.name)
280 {
281 self.managing = None;
282 self.refocus = true;
283 }
284 self.refilter();
285 cx.notify();
286 }
287
288 pub fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
292 self.managing = None;
293 let placeholder = self.placeholder.clone().unwrap_or(match self.create {
294 Some(_) => SEARCH_OR_CREATE.into(),
295 None => SEARCH.into(),
296 });
297 self.query
298 .update(cx, |query, cx| query.set_placeholder(placeholder, cx));
299 self.clear_query(cx);
300 window.focus(&self.query.focus_handle(cx), cx);
301 cx.notify();
302 }
303
304 fn clear_query(&mut self, cx: &mut Context<Self>) {
305 self.query.update(cx, |query, cx| query.clear(cx));
306 if self.searched && !self.text.is_empty() {
307 cx.emit(MultiSelectEvent::Searched("".into()));
308 }
309 self.text = "".into();
310 self.refilter();
311 }
312
313 fn refilter(&mut self) {
314 let names: Vec<&str> = self
315 .choices
316 .iter()
317 .map(|choice| choice.name.as_ref())
318 .collect();
319 let created = self
320 .create
321 .as_ref()
322 .and_then(|normalize| normalize(&self.text))
323 .filter(|name| !self.choices.iter().any(|choice| choice.name == *name));
324 self.rows = created
325 .map(Row::Create)
326 .into_iter()
327 .chain(
328 match self.searched {
329 true => (0..self.choices.len())
330 .filter(|&ix| !self.text.is_empty() && self.choices[ix].check != Check::On)
331 .collect(),
332 false => popover::filter_indices(&self.text, &names),
333 }
334 .into_iter()
335 .map(Row::Choice),
336 )
337 .collect();
338 self.active = (!self.rows.is_empty()).then_some(0);
339 self.scroll.set_offset(Point::default());
340 self.scrolled.set(None);
341 }
342
343 fn toggle(&mut self, choice: usize, cx: &mut Context<Self>) {
344 let Some(choice) = self.choices.get(choice) else {
345 return;
346 };
347 cx.emit(MultiSelectEvent::Toggled {
348 name: choice.name.clone(),
349 on: choice.check != Check::On,
350 });
351 }
352
353 fn take(&mut self, row: usize, cx: &mut Context<Self>) {
354 match self.rows.get(row).cloned() {
355 Some(Row::Create(name)) => cx.emit(MultiSelectEvent::Created(name)),
356 Some(Row::Choice(choice)) => self.toggle(choice, cx),
357 None => return,
358 }
359 self.clear_query(cx);
360 cx.notify();
361 }
362
363 fn manage_choice(&mut self, choice: usize, window: &mut Window, cx: &mut Context<Self>) {
364 let Some(name) = self.choices.get(choice).map(|choice| choice.name.clone()) else {
365 return;
366 };
367 let field = cx.new(|cx| {
368 let mut field = TextField::new(cx);
369 field.set_content(name.clone(), cx);
370 field.select(0..name.len(), cx);
371 field
372 });
373 window.focus(&field.focus_handle(cx), cx);
374 self.managing = Some(Managing { name, field });
375 cx.notify();
376 }
377
378 fn leave_managing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
379 self.managing = None;
380 window.focus(&self.query.focus_handle(cx), cx);
381 cx.notify();
382 }
383
384 fn rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
385 let Some(managing) = &self.managing else {
386 return;
387 };
388 let text = managing.field.read(cx).content().clone();
389 let to = match &self.create {
390 Some(normalize) => normalize(&text),
391 None => Some(SharedString::from(text.trim().to_owned())).filter(|to| !to.is_empty()),
392 };
393 if let Some(to) = to.filter(|to| *to != managing.name) {
394 cx.emit(MultiSelectEvent::Renamed {
395 from: managing.name.clone(),
396 to,
397 });
398 }
399 self.leave_managing(window, cx);
400 }
401
402 fn delete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
403 if let Some(managing) = &self.managing {
404 cx.emit(MultiSelectEvent::Deleted(managing.name.clone()));
405 }
406 self.leave_managing(window, cx);
407 }
408
409 fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
410 if self.managing.is_none() {
411 self.active = popover::menu_step(self.active, self.rows.len(), 1);
412 cx.notify();
413 }
414 }
415
416 fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
417 if self.managing.is_none() {
418 self.active = popover::menu_step(self.active, self.rows.len(), -1);
419 cx.notify();
420 }
421 }
422
423 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
424 if self.managing.is_some() {
425 self.rename(window, cx);
426 } else if let Some(active) = self.active {
427 self.take(active, cx);
428 }
429 }
430
431 fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
432 if self.managing.is_some() {
433 self.leave_managing(window, cx);
434 } else {
435 cx.emit(MultiSelectEvent::Dismissed);
436 }
437 }
438
439 fn backspace(&mut self, _: &input::Backspace, _: &mut Window, cx: &mut Context<Self>) {
442 if self.managing.is_some() || !self.text.is_empty() {
443 return;
444 }
445 if let Some(last) = self
446 .choices
447 .iter()
448 .rposition(|choice| choice.check == Check::On)
449 {
450 self.toggle(last, cx);
451 cx.stop_propagation();
452 }
453 }
454
455 fn query_line(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::Div {
458 let chips = self
459 .choices
460 .iter()
461 .enumerate()
462 .filter(|(_, choice)| choice.check == Check::On)
463 .map(|(index, choice)| {
464 theme
465 .chip(choice.name.clone(), (self.tint)(&choice.name, theme))
466 .max_w_full()
467 .child(
468 div()
469 .id(("multi-select-remove", index))
470 .flex_none()
471 .cursor_pointer()
472 .on_click(cx.listener(move |view, _, _, cx| view.toggle(index, cx)))
473 .child(
474 icons::icon(icons::glyph::X)
475 .size(px(10.0))
476 .text_color(theme.text_muted),
477 ),
478 )
479 });
480 let chips: Vec<_> = chips.collect();
481 div()
482 .flex()
483 .flex_col()
484 .when(!chips.is_empty(), |line| {
485 line.child(
486 div()
487 .flex()
488 .flex_row()
489 .flex_wrap()
490 .items_center()
491 .gap(px(4.0))
492 .px(px(popover::MENU_ROW_INSET))
493 .py(px(4.0))
494 .children(chips),
495 )
496 })
497 .child(popover::search_line(
498 theme,
499 self.query.clone().into_any_element(),
500 ))
501 }
502
503 fn list(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
504 if self.rows.is_empty() && self.searched && self.text.is_empty() {
505 return div().into_any_element();
506 }
507 if self.rows.is_empty() {
508 return div()
509 .px(px(popover::MENU_ROW_INSET))
510 .py(px(8.0))
511 .text_style(TextStyle::Body)
512 .text_color(theme.text_muted)
513 .child("No matches")
514 .into_any_element();
515 }
516 let rows = self.rows.iter().enumerate().map(|(position, row)| {
517 let active = Some(position) == self.active;
518 let base = popover::menu_row(theme, active, None).on_mouse_move(cx.listener(
519 move |view, _, _, cx| {
520 if view.active != Some(position) {
521 view.active = Some(position);
522 cx.notify();
523 }
524 },
525 ));
526 match row {
527 Row::Create(name) => base
528 .id("multi-select-create")
529 .on_click(cx.listener(move |view, _, _, cx| view.take(position, cx)))
530 .child(
531 icons::icon(icons::glyph::Plus)
532 .size(px(13.0))
533 .text_color(theme.text_muted),
534 )
535 .child("Create")
536 .child(
537 theme
538 .chip(name.clone(), (self.tint)(name, theme))
539 .max_w_full(),
540 )
541 .into_any_element(),
542 Row::Choice(index) => {
543 let choice = &self.choices[*index];
544 let index = *index;
545 let mark = match choice.check {
546 Check::On => Some(icons::glyph::Check),
547 Check::Mixed => Some(icons::glyph::Minus),
548 Check::Off => None,
549 };
550 base.id(("multi-select-row", index))
551 .on_click(cx.listener(move |view, _, _, cx| view.take(position, cx)))
552 .child(
553 div().flex_1().min_w_0().flex().child(match self.plain {
554 true => div()
555 .min_w_0()
556 .truncate()
557 .child(choice.name.clone())
558 .into_any_element(),
559 false => theme
560 .chip(choice.name.clone(), (self.tint)(&choice.name, theme))
561 .flex_shrink_1()
562 .min_w_0()
563 .into_any_element(),
564 }),
565 )
566 .when_some(choice.count, |row, count| {
567 row.child(
568 div()
569 .flex_none()
570 .text_style(TextStyle::Callout)
571 .text_color(theme.text_muted)
572 .child(SharedString::from(count.to_string())),
573 )
574 })
575 .child(
576 div()
577 .flex_none()
578 .size(px(13.0))
579 .when_some(mark, |slot, mark| {
580 slot.child(
581 icons::icon(mark).size(px(13.0)).text_color(theme.text),
582 )
583 }),
584 )
585 .when(self.manage, |row| {
586 row.child(
587 theme
588 .icon_button(icons::glyph::Ellipsis, ButtonStyle::Ghost, None)
589 .id(("multi-select-manage", index))
590 .when(!active, |button| button.invisible())
591 .on_click(cx.listener(move |view, _, window, cx| {
592 cx.stop_propagation();
593 view.manage_choice(index, window, cx);
594 })),
595 )
596 })
597 .into_any_element()
598 }
599 }
600 });
601 if self.scrolled.get() != self.active {
603 self.scrolled.set(self.active);
604 if let Some(active) = self.active {
605 self.scroll.scroll_to_item(active);
606 }
607 }
608 scroll::Viewport::new(
609 "multi-select-rows",
610 div()
611 .id("multi-select-rows-inner")
612 .max_h(px(search::MAX_ROWS * popover::menu_row_height()))
613 .flex()
614 .flex_col()
615 .children(rows),
616 Axis::Vertical,
617 )
618 .track_scroll(&self.scroll)
619 .into_any_element()
620 }
621
622 fn managing_pane(
623 &self,
624 managing: &Managing,
625 theme: &Theme,
626 cx: &mut Context<Self>,
627 ) -> gpui::Div {
628 div()
629 .flex()
630 .flex_col()
631 .child(
632 div()
633 .flex()
634 .flex_row()
635 .items_center()
636 .gap(px(4.0))
637 .p(px(popover::MENU_PAD))
638 .child(
639 theme
640 .icon_button(icons::glyph::ChevronLeft, ButtonStyle::Ghost, None)
641 .id("multi-select-back")
642 .on_click(
643 cx.listener(|view, _, window, cx| view.leave_managing(window, cx)),
644 ),
645 )
646 .child(div().flex_1().min_w_0().child(managing.field.clone())),
647 )
648 .child(popover::divider(theme))
649 .child(
650 popover::menu_row(theme, false, None)
651 .id("multi-select-delete")
652 .text_color(theme.danger)
653 .on_click(cx.listener(|view, _, window, cx| view.delete(window, cx)))
654 .child("Delete"),
655 )
656 }
657}
658
659impl Focusable for MultiSelect {
660 fn focus_handle(&self, _: &App) -> FocusHandle {
663 self.focus_handle.clone()
664 }
665}
666
667impl Render for MultiSelect {
668 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
669 if std::mem::take(&mut self.refocus) {
670 window.focus(&self.query.focus_handle(cx), cx);
671 }
672 let theme = Theme::of(cx).clone();
673 let body = match &self.managing {
674 Some(managing) => self.managing_pane(managing, &theme, cx).into_any_element(),
675 None => div()
676 .flex()
677 .flex_col()
678 .child(self.query_line(&theme, cx))
679 .child(self.list(&theme, cx))
680 .into_any_element(),
681 };
682 popover::popover_card(&theme)
683 .key_context(KEY_CONTEXT)
684 .track_focus(&self.focus_handle)
685 .min_w(self.width.0)
686 .max_w(self.width.1)
687 .on_action(cx.listener(Self::select_next))
688 .on_action(cx.listener(Self::select_previous))
689 .on_action(cx.listener(Self::confirm))
690 .on_action(cx.listener(Self::dismiss))
691 .capture_action(cx.listener(Self::backspace))
692 .on_mouse_down_out(cx.listener(|_, _, _, cx| {
693 cx.emit(MultiSelectEvent::Dismissed);
694 cx.stop_propagation();
695 }))
696 .child(body)
697 }
698}