bubba-core 0.2.2

Core runtime for the Bubba mobile framework
Documentation
//! # CSS
//!
//! Bubba supports real `.css` files. At build time, `cargo bubba build` bundles
//! all referenced CSS into the APK's assets folder. At runtime, the CSS parser
//! resolves class names to style properties and passes them to the native
//! renderer.
//!
//! ## Example
//! ```css
//! /* assets/main.css */
//! .title       { font-size: 24px; font-weight: bold; color: #1a1a2e; }
//! .primary-btn { background: #4CAF50; color: white; border-radius: 8px; }
//! .danger-btn  { background: #e53935; color: white; }
//! .avatar      { width: 80px; height: 80px; border-radius: 40px; }
//! ```

use std::collections::HashMap;
use anyhow::{Context, Result};

/// A parsed CSS stylesheet.
#[derive(Debug, Default, Clone)]
pub struct StyleSheet {
    /// Map from CSS class name → property map.
    rules: HashMap<String, HashMap<String, String>>,
}

impl StyleSheet {
    /// Parse a CSS string into a [`StyleSheet`].
    ///
    /// This is a minimal subset parser — enough for Bubba's declarative UI.
    /// Supported: class selectors (`.foo`), basic `property: value;` pairs.
    pub fn parse(css: &str) -> Result<Self> {
        let mut rules: HashMap<String, HashMap<String, String>> = HashMap::new();
        let mut chars = css.chars().peekable();

        while let Some(&c) = chars.peek() {
            // Skip whitespace and comments
            if c.is_whitespace() {
                chars.next();
                continue;
            }

            // Skip `/* ... */` comments
            if c == '/' {
                chars.next();
                if chars.peek() == Some(&'*') {
                    chars.next();
                    loop {
                        match chars.next() {
                            Some('*') if chars.peek() == Some(&'/') => {
                                chars.next();
                                break;
                            }
                            None => break,
                            _ => {}
                        }
                    }
                }
                continue;
            }

            // Read selector
            let selector: String = chars.by_ref().take_while(|&c| c != '{').collect();
            let selector = selector.trim().to_string();

            // Read block
            let block: String = chars.by_ref().take_while(|&c| c != '}').collect();

            if selector.is_empty() {
                continue;
            }

            let mut props = HashMap::new();
            for declaration in block.split(';') {
                let declaration = declaration.trim();
                if declaration.is_empty() {
                    continue;
                }
                if let Some((key, value)) = declaration.split_once(':') {
                    props.insert(
                        key.trim().to_string(),
                        value.trim().to_string(),
                    );
                }
            }

            // Handle multiple selectors: `.a, .b { ... }`
            for sel in selector.split(',') {
                let sel = sel.trim().trim_start_matches('.').to_string();
                rules
                    .entry(sel)
                    .or_default()
                    .extend(props.clone());
            }
        }

        Ok(Self { rules })
    }

    /// Load and parse a CSS file from disk.
    pub fn from_file(path: &str) -> Result<Self> {
        let css = std::fs::read_to_string(path)
            .with_context(|| format!("Failed to read CSS file: {}", path))?;
        Self::parse(&css)
    }

    /// Look up all properties for a given class name.
    pub fn resolve(&self, class: &str) -> Option<&HashMap<String, String>> {
        self.rules.get(class)
    }

    /// Look up a specific property for a class.
    pub fn get(&self, class: &str, property: &str) -> Option<&str> {
        self.rules.get(class)?.get(property).map(|s| s.as_str())
    }

    /// Merge another stylesheet into this one (later rules win).
    pub fn merge(&mut self, other: StyleSheet) {
        for (class, props) in other.rules {
            self.rules.entry(class).or_default().extend(props);
        }
    }

    /// Number of rules in this stylesheet.
    pub fn rule_count(&self) -> usize {
        self.rules.len()
    }
}

/// Bundled CSS string — embedded at compile time.
///
/// ```rust
/// use bubba_core::css::StyleSheet;
///
/// // Embed at compile time for zero-cost access:
/// // static MAIN_CSS: &str = include_str!("../assets/main.css");
///
/// // Or parse inline:
/// let sheet = StyleSheet::parse(".title { font-size: 24px; }").unwrap();
/// assert_eq!(sheet.get("title", "font-size"), Some("24px"));
/// ```
pub const MAIN_CSS_EXAMPLE: &str = r#"
/* ── Layout ─────────────────────────────────────── */
.screen {
    flex-direction: column;
    align-items: center;
    padding: 24px;
    background: #f8f9fa;
}

/* ── Typography ──────────────────────────────────── */
.title {
    font-size: 28px;
    font-weight: bold;
    color: #1a1a2e;
    margin-bottom: 16px;
}

.label {
    font-size: 16px;
    color: #555;
    margin-bottom: 8px;
}

/* ── Buttons ─────────────────────────────────────── */
.primary-btn {
    background: #4CAF50;
    color: white;
    padding: 12px 24px;
    border-radius: 8px;
    font-size: 16px;
    font-weight: 600;
    margin-top: 12px;
}

.link-btn {
    background: transparent;
    color: #4CAF50;
    padding: 10px 20px;
    border-radius: 8px;
    font-size: 14px;
    margin-top: 8px;
}

.danger-btn {
    background: #e53935;
    color: white;
    padding: 12px 24px;
    border-radius: 8px;
    font-size: 16px;
    font-weight: 600;
    margin-top: 12px;
}

/* ── Inputs ──────────────────────────────────────── */
.text-input {
    background: white;
    border: 1.5px solid #ddd;
    border-radius: 8px;
    padding: 12px 16px;
    font-size: 16px;
    width: 100%;
    margin-top: 16px;
}

/* ── Avatar ──────────────────────────────────────── */
.avatar {
    width: 80px;
    height: 80px;
    border-radius: 40px;
    margin-bottom: 16px;
    border: 3px solid #4CAF50;
}
"#;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_basic_rule() {
        let sheet = StyleSheet::parse(".title { font-size: 24px; color: red; }").unwrap();
        assert_eq!(sheet.get("title", "font-size"), Some("24px"));
        assert_eq!(sheet.get("title", "color"), Some("red"));
    }

    #[test]
    fn parse_multiple_selectors() {
        let sheet = StyleSheet::parse(".a, .b { color: blue; }").unwrap();
        assert_eq!(sheet.get("a", "color"), Some("blue"));
        assert_eq!(sheet.get("b", "color"), Some("blue"));
    }

    #[test]
    fn parse_main_css() {
        let sheet = StyleSheet::parse(MAIN_CSS_EXAMPLE).unwrap();
        assert!(sheet.rule_count() > 0);
        assert_eq!(sheet.get("primary-btn", "background"), Some("#4CAF50"));
        assert_eq!(sheet.get("danger-btn", "background"), Some("#e53935"));
    }
}