beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use super::config_types::UiThemeConfig;
use bevy::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThemeColor {
    pub red: u8,
    pub green: u8,
    pub blue: u8,
    pub alpha: u8,
}

impl ThemeColor {
    pub const fn hex(value: &str) -> Self {
        let bytes = value.as_bytes();
        let start = if !bytes.is_empty() && bytes[0] == b'#' {
            1
        } else {
            0
        };
        let len = bytes.len() - start;
        let alpha = if len == 8 {
            decode_hex(bytes[start + 6], bytes[start + 7])
        } else {
            255
        };

        Self {
            red: decode_hex(bytes[start], bytes[start + 1]),
            green: decode_hex(bytes[start + 2], bytes[start + 3]),
            blue: decode_hex(bytes[start + 4], bytes[start + 5]),
            alpha,
        }
    }

    pub fn to_bevy(self) -> Color {
        Color::srgba_u8(self.red, self.green, self.blue, self.alpha)
    }
}

impl Default for ThemeColor {
    fn default() -> Self {
        Self {
            red: 0,
            green: 0,
            blue: 0,
            alpha: 0,
        }
    }
}

#[allow(dead_code)]
pub fn resolve_theme_color_value(raw: &str) -> Option<ThemeColor> {
    resolve_theme_color_value_in(super::ui_theme_config(), raw)
}

#[allow(dead_code)]
pub fn resolve_theme_numeric_value(raw: &str) -> Option<f32> {
    resolve_theme_numeric_value_in(super::ui_theme_config(), raw)
}

pub fn resolve_theme_color_value_in(config: &UiThemeConfig, raw: &str) -> Option<ThemeColor> {
    let value = raw.trim();
    if let Some(name) = parse_theme_color_var(value) {
        return theme_color_variable(config, name);
    }
    parse_theme_color(value).ok()
}

pub fn resolve_theme_numeric_value_in(config: &UiThemeConfig, raw: &str) -> Option<f32> {
    let token = raw
        .trim()
        .strip_prefix("var(")
        .and_then(|value| value.strip_suffix(')'))
        .map(str::trim)?;
    match token {
        "--text-hint" => Some(config.typography.hint),
        "--text-meta" => Some(config.typography.meta),
        "--text-body" => Some(config.typography.body),
        "--text-control" => Some(config.typography.control),
        "--text-control-compact" => Some(config.typography.control_compact),
        "--text-title" => Some(config.typography.title),
        "--text-display" => Some(config.typography.display),
        "--leading-none" => Some(config.typography.leading_none),
        "--leading-tight" => Some(config.typography.leading_tight),
        "--leading-snug" => Some(config.typography.leading_snug),
        "--leading-normal" => Some(config.typography.leading_normal),
        "--leading-relaxed" => Some(config.typography.leading_relaxed),
        "--tracking-tighter" => Some(config.typography.tracking_tighter),
        "--tracking-tight" => Some(config.typography.tracking_tight),
        "--tracking-normal" => Some(config.typography.tracking_normal),
        "--tracking-wide" => Some(config.typography.tracking_wide),
        "--tracking-wider" => Some(config.typography.tracking_wider),
        "--font-weight-thin" => Some(config.typography.weight_thin as f32),
        "--font-weight-extralight" => Some(config.typography.weight_extralight as f32),
        "--font-weight-light" => Some(config.typography.weight_light as f32),
        "--font-weight-normal" => Some(config.typography.weight_normal as f32),
        "--font-weight-medium" => Some(config.typography.weight_medium as f32),
        "--font-weight-semibold" => Some(config.typography.weight_semibold as f32),
        "--font-weight-bold" => Some(config.typography.weight_bold as f32),
        "--font-weight-extrabold" => Some(config.typography.weight_extrabold as f32),
        "--font-weight-black" => Some(config.typography.weight_black as f32),
        "--spacing-scrollbar-width" => Some(config.spacing.scrollbar_width),
        "--spacing-panel-padding" => Some(config.spacing.panel_padding),
        "--spacing-panel-gap" => Some(config.spacing.panel_gap),
        "--spacing-section-gap" => Some(config.spacing.section_gap),
        "--spacing-section-grid-gap" => Some(config.spacing.section_grid_gap),
        "--spacing-content-padding" => Some(config.spacing.content_padding),
        "--spacing-form-padding-x" => Some(config.spacing.form_padding_x),
        "--spacing-form-padding-y" => Some(config.spacing.form_padding_y),
        "--spacing-form-gap" => Some(config.spacing.form_gap),
        "--spacing-button-padding-x" => Some(config.spacing.button_padding_x),
        "--spacing-button-padding-y" => Some(config.spacing.button_padding_y),
        "--spacing-button-compact-padding-x" => Some(config.spacing.button_compact_padding_x),
        "--spacing-button-compact-padding-y" => Some(config.spacing.button_compact_padding_y),
        "--border-regular" => Some(config.border.regular),
        "--border-tab" => Some(config.border.tab),
        "--border-emphasis" => Some(config.border.emphasis),
        "--border-focus-outline" => Some(config.border.focus_outline),
        "--border-divider" => Some(config.border.divider),
        "--radius-ui" => Some(config.radius.ui),
        "--radius-panel" => Some(config.radius.panel),
        "--radius-control" => Some(config.radius.control),
        "--radius-pill" => Some(config.radius.pill),
        _ => None,
    }
}

pub(super) fn parse_theme_color(raw: &str) -> Result<ThemeColor, String> {
    let value = raw.trim();
    let hex = value.strip_prefix('#').unwrap_or(value);
    if hex.len() != 6 && hex.len() != 8 {
        return Err(format!("expected color '{}' to use 6 or 8 hex digits", raw));
    }

    let red = u8::from_str_radix(&hex[0..2], 16)
        .map_err(|error| format!("invalid red channel in '{raw}': {error}"))?;
    let green = u8::from_str_radix(&hex[2..4], 16)
        .map_err(|error| format!("invalid green channel in '{raw}': {error}"))?;
    let blue = u8::from_str_radix(&hex[4..6], 16)
        .map_err(|error| format!("invalid blue channel in '{raw}': {error}"))?;
    let alpha = if hex.len() == 8 {
        u8::from_str_radix(&hex[6..8], 16)
            .map_err(|error| format!("invalid alpha channel in '{raw}': {error}"))?
    } else {
        255
    };

    Ok(ThemeColor {
        red,
        green,
        blue,
        alpha,
    })
}

fn parse_theme_color_var(raw: &str) -> Option<&str> {
    raw.strip_prefix("var(")
        .and_then(|value| value.strip_suffix(')'))
        .map(str::trim)
        .or_else(|| raw.strip_prefix("--color-").map(|_| raw))
}

fn theme_color_variable(config: &UiThemeConfig, name: &str) -> Option<ThemeColor> {
    let token = name.strip_prefix("--color-")?;
    if let Some(color) = config.tokens.color.get(token) {
        return Some(*color);
    }

    match token {
        "primary" | "text-primary" => Some(config.text.primary),
        "secondary" | "text-secondary" => Some(config.text.secondary),
        "disabled" | "text-disabled" => Some(config.text.disabled),
        "muted" | "text-muted" => Some(config.text.muted),
        "placeholder" | "text-placeholder" => Some(config.text.placeholder),
        "subtle-glyph" => Some(config.text.subtle_glyph),
        "selection-indicator-glyph" => Some(config.text.selection_indicator_glyph),
        "option-enabled" | "settings-option-enabled" => Some(config.text.option_enabled),
        "option-disabled" | "settings-option-disabled" => Some(config.text.option_disabled),
        "tab-active" => Some(config.text.tab_active),
        "panel-app-bg" => Some(config.panel.app.background),
        "panel-app-border" => Some(config.panel.app.border),
        "panel-main-bg" => Some(config.panel.main.background),
        "panel-main-border" => Some(config.panel.main.border),
        "panel-subtle-bg" => Some(config.panel.subtle.background),
        "panel-subtle-border" => Some(config.panel.subtle.border),
        "panel-prompt-bg" => Some(config.panel.prompt.background),
        "panel-prompt-border" => Some(config.panel.prompt.border),
        "panel-popup-bg" => Some(config.panel.popup.background),
        "panel-popup-border" => Some(config.panel.popup.border),
        "panel-detail-popup-bg" | "panel-inventory-popup-bg" => {
            Some(config.panel.detail_popup.background)
        }
        "panel-detail-popup-border" | "panel-inventory-popup-border" => {
            Some(config.panel.detail_popup.border)
        }
        "panel-media-preview-bg" | "panel-inventory-preview-bg" => {
            Some(config.panel.media_preview.background)
        }
        "panel-media-preview-border" | "panel-inventory-preview-border" => {
            Some(config.panel.media_preview.border)
        }
        "panel-media-preview-fallback-bg" | "panel-inventory-preview-fallback-bg" => {
            Some(config.panel.media_preview_fallback.background)
        }
        "panel-media-preview-fallback-border" | "panel-inventory-preview-fallback-border" => {
            Some(config.panel.media_preview_fallback.border)
        }
        "panel-count-badge-bg" | "panel-inventory-count-badge-bg" => {
            Some(config.panel.count_badge.background)
        }
        "panel-count-badge-border" | "panel-inventory-count-badge-border" => {
            Some(config.panel.count_badge.border)
        }
        "panel-list-item-idle-bg" | "panel-instant-message-idle-bg" => {
            Some(config.panel.list_item_idle.background)
        }
        "panel-list-item-idle-border" | "panel-instant-message-idle-border" => {
            Some(config.panel.list_item_idle.border)
        }
        "panel-list-item-selected-bg" | "panel-instant-message-selected-bg" => {
            Some(config.panel.list_item_selected.background)
        }
        "panel-list-item-selected-border" | "panel-instant-message-selected-border" => {
            Some(config.panel.list_item_selected.border)
        }
        "tile-label-bg" | "inventory-tile-label-bg" => {
            Some(config.panel.tile.tile_label_background)
        }
        "control-hover-outline" => Some(config.control.interaction.hover_outline),
        "control-focus-outline" => Some(config.control.interaction.focus_outline),
        "control-focus-hover-outline" => Some(config.control.interaction.focus_hover_outline),
        "control-active-bg" => Some(config.control.interaction.active_background),
        "control-active-border" => Some(config.control.interaction.active_border),
        "select-chip-bg" => Some(config.control.select.chip.background),
        "select-chip-border" => Some(config.control.select.chip.border),
        "select-indicator-bg" => Some(config.control.select.indicator.background),
        "select-indicator-border" => Some(config.control.select.indicator.border),
        "select-panel-bg" => Some(config.control.select.panel.background),
        "select-panel-border" => Some(config.control.select.panel.border),
        "select-glyph" => Some(config.control.select.glyph_color),
        "select-indicator-glyph" => Some(config.control.select.indicator_glyph_color),
        "slider-field-bg" => Some(config.control.slider.field.background),
        "slider-field-border" => Some(config.control.slider.field.border),
        "slider-field-active-bg" => Some(config.control.slider.field.active_background),
        "slider-field-active-border" => Some(config.control.slider.field.active_border),
        "slider-track-bg" => Some(config.control.slider.track.background),
        "slider-track-border" => Some(config.control.slider.track.border),
        "slider-fill-bg" => Some(config.control.slider.fill_background),
        "slider-thumb-bg" => Some(config.control.slider.thumb_background),
        "slider-thumb-border" => Some(config.control.slider.thumb_border),
        "button-bg" => Some(config.buttons.background.idle),
        "button-bg-hover" => Some(config.buttons.background.hover),
        "button-bg-active" => Some(config.buttons.background.active),
        "button-bg-active-hover" => Some(config.buttons.background.active_hover),
        "button-bg-disabled" => Some(config.buttons.background.disabled),
        "button-text" => Some(config.buttons.text.idle),
        "button-text-hover" => Some(config.buttons.text.hover),
        "button-text-active" => Some(config.buttons.text.active),
        "button-text-active-hover" => Some(config.buttons.text.active_hover),
        "button-text-disabled" => Some(config.buttons.text.disabled),
        _ => None,
    }
}

const fn decode_hex(high: u8, low: u8) -> u8 {
    (decode_nibble(high) << 4) | decode_nibble(low)
}

const fn decode_nibble(value: u8) -> u8 {
    match value {
        b'0'..=b'9' => value - b'0',
        b'a'..=b'f' => value - b'a' + 10,
        b'A'..=b'F' => value - b'A' + 10,
        _ => 0,
    }
}