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;
51
52pub fn init(cx: &mut App) {
55 cx.bind_keys(bindings());
56}
57
58pub 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
72pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Check {
84 Off,
85 On,
86 Mixed,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct Choice {
93 pub name: SharedString,
94 pub check: Check,
95 pub count: Option<usize>,
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum MultiSelectEvent {
102 Toggled { name: SharedString, on: bool },
104 Created(SharedString),
107 Renamed {
109 from: SharedString,
110 to: SharedString,
111 },
112 Deleted(SharedString),
114 Dismissed,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq)]
120enum Row {
121 Create(SharedString),
122 Choice(usize),
124}
125
126struct 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 active: Option<usize>,
142 create: Option<Rc<Normalize>>,
143 manage: bool,
144 tint: Rc<Tint>,
145 width: (Pixels, Pixels),
147 managing: Option<Managing>,
148 refocus: bool,
151 scroll: ScrollHandle,
152 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 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 pub fn with_manage(mut self) -> Self {
208 self.manage = true;
209 self
210 }
211
212 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 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 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 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 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 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 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 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}