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;
51
52/// Install the bindings — [`bindings`], bound. Call once, alongside
53/// [`crate::input::init`].
54pub fn init(cx: &mut App) {
55    cx.bind_keys(bindings());
56}
57
58/// The picker's navigation keymap, as data — see [`crate::keys`] for layering
59/// over it or taking a chord away.
60pub fn bindings() -> Vec<KeyBinding> {
61    let ctx = Some(KEY_CONTEXT);
62    vec![
63        KeyBinding::new("down", SelectNext, ctx),
64        KeyBinding::new("up", SelectPrevious, ctx),
65        KeyBinding::new("enter", Confirm, ctx),
66        KeyBinding::new("escape", Dismiss, ctx),
67        KeyBinding::new("ctrl-n", SelectNext, ctx),
68        KeyBinding::new("ctrl-p", SelectPrevious, ctx),
69    ]
70}
71
72/// A colour for `name`, the same for the same name on every machine: an
73/// FNV-1a hash of its bytes into [`Theme::categorical`].
74pub fn tint(theme: &Theme, name: &str) -> Hsla {
75    let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
76        (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
77    });
78    theme.categorical(hash as usize)
79}
80
81/// Whether a choice is on, for everything the picker is choosing for.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Check {
84    Off,
85    On,
86    /// On for some and off for others — a picker over several entries.
87    Mixed,
88}
89
90/// One name the picker offers.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct Choice {
93    pub name: SharedString,
94    pub check: Check,
95    /// Painted after the name when present — how many entries carry it.
96    pub count: Option<usize>,
97}
98
99/// What the picker asks of its host.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum MultiSelectEvent {
102    /// `name` turned on or off. A mixed choice turns on.
103    Toggled { name: SharedString, on: bool },
104    /// A name no choice has, already normalised by
105    /// [`MultiSelect::with_create`]'s function, to be created and turned on.
106    Created(SharedString),
107    /// `from` renamed to `to`, normalised. `to` may already be a choice.
108    Renamed {
109        from: SharedString,
110        to: SharedString,
111    },
112    /// `name` deleted. Not confirmed by the picker.
113    Deleted(SharedString),
114    /// Escape, or a press outside the card.
115    Dismissed,
116}
117
118/// The rows the list pane shows, in the order they paint.
119#[derive(Clone, Debug, PartialEq, Eq)]
120enum Row {
121    Create(SharedString),
122    /// An index into the choices.
123    Choice(usize),
124}
125
126/// The pane `···` opens: one choice's name, editable, and its Delete.
127struct Managing {
128    name: SharedString,
129    field: Entity<TextField>,
130}
131
132type Normalize = dyn Fn(&str) -> Option<SharedString>;
133type Tint = dyn Fn(&str, &Theme) -> Hsla;
134
135pub struct MultiSelect {
136    choices: Vec<Choice>,
137    query: Entity<TextField>,
138    text: SharedString,
139    rows: Vec<Row>,
140    /// Position in `rows`.
141    active: Option<usize>,
142    create: Option<Rc<Normalize>>,
143    manage: bool,
144    tint: Rc<Tint>,
145    /// The card's narrowest and widest.
146    width: (Pixels, Pixels),
147    managing: Option<Managing>,
148    /// The query takes focus at the next paint: the pane holding it closed
149    /// where no window was to hand.
150    refocus: bool,
151    scroll: ScrollHandle,
152    /// The row the list was last scrolled to — see [`search::SearchList`].
153    scrolled: Cell<Option<usize>>,
154    focus_handle: FocusHandle,
155}
156
157impl EventEmitter<MultiSelectEvent> for MultiSelect {}
158
159impl MultiSelect {
160    pub fn new(choices: Vec<Choice>, cx: &mut Context<Self>) -> Self {
161        let query = cx.new(|cx| search::query_field("Search or create…", cx));
162        cx.subscribe(&query, |view, query, event: &FieldEvent, cx| {
163            if matches!(event, FieldEvent::Changed(_)) {
164                let text = query.read(cx).content().clone();
165                if view.text != text {
166                    view.text = text;
167                    view.refilter();
168                    cx.notify();
169                }
170            }
171        })
172        .detach();
173        let mut view = Self {
174            choices,
175            query,
176            text: "".into(),
177            rows: Vec::new(),
178            active: None,
179            create: None,
180            manage: false,
181            tint: Rc::new(|name, theme| tint(theme, name)),
182            width: (px(WIDTH), px(WIDTH)),
183            managing: None,
184            refocus: false,
185            scroll: ScrollHandle::new(),
186            scrolled: Cell::new(None),
187            focus_handle: cx.focus_handle(),
188        };
189        view.refilter();
190        view
191    }
192
193    /// Offer a "Create" row for a query that names no choice. `normalize`
194    /// turns the query into the name it would create, or `None` for one that
195    /// cannot be a name; the row shows, and [`MultiSelectEvent::Created`]
196    /// carries, what it answers. Choices are matched against it as given.
197    pub fn with_create(
198        mut self,
199        normalize: impl Fn(&str) -> Option<SharedString> + 'static,
200    ) -> Self {
201        self.create = Some(Rc::new(normalize));
202        self.refilter();
203        self
204    }
205
206    /// Give each row a `···` opening a pane to rename or delete its choice.
207    pub fn with_manage(mut self) -> Self {
208        self.manage = true;
209        self
210    }
211
212    /// Colour each name with `tint` instead of [`tint`].
213    pub fn with_tint(mut self, tint: impl Fn(&str, &Theme) -> Hsla + 'static) -> Self {
214        self.tint = Rc::new(tint);
215        self
216    }
217
218    /// Size the card to its widest row, from `min` up to `max`. Equal bounds
219    /// fix the width. A name wider than `max` is cut at the card's edge.
220    pub fn with_width(mut self, min: Pixels, max: Pixels) -> Self {
221        self.width = (min, max);
222        self
223    }
224
225    pub fn choices(&self) -> &[Choice] {
226        &self.choices
227    }
228
229    /// Replace the choices — the host's answer to an event. The query and the
230    /// open pane stay; a pane whose choice is gone closes.
231    pub fn set_choices(&mut self, choices: Vec<Choice>, cx: &mut Context<Self>) {
232        self.choices = choices;
233        if let Some(managing) = &self.managing
234            && !self
235                .choices
236                .iter()
237                .any(|choice| choice.name == managing.name)
238        {
239            self.managing = None;
240            self.refocus = true;
241        }
242        self.refilter();
243        cx.notify();
244    }
245
246    /// Start a fresh pick: an empty query, the list pane, and focus in the
247    /// query. Call it as the popover opens.
248    pub fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
249        self.managing = None;
250        self.clear_query(cx);
251        window.focus(&self.query.focus_handle(cx), cx);
252        cx.notify();
253    }
254
255    fn clear_query(&mut self, cx: &mut Context<Self>) {
256        self.query.update(cx, |query, cx| query.clear(cx));
257        self.text = "".into();
258        self.refilter();
259    }
260
261    fn refilter(&mut self) {
262        let names: Vec<&str> = self
263            .choices
264            .iter()
265            .map(|choice| choice.name.as_ref())
266            .collect();
267        let created = self
268            .create
269            .as_ref()
270            .and_then(|normalize| normalize(&self.text))
271            .filter(|name| !self.choices.iter().any(|choice| choice.name == *name));
272        self.rows = created
273            .map(Row::Create)
274            .into_iter()
275            .chain(
276                popover::filter_indices(&self.text, &names)
277                    .into_iter()
278                    .map(Row::Choice),
279            )
280            .collect();
281        self.active = (!self.rows.is_empty()).then_some(0);
282        self.scroll.set_offset(Point::default());
283        self.scrolled.set(None);
284    }
285
286    fn toggle(&mut self, choice: usize, cx: &mut Context<Self>) {
287        let Some(choice) = self.choices.get(choice) else {
288            return;
289        };
290        cx.emit(MultiSelectEvent::Toggled {
291            name: choice.name.clone(),
292            on: choice.check != Check::On,
293        });
294    }
295
296    fn take(&mut self, row: usize, cx: &mut Context<Self>) {
297        match self.rows.get(row).cloned() {
298            Some(Row::Create(name)) => cx.emit(MultiSelectEvent::Created(name)),
299            Some(Row::Choice(choice)) => self.toggle(choice, cx),
300            None => return,
301        }
302        self.clear_query(cx);
303        cx.notify();
304    }
305
306    fn manage_choice(&mut self, choice: usize, window: &mut Window, cx: &mut Context<Self>) {
307        let Some(name) = self.choices.get(choice).map(|choice| choice.name.clone()) else {
308            return;
309        };
310        let field = cx.new(|cx| {
311            let mut field = TextField::new(cx);
312            field.set_content(name.clone(), cx);
313            field.select(0..name.len(), cx);
314            field
315        });
316        window.focus(&field.focus_handle(cx), cx);
317        self.managing = Some(Managing { name, field });
318        cx.notify();
319    }
320
321    fn leave_managing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
322        self.managing = None;
323        window.focus(&self.query.focus_handle(cx), cx);
324        cx.notify();
325    }
326
327    fn rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
328        let Some(managing) = &self.managing else {
329            return;
330        };
331        let text = managing.field.read(cx).content().clone();
332        let to = match &self.create {
333            Some(normalize) => normalize(&text),
334            None => Some(SharedString::from(text.trim().to_owned())).filter(|to| !to.is_empty()),
335        };
336        if let Some(to) = to.filter(|to| *to != managing.name) {
337            cx.emit(MultiSelectEvent::Renamed {
338                from: managing.name.clone(),
339                to,
340            });
341        }
342        self.leave_managing(window, cx);
343    }
344
345    fn delete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
346        if let Some(managing) = &self.managing {
347            cx.emit(MultiSelectEvent::Deleted(managing.name.clone()));
348        }
349        self.leave_managing(window, cx);
350    }
351
352    fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
353        if self.managing.is_none() {
354            self.active = popover::menu_step(self.active, self.rows.len(), 1);
355            cx.notify();
356        }
357    }
358
359    fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
360        if self.managing.is_none() {
361            self.active = popover::menu_step(self.active, self.rows.len(), -1);
362            cx.notify();
363        }
364    }
365
366    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
367        if self.managing.is_some() {
368            self.rename(window, cx);
369        } else if let Some(active) = self.active {
370            self.take(active, cx);
371        }
372    }
373
374    fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
375        if self.managing.is_some() {
376            self.leave_managing(window, cx);
377        } else {
378            cx.emit(MultiSelectEvent::Dismissed);
379        }
380    }
381
382    /// Backspace in an empty query turns off the last choice shown in it.
383    /// Taken in the capture phase, before the field sees it.
384    fn backspace(&mut self, _: &input::Backspace, _: &mut Window, cx: &mut Context<Self>) {
385        if self.managing.is_some() || !self.text.is_empty() {
386            return;
387        }
388        if let Some(last) = self
389            .choices
390            .iter()
391            .rposition(|choice| choice.check == Check::On)
392        {
393            self.toggle(last, cx);
394            cx.stop_propagation();
395        }
396    }
397
398    /// The query line: every choice that is on as a chip with a ×, then the
399    /// field.
400    fn query_line(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::Div {
401        let chips = self
402            .choices
403            .iter()
404            .enumerate()
405            .filter(|(_, choice)| choice.check == Check::On)
406            .map(|(index, choice)| {
407                theme
408                    .chip(choice.name.clone(), (self.tint)(&choice.name, theme))
409                    .max_w_full()
410                    .child(
411                        div()
412                            .id(("multi-select-remove", index))
413                            .flex_none()
414                            .cursor_pointer()
415                            .on_click(cx.listener(move |view, _, _, cx| view.toggle(index, cx)))
416                            .child(
417                                icons::icon(icons::glyph::X)
418                                    .size(px(10.0))
419                                    .text_color(theme.text_muted),
420                            ),
421                    )
422            });
423        popover::search_line(
424            theme,
425            div()
426                .flex()
427                .flex_row()
428                .flex_wrap()
429                .items_center()
430                .gap(px(4.0))
431                .children(chips)
432                .child(div().flex_1().min_w(px(80.0)).child(self.query.clone()))
433                .into_any_element(),
434        )
435    }
436
437    fn list(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
438        if self.rows.is_empty() {
439            return div()
440                .px(px(popover::MENU_ROW_INSET))
441                .py(px(8.0))
442                .text_style(TextStyle::Body)
443                .text_color(theme.text_muted)
444                .child("No matches")
445                .into_any_element();
446        }
447        let rows = self.rows.iter().enumerate().map(|(position, row)| {
448            let active = Some(position) == self.active;
449            let base = popover::menu_row(theme, active, None).on_mouse_move(cx.listener(
450                move |view, _, _, cx| {
451                    if view.active != Some(position) {
452                        view.active = Some(position);
453                        cx.notify();
454                    }
455                },
456            ));
457            match row {
458                Row::Create(name) => base
459                    .id("multi-select-create")
460                    .on_click(cx.listener(move |view, _, _, cx| view.take(position, cx)))
461                    .child(
462                        icons::icon(icons::glyph::Plus)
463                            .size(px(13.0))
464                            .text_color(theme.text_muted),
465                    )
466                    .child("Create")
467                    .child(
468                        theme
469                            .chip(name.clone(), (self.tint)(name, theme))
470                            .max_w_full(),
471                    )
472                    .into_any_element(),
473                Row::Choice(index) => {
474                    let choice = &self.choices[*index];
475                    let index = *index;
476                    let mark = match choice.check {
477                        Check::On => Some(icons::glyph::Check),
478                        Check::Mixed => Some(icons::glyph::Minus),
479                        Check::Off => None,
480                    };
481                    base.id(("multi-select-row", index))
482                        .on_click(cx.listener(move |view, _, _, cx| view.take(position, cx)))
483                        .child(
484                            div().flex_1().min_w_0().flex().child(
485                                theme
486                                    .chip(choice.name.clone(), (self.tint)(&choice.name, theme))
487                                    .flex_shrink_1()
488                                    .min_w_0(),
489                            ),
490                        )
491                        .when_some(choice.count, |row, count| {
492                            row.child(
493                                div()
494                                    .flex_none()
495                                    .text_style(TextStyle::Callout)
496                                    .text_color(theme.text_muted)
497                                    .child(SharedString::from(count.to_string())),
498                            )
499                        })
500                        .child(
501                            div()
502                                .flex_none()
503                                .size(px(13.0))
504                                .when_some(mark, |slot, mark| {
505                                    slot.child(
506                                        icons::icon(mark).size(px(13.0)).text_color(theme.text),
507                                    )
508                                }),
509                        )
510                        .when(self.manage, |row| {
511                            row.child(
512                                theme
513                                    .icon_button(icons::glyph::Ellipsis, ButtonStyle::Ghost, None)
514                                    .id(("multi-select-manage", index))
515                                    .when(!active, |button| button.invisible())
516                                    .on_click(cx.listener(move |view, _, window, cx| {
517                                        cx.stop_propagation();
518                                        view.manage_choice(index, window, cx);
519                                    })),
520                            )
521                        })
522                        .into_any_element()
523                }
524            }
525        });
526        // Following the active row, as the search list does.
527        if self.scrolled.get() != self.active {
528            self.scrolled.set(self.active);
529            if let Some(active) = self.active {
530                self.scroll.scroll_to_item(active);
531            }
532        }
533        scroll::Viewport::new(
534            "multi-select-rows",
535            div()
536                .id("multi-select-rows-inner")
537                .max_h(px(search::MAX_ROWS * popover::menu_row_height()))
538                .flex()
539                .flex_col()
540                .children(rows),
541            Axis::Vertical,
542        )
543        .track_scroll(&self.scroll)
544        .into_any_element()
545    }
546
547    fn managing_pane(
548        &self,
549        managing: &Managing,
550        theme: &Theme,
551        cx: &mut Context<Self>,
552    ) -> gpui::Div {
553        div()
554            .flex()
555            .flex_col()
556            .child(
557                div()
558                    .flex()
559                    .flex_row()
560                    .items_center()
561                    .gap(px(4.0))
562                    .p(px(popover::MENU_PAD))
563                    .child(
564                        theme
565                            .icon_button(icons::glyph::ChevronLeft, ButtonStyle::Ghost, None)
566                            .id("multi-select-back")
567                            .on_click(
568                                cx.listener(|view, _, window, cx| view.leave_managing(window, cx)),
569                            ),
570                    )
571                    .child(div().flex_1().min_w_0().child(managing.field.clone())),
572            )
573            .child(popover::divider(theme))
574            .child(
575                popover::menu_row(theme, false, None)
576                    .id("multi-select-delete")
577                    .text_color(theme.danger)
578                    .on_click(cx.listener(|view, _, window, cx| view.delete(window, cx)))
579                    .child("Delete"),
580            )
581    }
582}
583
584impl Focusable for MultiSelect {
585    /// The context around the fields; one of them holds focus while it is
586    /// open — see [`MultiSelect::reset`].
587    fn focus_handle(&self, _: &App) -> FocusHandle {
588        self.focus_handle.clone()
589    }
590}
591
592impl Render for MultiSelect {
593    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
594        if std::mem::take(&mut self.refocus) {
595            window.focus(&self.query.focus_handle(cx), cx);
596        }
597        let theme = Theme::of(cx).clone();
598        let body = match &self.managing {
599            Some(managing) => self.managing_pane(managing, &theme, cx).into_any_element(),
600            None => div()
601                .flex()
602                .flex_col()
603                .child(self.query_line(&theme, cx))
604                .child(self.list(&theme, cx))
605                .into_any_element(),
606        };
607        popover::popover_card(&theme)
608            .key_context(KEY_CONTEXT)
609            .track_focus(&self.focus_handle)
610            .min_w(self.width.0)
611            .max_w(self.width.1)
612            .on_action(cx.listener(Self::select_next))
613            .on_action(cx.listener(Self::select_previous))
614            .on_action(cx.listener(Self::confirm))
615            .on_action(cx.listener(Self::dismiss))
616            .capture_action(cx.listener(Self::backspace))
617            .on_mouse_down_out(cx.listener(|_, _, _, cx| {
618                cx.emit(MultiSelectEvent::Dismissed);
619                cx.stop_propagation();
620            }))
621            .child(body)
622    }
623}