beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
mod build;
mod sync;

use crate::style::{font_size_control, text_primary_color};
use crate::utility::{
    UtilityFontFamilyRole, UtilityFontStyle, UtilityStylePatch, UtilityTextTransform,
};
use bevy::prelude::*;
use bevy::text::{FontWeight, LineBreak, LineHeight, TextLayout};
use bevy_localization::{Localization, TextKey};

/// Materializes [`AddText`] components and keeps localized text in sync.
pub struct TextPlugin;

impl Plugin for TextPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, build::setup)
            .add_systems(Update, build::add_text)
            .add_systems(
                PostUpdate,
                (
                    sync::sync_localized_text_on_binding_change,
                    sync::sync_localized_text_on_locale_change,
                    sync::sync_localized_text_format_on_binding_change,
                    sync::sync_localized_text_format_on_locale_change,
                ),
            );
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontFamilyRole {
    Sans,
    Serif,
    Mono,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypographyFontStyle {
    Normal,
    Italic,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypographyTextTransform {
    None,
    Uppercase,
    Lowercase,
    Capitalize,
}

#[derive(Debug, Clone, PartialEq)]
pub struct TypographyStyle {
    pub family_role: FontFamilyRole,
    pub font_size: f32,
    pub font_weight: FontWeight,
    pub font_style: TypographyFontStyle,
    pub line_height: LineHeight,
    pub letter_spacing_em: f32,
    pub text_transform: TypographyTextTransform,
}

impl Default for TypographyStyle {
    fn default() -> Self {
        Self {
            family_role: FontFamilyRole::Sans,
            font_size: font_size_control(),
            font_weight: FontWeight::NORMAL,
            font_style: TypographyFontStyle::Normal,
            line_height: LineHeight::RelativeToFont(1.5),
            letter_spacing_em: 0.0,
            text_transform: TypographyTextTransform::None,
        }
    }
}

#[derive(Debug, Clone)]
pub struct FontFaceEntry {
    pub handle: Handle<Font>,
    pub weight: FontWeight,
    pub style: TypographyFontStyle,
}

#[derive(Resource, Debug, Default, Clone)]
pub struct FontRegistry {
    pub sans: Vec<FontFaceEntry>,
    pub serif: Vec<FontFaceEntry>,
    pub mono: Vec<FontFaceEntry>,
}

impl FontRegistry {
    pub fn resolve(&self, style: &TypographyStyle) -> Option<Handle<Font>> {
        let faces = match style.family_role {
            FontFamilyRole::Sans => &self.sans,
            FontFamilyRole::Serif => &self.serif,
            FontFamilyRole::Mono => &self.mono,
        };

        faces
            .iter()
            .min_by_key(|face| {
                let style_penalty = if face.style == style.font_style { 0u32 } else { 10_000u32 };
                let weight_penalty = face.weight.0.abs_diff(style.font_weight.0) as u32;
                style_penalty + weight_penalty
            })
            .map(|face| face.handle.clone())
    }
}

#[derive(Resource)]
pub struct FontResource {
    pub primary_font: Option<Handle<Font>>,
}

impl FontResource {
    pub fn from_handle(font: Handle<Font>) -> Self {
        Self {
            primary_font: Some(font),
        }
    }
}

impl Default for FontResource {
    fn default() -> Self {
        Self { primary_font: None }
    }
}

/// Declarative request to materialize a text entity using the active UI theme.
#[derive(Component, Debug, Clone)]
pub struct AddText {
    pub text: String,
    pub line_height: LineHeight,
    pub size: f32,
    pub color: Color,
    pub layout: TextLayout,
    pub localized_text: Option<TextKey>,
    pub localized_text_format: Option<LocalizedTextFormat>,
    pub typography: TypographyStyle,
}

#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalizedText {
    pub key: TextKey,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedArg {
    pub name: &'static str,
    pub value: String,
}

impl LocalizedArg {
    pub fn new(name: &'static str, value: impl std::fmt::Display) -> Self {
        Self {
            name,
            value: value.to_string(),
        }
    }
}

#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct LocalizedTextFormat {
    pub key: TextKey,
    pub args: Vec<LocalizedArg>,
}

impl LocalizedTextFormat {
    pub fn new(key: TextKey) -> Self {
        Self {
            key,
            args: Vec::new(),
        }
    }

    pub fn with_arg(mut self, name: &'static str, value: impl std::fmt::Display) -> Self {
        self.args.push(LocalizedArg::new(name, value));
        self
    }
}

impl Default for AddText {
    fn default() -> Self {
        Self {
            text: "[missing text]".to_string(),
            line_height: TypographyStyle::default().line_height,
            size: TypographyStyle::default().font_size,
            color: text_primary_color(),
            layout: TextLayout::new_with_linebreak(LineBreak::WordBoundary),
            localized_text: None,
            localized_text_format: None,
            typography: TypographyStyle::default(),
        }
    }
}

impl AddText {
    /// Uses a localized text key instead of the raw `text` field.
    pub fn with_localized(mut self, key: TextKey) -> Self {
        self.localized_text = Some(key);
        self.localized_text_format = None;
        self
    }

    /// Uses a localized format string instead of the raw `text` field.
    pub fn with_localized_format(mut self, localized_text_format: LocalizedTextFormat) -> Self {
        self.localized_text = None;
        self.localized_text_format = Some(localized_text_format);
        self
    }

    pub fn typography(mut self, typography: TypographyStyle) -> Self {
        self.size = typography.font_size;
        self.line_height = typography.line_height;
        self.typography = typography;
        self
    }
}

pub fn apply_text_transform(
    raw: &str,
    text_transform: TypographyTextTransform,
) -> String {
    match text_transform {
        TypographyTextTransform::None => raw.to_string(),
        TypographyTextTransform::Uppercase => raw.to_uppercase(),
        TypographyTextTransform::Lowercase => raw.to_lowercase(),
        TypographyTextTransform::Capitalize => {
            let mut result = String::with_capacity(raw.len());
            let mut new_word = true;
            for chr in raw.chars() {
                if chr.is_alphanumeric() {
                    if new_word {
                        result.extend(chr.to_uppercase());
                        new_word = false;
                    } else {
                        result.push(chr);
                    }
                } else {
                    new_word = chr.is_whitespace() || matches!(chr, '-' | '_' | '/');
                    result.push(chr);
                }
            }
            result
        }
    }
}

pub fn typography_from_patch(
    patch: &UtilityStylePatch,
    mut base: TypographyStyle,
) -> TypographyStyle {
    if let Some(role) = patch.font_family_role {
        base.family_role = match role {
            UtilityFontFamilyRole::Sans => FontFamilyRole::Sans,
            UtilityFontFamilyRole::Serif => FontFamilyRole::Serif,
            UtilityFontFamilyRole::Mono => FontFamilyRole::Mono,
        };
    }
    if let Some(font_size) = patch.font_size {
        base.font_size = font_size;
    }
    if let Some(font_weight) = patch.font_weight {
        base.font_weight = FontWeight(font_weight);
    }
    if let Some(font_style) = patch.font_style {
        base.font_style = match font_style {
            UtilityFontStyle::Normal => TypographyFontStyle::Normal,
            UtilityFontStyle::Italic => TypographyFontStyle::Italic,
        };
    }
    if let Some(line_height) = patch.line_height {
        base.line_height = if line_height > 8.0 {
            LineHeight::Px(line_height)
        } else {
            LineHeight::RelativeToFont(line_height)
        };
    }
    if let Some(letter_spacing_em) = patch.letter_spacing_em {
        base.letter_spacing_em = letter_spacing_em;
    }
    if let Some(text_transform) = patch.text_transform {
        base.text_transform = match text_transform {
            UtilityTextTransform::None => TypographyTextTransform::None,
            UtilityTextTransform::Uppercase => TypographyTextTransform::Uppercase,
            UtilityTextTransform::Lowercase => TypographyTextTransform::Lowercase,
            UtilityTextTransform::Capitalize => TypographyTextTransform::Capitalize,
        };
    }
    base
}

pub fn control_typography() -> TypographyStyle {
    TypographyStyle {
        font_size: font_size_control(),
        line_height: LineHeight::RelativeToFont(1.5),
        ..Default::default()
    }
}

/// Replaces the text content with plain text and clears localization bindings.
pub fn set_plain_text(commands: &mut Commands, entity: Entity, text: impl Into<String>) {
    let Ok(mut entity_commands) = commands.get_entity(entity) else {
        return;
    };
    entity_commands
        .try_insert(Text::new(text.into()))
        .try_remove::<LocalizedText>()
        .try_remove::<LocalizedTextFormat>();
}

/// Replaces the text content with a localized string resolved from `key`.
pub fn set_localized_text(
    commands: &mut Commands,
    entity: Entity,
    localization: &Localization,
    key: TextKey,
) {
    let Ok(mut entity_commands) = commands.get_entity(entity) else {
        return;
    };
    entity_commands
        .try_insert((Text::new(localization.text(key)), LocalizedText { key }))
        .try_remove::<LocalizedTextFormat>();
}

/// Replaces the text content with a localized format string.
pub fn set_localized_text_format(
    commands: &mut Commands,
    entity: Entity,
    localization: &Localization,
    localized_text_format: LocalizedTextFormat,
) {
    let text = localization.format_text(
        localized_text_format.key,
        localized_text_format
            .args
            .iter()
            .map(|arg| (arg.name, arg.value.as_str())),
    );
    let Ok(mut entity_commands) = commands.get_entity(entity) else {
        return;
    };
    entity_commands
        .try_insert((Text::new(text), localized_text_format))
        .try_remove::<LocalizedText>();
}