sickle_ui/widgets/inputs/
dropdown.rs

1use std::collections::VecDeque;
2
3use bevy::{prelude::*, ui::FocusPolicy};
4
5use sickle_ui_scaffold::{prelude::*, ui_commands::UpdateTextExt};
6
7use crate::widgets::layout::{
8    container::UiContainerExt,
9    label::{LabelConfig, UiLabelExt},
10    panel::UiPanelExt,
11    scroll_view::{ScrollView, ScrollViewLayoutUpdate, UiScrollViewExt},
12};
13
14const DROPDOWN_PANEL_Z_INDEX: usize = 11000;
15
16#[cfg(feature = "observable")]
17#[derive(Event, Copy, Clone, Debug)]
18pub struct DropdownChanged {
19    pub value: Option<usize>,
20}
21
22pub struct DropdownPlugin;
23
24impl Plugin for DropdownPlugin {
25    fn build(&self, app: &mut App) {
26        app.add_plugins((
27            ComponentThemePlugin::<Dropdown>::default(),
28            ComponentThemePlugin::<DropdownOption>::default(),
29        ))
30        .add_systems(
31            Update,
32            (
33                handle_option_press,
34                update_dropdown_label,
35                handle_click_or_touch,
36                update_drowdown_pseudo_state,
37                update_dropdown_panel_visibility,
38            )
39                .chain()
40                .after(FluxInteractionUpdate)
41                .before(ScrollViewLayoutUpdate),
42        );
43
44        #[cfg(feature = "observable")]
45        app.add_event::<DropdownChanged>();
46    }
47}
48
49fn update_dropdown_label(
50    mut q_dropdowns: Query<(&mut Dropdown, &DropdownOptions), Changed<Dropdown>>,
51    mut commands: Commands,
52) {
53    for (mut dropdown, options) in &mut q_dropdowns {
54        if let Some(value) = dropdown.value {
55            if value >= options.0.len() {
56                dropdown.value = None;
57            }
58        }
59
60        let text = if let Some(value) = dropdown.value {
61            options.0[value].clone()
62        } else {
63            String::from("---")
64        };
65
66        commands.entity(dropdown.label).update_text(text);
67    }
68}
69
70fn handle_click_or_touch(
71    r_mouse: Res<ButtonInput<MouseButton>>,
72    r_touches: Res<Touches>,
73    mut q_dropdowns: Query<(Entity, &mut Dropdown, &FluxInteraction)>,
74) {
75    if r_mouse.any_just_released([MouseButton::Left, MouseButton::Middle, MouseButton::Right])
76        || r_touches.any_just_released()
77    {
78        let mut open: Option<Entity> = None;
79        for (entity, _, interaction) in &mut q_dropdowns {
80            if *interaction == FluxInteraction::Released {
81                open = entity.into();
82                break;
83            }
84        }
85
86        for (entity, mut dropdown, _) in &mut q_dropdowns {
87            if let Some(open_dropdown) = open {
88                if entity == open_dropdown {
89                    dropdown.is_open = !dropdown.is_open;
90                } else if dropdown.is_open {
91                    dropdown.is_open = false;
92                }
93            } else if dropdown.is_open {
94                dropdown.is_open = false;
95            }
96        }
97    }
98}
99
100fn handle_option_press(
101    q_options: Query<(&DropdownOption, &FluxInteraction), Changed<FluxInteraction>>,
102    mut q_dropdown: Query<&mut Dropdown>,
103    mut commands: Commands,
104) {
105    for (option, interaction) in &q_options {
106        if *interaction == FluxInteraction::Released {
107            let Ok(mut dropdown) = q_dropdown.get_mut(option.dropdown) else {
108                continue;
109            };
110
111            dropdown.value = option.option.into();
112
113            #[cfg(feature = "observable")]
114            commands.trigger_targets(
115                DropdownChanged {
116                    value: dropdown.value,
117                },
118                option.dropdown,
119            );
120        }
121    }
122}
123
124fn update_drowdown_pseudo_state(
125    q_panels: Query<(&DropdownPanel, &PseudoStates), Changed<PseudoStates>>,
126    mut commands: Commands,
127) {
128    for (panel, states) in &q_panels {
129        if states.has(&PseudoState::Visible) {
130            commands
131                .entity(panel.dropdown)
132                .add_pseudo_state(PseudoState::Open);
133        } else {
134            commands
135                .entity(panel.dropdown)
136                .remove_pseudo_state(PseudoState::Open);
137        }
138    }
139}
140
141fn update_dropdown_panel_visibility(
142    q_dropdowns: Query<&Dropdown, Changed<Dropdown>>,
143    mut q_scroll_view: Query<&mut ScrollView>,
144    mut commands: Commands,
145) {
146    for dropdown in &q_dropdowns {
147        if dropdown.is_open {
148            commands
149                .style_unchecked(dropdown.panel)
150                .display(Display::Flex)
151                .visibility(Visibility::Inherited)
152                .height(Val::Px(0.));
153
154            let Ok(mut scroll_view) = q_scroll_view.get_mut(dropdown.scroll_view) else {
155                continue;
156            };
157
158            scroll_view.disabled = true;
159        } else {
160            commands
161                .style_unchecked(dropdown.panel)
162                .display(Display::None)
163                .visibility(Visibility::Hidden);
164        }
165    }
166}
167
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum DropdownPanelAnchor {
170    TopLeft,
171    TopRight,
172    #[default]
173    BottomLeft,
174    BottomRight,
175}
176
177#[derive(Clone, Copy, Debug, Default)]
178pub struct DropdownPanelPlacement {
179    pub anchor: DropdownPanelAnchor,
180    pub top: Val,
181    pub right: Val,
182    pub bottom: Val,
183    pub left: Val,
184    pub width: Val,
185    pub height: Val,
186    pub panel_width: f32,
187    pub button_width: f32,
188    pub wider_than_button: bool,
189}
190
191#[derive(Component, Clone, Debug, Default, Reflect)]
192#[reflect(Component)]
193pub struct DropdownOptions(Vec<String>);
194
195impl DropdownOptions {
196    pub fn labels(&self) -> &Vec<String> {
197        &self.0
198    }
199}
200
201#[derive(Component, Debug, Reflect)]
202#[reflect(Component)]
203pub struct DropdownOption {
204    dropdown: Entity,
205    label: Entity,
206    option: usize,
207}
208
209impl Default for DropdownOption {
210    fn default() -> Self {
211        Self {
212            dropdown: Entity::PLACEHOLDER,
213            label: Entity::PLACEHOLDER,
214            option: Default::default(),
215        }
216    }
217}
218
219impl UiContext for DropdownOption {
220    fn get(&self, target: &str) -> Result<Entity, String> {
221        match target {
222            DropdownOption::LABEL => Ok(self.label),
223            _ => Err(format!(
224                "{} doesn't exist for DropdownOption. Possible contexts: {:?}",
225                target,
226                Vec::from_iter(self.contexts())
227            )),
228        }
229    }
230
231    fn contexts(&self) -> impl Iterator<Item = &str> + '_ {
232        [DropdownOption::LABEL].into_iter()
233    }
234}
235
236impl DefaultTheme for DropdownOption {
237    fn default_theme() -> Option<Theme<DropdownOption>> {
238        DropdownOption::theme().into()
239    }
240}
241
242impl DropdownOption {
243    pub const LABEL: &'static str = "Label";
244
245    pub fn dropdown(&self) -> Entity {
246        self.dropdown
247    }
248
249    pub fn option(&self) -> usize {
250        self.option
251    }
252
253    pub fn theme() -> Theme<DropdownOption> {
254        let base_theme = PseudoTheme::deferred(None, DropdownOption::primary_style);
255
256        Theme::new(vec![base_theme])
257    }
258
259    fn primary_style(style_builder: &mut StyleBuilder, theme_data: &ThemeData) {
260        let theme_spacing = theme_data.spacing;
261        let colors = theme_data.colors();
262        let font = theme_data
263            .text
264            .get(FontStyle::Body, FontScale::Medium, FontType::Regular);
265
266        style_builder
267            .align_items(AlignItems::Center)
268            .min_width(Val::Percent(100.))
269            .padding(UiRect::axes(
270                Val::Px(theme_spacing.gaps.medium),
271                Val::Px(theme_spacing.gaps.medium),
272            ))
273            .margin(UiRect::bottom(Val::Px(theme_spacing.gaps.tiny)))
274            .animated()
275            .background_color(AnimatedVals {
276                idle: colors.container(Container::Primary),
277                hover: colors.accent(Accent::Primary).into(),
278                ..default()
279            })
280            .copy_from(theme_data.interaction_animation);
281
282        style_builder
283            .switch_target(DropdownOption::LABEL)
284            .sized_font(font)
285            .animated()
286            .font_color(AnimatedVals {
287                idle: colors.on(OnColor::PrimaryContainer),
288                hover: colors.on(OnColor::Primary).into(),
289                ..default()
290            })
291            .copy_from(theme_data.interaction_animation);
292    }
293}
294
295#[derive(Component, Debug, Reflect)]
296#[reflect(Component)]
297pub struct DropdownPanel {
298    dropdown: Entity,
299}
300
301impl Default for DropdownPanel {
302    fn default() -> Self {
303        Self {
304            dropdown: Entity::PLACEHOLDER,
305        }
306    }
307}
308
309#[derive(Component, Debug, Reflect)]
310#[reflect(Component)]
311pub struct Dropdown {
312    value: Option<usize>,
313    label: Entity,
314    icon: Entity,
315    panel: Entity,
316    scroll_view: Entity,
317    scroll_view_content: Entity,
318    is_open: bool,
319}
320
321impl Default for Dropdown {
322    fn default() -> Self {
323        Self {
324            value: Default::default(),
325            label: Entity::PLACEHOLDER,
326            icon: Entity::PLACEHOLDER,
327            panel: Entity::PLACEHOLDER,
328            scroll_view: Entity::PLACEHOLDER,
329            scroll_view_content: Entity::PLACEHOLDER,
330            is_open: false,
331        }
332    }
333}
334
335impl UiContext for Dropdown {
336    fn get(&self, target: &str) -> Result<Entity, String> {
337        match target {
338            Dropdown::LABEL => Ok(self.label),
339            Dropdown::ICON => Ok(self.icon),
340            Dropdown::PANEL => Ok(self.panel),
341            Dropdown::SCROLL_VIEW => Ok(self.scroll_view),
342            Dropdown::SCROLL_VIEW_CONTENT => Ok(self.scroll_view_content),
343            _ => Err(format!(
344                "{} doesn't exist for Dropdown. Possible contexts: {:?}",
345                target,
346                Vec::from_iter(self.contexts())
347            )),
348        }
349    }
350
351    fn contexts(&self) -> impl Iterator<Item = &str> + '_ {
352        [
353            Dropdown::LABEL,
354            Dropdown::ICON,
355            Dropdown::PANEL,
356            Dropdown::SCROLL_VIEW,
357            Dropdown::SCROLL_VIEW_CONTENT,
358        ]
359        .into_iter()
360    }
361}
362
363impl DefaultTheme for Dropdown {
364    fn default_theme() -> Option<Theme<Dropdown>> {
365        Dropdown::theme().into()
366    }
367}
368
369impl Dropdown {
370    pub const LABEL: &'static str = "Label";
371    pub const ICON: &'static str = "Icon";
372    pub const PANEL: &'static str = "Panel";
373    pub const SCROLL_VIEW: &'static str = "ScrollView";
374    pub const SCROLL_VIEW_CONTENT: &'static str = "ScrollViewContent";
375
376    pub fn value(&self) -> Option<usize> {
377        self.value
378    }
379
380    pub fn set_value(&mut self, value: impl Into<Option<usize>>) {
381        let value = value.into();
382        if self.value != value {
383            self.value = value;
384        }
385    }
386
387    pub fn options_container(&self) -> Entity {
388        self.scroll_view_content
389    }
390
391    pub fn theme() -> Theme<Dropdown> {
392        let base_theme = PseudoTheme::deferred(None, Dropdown::primary_style);
393        let open_theme = PseudoTheme::deferred_world(vec![PseudoState::Open], Dropdown::open_style);
394
395        Theme::new(vec![base_theme, open_theme])
396    }
397
398    fn primary_style(style_builder: &mut StyleBuilder, theme_data: &ThemeData) {
399        let theme_spacing = theme_data.spacing;
400        let colors = theme_data.colors();
401        let font = theme_data
402            .text
403            .get(FontStyle::Body, FontScale::Medium, FontType::Regular);
404
405        style_builder
406            .align_self(AlignSelf::Start)
407            .align_items(AlignItems::Center)
408            .justify_content(JustifyContent::SpaceBetween)
409            .height(Val::Px(theme_spacing.areas.small))
410            .padding(UiRect::axes(
411                Val::Px(theme_spacing.gaps.medium),
412                Val::Px(theme_spacing.gaps.extra_small),
413            ))
414            .border(UiRect::all(Val::Px(0.)))
415            .border_color(colors.accent(Accent::Outline))
416            .border_radius(BorderRadius::all(Val::Px(
417                theme_spacing.corners.extra_small,
418            )))
419            .animated()
420            .background_color(AnimatedVals {
421                idle: colors.accent(Accent::Primary),
422                hover: colors.container(Container::Primary).into(),
423                ..default()
424            })
425            .copy_from(theme_data.interaction_animation);
426
427        style_builder
428            .switch_target(Dropdown::LABEL)
429            .sized_font(font)
430            .animated()
431            .font_color(AnimatedVals {
432                idle: colors.on(OnColor::Primary),
433                hover: colors.on(OnColor::PrimaryContainer).into(),
434                ..default()
435            })
436            .copy_from(theme_data.interaction_animation);
437
438        style_builder
439            .switch_target(Dropdown::ICON)
440            .size(Val::Px(theme_spacing.icons.small))
441            .margin(UiRect::left(Val::Px(theme_spacing.gaps.large)))
442            .icon(
443                theme_data
444                    .icons
445                    .expand_more
446                    .with(colors.on(OnColor::Primary), theme_spacing.icons.small),
447            )
448            .animated()
449            .font_color(AnimatedVals {
450                idle: colors.on(OnColor::Primary),
451                hover: colors.on(OnColor::PrimaryContainer).into(),
452                ..default()
453            })
454            .copy_from(theme_data.interaction_animation);
455
456        style_builder
457            .switch_target(Dropdown::PANEL)
458            .position_type(PositionType::Absolute)
459            .min_width(Val::Percent(100.))
460            .max_height(Val::Px(theme_spacing.areas.extra_large))
461            .top(Val::Px(theme_spacing.areas.medium))
462            .z_index(ZIndex::Global(DROPDOWN_PANEL_Z_INDEX as i32))
463            .border(UiRect::all(Val::Px(theme_spacing.gaps.tiny)))
464            .border_color(Color::NONE)
465            .background_color(colors.container(Container::Primary));
466
467        style_builder
468            .switch_target(Dropdown::SCROLL_VIEW_CONTENT)
469            .margin(UiRect::px(
470                0.,
471                theme_spacing.scroll_bar_size,
472                0.,
473                theme_spacing.scroll_bar_size,
474            ));
475    }
476
477    fn open_style(style_builder: &mut StyleBuilder, entity: Entity, _: &Dropdown, world: &World) {
478        let placement = match Dropdown::panel_placement_for(entity, world) {
479            Ok(placement) => placement,
480            Err(msg) => {
481                error!("Error placing Dropdown panel: {}", msg);
482                return;
483            }
484        };
485
486        let theme_data = world.resource::<ThemeData>();
487        let theme_spacing = theme_data.spacing;
488        let colors = theme_data.colors();
489        let enter_animation = theme_data.enter_animation.clone();
490        let corner_from_width =
491            placement.panel_width < placement.button_width - theme_spacing.corners.extra_small;
492        let extra_small_border = theme_spacing.borders.extra_small;
493        let extra_small_corner = theme_spacing.corners.extra_small;
494        let maybe_button_corner = match corner_from_width {
495            true => extra_small_corner,
496            false => 0.,
497        };
498
499        style_builder.background_color(colors.container(Container::Primary));
500
501        match placement.anchor {
502            DropdownPanelAnchor::TopLeft => {
503                style_builder
504                    .border(UiRect::top(Val::Px(extra_small_border)))
505                    .border_radius(BorderRadius::px(
506                        0.,
507                        maybe_button_corner,
508                        extra_small_corner,
509                        extra_small_corner,
510                    ));
511            }
512            DropdownPanelAnchor::TopRight => {
513                style_builder
514                    .border(UiRect::top(Val::Px(extra_small_border)))
515                    .border_radius(BorderRadius::px(
516                        maybe_button_corner,
517                        0.,
518                        extra_small_corner,
519                        extra_small_corner,
520                    ));
521            }
522            DropdownPanelAnchor::BottomLeft => {
523                style_builder
524                    .border(UiRect::bottom(Val::Px(extra_small_border)))
525                    .border_radius(BorderRadius::px(
526                        extra_small_corner,
527                        extra_small_corner,
528                        maybe_button_corner,
529                        0.,
530                    ));
531            }
532            DropdownPanelAnchor::BottomRight => {
533                style_builder
534                    .border(UiRect::bottom(Val::Px(extra_small_border)))
535                    .border_radius(BorderRadius::px(
536                        extra_small_corner,
537                        extra_small_corner,
538                        0.,
539                        maybe_button_corner,
540                    ));
541            }
542        }
543
544        style_builder
545            .switch_target(Dropdown::LABEL)
546            .font_color(colors.on(OnColor::PrimaryContainer));
547        style_builder
548            .switch_target(Dropdown::ICON)
549            .font_color(colors.on(OnColor::PrimaryContainer));
550
551        style_builder
552            .switch_target(Dropdown::PANEL)
553            .top(placement.top)
554            .right(placement.right)
555            .bottom(placement.bottom)
556            .left(placement.left)
557            .width(placement.width)
558            .border_color(colors.accent(Accent::Shadow))
559            .animated()
560            .height(AnimatedVals {
561                idle: placement.height,
562                enter_from: Val::Px(0.).into(),
563                ..default()
564            })
565            .copy_from(enter_animation);
566
567        let maybe_border = match placement.wider_than_button {
568            true => extra_small_border,
569            false => 0.,
570        };
571
572        let maybe_corner = match placement.wider_than_button {
573            true => extra_small_corner,
574            false => 0.,
575        };
576
577        match placement.anchor {
578            DropdownPanelAnchor::TopLeft => {
579                style_builder
580                    .switch_target(Dropdown::PANEL)
581                    .border(UiRect::px(0., maybe_border, extra_small_border, 0.))
582                    .border_radius(BorderRadius::px(
583                        extra_small_corner,
584                        extra_small_corner,
585                        maybe_corner,
586                        0.,
587                    ));
588            }
589            DropdownPanelAnchor::TopRight => {
590                style_builder
591                    .switch_target(Dropdown::PANEL)
592                    .border(UiRect::px(maybe_border, 0., extra_small_border, 0.))
593                    .border_radius(BorderRadius::px(
594                        extra_small_corner,
595                        extra_small_corner,
596                        0.,
597                        maybe_corner,
598                    ));
599            }
600            DropdownPanelAnchor::BottomLeft => {
601                style_builder
602                    .switch_target(Dropdown::PANEL)
603                    .border(UiRect::px(0., maybe_border, 0., extra_small_border))
604                    .border_radius(BorderRadius::px(
605                        0.,
606                        maybe_corner,
607                        extra_small_corner,
608                        extra_small_corner,
609                    ));
610            }
611            DropdownPanelAnchor::BottomRight => {
612                style_builder
613                    .switch_target(Dropdown::PANEL)
614                    .border(UiRect::px(maybe_border, 0., 0., extra_small_border))
615                    .border_radius(BorderRadius::px(
616                        maybe_corner,
617                        0.,
618                        extra_small_corner,
619                        extra_small_corner,
620                    ));
621            }
622        }
623
624        style_builder
625            .switch_target(Dropdown::SCROLL_VIEW)
626            .animated()
627            .tracked_style_state(TrackedStyleState::default_vals())
628            .copy_from(enter_animation);
629    }
630
631    pub fn panel_placement_for(
632        entity: Entity,
633        world: &World,
634    ) -> Result<DropdownPanelPlacement, String> {
635        let Some(dropdown) = world.get::<Dropdown>(entity) else {
636            return Err("Entity has no Dropdown component".into());
637        };
638        let dropdown_panel = dropdown.panel;
639        let scroll_view_content = dropdown.scroll_view_content;
640
641        // Unsafe unwrap: If a UI element doesn't have a Node, we should panic!
642        let dropdown_node = world.get::<Node>(entity).unwrap();
643        let dropdown_size = dropdown_node.unrounded_size();
644        let dropdown_borders = UiUtils::border_as_px(entity, world);
645        let panel_borders = UiUtils::border_as_px(dropdown_panel, world);
646
647        // Calculate height for five options (opinionated soft height limit)
648        let Some(option_list) = world.get::<Children>(scroll_view_content) else {
649            return Err("Dropdown has no options".into());
650        };
651
652        let option_list: Vec<Entity> = option_list.iter().map(|child| *child).collect();
653        let mut five_children_height = panel_borders.x + panel_borders.z;
654        let mut counted = 0;
655        for child in option_list {
656            let Some(option_node) = world.get::<Node>(child) else {
657                continue;
658            };
659
660            if counted < 5 {
661                five_children_height += option_node.unrounded_size().y;
662
663                let margin_sizes = UiUtils::margin_as_px(child, world);
664                five_children_height += margin_sizes.x + margin_sizes.z;
665                counted += 1;
666            }
667        }
668
669        let (container_size, tl_corner) = UiUtils::container_size_and_offset(entity, world);
670        let halfway_point = container_size / 2.;
671        let space_below = (container_size - tl_corner - dropdown_size).y;
672
673        let anchor = if tl_corner.x > halfway_point.x {
674            if space_below < five_children_height {
675                DropdownPanelAnchor::TopRight
676            } else {
677                DropdownPanelAnchor::BottomRight
678            }
679        } else {
680            if space_below < five_children_height {
681                DropdownPanelAnchor::TopLeft
682            } else {
683                DropdownPanelAnchor::BottomLeft
684            }
685        };
686
687        let panel_size_limit = match anchor {
688            DropdownPanelAnchor::TopLeft => Vec2::new(container_size.x - tl_corner.x, tl_corner.y),
689            DropdownPanelAnchor::TopRight => Vec2::new(tl_corner.x + dropdown_size.x, tl_corner.y),
690            DropdownPanelAnchor::BottomLeft => Vec2::new(
691                container_size.x - tl_corner.x,
692                container_size.y - (tl_corner.y + dropdown_size.y),
693            ),
694            DropdownPanelAnchor::BottomRight => Vec2::new(
695                tl_corner.x + dropdown_size.x,
696                container_size.y - (tl_corner.y + dropdown_size.y),
697            ),
698        }
699        .max(Vec2::ZERO);
700
701        // Unsafe unwrap: If a ScrollView's content doesn't have a Node, we should panic!
702        let panel_width = (world
703            .get::<Node>(scroll_view_content)
704            .unwrap()
705            .unrounded_size()
706            .x
707            + panel_borders.y
708            + panel_borders.w)
709            .clamp(0., panel_size_limit.x.max(0.));
710        let idle_height = five_children_height.clamp(0., panel_size_limit.y.max(0.));
711
712        let (top, right, bottom, left) = match anchor {
713            DropdownPanelAnchor::TopLeft => (
714                Val::Auto,
715                Val::Auto,
716                Val::Px(dropdown_size.y - dropdown_borders.z),
717                Val::Px(-dropdown_borders.w),
718            ),
719            DropdownPanelAnchor::TopRight => (
720                Val::Auto,
721                Val::Px(-dropdown_borders.y),
722                Val::Px(dropdown_size.y - dropdown_borders.z),
723                Val::Auto,
724            ),
725            DropdownPanelAnchor::BottomLeft => (
726                Val::Px(dropdown_size.y - dropdown_borders.x),
727                Val::Auto,
728                Val::Auto,
729                Val::Px(-dropdown_borders.w),
730            ),
731            DropdownPanelAnchor::BottomRight => (
732                Val::Px(dropdown_size.y - dropdown_borders.x),
733                Val::Px(-dropdown_borders.y),
734                Val::Auto,
735                Val::Auto,
736            ),
737        };
738
739        Ok(DropdownPanelPlacement {
740            anchor,
741            top,
742            right,
743            bottom,
744            left,
745            width: Val::Px(panel_width),
746            height: Val::Px(idle_height),
747            panel_width,
748            button_width: dropdown_size.x,
749            wider_than_button: panel_width > dropdown_size.x,
750        })
751    }
752
753    fn button(options: Vec<String>) -> impl Bundle {
754        (
755            Name::new("Dropdown"),
756            ButtonBundle {
757                style: Style {
758                    flex_direction: FlexDirection::Row,
759                    overflow: Overflow::visible(),
760                    ..default()
761                },
762                ..default()
763            },
764            TrackedInteraction::default(),
765            LockedStyleAttributes::from_vec(vec![
766                LockableStyleAttribute::FlexDirection,
767                LockableStyleAttribute::Overflow,
768            ]),
769            DropdownOptions(options),
770        )
771    }
772
773    fn button_icon() -> impl Bundle {
774        (
775            Name::new("Dropdown Icon"),
776            ImageBundle {
777                focus_policy: FocusPolicy::Pass,
778                ..default()
779            },
780            BorderColor::default(),
781            LockedStyleAttributes::lock(LockableStyleAttribute::FocusPolicy),
782        )
783    }
784
785    fn option_bundle(option: usize) -> impl Bundle {
786        (
787            Name::new(format!("Option {}", option)),
788            ButtonBundle {
789                focus_policy: FocusPolicy::Pass,
790                ..default()
791            },
792            TrackedInteraction::default(),
793            LockedStyleAttributes::lock(LockableStyleAttribute::FocusPolicy),
794        )
795    }
796}
797
798pub trait UiDropdownExt {
799    fn dropdown(
800        &mut self,
801        options: Vec<impl Into<String>>,
802        value: impl Into<Option<usize>>,
803    ) -> UiBuilder<Entity>;
804}
805
806impl UiDropdownExt for UiBuilder<'_, Entity> {
807    /// A simple dropdown with options.
808    ///
809    /// ### PseudoState usage
810    /// - `PseudoState::Open`, when the options panel should be visible
811    fn dropdown(
812        &mut self,
813        options: Vec<impl Into<String>>,
814        value: impl Into<Option<usize>>,
815    ) -> UiBuilder<Entity> {
816        let mut label_id = Entity::PLACEHOLDER;
817        let mut icon_id = Entity::PLACEHOLDER;
818        let mut panel_id = Entity::PLACEHOLDER;
819        let mut scroll_view_id = Entity::PLACEHOLDER;
820        let mut scroll_view_content_id = Entity::PLACEHOLDER;
821
822        let option_count = options.len();
823        let mut string_options: Vec<String> = Vec::with_capacity(option_count);
824        let mut queue = VecDeque::from(options);
825        for _ in 0..option_count {
826            let label: String = queue.pop_front().unwrap().into();
827            string_options.push(label);
828        }
829
830        let mut dropdown = self.container(Dropdown::button(string_options.clone()), |builder| {
831            let dropdown_id = builder.id();
832            label_id = builder.label(LabelConfig::default()).id();
833            icon_id = builder.spawn(Dropdown::button_icon()).id();
834            panel_id = builder
835                .panel("Dropdown Options".into(), |container| {
836                    scroll_view_id = container
837                        .scroll_view(None, |scroll_view| {
838                            scroll_view_content_id = scroll_view.id();
839
840                            for (index, label) in string_options.iter().enumerate() {
841                                let mut label_id = Entity::PLACEHOLDER;
842                                scroll_view.container(Dropdown::option_bundle(index), |option| {
843                                    label_id = option
844                                        .label(LabelConfig {
845                                            label: label.clone(),
846                                            ..default()
847                                        })
848                                        .id();
849
850                                    option.insert(DropdownOption {
851                                        dropdown: dropdown_id,
852                                        option: index,
853                                        label: label_id,
854                                    });
855                                });
856                            }
857                        })
858                        .insert(TrackedStyleState::default())
859                        .id();
860                })
861                .insert((
862                    DropdownPanel {
863                        dropdown: dropdown_id,
864                    },
865                    LockedStyleAttributes::from_vec(vec![
866                        LockableStyleAttribute::Visibility,
867                        LockableStyleAttribute::Display,
868                        LockableStyleAttribute::FocusPolicy,
869                    ]),
870                    PseudoStates::default(),
871                    VisibilityToPseudoState,
872                ))
873                .style_unchecked()
874                .focus_policy(bevy::ui::FocusPolicy::Block)
875                .id();
876        });
877
878        dropdown.insert(Dropdown {
879            value: value.into(),
880            label: label_id,
881            icon: icon_id,
882            panel: panel_id,
883            scroll_view: scroll_view_id,
884            scroll_view_content: scroll_view_content_id,
885            ..default()
886        });
887
888        dropdown
889    }
890}