1use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CascaderOption {
29 pub id: SharedString,
30 pub label: SharedString,
31 pub disabled: bool,
32 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
87pub 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 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 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}