bevy_pages 0.2.0

A lightweight and elegant framework to upgrade your Bevy UI experience.
Documentation
use crate::element::{ElementProps, ElementWidget};
use crate::parser::color::parse_color;
use crate::parser::values::parse_attribute;
use crate::widgets::Widget;
use bevy::asset::AssetServer;
use bevy::color::Color;
use bevy::prelude::*;
use roxmltree::Node as XmlNode;

pub(crate) fn trigger_menu(
    mut trigger_query: Query<
        (&Interaction, &ChildOf),
        (With<DropdownTrigger>, Changed<Interaction>),
    >,
    mut root_query: Query<&mut DropdownState>,
) {
    for (interaction, child_of) in &mut trigger_query {
        if *interaction == Interaction::Pressed
            && let Ok(mut state) = root_query.get_mut(child_of.0)
        {
            state.is_open = !state.is_open;
        }
    }
}

pub(crate) fn option_select(
    interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
    child_of_query: Query<&ChildOf>,
    menu_query: Query<&ChildOf, With<DropdownMenu>>,
    mut root_query: Query<(&mut DropdownState, &Children)>,
    trigger_query: Query<&Children, With<DropdownTrigger>>,
    label_query: Query<Entity, With<DropdownLabelText>>,
    mut text_query: Query<&mut Text>,
    children_query: Query<&Children>,
) {
    for (clicked_entity, interaction) in &interaction_query {
        if *interaction != Interaction::Pressed {
            continue;
        }

        if let Some(root_entity) = find_dropdown_root(clicked_entity, &child_of_query, &menu_query)
            && let Ok((mut state, root_children)) = root_query.get_mut(root_entity)
        {
            // Extract label text from the clicked entity or its child subtree
            let extracted_label =
                extract_text_from_entity(clicked_entity, &text_query, &children_query)
                    .unwrap_or_else(|| "Selected Option".to_string());

            state.selected_value = extracted_label.clone();
            state.selected_label = extracted_label.clone();
            state.is_open = false; // Auto-close menu on selection

            // Sync the trigger header label text
            for child in root_children.iter() {
                if let Ok(trigger_children) = trigger_query.get(child) {
                    for trigger_child in trigger_children.iter() {
                        if label_query.contains(trigger_child)
                            && let Ok(mut text) = text_query.get_mut(trigger_child)
                        {
                            **text = extracted_label.clone();
                        }
                    }
                }
            }
        }
    }
}

pub(crate) fn visibility(
    root_query: Query<(&DropdownState, &Children), Changed<DropdownState>>,
    mut menu_query: Query<(&mut Node, &mut ZIndex), With<DropdownMenu>>,
) {
    for (state, children) in &root_query {
        for child in children.iter() {
            if let Ok((mut node, mut z_index)) = menu_query.get_mut(child) {
                if state.is_open {
                    node.display = Display::Flex;
                    *z_index = ZIndex(100);
                } else {
                    node.display = Display::None;
                    *z_index = ZIndex(0);
                }
            }
        }
    }
}

pub(crate) fn close_on_outside_click(
    mouse_button: Res<ButtonInput<MouseButton>>,
    mut root_query: Query<(Entity, &mut DropdownState)>,
    interaction_query: Query<&Interaction>,
    children_query: Query<&Children>,
) {
    if !mouse_button.just_pressed(MouseButton::Left) {
        return;
    }

    for (root_entity, mut state) in &mut root_query {
        if !state.is_open {
            continue;
        }

        // Check if any entity within this specific dropdown's tree is hovered or pressed
        let is_interacting =
            is_hierarchy_interacting(root_entity, &interaction_query, &children_query);

        if !is_interacting {
            state.is_open = false;
        }
    }
}

fn find_dropdown_root(
    start_entity: Entity,
    child_of_query: &Query<&ChildOf>,
    menu_query: &Query<&ChildOf, With<DropdownMenu>>,
) -> Option<Entity> {
    let mut current = start_entity;

    loop {
        // Check if current entity is the DropdownMenu container
        if let Ok(menu_child_of) = menu_query.get(current) {
            return Some(menu_child_of.0); // Returns root Dropdown entity
        }

        // Ascend to parent
        if let Ok(parent) = child_of_query.get(current) {
            current = parent.0;
        } else {
            return None;
        }
    }
}

fn extract_text_from_entity(
    entity: Entity,
    text_query: &Query<&mut Text>,
    children_query: &Query<&Children>,
) -> Option<String> {
    if let Ok(text) = text_query.get(entity)
        && !text.0.is_empty()
    {
        return Some(text.0.clone());
    }

    if let Ok(children) = children_query.get(entity) {
        for child in children.iter() {
            if let Some(found) = extract_text_from_entity(child, text_query, children_query) {
                return Some(found);
            }
        }
    }

    None
}

fn is_hierarchy_interacting(
    entity: Entity,
    interaction_query: &Query<&Interaction>,
    children_query: &Query<&Children>,
) -> bool {
    if let Ok(interaction) = interaction_query.get(entity)
        && matches!(interaction, Interaction::Hovered | Interaction::Pressed)
    {
        return true;
    }

    if let Ok(children) = children_query.get(entity) {
        for child in children.iter() {
            if is_hierarchy_interacting(child, interaction_query, children_query) {
                return true;
            }
        }
    }

    false
}

/// The internal state of a dropdown widget.
#[derive(Component, Debug, Clone, Reflect)]
#[reflect(Component)]
pub struct DropdownState {
    /// If the dropdown menu is open.
    pub is_open: bool,
    /// The selected item index.
    pub selected_index: Option<usize>,
    /// The selected value.
    pub selected_value: String,
    /// The selected label.
    pub selected_label: String,
}

impl Default for DropdownState {
    fn default() -> Self {
        Self {
            is_open: false,
            selected_index: None,
            selected_value: String::new(),
            selected_label: "Select an option...".to_string(),
        }
    }
}

/// A dropdown widget with a list of options.
///
/// ## XML Usage
///
/// Build a dropdown widget using the `<Dropdown></Dropdown>` tag.
///
/// Every "root" children of the dropdown is considered a new option.
///
/// ### Attributes
/// - `placeholder = "<string>"`: The placeholder text to display when no option is selected.
/// - `bg-color = "<color>"`: The background color of the dropdown.
/// - `menu-bg-color = "<color>"`: The background color of the dropdown menu.
///
/// ## Logic
///
/// Use the [DropdownState] for more info about the underlying widget.
/// You can also use element events to implement custom behavior.
#[derive(Clone, Debug, PartialEq)]
pub struct DropdownWidget {
    /// The placeholder text to display when no option is selected.
    pub placeholder: String,
    /// The dropdown background color.
    pub bg_color: Option<Color>,
    /// The dropdown menu background color.
    pub menu_bg_color: Option<Color>,
}

impl Default for DropdownWidget {
    fn default() -> Self {
        Self {
            placeholder: "Select an option...".to_string(),
            bg_color: Some(Color::srgb(0.2, 0.2, 0.2)),
            menu_bg_color: Some(Color::srgb(0.15, 0.15, 0.15)),
        }
    }
}

impl Widget for DropdownWidget {
    fn spawn(&self, commands: &mut EntityCommands, _assets: &AssetServer) -> Entity {
        let root_entity = commands.id();
        let bg_color = self.bg_color.unwrap_or(Color::srgb(0.2, 0.2, 0.2));
        let menu_bg = self.menu_bg_color.unwrap_or(Color::srgb(0.15, 0.15, 0.15));

        commands.insert(DropdownState {
            selected_label: self.placeholder.clone(),
            ..default()
        });

        let mut menu_entity = None;

        commands.with_children(|parent| {
            parent
                .spawn((
                    Transform::default(),
                    GlobalTransform::default(),
                    DropdownTrigger,
                    Interaction::None,
                    Node {
                        width: Val::Percent(100.0),
                        height: Val::Px(36.0),
                        padding: UiRect::axes(Val::Px(12.0), Val::Px(8.0)),
                        justify_content: JustifyContent::SpaceBetween,
                        align_items: AlignItems::Center,
                        border: UiRect::all(Val::Px(1.0)),
                        border_radius: BorderRadius::all(Val::Px(4.0)),
                        ..default()
                    },
                    BackgroundColor(bg_color),
                    BorderColor::all(Color::srgb(0.4, 0.4, 0.4)),
                ))
                .with_children(|trigger| {
                    trigger.spawn((
                        Transform::default(),
                        GlobalTransform::default(),
                        DropdownLabelText,
                        Text::new(&self.placeholder),
                        TextFont {
                            font_size: FontSize::Px(14.0),
                            ..default()
                        },
                        TextColor(Color::srgb(0.9, 0.9, 0.9)),
                    ));

                    trigger.spawn((
                        Transform::default(),
                        GlobalTransform::default(),
                        Text::new(">"),
                        TextFont {
                            font_size: FontSize::Px(10.0),
                            ..default()
                        },
                        TextColor(Color::srgb(0.6, 0.6, 0.6)),
                    ));
                });

            let menu_cmd = parent.spawn((
                Transform::default(),
                GlobalTransform::default(),
                DropdownMenu,
                Node {
                    display: Display::None,
                    position_type: PositionType::Absolute,
                    top: Val::Percent(100.0),
                    left: Val::Px(0.0),
                    width: Val::Percent(100.0),
                    max_height: Val::Px(200.0),
                    flex_direction: FlexDirection::Column,
                    overflow: Overflow::clip_y(),
                    margin: UiRect::top(Val::Px(4.0)),
                    border: UiRect::all(Val::Px(1.0)),
                    border_radius: BorderRadius::all(Val::Px(4.0)),
                    ..default()
                },
                ZIndex(100),
                BackgroundColor(menu_bg),
                BorderColor::all(Color::srgb(0.3, 0.3, 0.3)),
            ));

            menu_entity = Some(menu_cmd.id());
        });

        menu_entity.unwrap_or(root_entity)
    }

    fn parse(
        node: &XmlNode,
        prefix: Option<&str>,
        _base: Option<&ElementWidget>,
    ) -> Result<Self, String> {
        let placeholder = parse_attribute(node, "placeholder", prefix, |s| Ok(s.to_string()))?
            .unwrap_or_else(|| "Select an option...".to_string());

        let bg_color = parse_attribute(node, "bg-color", prefix, parse_color)?;
        let menu_bg_color = parse_attribute(node, "menu-bg-color", prefix, parse_color)?;

        Ok(Self {
            placeholder,
            bg_color,
            menu_bg_color,
        })
    }

    fn apply_defaults(
        node: &XmlNode,
        default: &mut ElementProps,
        hover: &mut ElementProps,
        click: &mut ElementProps,
    ) {
        if !node.has_attribute("width") {
            default.node.width = Val::Px(200.0);
            hover.node.width = Val::Px(200.0);
            click.node.width = Val::Px(200.0);
        }
    }
}

/// Marker component for the header button.
#[derive(Component, Debug, Clone, Copy)]
pub struct DropdownTrigger;

/// Marker component for the overlay container holding the dropdown items.
#[derive(Component, Debug, Clone, Copy)]
pub struct DropdownMenu;

/// Marker for the dropdown label text.
#[derive(Component, Debug, Clone, Copy)]
pub struct DropdownLabelText;