1use crate::result::ProbarResult;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GeneratedCss {
11 pub content: String,
13 pub rules: Vec<CssRule>,
15 pub variables: Vec<(String, String)>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CssRule {
22 pub selector: String,
24 pub declarations: Vec<(String, String)>,
26}
27
28impl CssRule {
29 #[must_use]
31 pub fn new(selector: &str) -> Self {
32 Self {
33 selector: selector.to_string(),
34 declarations: Vec::new(),
35 }
36 }
37
38 #[must_use]
40 pub fn declaration(mut self, property: &str, value: &str) -> Self {
41 self.declarations
42 .push((property.to_string(), value.to_string()));
43 self
44 }
45
46 #[must_use]
48 pub fn render(&self) -> String {
49 if self.declarations.is_empty() {
50 return String::new();
51 }
52
53 let decls = self
54 .declarations
55 .iter()
56 .map(|(prop, val)| format!(" {prop}: {val};"))
57 .collect::<Vec<_>>()
58 .join("\n");
59
60 format!("{} {{\n{}\n}}", self.selector, decls)
61 }
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct CssBuilder {
67 variables: Vec<(String, String)>,
68 rules: Vec<CssRule>,
69}
70
71impl CssBuilder {
72 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 #[must_use]
80 pub fn variable(mut self, name: &str, value: &str) -> Self {
81 self.variables.push((name.to_string(), value.to_string()));
82 self
83 }
84
85 #[must_use]
87 pub fn rule(mut self, rule: CssRule) -> Self {
88 self.rules.push(rule);
89 self
90 }
91
92 #[must_use]
94 pub fn reset(mut self) -> Self {
95 self.rules.push(
96 CssRule::new("*, *::before, *::after")
97 .declaration("box-sizing", "border-box")
98 .declaration("margin", "0")
99 .declaration("padding", "0"),
100 );
101 self
102 }
103
104 #[must_use]
106 pub fn responsive_canvas(mut self, id: &str) -> Self {
107 self.rules.push(
108 CssRule::new(&format!("#{id}"))
109 .declaration("width", "100vw")
110 .declaration("height", "100vh")
111 .declaration("display", "block")
112 .declaration("touch-action", "none"),
113 );
114 self
115 }
116
117 #[must_use]
119 pub fn fullscreen_body(mut self) -> Self {
120 self.rules.push(
121 CssRule::new("html, body")
122 .declaration("width", "100%")
123 .declaration("height", "100%")
124 .declaration("margin", "0")
125 .declaration("padding", "0")
126 .declaration("overflow", "hidden"),
127 );
128 self
129 }
130
131 #[must_use]
133 pub fn media_query(mut self, query: &str, rule: CssRule) -> Self {
134 let media_rule = CssRule {
136 selector: format!("@media {query} {{ {} }}", rule.selector),
137 declarations: rule.declarations,
138 };
139 self.rules.push(media_rule);
140 self
141 }
142
143 #[must_use]
145 #[allow(clippy::literal_string_with_formatting_args)]
146 pub fn dark_mode(mut self, background: &str, foreground: &str) -> Self {
147 self.rules.push(
148 CssRule::new("@media (prefers-color-scheme: dark) { :root }")
149 .declaration("--bg-color", background)
150 .declaration("--fg-color", foreground),
151 );
152 self
153 }
154
155 pub fn build(self) -> ProbarResult<GeneratedCss> {
161 let mut content = String::new();
162
163 if !self.variables.is_empty() {
165 content.push_str(":root {\n");
166 for (name, value) in &self.variables {
167 content.push_str(&format!(" --{name}: {value};\n"));
168 }
169 content.push_str("}\n\n");
170 }
171
172 let rule_strings: Vec<String> = self.rules.iter().map(CssRule::render).collect();
174 content.push_str(&rule_strings.join("\n\n"));
175
176 Ok(GeneratedCss {
177 content,
178 rules: self.rules,
179 variables: self.variables,
180 })
181 }
182}
183
184#[allow(dead_code)]
186pub mod presets {
187 use super::{CssBuilder, CssRule};
188
189 #[must_use]
191 pub fn wasm_app(canvas_id: &str) -> CssBuilder {
192 CssBuilder::new()
193 .reset()
194 .fullscreen_body()
195 .responsive_canvas(canvas_id)
196 }
197
198 #[must_use]
200 pub fn calculator() -> CssBuilder {
201 CssBuilder::new()
202 .reset()
203 .variable("primary-color", "#4a90d9")
204 .variable("secondary-color", "#2c3e50")
205 .variable("bg-color", "#1a1a2e")
206 .rule(
207 CssRule::new("body")
208 .declaration("font-family", "system-ui, sans-serif")
209 .declaration("background", "var(--bg-color)")
210 .declaration("color", "#fff")
211 .declaration("display", "flex")
212 .declaration("justify-content", "center")
213 .declaration("align-items", "center")
214 .declaration("min-height", "100vh"),
215 )
216 }
217
218 #[must_use]
220 pub fn game(canvas_id: &str) -> CssBuilder {
221 CssBuilder::new()
222 .reset()
223 .fullscreen_body()
224 .responsive_canvas(canvas_id)
225 .rule(
226 CssRule::new(".loading")
227 .declaration("position", "fixed")
228 .declaration("inset", "0")
229 .declaration("display", "flex")
230 .declaration("justify-content", "center")
231 .declaration("align-items", "center")
232 .declaration("background", "#000")
233 .declaration("color", "#fff")
234 .declaration("font-size", "1.5rem"),
235 )
236 }
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used, clippy::wildcard_imports)]
241mod tests {
242 use super::*;
243
244 #[test]
249 fn h0_css_01_builder_new() {
250 let builder = CssBuilder::new();
251 assert!(builder.variables.is_empty());
252 assert!(builder.rules.is_empty());
253 }
254
255 #[test]
256 fn h0_css_02_builder_variable() {
257 let css = CssBuilder::new()
258 .variable("primary", "#ff0000")
259 .build()
260 .unwrap();
261
262 assert!(css.content.contains(":root {"));
263 assert!(css.content.contains("--primary: #ff0000;"));
264 }
265
266 #[test]
271 fn h0_css_03_rule_render() {
272 let rule = CssRule::new("body")
273 .declaration("margin", "0")
274 .declaration("padding", "0");
275
276 let rendered = rule.render();
277 assert!(rendered.contains("body {"));
278 assert!(rendered.contains("margin: 0;"));
279 assert!(rendered.contains("padding: 0;"));
280 }
281
282 #[test]
283 fn h0_css_04_rule_empty_declarations() {
284 let rule = CssRule::new("div");
285 let rendered = rule.render();
286 assert!(rendered.is_empty());
287 }
288
289 #[test]
290 fn h0_css_05_builder_rule() {
291 let css = CssBuilder::new()
292 .rule(CssRule::new(".test").declaration("color", "red"))
293 .build()
294 .unwrap();
295
296 assert!(css.content.contains(".test {"));
297 assert!(css.content.contains("color: red;"));
298 }
299
300 #[test]
305 fn h0_css_06_reset() {
306 let css = CssBuilder::new().reset().build().unwrap();
307
308 assert!(css.content.contains("box-sizing: border-box;"));
309 assert!(css.content.contains("margin: 0;"));
310 assert!(css.content.contains("padding: 0;"));
311 }
312
313 #[test]
314 fn h0_css_07_responsive_canvas() {
315 let css = CssBuilder::new().responsive_canvas("game").build().unwrap();
316
317 assert!(css.content.contains("#game {"));
318 assert!(css.content.contains("width: 100vw;"));
319 assert!(css.content.contains("height: 100vh;"));
320 assert!(css.content.contains("touch-action: none;"));
321 }
322
323 #[test]
324 fn h0_css_08_fullscreen_body() {
325 let css = CssBuilder::new().fullscreen_body().build().unwrap();
326
327 assert!(css.content.contains("html, body {"));
328 assert!(css.content.contains("overflow: hidden;"));
329 }
330
331 #[test]
332 fn h0_css_09_dark_mode() {
333 let css = CssBuilder::new().dark_mode("#000", "#fff").build().unwrap();
334
335 assert!(css.content.contains("prefers-color-scheme: dark"));
336 assert!(css.content.contains("--bg-color: #000;"));
337 assert!(css.content.contains("--fg-color: #fff;"));
338 }
339
340 #[test]
345 fn h0_css_10_preset_wasm_app() {
346 let css = presets::wasm_app("app").build().unwrap();
347
348 assert!(css.content.contains("#app {"));
349 assert!(css.content.contains("100vw"));
350 }
351
352 #[test]
353 fn h0_css_11_preset_calculator() {
354 let css = presets::calculator().build().unwrap();
355
356 assert!(css.content.contains("--primary-color"));
357 assert!(css.content.contains("system-ui"));
358 }
359
360 #[test]
361 fn h0_css_12_preset_game() {
362 let css = presets::game("canvas").build().unwrap();
363
364 assert!(css.content.contains("#canvas"));
365 assert!(css.content.contains(".loading"));
366 }
367
368 #[test]
373 fn h0_css_13_generated_css_fields() {
374 let css = CssBuilder::new()
375 .variable("x", "1")
376 .rule(CssRule::new("a").declaration("b", "c"))
377 .build()
378 .unwrap();
379
380 assert_eq!(css.variables.len(), 1);
381 assert_eq!(css.rules.len(), 1);
382 assert!(!css.content.is_empty());
383 }
384
385 #[test]
386 fn h0_css_14_chained_methods() {
387 let css = CssBuilder::new()
388 .reset()
389 .fullscreen_body()
390 .responsive_canvas("c")
391 .variable("color", "blue")
392 .build()
393 .unwrap();
394
395 assert_eq!(css.rules.len(), 3);
397 assert_eq!(css.variables.len(), 1);
398 }
399}