beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use super::{
    AddText, FontFaceEntry, FontRegistry, FontResource, LocalizedText, LocalizedTextFormat,
    TypographyFontStyle, apply_text_transform,
};
use crate::build_pending::UiBuildPending;
use crate::style::{mono_font_asset_path, sans_font_asset_path, serif_font_asset_path};
use crate::theme_config::ui_theme_asset_exists;
use bevy::prelude::*;
use bevy::text::FontWeight;
use bevy_localization::Localization;

pub(super) fn setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    font_registry: Option<Res<FontRegistry>>,
    font_resource: Option<Res<FontResource>>,
) {
    if font_registry.is_some() {
        return;
    }

    let mut registry = FontRegistry::default();
    if let Some(path) = non_empty_path(sans_font_asset_path()) {
        if ui_theme_asset_exists(&path) {
            registry.sans.push(FontFaceEntry {
                handle: asset_server.load(path),
                weight: FontWeight::NORMAL,
                style: TypographyFontStyle::Normal,
            });
        } else {
            bevy::log::warn!(
                "theme font asset not found for --font-sans: {}; falling back",
                path
            );
        }
    }
    if let Some(path) = non_empty_path(serif_font_asset_path()) {
        if ui_theme_asset_exists(&path) {
            registry.serif.push(FontFaceEntry {
                handle: asset_server.load(path),
                weight: FontWeight::NORMAL,
                style: TypographyFontStyle::Normal,
            });
        } else {
            bevy::log::warn!(
                "theme font asset not found for --font-serif: {}; falling back",
                path
            );
        }
    }
    if let Some(path) = non_empty_path(mono_font_asset_path()) {
        if ui_theme_asset_exists(&path) {
            registry.mono.push(FontFaceEntry {
                handle: asset_server.load(path),
                weight: FontWeight::NORMAL,
                style: TypographyFontStyle::Normal,
            });
        } else {
            bevy::log::warn!(
                "theme font asset not found for --font-mono: {}; falling back",
                path
            );
        }
    }

    if registry.sans.is_empty()
        && registry.serif.is_empty()
        && registry.mono.is_empty()
        && font_resource.is_none()
    {
        commands.insert_resource(FontResource::default());
        return;
    }

    commands.insert_resource(registry);
    if font_resource.is_none() {
        commands.insert_resource(FontResource::default());
    }
}

pub(super) fn add_text(
    mut commands: Commands,
    query: Query<(Entity, &AddText)>,
    font_registry: Option<Res<FontRegistry>>,
    font_resource: Res<FontResource>,
    localization: Option<Res<Localization>>,
) {
    for (entity, add_text) in query {
        let Ok(mut entity_commands) = commands.get_entity(entity) else {
            continue;
        };

        debug_assert!(
            !(add_text.localized_text.is_some() && add_text.localized_text_format.is_some()),
            "text entity cannot bind both LocalizedText and LocalizedTextFormat"
        );

        let initial_text = match (
            localization.as_deref(),
            add_text.localized_text,
            add_text.localized_text_format.clone(),
        ) {
            (Some(localization), Some(key), _) => localization.text(key).to_string(),
            (Some(localization), _, Some(localized_text_format)) => localization.format_text(
                localized_text_format.key,
                localized_text_format
                    .args
                    .iter()
                    .map(|arg| (arg.name, arg.value.as_str())),
            ),
            _ => add_text.text.clone(),
        };
        let initial_text = apply_text_transform(&initial_text, add_text.typography.text_transform);

        let font_handle = font_registry
            .as_deref()
            .and_then(|registry| registry.resolve(&add_text.typography))
            .or_else(|| font_resource.primary_font.clone());
        let mut text_font = font_handle.map(TextFont::from).unwrap_or_default();
        text_font.font_size = add_text.typography.font_size;
        text_font.weight = add_text.typography.font_weight;

        entity_commands.try_insert((
            Text::new(initial_text),
            text_font,
            add_text.typography.line_height,
            add_text.layout.clone(),
            TextColor(add_text.color),
        ));

        if let Some(localized_text_format) = add_text.localized_text_format.clone() {
            entity_commands.try_remove::<LocalizedText>();
            entity_commands.try_insert(localized_text_format);
        } else if let Some(key) = add_text.localized_text {
            entity_commands.try_remove::<LocalizedTextFormat>();
            entity_commands.try_insert(LocalizedText { key });
        } else {
            entity_commands.try_remove::<LocalizedTextFormat>();
            entity_commands.try_remove::<LocalizedText>();
        }

        entity_commands.try_remove::<AddText>();
        entity_commands.try_remove::<UiBuildPending>();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy::asset::AssetPlugin;
    use bevy::ecs::system::SystemState;

    #[test]
    fn add_text_ignores_entities_despawned_before_apply() {
        let mut app = App::new();
        app.insert_resource(FontResource::default())
            .register_required_components::<AddText, UiBuildPending>();

        let entity = app.world_mut().spawn(AddText::default()).id();

        let mut system_state: SystemState<(
            Commands,
            Query<(Entity, &AddText)>,
            Option<Res<FontRegistry>>,
            Res<FontResource>,
            Option<Res<Localization>>,
        )> = SystemState::new(app.world_mut());
        let (commands, query, font_registry, font_resource, localization) =
            system_state.get_mut(app.world_mut());
        add_text(commands, query, font_registry, font_resource, localization);

        app.world_mut().despawn(entity);
        system_state.apply(app.world_mut());
    }

    #[test]
fn setup_keeps_existing_font_resource() {
        let mut app = App::new();
        app.add_plugins(AssetPlugin::default());
        app.insert_resource(FontResource::default());

        let mut system_state: SystemState<(
            Commands,
            Res<AssetServer>,
            Option<Res<FontRegistry>>,
            Option<Res<FontResource>>,
        )> =
            SystemState::new(app.world_mut());
        let (commands, asset_server, font_registry, font_resource) =
            system_state.get_mut(app.world_mut());
        setup(commands, asset_server, font_registry, font_resource);
        system_state.apply(app.world_mut());

        let font_resource = app.world().resource::<FontResource>();
        assert!(font_resource.primary_font.is_none());
    }
}

fn non_empty_path(path: String) -> Option<String> {
    let path = path.trim().to_string();
    (!path.is_empty()).then_some(path)
}