Skip to main content

bubba_core/css/
mod.rs

1//! # CSS
2//!
3//! Bubba supports real `.css` files. At build time, `cargo bubba build` bundles
4//! all referenced CSS into the APK's assets folder. At runtime, the CSS parser
5//! resolves class names to style properties and passes them to the native
6//! renderer.
7//!
8//! ## Example
9//! ```css
10//! /* assets/main.css */
11//! .title       { font-size: 24px; font-weight: bold; color: #1a1a2e; }
12//! .primary-btn { background: #4CAF50; color: white; border-radius: 8px; }
13//! .danger-btn  { background: #e53935; color: white; }
14//! .avatar      { width: 80px; height: 80px; border-radius: 40px; }
15//! ```
16
17use std::collections::HashMap;
18use anyhow::{Context, Result};
19
20/// A parsed CSS stylesheet.
21#[derive(Debug, Default, Clone)]
22pub struct StyleSheet {
23    /// Map from CSS class name → property map.
24    rules: HashMap<String, HashMap<String, String>>,
25}
26
27impl StyleSheet {
28    /// Parse a CSS string into a [`StyleSheet`].
29    ///
30    /// This is a minimal subset parser — enough for Bubba's declarative UI.
31    /// Supported: class selectors (`.foo`), basic `property: value;` pairs.
32    pub fn parse(css: &str) -> Result<Self> {
33        let mut rules: HashMap<String, HashMap<String, String>> = HashMap::new();
34        let mut chars = css.chars().peekable();
35
36        while let Some(&c) = chars.peek() {
37            // Skip whitespace and comments
38            if c.is_whitespace() {
39                chars.next();
40                continue;
41            }
42
43            // Skip `/* ... */` comments
44            if c == '/' {
45                chars.next();
46                if chars.peek() == Some(&'*') {
47                    chars.next();
48                    loop {
49                        match chars.next() {
50                            Some('*') if chars.peek() == Some(&'/') => {
51                                chars.next();
52                                break;
53                            }
54                            None => break,
55                            _ => {}
56                        }
57                    }
58                }
59                continue;
60            }
61
62            // Read selector
63            let selector: String = chars.by_ref().take_while(|&c| c != '{').collect();
64            let selector = selector.trim().to_string();
65
66            // Read block
67            let block: String = chars.by_ref().take_while(|&c| c != '}').collect();
68
69            if selector.is_empty() {
70                continue;
71            }
72
73            let mut props = HashMap::new();
74            for declaration in block.split(';') {
75                let declaration = declaration.trim();
76                if declaration.is_empty() {
77                    continue;
78                }
79                if let Some((key, value)) = declaration.split_once(':') {
80                    props.insert(
81                        key.trim().to_string(),
82                        value.trim().to_string(),
83                    );
84                }
85            }
86
87            // Handle multiple selectors: `.a, .b { ... }`
88            for sel in selector.split(',') {
89                let sel = sel.trim().trim_start_matches('.').to_string();
90                rules
91                    .entry(sel)
92                    .or_default()
93                    .extend(props.clone());
94            }
95        }
96
97        Ok(Self { rules })
98    }
99
100    /// Load and parse a CSS file from disk.
101    pub fn from_file(path: &str) -> Result<Self> {
102        let css = std::fs::read_to_string(path)
103            .with_context(|| format!("Failed to read CSS file: {}", path))?;
104        Self::parse(&css)
105    }
106
107    /// Look up all properties for a given class name.
108    pub fn resolve(&self, class: &str) -> Option<&HashMap<String, String>> {
109        self.rules.get(class)
110    }
111
112    /// Look up a specific property for a class.
113    pub fn get(&self, class: &str, property: &str) -> Option<&str> {
114        self.rules.get(class)?.get(property).map(|s| s.as_str())
115    }
116
117    /// Merge another stylesheet into this one (later rules win).
118    pub fn merge(&mut self, other: StyleSheet) {
119        for (class, props) in other.rules {
120            self.rules.entry(class).or_default().extend(props);
121        }
122    }
123
124    /// Number of rules in this stylesheet.
125    pub fn rule_count(&self) -> usize {
126        self.rules.len()
127    }
128}
129
130/// Bundled CSS string — embedded at compile time.
131///
132/// ```rust
133/// use bubba_core::css::StyleSheet;
134///
135/// // Embed at compile time for zero-cost access:
136/// // static MAIN_CSS: &str = include_str!("../assets/main.css");
137///
138/// // Or parse inline:
139/// let sheet = StyleSheet::parse(".title { font-size: 24px; }").unwrap();
140/// assert_eq!(sheet.get("title", "font-size"), Some("24px"));
141/// ```
142pub const MAIN_CSS_EXAMPLE: &str = r#"
143/* ── Layout ─────────────────────────────────────── */
144.screen {
145    flex-direction: column;
146    align-items: center;
147    padding: 24px;
148    background: #f8f9fa;
149}
150
151/* ── Typography ──────────────────────────────────── */
152.title {
153    font-size: 28px;
154    font-weight: bold;
155    color: #1a1a2e;
156    margin-bottom: 16px;
157}
158
159.label {
160    font-size: 16px;
161    color: #555;
162    margin-bottom: 8px;
163}
164
165/* ── Buttons ─────────────────────────────────────── */
166.primary-btn {
167    background: #4CAF50;
168    color: white;
169    padding: 12px 24px;
170    border-radius: 8px;
171    font-size: 16px;
172    font-weight: 600;
173    margin-top: 12px;
174}
175
176.link-btn {
177    background: transparent;
178    color: #4CAF50;
179    padding: 10px 20px;
180    border-radius: 8px;
181    font-size: 14px;
182    margin-top: 8px;
183}
184
185.danger-btn {
186    background: #e53935;
187    color: white;
188    padding: 12px 24px;
189    border-radius: 8px;
190    font-size: 16px;
191    font-weight: 600;
192    margin-top: 12px;
193}
194
195/* ── Inputs ──────────────────────────────────────── */
196.text-input {
197    background: white;
198    border: 1.5px solid #ddd;
199    border-radius: 8px;
200    padding: 12px 16px;
201    font-size: 16px;
202    width: 100%;
203    margin-top: 16px;
204}
205
206/* ── Avatar ──────────────────────────────────────── */
207.avatar {
208    width: 80px;
209    height: 80px;
210    border-radius: 40px;
211    margin-bottom: 16px;
212    border: 3px solid #4CAF50;
213}
214"#;
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn parse_basic_rule() {
222        let sheet = StyleSheet::parse(".title { font-size: 24px; color: red; }").unwrap();
223        assert_eq!(sheet.get("title", "font-size"), Some("24px"));
224        assert_eq!(sheet.get("title", "color"), Some("red"));
225    }
226
227    #[test]
228    fn parse_multiple_selectors() {
229        let sheet = StyleSheet::parse(".a, .b { color: blue; }").unwrap();
230        assert_eq!(sheet.get("a", "color"), Some("blue"));
231        assert_eq!(sheet.get("b", "color"), Some("blue"));
232    }
233
234    #[test]
235    fn parse_main_css() {
236        let sheet = StyleSheet::parse(MAIN_CSS_EXAMPLE).unwrap();
237        assert!(sheet.rule_count() > 0);
238        assert_eq!(sheet.get("primary-btn", "background"), Some("#4CAF50"));
239        assert_eq!(sheet.get("danger-btn", "background"), Some("#e53935"));
240    }
241}