beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use super::model::{
    UiDisabled, UiStateStyleSource, UiStateVisualSnapshot, UiStateVisualStyles, UiStateVisualTarget,
};
use super::transition::{UiStateVisualTransition, transition_color};
use crate::focus::{UiFocused, UiHovered, UiPressed};
use crate::style::{UiThemeConfig, resolve_color_value_with_config};
use crate::stylesheet::runtime_style_sheet;
use crate::utility::{
    UtilityTransitionProperty, UtilityTransitionTiming, UtilityVal, UtilityVisualStylePatch,
};
use bevy::prelude::*;

pub(super) fn capture_state_visual_snapshots(
    mut commands: Commands,
    query: Query<
        (
            Entity,
            Option<&BackgroundColor>,
            Option<&BorderColor>,
            Option<&TextColor>,
            Option<&Outline>,
        ),
        (
            With<UiStateVisualStyles>,
            Or<(Added<UiStateVisualStyles>, Added<UiStateStyleSource>)>,
            Without<UiStateVisualSnapshot>,
        ),
    >,
) {
    for (entity, background, border, text, outline) in &query {
        let snapshot = UiStateVisualSnapshot {
            background: background.map(|value| value.0),
            border: border.map(|value| value.top),
            text: text.map(|value| value.0),
            outline_width: outline.map(|value| value.width),
            outline_offset: outline.map(|value| value.offset),
            outline_color: outline.map(|value| value.color),
        };
        if let Ok(mut entity_commands) = commands.get_entity(entity) {
            entity_commands.try_insert(snapshot);
        }
    }
}

#[allow(clippy::type_complexity)]
pub(super) fn resolve_state_visual_styles(
    mut commands: Commands,
    mut query: Query<(
        Entity,
        &UiStateVisualStyles,
        Option<&UiStateStyleSource>,
        &UiStateVisualSnapshot,
        Option<&mut BackgroundColor>,
        Option<&mut BorderColor>,
        Option<&mut TextColor>,
        Option<&mut Outline>,
        Option<&UiStateVisualTarget>,
    )>,
    states: Query<(
        Has<UiHovered>,
        Has<UiPressed>,
        Has<UiFocused>,
        Has<UiDisabled>,
    )>,
) {
    let runtime_sheet = runtime_style_sheet();
    let runtime_config = runtime_sheet.config();
    for (
        entity,
        styles,
        state_source,
        snapshot,
        background,
        border,
        text,
        outline,
        current_target,
    ) in &mut query
    {
        let has_background = background.is_some();
        let has_border = border.is_some();
        let has_text = text.is_some();
        let has_outline = outline.is_some();
        let state_entity = state_source.map(|value| value.0).unwrap_or(entity);
        let (hovered, pressed, focused, disabled) = states
            .get(state_entity)
            .unwrap_or((false, false, false, false));
        let patch = effective_visual_patch(styles, hovered, pressed, focused, disabled);
        let target = resolve_visual_target(runtime_config, &patch, snapshot);

        if current_target.copied() == Some(target) {
            continue;
        }

        let should_transition = matches!(
            patch.transition_property,
            Some(UtilityTransitionProperty::All | UtilityTransitionProperty::Colors)
        ) && patch.transition_duration_ms.unwrap_or_default() > 0.0;

        if should_transition {
            let transition = UiStateVisualTransition {
                background: transition_color(
                    background.as_deref().map(|value| value.0),
                    target.background,
                ),
                border: transition_color(border.as_deref().map(|value| value.top), target.border),
                text: transition_color(text.as_deref().map(|value| value.0), target.text),
                outline: transition_color(snapshot.outline_color, target.outline_color),
                duration_secs: patch.transition_duration_ms.unwrap_or_default() / 1000.0,
                elapsed_secs: 0.0,
                timing: patch
                    .transition_timing
                    .unwrap_or(UtilityTransitionTiming::EaseInOut),
            };

            if let Ok(mut entity_commands) = commands.get_entity(entity) {
                ensure_visual_target_components(
                    &mut entity_commands,
                    &target,
                    has_background,
                    has_border,
                    has_text,
                    has_outline,
                );
                if transition.has_values() {
                    entity_commands.try_insert(transition);
                } else {
                    entity_commands.try_remove::<UiStateVisualTransition>();
                }
                entity_commands.try_insert(target);
            }

            if !transition.has_values() {
                apply_visual_target(background, border, text, outline, snapshot, &target);
            }
        } else {
            apply_visual_target(background, border, text, outline, snapshot, &target);
            if let Ok(mut entity_commands) = commands.get_entity(entity) {
                ensure_visual_target_components(
                    &mut entity_commands,
                    &target,
                    has_background,
                    has_border,
                    has_text,
                    has_outline,
                );
                entity_commands.try_remove::<UiStateVisualTransition>();
                entity_commands.try_insert(target);
            }
        }
    }
}

fn effective_visual_patch(
    styles: &UiStateVisualStyles,
    hovered: bool,
    pressed: bool,
    focused: bool,
    disabled: bool,
) -> UtilityVisualStylePatch {
    let mut patch = styles.base.clone();
    if disabled {
        merge_visual_patch(&mut patch, &styles.disabled);
    } else {
        if focused {
            merge_visual_patch(&mut patch, &styles.focus);
        }
        if hovered {
            merge_visual_patch(&mut patch, &styles.hover);
        }
        if pressed {
            merge_visual_patch(&mut patch, &styles.active);
        }
    }
    patch
}

fn merge_visual_patch(target: &mut UtilityVisualStylePatch, patch: &UtilityVisualStylePatch) {
    if let Some(value) = &patch.background_color {
        target.background_color = Some(value.clone());
    }
    if let Some(value) = &patch.text_color {
        target.text_color = Some(value.clone());
    }
    if let Some(value) = &patch.border_color {
        target.border_color = Some(value.clone());
    }
    if let Some(value) = patch.outline_width {
        target.outline_width = Some(value);
    }
    if let Some(value) = &patch.outline_color {
        target.outline_color = Some(value.clone());
    }
    if let Some(value) = patch.opacity {
        target.opacity = Some(value);
    }
    if let Some(value) = patch.transition_property {
        target.transition_property = Some(value);
    }
    if let Some(value) = patch.transition_duration_ms {
        target.transition_duration_ms = Some(value);
    }
    if let Some(value) = patch.transition_timing {
        target.transition_timing = Some(value);
    }
}

fn resolve_visual_target(
    config: &UiThemeConfig,
    patch: &UtilityVisualStylePatch,
    snapshot: &UiStateVisualSnapshot,
) -> UiStateVisualTarget {
    UiStateVisualTarget {
        background: resolve_color_target(
            config,
            patch.background_color.as_deref(),
            snapshot.background,
            patch.opacity,
        ),
        border: resolve_color_target(
            config,
            patch.border_color.as_deref(),
            snapshot.border,
            patch.opacity,
        ),
        text: resolve_color_target(
            config,
            patch.text_color.as_deref(),
            snapshot.text,
            patch.opacity,
        ),
        outline_width: patch
            .outline_width
            .and_then(utility_val_to_val)
            .or(snapshot.outline_width),
        outline_color: resolve_color_target(
            config,
            patch.outline_color.as_deref(),
            snapshot.outline_color,
            patch.opacity,
        ),
    }
}

fn resolve_color_target(
    config: &UiThemeConfig,
    raw: Option<&str>,
    fallback: Option<Color>,
    opacity: Option<f32>,
) -> Option<Color> {
    let color = raw
        .and_then(|value| resolve_color_value_with_config(config, value))
        .or(fallback)?;
    Some(apply_opacity(color, opacity))
}

fn apply_opacity(color: Color, opacity: Option<f32>) -> Color {
    let Some(opacity) = opacity else {
        return color;
    };
    let [red, green, blue, alpha] = color.to_srgba().to_f32_array();
    Color::srgba(red, green, blue, alpha * opacity.clamp(0.0, 1.0))
}

fn utility_val_to_val(value: UtilityVal) -> Option<Val> {
    match value {
        UtilityVal::Auto => Some(Val::Auto),
        UtilityVal::Px(value) => Some(Val::Px(value)),
        UtilityVal::Percent(value) => Some(Val::Percent(value)),
        UtilityVal::Vw(value) => Some(Val::Vw(value)),
        UtilityVal::Vh(value) => Some(Val::Vh(value)),
    }
}

pub(super) fn apply_visual_target(
    background: Option<Mut<BackgroundColor>>,
    border: Option<Mut<BorderColor>>,
    text: Option<Mut<TextColor>>,
    outline: Option<Mut<Outline>>,
    snapshot: &UiStateVisualSnapshot,
    target: &UiStateVisualTarget,
) {
    if let Some(mut background) = background
        && let Some(color) = target.background
    {
        background.0 = color;
    }
    if let Some(mut border) = border
        && let Some(color) = target.border
    {
        border.set_all(color);
    }
    if let Some(mut text) = text
        && let Some(color) = target.text
    {
        text.0 = color;
    }
    if let Some(mut outline) = outline {
        if let Some(width) = target.outline_width.or(snapshot.outline_width) {
            outline.width = width;
        }
        if let Some(color) = target.outline_color {
            outline.color = color;
        }
        if let Some(offset) = snapshot.outline_offset {
            outline.offset = offset;
        }
    }
}

fn ensure_visual_target_components(
    entity: &mut EntityCommands,
    target: &UiStateVisualTarget,
    has_background: bool,
    has_border: bool,
    has_text: bool,
    has_outline: bool,
) {
    if !has_background && let Some(color) = target.background {
        entity.try_insert(BackgroundColor(color));
    }
    if !has_border && let Some(color) = target.border {
        entity.try_insert(BorderColor::all(color));
    }
    if !has_text && let Some(color) = target.text {
        entity.try_insert(TextColor(color));
    }
    if !has_outline && (target.outline_width.is_some() || target.outline_color.is_some()) {
        entity.try_insert(Outline::new(
            target.outline_width.unwrap_or(Val::Px(0.0)),
            Val::Px(0.0),
            target.outline_color.unwrap_or(Color::NONE),
        ));
    }
}