beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use crate::button::Button;
use crate::style::{ui_focus_outline_color, ui_focus_outline_hover_color, ui_hover_outline_color};
use bevy::prelude::*;
use bevy::ui::Val::Px;

pub struct FocusableUiPlugin;

#[derive(Component, Debug, Clone, Copy)]
pub struct UiFocusable;

#[derive(Component, Debug, Clone, Copy)]
pub struct UiFocusOutlineOnFocusOnly;

#[derive(Component, Debug, Clone, Copy)]
pub struct UiHovered;

#[derive(Component, Debug, Clone, Copy)]
pub struct UiFocused;

#[derive(Component, Debug, Clone, Copy)]
pub struct UiPressed;

pub const UI_FOCUS_OUTLINE_WIDTH: f32 = 3.0;
pub const UI_FOCUS_OUTLINE_OFFSET: f32 = 2.0;

impl Plugin for FocusableUiPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(
            Update,
            (
                sync_focusable_outlines_on_state_change,
                sync_focusable_outlines_on_state_removal,
            ),
        );
    }
}

pub fn hidden_outline() -> Outline {
    Outline::new(
        Px(UI_FOCUS_OUTLINE_WIDTH),
        Px(UI_FOCUS_OUTLINE_OFFSET),
        Color::NONE,
    )
}

fn sync_focusable_outlines_on_state_change(
    mut focusables: Query<
        (
            Has<UiHovered>,
            Has<UiFocused>,
            Has<UiFocusOutlineOnFocusOnly>,
            &mut Outline,
        ),
        (
            With<UiFocusable>,
            Without<Button>,
            Or<(Added<UiFocusable>, Changed<UiHovered>, Changed<UiFocused>)>,
        ),
    >,
) {
    for (hovered, focused, focus_only, mut outline) in &mut focusables {
        outline.color = outline_color(hovered, focused, focus_only);
    }
}

fn sync_focusable_outlines_on_state_removal(
    mut removed_hovered: RemovedComponents<UiHovered>,
    mut removed_focused: RemovedComponents<UiFocused>,
    mut focusables: Query<
        (
            Has<UiHovered>,
            Has<UiFocused>,
            Has<UiFocusOutlineOnFocusOnly>,
            &mut Outline,
        ),
        (With<UiFocusable>, Without<Button>),
    >,
) {
    for entity in removed_hovered.read() {
        let Ok((hovered, focused, focus_only, mut outline)) = focusables.get_mut(entity) else {
            continue;
        };
        outline.color = outline_color(hovered, focused, focus_only);
    }

    for entity in removed_focused.read() {
        let Ok((hovered, focused, focus_only, mut outline)) = focusables.get_mut(entity) else {
            continue;
        };
        outline.color = outline_color(hovered, focused, focus_only);
    }
}

pub fn outline_color(hovered: bool, focused: bool, focus_only: bool) -> Color {
    if focus_only {
        return if focused {
            ui_focus_outline_color()
        } else {
            Color::NONE
        };
    }

    match (hovered, focused) {
        (true, true) => ui_focus_outline_hover_color(),
        (true, false) => ui_hover_outline_color(),
        (false, true) => ui_focus_outline_color(),
        (false, false) => Color::NONE,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn focus_only_outline_ignores_hover_state() {
        assert_eq!(outline_color(true, false, true), Color::NONE);
        assert_eq!(outline_color(true, true, true), ui_focus_outline_color());
    }
}