beuvy-runtime 0.1.0

A low-level Bevy UI kit with reusable controls and utility-class styling.
Documentation
use super::model::{StyleSheetError, UiStyleSheet};
use super::tokens::{ParsedTokenSets, parse_theme_block};
use super::utility::resolve_utility_definitions;
use crate::style::UiThemeConfig;

pub fn parse_style_sheet(raw: &str) -> Result<UiStyleSheet, StyleSheetError> {
    parse_style_sheet_with_base(None, raw)
}

pub fn compose_style_sheet(
    base: &UiStyleSheet,
    raw: &str,
) -> Result<UiStyleSheet, StyleSheetError> {
    parse_style_sheet_with_base(Some(base), raw)
}

fn parse_style_sheet_with_base(
    base: Option<&UiStyleSheet>,
    raw: &str,
) -> Result<UiStyleSheet, StyleSheetError> {
    let stripped = strip_block_comments(raw);
    let mut cursor = 0usize;
    let mut config = base.map(|sheet| sheet.config.clone()).unwrap_or_default();
    let mut tokens = base
        .map(|sheet| ParsedTokenSets {
            color: sheet.color_tokens.clone(),
            text: sheet.text_tokens.clone(),
            radius: sheet.radius_tokens.clone(),
        })
        .unwrap_or_default();
    let mut utilities = base
        .map(|sheet| sheet.utilities.clone())
        .unwrap_or_default();

    if base.is_none() {
        config.tokens.color.clear();
        config.tokens.utility.clear();
    }

    while let Some(ch) = stripped[cursor..].chars().next() {
        if ch.is_whitespace() {
            cursor += ch.len_utf8();
            continue;
        }
        if stripped[cursor..].starts_with("@theme") {
            cursor += "@theme".len();
            let body = parse_block_body(&stripped, &mut cursor)?;
            parse_theme_block(&mut config, &mut tokens, &body)?;
            continue;
        }
        if stripped[cursor..].starts_with("@utility") {
            cursor += "@utility".len();
            skip_whitespace(&stripped, &mut cursor);
            let name = parse_identifier(&stripped, &mut cursor);
            if name.is_empty() {
                return Err(StyleSheetError::new("expected utility name after @utility"));
            }
            let body = parse_block_body(&stripped, &mut cursor)?;
            let body_tokens = parse_utility_body(&name, &body)?;
            if utilities.insert(name.clone(), body_tokens).is_some() {
                return Err(StyleSheetError::new(format!("duplicate @utility `{name}`")));
            }
            continue;
        }

        return Err(StyleSheetError::new(
            "styles.css only supports @theme and @utility blocks",
        ));
    }

    let utilities = resolve_utility_definitions(&config, &tokens, &utilities)?;
    config.tokens.utility = utilities.clone();

    Ok(UiStyleSheet {
        config,
        color_tokens: tokens.color,
        text_tokens: tokens.text,
        radius_tokens: tokens.radius,
        utilities,
    })
}

pub fn font_size_for_tag(config: &UiThemeConfig, tag: &str) -> f32 {
    match tag {
        "h1" => config.typography.display,
        "h2" | "h3" => config.typography.title,
        "h4" | "label" => config.typography.control,
        "h5" | "span" | "p" | "text" => config.typography.body,
        "h6" | "small" => config.typography.meta,
        _ => config.typography.body,
    }
}

fn parse_utility_body(name: &str, body: &str) -> Result<Vec<String>, StyleSheetError> {
    let mut tokens = Vec::new();
    for statement in body.split(';') {
        let statement = statement.trim();
        if statement.is_empty() {
            continue;
        }
        let Some(values) = statement.strip_prefix("@apply") else {
            return Err(StyleSheetError::new(format!(
                "@utility `{name}` only supports @apply declarations"
            )));
        };
        tokens.extend(values.split_whitespace().map(ToString::to_string));
    }

    if tokens.is_empty() {
        return Err(StyleSheetError::new(format!(
            "@utility `{name}` must contain at least one @apply token"
        )));
    }

    Ok(tokens)
}

fn parse_block_body(raw: &str, cursor: &mut usize) -> Result<String, StyleSheetError> {
    skip_whitespace(raw, cursor);
    if raw[*cursor..].chars().next() != Some('{') {
        return Err(StyleSheetError::new("expected `{`"));
    }
    *cursor += 1;
    let start = *cursor;
    let mut depth = 1usize;
    let chars = raw.as_bytes();
    while *cursor < raw.len() {
        match chars[*cursor] {
            b'{' => depth += 1,
            b'}' => {
                depth -= 1;
                if depth == 0 {
                    let body = raw[start..*cursor].to_string();
                    *cursor += 1;
                    return Ok(body);
                }
            }
            _ => {}
        }
        *cursor += 1;
    }
    Err(StyleSheetError::new("unterminated style block"))
}

fn parse_identifier(raw: &str, cursor: &mut usize) -> String {
    let start = *cursor;
    while *cursor < raw.len() {
        let ch = raw[*cursor..].chars().next().expect("char at cursor");
        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
            *cursor += ch.len_utf8();
        } else {
            break;
        }
    }
    raw[start..*cursor].trim().to_string()
}

fn skip_whitespace(raw: &str, cursor: &mut usize) {
    while *cursor < raw.len() {
        let ch = raw[*cursor..].chars().next().expect("char at cursor");
        if ch.is_whitespace() {
            *cursor += ch.len_utf8();
        } else {
            break;
        }
    }
}

fn strip_block_comments(raw: &str) -> String {
    let mut output = String::with_capacity(raw.len());
    let bytes = raw.as_bytes();
    let mut index = 0usize;
    while index < bytes.len() {
        if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'*') {
            index += 2;
            while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
                index += 1;
            }
            index = (index + 2).min(bytes.len());
            continue;
        }
        output.push(bytes[index] as char);
        index += 1;
    }
    output
}