bevy_pages 0.2.0

A lightweight and elegant framework to upgrade your Bevy UI experience.
Documentation
use crate::element::{Element, ElementId, ElementProps, ElementWidget};
use crate::events::ElementSet;
use crate::parser::color::parse_color;
use crate::parser::values::{parse_attribute, parse_float};
use crate::widgets::Widget;
use bevy::asset::AssetServer;
use bevy::color::Color;
use bevy::ecs::system::EntityCommands;
use bevy::prelude::*;
use bevy::ui::{BackgroundColor, BorderRadius, FocusPolicy, Node, PositionType, UiRect, Val};
use roxmltree::Node as XmlNode;

pub(crate) fn handle_slider_input(
    mut commands: Commands,
    touches: Res<Touches>,
    mouse_button: Res<ButtonInput<MouseButton>>,
    window: Single<&Window>,
    mut slider_query: Query<(
        Entity,
        &Element,
        &mut SliderState,
        &Interaction,
        &ComputedNode,
        &GlobalTransform,
        Option<&ElementId>,
        Has<SliderDragging>,
    )>,
) {
    let active_touch = touches.iter().next();
    let cursor_pos = window
        .cursor_position()
        .or_else(|| active_touch.map(|t| t.position()));

    let mouse_down = mouse_button.pressed(MouseButton::Left) || active_touch.is_some();
    let mouse_just_released = mouse_button.just_released(MouseButton::Left)
        || touches.iter_just_released().next().is_some();

    let half_window_width = window.width() / 2.0;

    for (entity, element, mut state, interaction, computed_node, transform, id, is_dragging) in
        &mut slider_query
    {
        // Extract slider widget properties directly from the Element component
        let Some(slider_config) = element.props.widget.as_slider() else {
            continue;
        };

        // Manage Drag Lifecycle
        if *interaction == Interaction::Pressed && mouse_down && !is_dragging {
            commands.entity(entity).insert(SliderDragging);
        }

        if is_dragging && (!mouse_down || mouse_just_released) {
            commands.entity(entity).remove::<SliderDragging>();
            continue;
        }

        // Update Slider Value During Active Drag
        if is_dragging {
            let Some(cursor) = cursor_pos else { continue };

            let node_size = computed_node.size();
            if node_size.x <= 0.0 {
                continue;
            }

            // Convert cursor (top-left = 0,0) to Bevy UI space (center = 0,0)
            let ui_cursor_x = cursor.x - half_window_width;

            let node_center_x = transform.translation().x;
            let node_left = node_center_x - (node_size.x / 2.0);

            // Normalized position along horizontal track [0.0, 1.0]
            let norm = ((ui_cursor_x - node_left) / node_size.x).clamp(0.0, 1.0);

            let min = slider_config.min;
            let max = slider_config.max;
            let mut new_val = min + norm * (max - min);

            if let Some(step) = slider_config.step
                && step > 0.0
            {
                new_val = (new_val / step).round() * step;
            }

            new_val = new_val.clamp(min, max);

            if (state.0 - new_val).abs() > f32::EPSILON {
                let delta = new_val - state.0;
                state.0 = new_val;

                commands.trigger(ElementSet {
                    entity,
                    id: id.cloned(),
                    value: new_val,
                    delta: Some(delta),
                });
            }
        }
    }
}

pub(crate) fn sync_visuals(
    slider_query: Query<(&Element, &SliderState, &Children), Changed<SliderState>>,
    mut fill_query: Query<&mut Node, (With<SliderFill>, Without<SliderThumb>)>,
    mut thumb_query: Query<&mut Node, (With<SliderThumb>, Without<SliderFill>)>,
) {
    for (element, state, children) in &slider_query {
        let Some(slider_config) = element.props.widget.as_slider() else {
            continue;
        };

        let norm_val = if slider_config.max > slider_config.min {
            ((state.0 - slider_config.min) / (slider_config.max - slider_config.min))
                .clamp(0.0, 1.0)
        } else {
            0.0
        };

        let pct = norm_val * 100.0;

        for &child in children {
            if let Ok(mut fill_node) = fill_query.get_mut(child) {
                fill_node.width = Val::Percent(pct);
            }

            if let Ok(mut thumb_node) = thumb_query.get_mut(child) {
                thumb_node.left = Val::Percent(pct);
            }
        }
    }
}

/// The internal state of the slider.
///
/// Contains the current slider value.
#[derive(Copy, Clone, Debug, Component)]
pub struct SliderState(pub f32);

/// A slider widget.
///
/// ## XML Usage
///
/// Build a slider widget using the `<Slider />` tag.
///
/// ### Attributes
/// - `min = "<float>"`: The minimum of the slider.
/// - `max = "<float>"`: The maximum of the slider.
/// - `step = "<float>"`: The step size for the slider.
/// - `value = "<float>"`: The **initial** value of the slider.
/// - `track-color = "<color>"`: The color of the slider track.
/// - `thumb-color = "<color>"`: The color of the slider thumb.
/// - `fill-color = "<color>"`: The color of the slider fill.
/// - `track-height = "<float>"`: The height of the slider track in pixels.
/// - `thumb-size = "<float>"`: The size of the slider thumb in pixels.
///
/// ## Logic
///
/// The slider widget uses the [SliderState] for internal state.
/// You can also use the [ElementSet] event or other element events.
#[derive(Clone, Debug, PartialEq)]
pub struct SliderWidget {
    /// The minimum slider value.
    pub min: f32,
    /// The maximum slider value.
    pub max: f32,
    /// The step size for the slider.
    pub step: Option<f32>,
    /// The initial value of the slider.
    pub value: f32,
    /// The slider track color.
    pub track_color: Option<Color>,
    /// The slider thumb color.
    pub thumb_color: Option<Color>,
    /// The slider fill color.
    pub fill_color: Option<Color>,
    /// The slider track height in pixels.
    ///
    /// TODO: Support bevy's [Val]
    pub track_height: Option<f32>,
    /// The slider thumb size in pixels.
    ///
    /// TODO: Support bevy's [Val]
    pub thumb_size: Option<f32>,
}

impl Widget for SliderWidget {
    fn spawn(&self, commands: &mut EntityCommands, _: &AssetServer) -> Entity {
        let norm_val = if self.max > self.min {
            ((self.value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
        } else {
            0.0
        };

        let pct = norm_val * 100.0;
        let track_color = self.track_color.unwrap_or(Color::srgb(0.16, 0.17, 0.20));
        let fill_color = self.fill_color.unwrap_or(Color::srgb(0.38, 0.69, 0.94));
        let thumb_color = self.thumb_color.unwrap_or(Color::WHITE);
        let track_h = self.track_height.unwrap_or(6.0);
        let thumb_s = self.thumb_size.unwrap_or(16.0);

        commands
            .insert((SliderState(self.value), FocusPolicy::Block))
            .with_children(|parent| {
                // Background Track
                parent.spawn((
                    SliderTrack,
                    Node {
                        width: Val::Percent(100.0),
                        height: Val::Px(track_h),
                        border_radius: BorderRadius::all(Val::Px(track_h / 2.0)),
                        position_type: PositionType::Absolute,
                        ..default()
                    },
                    BackgroundColor(track_color),
                    FocusPolicy::Pass,
                ));

                // Active Fill Bar
                parent.spawn((
                    SliderFill,
                    Node {
                        width: Val::Percent(pct),
                        height: Val::Px(track_h),
                        border_radius: BorderRadius::all(Val::Px(track_h / 2.0)),
                        position_type: PositionType::Absolute,
                        ..default()
                    },
                    BackgroundColor(fill_color),
                    FocusPolicy::Pass,
                ));

                // Draggable Thumb Knob
                parent.spawn((
                    SliderThumb,
                    Node {
                        width: Val::Px(thumb_s),
                        height: Val::Px(thumb_s),
                        border_radius: BorderRadius::all(Val::Px(thumb_s / 2.0)),
                        position_type: PositionType::Absolute,
                        left: Val::Percent(pct),
                        margin: UiRect::left(Val::Px(-thumb_s / 2.0)),
                        ..default()
                    },
                    BackgroundColor(thumb_color),
                    FocusPolicy::Pass,
                ));
            });

        commands.id()
    }

    fn parse(
        node: &XmlNode,
        prefix: Option<&str>,
        base: Option<&ElementWidget>,
    ) -> Result<Self, String>
    where
        Self: Sized,
    {
        let base_slider = match base {
            Some(ElementWidget::Slider(s)) => Some(s),
            _ => None,
        };

        let min = parse_attribute(node, "min", prefix, parse_float)?
            .or_else(|| base_slider.map(|s| s.min))
            .unwrap_or(0.0);

        let max = parse_attribute(node, "max", prefix, parse_float)?
            .or_else(|| base_slider.map(|s| s.max))
            .unwrap_or(100.0);

        if min >= max {
            return Err(format!(
                "Slider 'min' ({}) must be strictly less than 'max' ({})",
                min, max
            ));
        }

        let step = parse_attribute(node, "step", prefix, parse_float)?
            .or_else(|| base_slider.and_then(|s| s.step));

        let mut value = parse_attribute(node, "value", prefix, parse_float)?
            .or_else(|| base_slider.map(|s| s.value))
            .unwrap_or(min);

        if let Some(step_val) = step
            && step_val > 0.0
        {
            value = (value / step_val).round() * step_val;
        }
        value = value.clamp(min, max);

        let track_color = parse_attribute(node, "track-color", prefix, parse_color)?
            .or_else(|| base_slider.and_then(|s| s.track_color));

        let thumb_color = parse_attribute(node, "thumb-color", prefix, parse_color)?
            .or_else(|| base_slider.and_then(|s| s.thumb_color));

        let fill_color = parse_attribute(node, "fill-color", prefix, parse_color)?
            .or_else(|| base_slider.and_then(|s| s.fill_color));

        let track_height = parse_attribute(node, "track-height", prefix, parse_float)?
            .or_else(|| base_slider.and_then(|s| s.track_height));

        let thumb_size = parse_attribute(node, "thumb-size", prefix, parse_float)?
            .or_else(|| base_slider.and_then(|s| s.thumb_size));

        Ok(SliderWidget {
            min,
            max,
            step,
            value,
            track_color,
            thumb_color,
            fill_color,
            track_height,
            thumb_size,
        })
    }

    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);
        }

        if !node.has_attribute("height") {
            default.node.height = Val::Px(24.0);
        }

        default.node.align_items = AlignItems::Center;
        default.node.justify_content = JustifyContent::FlexStart;
        default.node.position_type = PositionType::Relative;
    }
}

/// Marker component for the track of a Slider.
#[derive(Component)]
pub struct SliderTrack;

/// Marker component for the fill of a Slider.
#[derive(Component)]
pub struct SliderFill;

/// Marker component for the thumb of a Slider.
#[derive(Component)]
pub struct SliderThumb;

/// Component added to the slider when it's being dragged.
#[derive(Component)]
pub struct SliderDragging;