Skip to main content

gpui_kit/controls/
cascader.rs

1//! A hierarchical choice whose data and accepted value remain caller-owned.
2
3use gpui::{
4    AnyElement, App, Context, ElementId, EventEmitter, FocusHandle, Focusable, InteractiveElement,
5    IntoElement, KeyDownEvent, MouseButton, ParentElement, Render, SharedString,
6    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
7};
8use gpui_kit_assets::{Icon, icon};
9use gpui_kit_semantics::{NodeSpec, Role, Semantic};
10use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TypeScale};
11
12use crate::controls::button::{Button, ButtonVariant};
13use crate::display::empty::{EmptyKind, EmptyState};
14use crate::display::loading::PulseLoader;
15use crate::foundation::{
16    ActiveDirection, DirectionalExt, Disableable, Ident, LayoutDirection, Pressable, Sizable,
17    StyledExt, text as foundation_text,
18};
19use crate::overlay::{
20    Placement,
21    popover::{self, MenuKey},
22};
23use crate::state::Loadable;
24use crate::strings::{ActiveStrings, StringKey};
25
26/// One caller-owned node in a cascader.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CascaderOption {
29    pub id: SharedString,
30    pub label: SharedString,
31    pub disabled: bool,
32    /// `None` is a leaf; `Some` is a branch, including before it is loaded.
33    pub children: Option<Loadable<Vec<CascaderOption>, SharedString>>,
34}
35
36impl CascaderOption {
37    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
38        Self {
39            id: id.into(),
40            label: label.into(),
41            disabled: false,
42            children: None,
43        }
44    }
45
46    pub fn disabled(mut self, disabled: bool) -> Self {
47        self.disabled = disabled;
48        self
49    }
50    pub fn idle_children(mut self) -> Self {
51        self.children = Some(Loadable::Idle);
52        self
53    }
54    pub fn loading_children(mut self) -> Self {
55        self.children = Some(Loadable::Loading);
56        self
57    }
58    pub fn empty_children(mut self) -> Self {
59        self.children = Some(Loadable::Empty);
60        self
61    }
62    pub fn unavailable_children(mut self, reason: impl Into<SharedString>) -> Self {
63        self.children = Some(Loadable::Unavailable(reason.into().to_string()));
64        self
65    }
66    pub fn error_children(mut self, reason: impl Into<SharedString>) -> Self {
67        self.children = Some(Loadable::Error(reason.into()));
68        self
69    }
70    pub fn children(mut self, children: impl IntoIterator<Item = CascaderOption>) -> Self {
71        self.children = Some(Loadable::Ready(children.into_iter().collect()));
72        self
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum CascaderEvent {
78    Selected(SharedString),
79    Expanded(SharedString),
80    Retry(SharedString),
81    Opened,
82    Closed,
83}
84
85impl EventEmitter<CascaderEvent> for Cascader {}
86
87/// A menu of progressively revealed caller-owned option columns.
88pub struct Cascader {
89    ident: Ident,
90    focus_handle: FocusHandle,
91    options: Vec<CascaderOption>,
92    selected: Option<SharedString>,
93    name: SharedString,
94    placeholder: Option<SharedString>,
95    size: ControlSize,
96    disabled: bool,
97    open: bool,
98    open_path: Vec<SharedString>,
99    active: Option<SharedString>,
100}
101
102impl std::fmt::Debug for Cascader {
103    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        formatter
105            .debug_struct("Cascader")
106            .field("ident", &self.ident)
107            .field("options", &self.options.len())
108            .field("selected", &self.selected)
109            .field("disabled", &self.disabled)
110            .field("open", &self.open)
111            .field("open_path", &self.open_path)
112            .finish()
113    }
114}
115
116impl Cascader {
117    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
118        Self {
119            ident: ident.into(),
120            focus_handle: cx.focus_handle(),
121            options: Vec::new(),
122            selected: None,
123            name: SharedString::default(),
124            placeholder: None,
125            size: ControlSize::Md,
126            disabled: false,
127            open: false,
128            open_path: Vec::new(),
129            active: None,
130        }
131    }
132
133    pub fn options(mut self, options: impl IntoIterator<Item = CascaderOption>) -> Self {
134        self.options = options.into_iter().collect();
135        self
136    }
137    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
138        self.selected = Some(id.into());
139        self
140    }
141    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
142        self.name = name.into();
143        self
144    }
145    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
146        self.placeholder = Some(placeholder.into());
147        self
148    }
149    pub fn set_options(&mut self, options: Vec<CascaderOption>, cx: &mut Context<Self>) {
150        let open_path = Self::valid_open_path(&options, &self.open_path);
151        let active = self.active.take();
152        self.options = options;
153        self.open_path = open_path;
154        self.active = if self.open {
155            let current = self.current_options();
156            active
157                .filter(|id| {
158                    current
159                        .iter()
160                        .any(|option| &option.id == id && !option.disabled)
161                })
162                .or_else(|| Self::first_enabled(current, false))
163        } else {
164            None
165        };
166        cx.notify();
167    }
168    pub fn set_selected(&mut self, selected: Option<SharedString>, cx: &mut Context<Self>) {
169        self.selected = selected;
170        cx.notify();
171    }
172    pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
173        self.name = name.into();
174        cx.notify();
175    }
176    pub fn set_placeholder(&mut self, placeholder: Option<SharedString>, cx: &mut Context<Self>) {
177        self.placeholder = placeholder;
178        cx.notify();
179    }
180    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
181        self.disabled = disabled;
182        if disabled {
183            self.close(cx);
184        }
185        cx.notify();
186    }
187    pub fn selected_id(&self) -> Option<&SharedString> {
188        self.selected.as_ref()
189    }
190    pub fn is_open(&self) -> bool {
191        self.open
192    }
193    pub fn open_path(&self) -> &[SharedString] {
194        &self.open_path
195    }
196
197    fn find<'a>(options: &'a [CascaderOption], id: &SharedString) -> Option<&'a CascaderOption> {
198        for option in options {
199            if &option.id == id {
200                return Some(option);
201            }
202            if let Some(Loadable::Ready(children)) = &option.children
203                && let Some(found) = Self::find(children, id)
204            {
205                return Some(found);
206            }
207        }
208        None
209    }
210
211    fn path_to(
212        options: &[CascaderOption],
213        id: &SharedString,
214        path: &mut Vec<SharedString>,
215    ) -> bool {
216        for option in options {
217            if &option.id == id {
218                return true;
219            }
220            if let Some(Loadable::Ready(children)) = &option.children {
221                path.push(option.id.clone());
222                if Self::path_to(children, id, path) {
223                    return true;
224                }
225                path.pop();
226            }
227        }
228        false
229    }
230
231    fn valid_open_path(options: &[CascaderOption], path: &[SharedString]) -> Vec<SharedString> {
232        let mut current = options;
233        let mut valid = Vec::new();
234        for id in path {
235            let Some(option) = current
236                .iter()
237                .find(|option| &option.id == id && !option.disabled)
238            else {
239                break;
240            };
241            let Some(children) = &option.children else {
242                break;
243            };
244            valid.push(id.clone());
245            match children {
246                Loadable::Ready(children) => current = children,
247                _ => break,
248            }
249        }
250        valid
251    }
252
253    fn current_options(&self) -> &[CascaderOption] {
254        let mut options = self.options.as_slice();
255        for id in &self.open_path {
256            let Some(option) = options.iter().find(|option| &option.id == id) else {
257                return &[];
258            };
259            let Some(Loadable::Ready(children)) = &option.children else {
260                return &[];
261            };
262            options = children;
263        }
264        options
265    }
266
267    fn first_enabled(options: &[CascaderOption], reverse: bool) -> Option<SharedString> {
268        if reverse {
269            options
270                .iter()
271                .rev()
272                .find(|o| !o.disabled)
273                .map(|o| o.id.clone())
274        } else {
275            options.iter().find(|o| !o.disabled).map(|o| o.id.clone())
276        }
277    }
278
279    /// Opens the transient option surface without changing any caller-owned value.
280    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
281        if self.disabled || self.open {
282            return;
283        }
284        self.open = true;
285        self.active = Self::first_enabled(&self.options, false);
286        window.focus(&self.focus_handle, cx);
287        cx.emit(CascaderEvent::Opened);
288        cx.notify();
289    }
290
291    /// Closes the transient option surface and clears its open path.
292    pub fn close(&mut self, cx: &mut Context<Self>) {
293        if !self.open {
294            return;
295        }
296        self.open = false;
297        self.open_path.clear();
298        self.active = None;
299        cx.emit(CascaderEvent::Closed);
300        cx.notify();
301    }
302
303    fn activate(&mut self, id: SharedString, cx: &mut Context<Self>) {
304        let Some(option) = Self::find(&self.options, &id) else {
305            return;
306        };
307        if option.disabled {
308            return;
309        }
310        let branch = option.children.is_some();
311        let child_active = match &option.children {
312            Some(Loadable::Ready(children)) => Self::first_enabled(children, false),
313            _ => None,
314        };
315        if branch {
316            let mut path = Vec::new();
317            Self::path_to(&self.options, &id, &mut path);
318            path.push(id.clone());
319            self.open_path = path;
320            self.active = child_active;
321            cx.emit(CascaderEvent::Expanded(id));
322            cx.notify();
323        } else {
324            self.open = false;
325            self.open_path.clear();
326            self.active = None;
327            cx.emit(CascaderEvent::Selected(id));
328            cx.emit(CascaderEvent::Closed);
329            cx.notify();
330        }
331    }
332
333    fn back(&mut self, cx: &mut Context<Self>) {
334        if let Some(parent) = self.open_path.pop() {
335            self.active = Some(parent);
336            cx.notify();
337        }
338    }
339
340    fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
341        let options = self.current_options();
342        let start = self
343            .active
344            .as_ref()
345            .and_then(|id| options.iter().position(|o| &o.id == id));
346        let mut next = popover::step(start, options.len(), delta);
347        for _ in 0..options.len() {
348            let Some(index) = next else { return };
349            if !options[index].disabled {
350                self.active = Some(options[index].id.clone());
351                cx.notify();
352                return;
353            }
354            next = popover::step(next, options.len(), delta);
355        }
356    }
357
358    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
359        if self.disabled {
360            return;
361        }
362        let raw = event.keystroke.key.as_str();
363        let key = popover::classify_key(
364            raw,
365            event.keystroke.modifiers.platform,
366            event.keystroke.modifiers.control,
367        );
368        if !self.open && matches!(key, MenuKey::Enter | MenuKey::Up | MenuKey::Down) {
369            self.open(window, cx);
370            cx.stop_propagation();
371            return;
372        }
373        if !self.open {
374            return;
375        }
376        let direction = cx.layout_direction();
377        let toward_children = matches!(
378            (direction, key),
379            (LayoutDirection::LeftToRight, MenuKey::Right)
380                | (LayoutDirection::RightToLeft, MenuKey::Left)
381        );
382        let toward_parent = matches!(
383            (direction, key),
384            (LayoutDirection::LeftToRight, MenuKey::Left)
385                | (LayoutDirection::RightToLeft, MenuKey::Right)
386        );
387        match key {
388            MenuKey::Down => self.step(1, cx),
389            MenuKey::Up => self.step(-1, cx),
390            MenuKey::Enter => {
391                let Some(active) = self.active.clone() else {
392                    return;
393                };
394                self.activate(active, cx)
395            }
396            MenuKey::Escape => self.close(cx),
397            _ if raw == "home" => {
398                self.active = Self::first_enabled(self.current_options(), false);
399                cx.notify();
400            }
401            _ if raw == "end" => {
402                self.active = Self::first_enabled(self.current_options(), true);
403                cx.notify();
404            }
405            _ if toward_children => {
406                let Some(active) = self.active.clone() else {
407                    return;
408                };
409                self.activate(active, cx)
410            }
411            _ if toward_parent => self.back(cx),
412            _ => return,
413        }
414        cx.stop_propagation();
415    }
416
417    fn row(&self, option: &CascaderOption, column: &Ident, cx: &mut Context<Self>) -> AnyElement {
418        let theme = cx.theme().clone();
419        let direction = cx.layout_direction();
420        let ident = self.ident.child(option.id.as_ref());
421        let hover_group = ident.child("hover").semantic_id();
422        let active = self.active.as_ref() == Some(&option.id);
423        let expanded = self.open_path.contains(&option.id);
424        let id = option.id.clone();
425        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Option)
426            .parent(column.semantic_id())
427            .text(option.label.clone())
428            .hovered(active)
429            .disabled(option.disabled);
430        if option.children.is_some() {
431            spec = spec.expanded(expanded);
432        }
433        popover::menu_row(&theme, false, active)
434            .id(ident.element_id())
435            .group(hover_group.clone())
436            .row_reading(direction)
437            .when(!option.disabled, |row| row.cursor_pointer().pressable(cx))
438            .when(option.disabled, |row| row.opacity(theme.opacity.disabled))
439            .child(popover::menu_label(
440                &theme,
441                option.label.clone(),
442                false,
443                active,
444                hover_group,
445            ))
446            .when(option.children.is_some(), |row| {
447                row.child(div().flex_1()).child(
448                    icon(if direction.is_rtl() {
449                        Icon::AltArrowLeft
450                    } else {
451                        Icon::AltArrowRight
452                    })
453                    .size(px(14.0))
454                    .text_color(theme.colors.text_muted),
455                )
456            })
457            .when(!option.disabled, |row| {
458                row.on_mouse_down(
459                    MouseButton::Left,
460                    cx.listener(move |this, _, _, cx| this.activate(id.clone(), cx)),
461                )
462            })
463            .semantic_in(cx, spec)
464            .into_any_element()
465    }
466
467    fn state_column(&self, parent: &CascaderOption, cx: &mut Context<Self>) -> AnyElement {
468        let ident = self.ident.child(parent.id.as_ref()).child("state");
469        let strings = cx.strings();
470        let state = parent.children.as_ref().expect("branch");
471        let content: AnyElement = match state {
472            Loadable::Ready(children) if children.is_empty() => {
473                EmptyState::new(ident, strings.text(StringKey::CascaderEmpty))
474                    .kind(EmptyKind::Empty)
475                    .into_any_element()
476            }
477            Loadable::Ready(children) => self.option_column(children, &ident, cx),
478            Loadable::Loading => div()
479                .p(px(24.0))
480                .child(PulseLoader::new(ident.clone()).label(strings.text(StringKey::Loading)))
481                .into_any_element(),
482            Loadable::Idle => EmptyState::new(ident, strings.text(StringKey::CascaderUnstarted))
483                .kind(EmptyKind::Unstarted)
484                .into_any_element(),
485            Loadable::Empty => EmptyState::new(ident, strings.text(StringKey::CascaderEmpty))
486                .kind(EmptyKind::Empty)
487                .into_any_element(),
488            Loadable::Unavailable(reason) => {
489                let weak = cx.entity().downgrade();
490                let parent_id = parent.id.clone();
491                let action_id = ident.child("retry");
492                EmptyState::new(ident.clone(), strings.text(StringKey::CascaderUnavailable))
493                    .kind(EmptyKind::Unavailable)
494                    .detail(reason.as_str())
495                    .action(
496                        Button::new(action_id)
497                            .semantic_parent(ident.semantic_id())
498                            .variant(ButtonVariant::Secondary)
499                            .label(strings.text(StringKey::TryAgain))
500                            .on_click(move |_, cx| {
501                                let _ = weak.update(cx, |_, cx| {
502                                    cx.emit(CascaderEvent::Retry(parent_id.clone()))
503                                });
504                            }),
505                    )
506                    .into_any_element()
507            }
508            Loadable::Error(reason) => {
509                let weak = cx.entity().downgrade();
510                let parent_id = parent.id.clone();
511                let action_id = ident.child("retry");
512                EmptyState::new(ident.clone(), strings.text(StringKey::CascaderError))
513                    .kind(EmptyKind::Failed)
514                    .detail(reason.clone())
515                    .action(
516                        Button::new(action_id)
517                            .semantic_parent(ident.semantic_id())
518                            .variant(ButtonVariant::Secondary)
519                            .label(strings.text(StringKey::TryAgain))
520                            .on_click(move |_, cx| {
521                                let _ = weak.update(cx, |_, cx| {
522                                    cx.emit(CascaderEvent::Retry(parent_id.clone()))
523                                });
524                            }),
525                    )
526                    .into_any_element()
527            }
528        };
529        content
530    }
531
532    fn option_column(
533        &self,
534        options: &[CascaderOption],
535        ident: &Ident,
536        cx: &mut Context<Self>,
537    ) -> AnyElement {
538        div()
539            .id(ident.element_id())
540            .min_w(px(180.0))
541            .max_h(px(320.0))
542            .overflow_y_scroll()
543            .flex()
544            .flex_col()
545            .children(options.iter().map(|option| self.row(option, ident, cx)))
546            .semantic_in(
547                cx,
548                NodeSpec::new(ident.semantic_id(), Role::Menu)
549                    .parent(self.ident.child("menu").semantic_id()),
550            )
551            .into_any_element()
552    }
553
554    fn menu(&self, cx: &mut Context<Self>) -> AnyElement {
555        let theme = cx.theme().clone();
556        let root = self.ident.child("menu.root");
557        let mut columns = vec![self.option_column(&self.options, &root, cx)];
558        let mut options = self.options.as_slice();
559        for id in &self.open_path {
560            let Some(parent) = options.iter().find(|option| &option.id == id) else {
561                break;
562            };
563            columns.push(self.state_column(parent, cx));
564            match &parent.children {
565                Some(Loadable::Ready(children)) => options = children,
566                _ => break,
567            }
568        }
569        let card = popover::card_flush(&theme)
570            .p(px(theme.space(Space::Xs)))
571            .flex()
572            .row_reading(cx.layout_direction())
573            .children(columns)
574            .id(self.ident.child("menu").element_id())
575            .semantic_in(
576                cx,
577                NodeSpec::new(self.ident.child("menu").semantic_id(), Role::Menu)
578                    .parent(self.ident.semantic_id()),
579            );
580        popover::anchored_below(
581            ElementId::from(self.ident.child("menu.anchor").semantic_id()),
582            &theme,
583            card.into_any_element(),
584        )
585    }
586}
587
588impl Disableable for Cascader {
589    fn disabled(mut self, disabled: bool) -> Self {
590        self.disabled = disabled;
591        self
592    }
593}
594impl Sizable for Cascader {
595    fn control_size(mut self, size: ControlSize) -> Self {
596        self.size = size;
597        self
598    }
599}
600impl Focusable for Cascader {
601    fn focus_handle(&self, _cx: &App) -> FocusHandle {
602        self.focus_handle.clone()
603    }
604}
605
606impl Render for Cascader {
607    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
608        let theme = cx.theme().clone();
609        let metrics = theme.control.get(self.size);
610        let focused = self.focus_handle.is_focused(window);
611        let placeholder = self
612            .placeholder
613            .clone()
614            .unwrap_or_else(|| cx.strings().text(StringKey::CascaderPlaceholder));
615        let selected = self
616            .selected
617            .as_ref()
618            .and_then(|id| Self::find(&self.options, id));
619        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Combobox)
620            .disabled(self.disabled)
621            .expanded(self.open)
622            .text(self.name.clone())
623            .placeholder(placeholder.clone());
624        if !self.disabled {
625            spec = spec.focus(&self.focus_handle);
626        }
627        if let Some(option) = selected {
628            spec = spec.value(option.label.clone());
629        }
630        let label = selected.map(|o| o.label.clone()).unwrap_or(placeholder);
631        let menu = self.open.then(|| self.menu(cx));
632        let trigger = div()
633            .w_full()
634            .flex()
635            .items_center()
636            .justify_between()
637            .h(px(metrics.height))
638            .px(px(metrics.padding_x))
639            .radius(&theme, Radius::Control)
640            .well(&theme)
641            .when(focused, |element| element.shadow(theme.focus_ring()))
642            .when(self.disabled, |el| el.opacity(theme.opacity.disabled))
643            .when(!self.disabled, |el| {
644                el.cursor_pointer().on_mouse_down(
645                    MouseButton::Left,
646                    cx.listener(|this, _, window, cx| {
647                        if this.open {
648                            this.close(cx)
649                        } else {
650                            this.open(window, cx)
651                        }
652                    }),
653                )
654            })
655            .child(
656                foundation_text(&theme, TypeScale::Label, label)
657                    .text_size(px(metrics.font_size))
658                    .text_color(if self.disabled || selected.is_none() {
659                        theme.colors.text_faint
660                    } else {
661                        theme.colors.text
662                    }),
663            )
664            .child(
665                icon(Icon::AltArrowDown)
666                    .size(px(metrics.icon_size * 0.9))
667                    .text_color(theme.colors.text_muted),
668            )
669            .into_any_element();
670        popover::anchored_slot(Placement::Below, trigger, menu)
671            .id(self.ident.element_id())
672            .when(!self.disabled, |el| {
673                el.track_focus(&self.focus_handle)
674                    .on_key_down(cx.listener(Self::on_key_down))
675            })
676            .w_full()
677            .semantic_in(cx, spec)
678    }
679}