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
141pub struct MultiSelect {
142    choices: Vec<Choice>,
143    query: Entity<TextField>,
144    text: SharedString,
145    rows: Vec<Row>,
146    /// Position in `rows`.
147    active: Option<usize>,
148    create: Option<Rc<Normalize>>,
149    placeholder: Option<SharedString>,
150    /// Rows paint their names as text rather than as chips — see
151    /// [`MultiSelect::with_plain_rows`].
152    plain: bool,
153    /// The host answers each query — see [`MultiSelect::with_search`].
154    searched: bool,
155    manage: bool,
156    tint: Rc<Tint>,
157    /// The card's narrowest and widest.
158    width: (Pixels, Pixels),
159    managing: Option<Managing>,
160    /// The query takes focus at the next paint: the pane holding it closed
161    /// where no window was to hand.
162    refocus: bool,
163    scroll: ScrollHandle,
164    /// The row the list was last scrolled to — see [`search::SearchList`].
165    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    /// Offer a "Create" row for a query that names no choice. `normalize`
212    /// turns the query into the name it would create, or `None` for one that
213    /// cannot be a name; the row shows, and [`MultiSelectEvent::Created`]
214    /// carries, what it answers. Choices are matched against it as given.
215    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    /// Paint each row's name as text rather than as a chip, for choices that
225    /// are not labels. The choices that are on stay chips over the query.
226    pub fn with_plain_rows(mut self) -> Self {
227        self.plain = true;
228        self
229    }
230
231    /// Let the host search: each change of the query is a
232    /// [`MultiSelectEvent::Searched`], and the rows are the choices that are
233    /// off, in the order [`Self::set_choices`] answers them, unfiltered. The
234    /// choices that are on show only as chips. An empty query shows no rows.
235    pub fn with_search(mut self) -> Self {
236        self.searched = true;
237        self.refilter();
238        self
239    }
240
241    /// What the empty query field says. Left out, it is "Search…", or
242    /// "Search or create…" with [`Self::with_create`].
243    pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
244        self.placeholder = Some(placeholder.into());
245        self
246    }
247
248    /// Give each row a `···` opening a pane to rename or delete its choice.
249    pub fn with_manage(mut self) -> Self {
250        self.manage = true;
251        self
252    }
253
254    /// Colour each name with `tint` instead of [`tint`].
255    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    /// Size the card to its widest row, from `min` up to `max`. Equal bounds
261    /// fix the width. A name wider than `max` is cut at the card's edge.
262    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    /// Replace the choices — the host's answer to an event. The query and the
272    /// open pane stay; a pane whose choice is gone closes.
273    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    /// Start a fresh pick: an empty query, the list pane, and focus in the
289    /// query. Call it as the popover opens: the builders' placeholder takes
290    /// effect here.
291    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    /// Backspace in an empty query turns off the last choice shown in it.
440    /// Taken in the capture phase, before the field sees it.
441    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    /// Every choice that is on as a chip with a ×, then the field on a line
456    /// of its own.
457    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        // Following the active row, as the search list does.
602        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    /// The context around the fields; one of them holds focus while it is
661    /// open — see [`MultiSelect::reset`].
662    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}