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, parse_font_size, parse_matches};
use crate::widgets::Widget;
use bevy::asset::AssetServer;
use bevy::color::Color;
use bevy::prelude::*;
use bevy::ui::{FocusPolicy, PositionType, UiRect, Val, ZIndex};
use roxmltree::Node as XmlNode;

pub(crate) fn sync_visuals(
    query: Query<(&Interaction, &Children), (With<TooltipWidget>, Changed<Interaction>)>,
    mut popup_query: Query<&mut Node, With<TooltipPopup>>,
) {
    for (interaction, children) in &query {
        let is_hovered =
            *interaction == Interaction::Hovered || *interaction == Interaction::Pressed;

        for &child in children {
            if let Ok(mut node) = popup_query.get_mut(child) {
                node.display = if is_hovered {
                    Display::Flex
                } else {
                    Display::None
                };
            }
        }
    }
}

/// The anchor point for the tooltip popup relative to its children.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum TooltipAnchor {
    /// The tooltip will be displayed above the children.
    #[default]
    Top,
    /// The tooltip will be displayed under the children.
    Bottom,
    /// The tooltip will be displayed to the left to the children.
    Left,
    /// The tooltip will be displayed to the right to the children.
    Right,
}

impl TooltipAnchor {
    /// Parses a string into a [TooltipAnchor].
    pub fn parse(s: &str) -> Result<Self, String> {
        parse_matches(
            s,
            &[
                ("top", || Ok(Self::Top)),
                ("bottom", || Ok(Self::Bottom)),
                ("left", || Ok(Self::Left)),
                ("right", || Ok(Self::Right)),
            ],
        )
    }
}

/// A tooltip widget that displays additional information when hovered.
///
/// ## XML Usage
///
/// Build a new tooltip widget using the `<Tooltip></Tooltip>` tag.
///
/// The children of the tooltip will trigger the tooltip popup when hovered on.
///
/// ### Attributes
/// - `text = "<string>"`: The tooltip text.
/// - `anchor = "<top|bottom|left|right>"`: The anchor/position of the tooltip relative to its children. See [TooltipAnchor].
/// - `bg-color = "<color>"`: The background color of the tooltip popup.
/// - `text-color = "<color>"`: The text color inside the tooltip popup.
/// - `font-size = "<fontSize>"`: The font size of the text. See [parse_font_size].
///
/// ## Logic
///
/// This widget does not have any special code logic.
/// You can use the element events to implement custom behavior.
#[derive(Component, Clone, Debug, PartialEq)]
pub struct TooltipWidget {
    /// The text content displayed inside the tooltip popup.
    pub text: String,
    /// The anchor point for the tooltip popup.
    pub anchor: TooltipAnchor,
    /// Background color of the tooltip bubble.
    pub bg_color: Option<Color>,
    /// Text color inside the tooltip bubble.
    pub text_color: Option<Color>,
    /// Font size of the text.
    pub font_size: FontSize,
}

impl Widget for TooltipWidget {
    fn spawn(&self, commands: &mut EntityCommands, _: &AssetServer) -> Entity {
        let bg_color = self.bg_color.unwrap_or(Color::srgb(0.1, 0.1, 0.12));
        let text_color = self.text_color.unwrap_or(Color::WHITE);

        // Position popup based on configured orientation
        let mut popup_node = Node {
            position_type: PositionType::Absolute,
            display: Display::None, // Hidden by default; shown on hover
            padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
            border_radius: BorderRadius::all(Val::Px(4.0)),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            ..default()
        };

        const OFFSET: Val = Val::Px(6.0);

        match self.anchor {
            TooltipAnchor::Top => {
                popup_node.bottom = Val::Percent(100.0);
                popup_node.margin = UiRect::bottom(OFFSET);
            }
            TooltipAnchor::Bottom => {
                popup_node.top = Val::Percent(100.0);
                popup_node.margin = UiRect::top(OFFSET);
            }
            TooltipAnchor::Left => {
                popup_node.right = Val::Percent(100.0);
                popup_node.margin = UiRect::right(OFFSET);
            }
            TooltipAnchor::Right => {
                popup_node.left = Val::Percent(100.0);
                popup_node.margin = UiRect::left(OFFSET);
            }
        }

        commands.insert((self.clone(), FocusPolicy::Block));

        commands.with_children(|parent| {
            parent
                .spawn((
                    TooltipPopup,
                    popup_node,
                    BackgroundColor(bg_color),
                    ZIndex(100), // Renders above adjacent UI nodes
                    FocusPolicy::Pass,
                ))
                .with_children(|popup| {
                    popup.spawn((
                        TooltipText,
                        Text::new(&self.text),
                        TextFont {
                            font_size: self.font_size,
                            ..default()
                        },
                        TextColor(text_color),
                        FocusPolicy::Pass,
                    ));
                });
        });

        commands.id()
    }

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

        let text = parse_attribute(node, "text", prefix, |s| Ok(s.to_string()))?
            .or_else(|| base_tt.map(|b| b.text.clone()))
            .unwrap_or_default();

        let anchor = parse_attribute(node, "anchor", prefix, TooltipAnchor::parse)?
            .or_else(|| base_tt.map(|b| b.anchor))
            .unwrap_or_default();

        let bg_color = parse_attribute(node, "bg-color", prefix, parse_color)?
            .or_else(|| base_tt.and_then(|b| b.bg_color));

        let text_color = parse_attribute(node, "text-color", prefix, parse_color)?
            .or_else(|| base_tt.and_then(|b| b.text_color));

        let font_size = parse_attribute(node, "font-size", prefix, parse_font_size)?
            .or_else(|| base_tt.map(|b| b.font_size))
            .unwrap_or(FontSize::Px(12.0));

        Ok(Self {
            text,
            anchor,
            bg_color,
            text_color,
            font_size,
        })
    }

    fn apply_defaults(
        _node: &XmlNode,
        default: &mut ElementProps,
        hover: &mut ElementProps,
        click: &mut ElementProps,
    ) {
        default.node.position_type = PositionType::Relative;
        default.node.display = Display::Flex;
        default.node.align_items = AlignItems::Center;
        default.node.justify_content = JustifyContent::Center;

        hover.node.position_type = PositionType::Relative;
        click.node.position_type = PositionType::Relative;
    }
}

/// Marker component for the tooltip floating popup box.
#[derive(Component, Debug, Clone, Copy)]
pub struct TooltipPopup;

/// Marker component for the tooltip text node.
#[derive(Component, Debug, Clone, Copy)]
pub struct TooltipText;