Skip to main content

ui/
multi_select.rs

1//! [`MultiSelect`] — Notion's multi-select picker: names chosen from a shared
2//! set, several at once, and the set managed in place.
3//!
4//! The picker is the popover's body, not its trigger: it renders its own
5//! [`popover::popover_card`] and the host mounts it through one of
6//! [`popover`]'s anchored layers, so one picker serves any trigger — a table
7//! cell, a column heading, a toolbar button.
8//!
9//! The host owns the data. Every event is a request, and the picker paints
10//! what [`MultiSelect::set_choices`] last handed it.
11//!
12//! ```ignore
13//! ui::multi_select::init(cx);   // once, at startup (with input::init)
14//! let labels = cx.new(|cx| {
15//!     MultiSelect::new(choices, cx).with_create(normalize).with_manage()
16//! });
17//! cx.subscribe(&labels, |_, _, event, _| match event {
18//!     MultiSelectEvent::Toggled { name, on } => { /* apply or remove */ }
19//!     MultiSelectEvent::Dismissed => { /* close the popover */ }
20//!     _ => {}
21//! })
22//! .detach();
23//! ```
24
25use 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
45/// The key context the picker claims. It wraps its fields' own context, so
46/// typing goes to a field while navigation keys fall through.
47pub const KEY_CONTEXT: &str = "MultiSelect";
48
49/// The card's width until [`MultiSelect::with_width`] says otherwise.
50const WIDTH: f32 = 280.0;
51const SEARCH: &str = "Search…";
52const SEARCH_OR_CREATE: &str = "Search or create…";
53
54/// Install the bindings — [`bindings`], bound. Call once, alongside
55/// [`crate::input::init`].
56pub fn init(cx: &mut App) {
57    cx.bind_keys(bindings());
58}
59
60/// The picker's navigation keymap, as data — see [`crate::keys`] for layering
61/// over it or taking a chord away.
62pub 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
74/// A colour for `name`, the same for the same name on every machine: an
75/// FNV-1a hash of its bytes into [`Theme::categorical`].
76pub 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/// Whether a choice is on, for everything the picker is choosing for.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum Check {
86    Off,
87    On,
88    /// On for some and off for others — a picker over several entries.
89    Mixed,
90}
91
92/// One name the picker offers.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct Choice {
95    pub name: SharedString,
96    pub check: Check,
97    /// Painted after the name when present — how many entries carry it.
98    pub count: Option<usize>,
99}
100
101/// What the picker asks of its host.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub enum MultiSelectEvent {
104    /// `name` turned on or off. A mixed choice turns on.
105    Toggled { name: SharedString, on: bool },
106    /// A name no choice has, already normalised by
107    /// [`MultiSelect::with_create`]'s function, to be created and turned on.
108    Created(SharedString),
109    /// `from` renamed to `to`, normalised. `to` may already be a choice.
110    Renamed {
111        from: SharedString,
112        to: SharedString,
113    },
114    /// `name` deleted. Not confirmed by the picker.
115    Deleted(SharedString),
116    /// Escape, or a press outside the card.
117    Dismissed,
118    /// The query changed, in a picker the host searches — see
119    /// [`MultiSelect::with_search`]. Answered with
120    /// [`MultiSelect::set_choices`].
121    Searched(SharedString),
122}
123
124/// The rows the list pane shows, in the order they paint.
125#[derive(Clone, Debug, PartialEq, Eq)]
126enum Row {
127    Create(SharedString),
128    /// An index into the choices.
129    Choice(usize),
130}
131
132/// The pane `···` opens: one choice's name, editable, and its Delete.
133struct 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
141/// Rows show a check for on and a dash for mixed. Taking a row clears the
142/// query. The Create row sits first and is active while it shows. Escape in
143/// the manage pane returns to the list. The list shows twelve rows and
144/// scrolls past that.
145pub struct MultiSelect {
146    choices: Vec<Choice>,
147    query: Entity<TextField>,
148    text: SharedString,
149    rows: Vec<Row>,
150    /// Position in `rows`.
151    active: Option<usize>,
152    create: Option<Rc<Normalize>>,
153    placeholder: Option<SharedString>,
154    /// Rows paint their names as text rather than as chips — see
155    /// [`MultiSelect::with_plain_rows`].
156    plain: bool,
157    /// The host answers each query — see [`MultiSelect::with_search`].
158    searched: bool,
159    manage: bool,
160    tint: Rc<Tint>,
161    /// The card's narrowest and widest.
162    width: (Pixels, Pixels),
163    managing: Option<Managing>,
164    /// The query takes focus at the next paint: the pane holding it closed
165    /// where no window was to hand.
166    refocus: bool,
167    scroll: ScrollHandle,
168    /// The row the list was last scrolled to — see [`search::SearchList`].
169    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    /// Offer a "Create" row for a query that names no choice. `normalize`
216    /// turns the query into the name it would create, or `None` for one that
217    /// cannot be a name; the row shows, and [`MultiSelectEvent::Created`]
218    /// carries, what it answers. Choices are matched against it as given.
219    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    /// Paint each row's name as text rather than as a chip, for choices that
229    /// are not labels. The choices that are on stay chips over the query.
230    pub fn with_plain_rows(mut self) -> Self {
231        self.plain = true;
232        self
233    }
234
235    /// Let the host search: each change of the query is a
236    /// [`MultiSelectEvent::Searched`], and the rows are the choices that are
237    /// off, in the order [`Self::set_choices`] answers them, unfiltered. The
238    /// choices that are on show only as chips. An empty query shows no rows.
239    pub fn with_search(mut self) -> Self {
240        self.searched = true;
241        self.refilter();
242        self
243    }
244
245    /// What the empty query field says. Left out, it is "Search…", or
246    /// "Search or create…" with [`Self::with_create`].
247    pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
248        self.placeholder = Some(placeholder.into());
249        self
250    }
251
252    /// Give each row a `···` opening a pane to rename or delete its choice.
253    pub fn with_manage(mut self) -> Self {
254        self.manage = true;
255        self
256    }
257
258    /// Colour each name with `tint` instead of [`tint`].
259    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    /// Size the card to its widest row, from `min` up to `max`. Equal bounds
265    /// fix the width. A name wider than `max` is cut at the card's edge.
266    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    /// Replace the choices — the host's answer to an event. The query and the
276    /// open pane stay; a pane whose choice is gone closes.
277    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    /// Start a fresh pick: an empty query, the list pane, and focus in the
293    /// query. Call it as the popover opens: the builders' placeholder takes
294    /// effect here.
295    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    /// Backspace in an empty query turns off the last choice shown in it.
444    /// Taken in the capture phase, before the field sees it.
445    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    /// Every choice that is on as a chip with a ×, then the field on a line
460    /// of its own.
461    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        // Following the active row, as the search list does.
606        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    /// The context around the fields; one of them holds focus while it is
665    /// open — see [`MultiSelect::reset`].
666    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}