bevy_pages 0.2.0

A lightweight and elegant framework to upgrade your Bevy UI experience.
Documentation
use crate::element::{ElementId, ElementProps, ElementWidget};
use crate::events::{ElementClick, ElementSet};
use crate::parser::color::{lighten_color, parse_color};
use crate::parser::values::parse_attribute;
use crate::parser::values::parse_float;
use crate::widgets::Widget;
use bevy::color::Color;
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput};
use bevy::prelude::*;
use bevy::ui::FocusPolicy;
use roxmltree::Node as XmlNode;

pub(crate) fn handle_focus(
    trigger: On<ElementClick>,
    mut all_inputs: Query<(Entity, &mut TextInputState, Option<&ElementId>)>,
) {
    let clicked_entity = trigger.entity;

    for (entity, mut state, _id) in all_inputs.iter_mut() {
        if entity == clicked_entity {
            if !state.is_focused {
                state.is_focused = true;
            }
        } else if state.is_focused {
            state.is_focused = false;
        }
    }
}

pub(crate) fn handle_click_outside(
    mut commands: Commands,
    mouse_button: Res<ButtonInput<MouseButton>>,
    touches: Res<Touches>,
    interaction_query: Query<&Interaction, With<TextInputState>>,
    mut all_inputs: Query<(Entity, &mut TextInputState, Option<&ElementId>)>,
) {
    let just_pressed = mouse_button.just_pressed(MouseButton::Left)
        || touches.iter_just_pressed().next().is_some();

    if !just_pressed {
        return;
    }

    let clicking_input = interaction_query.iter().any(|i| *i == Interaction::Pressed);

    if !clicking_input {
        for (entity, mut state, id) in all_inputs.iter_mut() {
            if state.is_focused {
                state.is_focused = false;
                commands.trigger(ElementSet {
                    entity,
                    id: id.cloned(),
                    value: state.value.clone(),
                    delta: None,
                });
            }
        }
    }
}

pub(crate) fn handle_typing(
    mut commands: Commands,
    mut key_events: MessageReader<KeyboardInput>,
    mut inputs: Query<(Entity, &mut TextInputState, Option<&ElementId>)>,
) {
    for event in key_events.read() {
        if event.state != ButtonState::Pressed {
            continue;
        }

        for (entity, mut state, id) in inputs.iter_mut() {
            if !state.is_focused {
                continue;
            }

            match &event.logical_key {
                Key::Backspace => {
                    state.value.pop();
                }

                Key::Enter => {
                    state.is_focused = false;
                    commands.trigger(ElementSet {
                        entity,
                        id: id.cloned(),
                        value: state.value.clone(),
                        delta: None,
                    });
                }

                Key::Character(input_str) => {
                    // Ignore control key sequences
                    if !input_str.chars().any(|c| c.is_control()) {
                        state.value.push_str(input_str.as_str());
                    }
                }
                Key::Space => {
                    state.value.push(' ');
                }

                _ => {}
            }
        }
    }
}

pub(crate) fn sync_visuals(
    query: Query<
        (Entity, &TextInputState, &TextInputWidget, &Children),
        Or<(Changed<TextInputState>, Changed<Interaction>)>,
    >,
    mut border_query: Query<&mut BorderColor>,
    mut text_query: Query<(&mut Text, &mut TextColor), With<TextInputText>>,
    mut cursor_query: Query<&mut Node, With<TextInputCursor>>,
) {
    for (entity, state, widget, children) in &query {
        let text_color = widget.text_color.unwrap_or(Color::WHITE);
        let placeholder_color = widget
            .placeholder_color
            .unwrap_or(Color::srgb(0.5, 0.5, 0.5));

        // Update border highlight if focused
        if let Ok(mut border) = border_query.get_mut(entity)
            && state.is_focused
        {
            *border = BorderColor::all(Color::srgb(0.3, 0.6, 1.0));
        }

        for child in children.iter() {
            // Update Text Node
            if let Ok((mut text, mut color)) = text_query.get_mut(child) {
                if state.value.is_empty() {
                    text.0 = state.placeholder.clone();
                    color.0 = placeholder_color;
                } else {
                    text.0 = state.value.clone();
                    color.0 = text_color;
                }
            }

            // Update Cursor display state
            if let Ok(mut cursor_node) = cursor_query.get_mut(child) {
                cursor_node.display = if state.is_focused {
                    Display::Flex
                } else {
                    Display::None
                };
            }
        }
    }
}

/// The internal value and focus state of the text input widget.
#[derive(Component, Debug, Clone)]
pub struct TextInputState {
    /// The current value of the text input.
    pub value: String,
    /// If the widget is currently focused.
    pub is_focused: bool,
    /// The placeholder text to display when the text input is empty.
    pub placeholder: String,
}

/// A text input widget allowing user text input.
///
/// ## XML Usage
///
/// Build a text input widget using the `<TextInput />` tag.
///
/// ### Attributes
/// - `value = "<string>"`: The **initial** value of the text input.
/// - `placeholder = "<string>"`: The placeholder text to display when the text input is empty.
/// - `text-color = "<color>"`: The text color.
/// - `placeholder-color = "<color>"`: The placeholder color.
/// - `font-size = "<float>"`: The font size of the text in pixels.
///
/// ## Logic
///
/// Use the [TextInputState] component to read the inner text value.
/// The widget also emits an [ElementSet] event when the text changes.
/// You may also use the other element events to implement custom behavior.
#[derive(Clone, Debug, Component)]
pub struct TextInputWidget {
    /// The initial value of the text input.
    pub initial_value: String,
    /// The placeholder text to display when the text input is empty.
    pub placeholder: String,
    /// The color of the inner text.
    pub text_color: Option<Color>,
    /// The color of the placeholder text.
    pub placeholder_color: Option<Color>,
    /// The font size of the text in pixels.
    pub font_size: f32,
}

impl Widget for TextInputWidget {
    fn spawn(&self, commands: &mut EntityCommands, _: &AssetServer) -> Entity {
        let text_color = self.text_color.unwrap_or(Color::WHITE);
        let placeholder_color = self.placeholder_color.unwrap_or(Color::srgb(0.5, 0.5, 0.5));
        let display_text = if self.initial_value.is_empty() {
            self.placeholder.clone()
        } else {
            self.initial_value.clone()
        };
        let active_color = if self.initial_value.is_empty() {
            placeholder_color
        } else {
            text_color
        };

        commands.insert((
            self.clone(),
            TextInputState {
                value: self.initial_value.clone(),
                is_focused: false,
                placeholder: self.placeholder.clone(),
            },
            FocusPolicy::Block,
        ));

        commands.with_children(|parent| {
            parent.spawn((
                TextInputText,
                Text::new(display_text),
                TextFont {
                    font_size: FontSize::Px(self.font_size),
                    ..Default::default()
                },
                TextColor(active_color),
                Node {
                    align_self: AlignSelf::Center,
                    ..Default::default()
                },
                FocusPolicy::Pass,
            ));

            parent.spawn((
                TextInputCursor,
                Node {
                    width: Val::Px(2.0),
                    height: Val::Px(self.font_size),
                    margin: UiRect::left(Val::Px(2.0)),
                    display: Display::None,
                    align_self: AlignSelf::Center,
                    ..Default::default()
                },
                BackgroundColor(text_color),
                FocusPolicy::Pass,
            ));
        });

        commands.id()
    }

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

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

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

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

        let placeholder_color = parse_attribute(node, "placeholder-color", prefix, parse_color)?
            .or_else(|| base_ti.and_then(|b| b.placeholder_color));

        let font_size = parse_attribute(node, "font-size", prefix, parse_float)?
            .or_else(|| base_ti.map(|b| b.font_size))
            .unwrap_or(14.0);

        Ok(Self {
            initial_value,
            placeholder,
            text_color,
            placeholder_color,
            font_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(160.0);
        }
        if !node.has_attribute("height") {
            default.node.height = Val::Px(32.0);
        }

        default.node.padding = UiRect::axes(Val::Px(8.0), Val::Px(4.0));
        default.node.align_items = AlignItems::Center;
        default.node.justify_content = JustifyContent::FlexStart;

        hover.node.padding = default.node.padding;
        click.node.padding = default.node.padding;

        let base_bg = default
            .bg_color
            .unwrap_or_else(|| Color::srgb(0.12, 0.12, 0.14));

        if default.bg_color.is_none() {
            default.bg_color = Some(base_bg);
        }

        if !node.has_attribute("hover.bg-color") {
            hover.bg_color = Some(lighten_color(base_bg, 0.05));
        }

        if default.border_color.is_none() {
            default.border_color = Some(BorderColor::all(Color::srgb(0.3, 0.3, 0.35)));
        }

        if !node.has_attribute("hover.border-color") {
            hover.border_color = Some(BorderColor::all(Color::srgb(0.5, 0.5, 0.6)));
        }

        if default.node.border == UiRect::ZERO {
            let border_rect = UiRect::all(Val::Px(1.0));
            default.node.border = border_rect;
            hover.node.border = border_rect;
            click.node.border = border_rect;
        }
    }
}

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

/// Marker component for the text input cursor.
#[derive(Component, Debug, Clone, Copy)]
pub struct TextInputCursor;