1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::style::{CssValue, Style};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct Responsive {
14 pub version: String,
16
17 #[serde(rename = "type")]
19 pub presentation_type: String,
20
21 pub defaults: ResponsiveDefaults,
23
24 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
26 pub styles: HashMap<String, ResponsiveStyle>,
27}
28
29impl Default for Responsive {
30 fn default() -> Self {
31 Self::new()
32 }
33}
34
35impl Responsive {
36 #[must_use]
38 pub fn new() -> Self {
39 Self {
40 version: crate::SPEC_VERSION.to_string(),
41 presentation_type: "responsive".to_string(),
42 defaults: ResponsiveDefaults::default(),
43 styles: HashMap::new(),
44 }
45 }
46
47 #[must_use]
49 pub fn with_breakpoints(breakpoints: Vec<Breakpoint>) -> Self {
50 Self {
51 version: crate::SPEC_VERSION.to_string(),
52 presentation_type: "responsive".to_string(),
53 defaults: ResponsiveDefaults {
54 breakpoints,
55 ..Default::default()
56 },
57 styles: HashMap::new(),
58 }
59 }
60
61 #[must_use]
63 pub fn with_style(mut self, name: impl Into<String>, style: ResponsiveStyle) -> Self {
64 self.styles.insert(name.into(), style);
65 self
66 }
67
68 #[must_use]
70 pub fn with_standard_breakpoints() -> Self {
71 Self::with_breakpoints(Breakpoint::standard())
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ResponsiveDefaults {
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub root_font_size: Option<CssValue>,
82
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub breakpoints: Vec<Breakpoint>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub max_width: Option<CssValue>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub padding: Option<CssValue>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub font_family: Option<String>,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub line_height: Option<CssValue>,
102}
103
104impl Default for ResponsiveDefaults {
105 fn default() -> Self {
106 Self {
107 root_font_size: Some(CssValue::String("16px".to_string())),
108 breakpoints: Breakpoint::standard(),
109 max_width: Some(CssValue::String("1200px".to_string())),
110 padding: Some(CssValue::String("16px".to_string())),
111 font_family: None,
112 line_height: Some(CssValue::Number(1.6)),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Breakpoint {
121 pub name: String,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub min_width: Option<String>,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub max_width: Option<String>,
131}
132
133impl Breakpoint {
134 #[must_use]
136 pub fn new(
137 name: impl Into<String>,
138 min_width: Option<String>,
139 max_width: Option<String>,
140 ) -> Self {
141 Self {
142 name: name.into(),
143 min_width,
144 max_width,
145 }
146 }
147
148 #[must_use]
150 pub fn mobile() -> Self {
151 Self {
152 name: "mobile".to_string(),
153 min_width: None,
154 max_width: Some("599px".to_string()),
155 }
156 }
157
158 #[must_use]
160 pub fn tablet() -> Self {
161 Self {
162 name: "tablet".to_string(),
163 min_width: Some("600px".to_string()),
164 max_width: Some("1023px".to_string()),
165 }
166 }
167
168 #[must_use]
170 pub fn desktop() -> Self {
171 Self {
172 name: "desktop".to_string(),
173 min_width: Some("1024px".to_string()),
174 max_width: None,
175 }
176 }
177
178 #[must_use]
180 pub fn large_desktop() -> Self {
181 Self {
182 name: "large-desktop".to_string(),
183 min_width: Some("1440px".to_string()),
184 max_width: None,
185 }
186 }
187
188 #[must_use]
190 pub fn standard() -> Vec<Self> {
191 vec![Self::mobile(), Self::tablet(), Self::desktop()]
192 }
193
194 #[must_use]
196 pub fn to_media_query(&self) -> String {
197 match (&self.min_width, &self.max_width) {
198 (Some(min), Some(max)) => {
199 format!("@media (min-width: {min}) and (max-width: {max})")
200 }
201 (Some(min), None) => format!("@media (min-width: {min})"),
202 (None, Some(max)) => format!("@media (max-width: {max})"),
203 (None, None) => "@media all".to_string(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
210pub struct ResponsiveStyle {
211 #[serde(flatten)]
213 pub base: Style,
214
215 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
218 pub breakpoints: HashMap<String, Style>,
219}
220
221impl ResponsiveStyle {
222 #[must_use]
224 pub fn new(base: Style) -> Self {
225 Self {
226 base,
227 breakpoints: HashMap::new(),
228 }
229 }
230
231 #[must_use]
233 pub fn with_breakpoint(mut self, name: impl Into<String>, style: Style) -> Self {
234 self.breakpoints.insert(name.into(), style);
235 self
236 }
237
238 #[must_use]
240 pub fn with_mobile(self, style: Style) -> Self {
241 self.with_breakpoint("mobile", style)
242 }
243
244 #[must_use]
246 pub fn with_tablet(self, style: Style) -> Self {
247 self.with_breakpoint("tablet", style)
248 }
249
250 #[must_use]
252 pub fn with_desktop(self, style: Style) -> Self {
253 self.with_breakpoint("desktop", style)
254 }
255
256 #[must_use]
260 pub fn style_for_breakpoint(&self, breakpoint: &str) -> Style {
261 let mut merged = self.base.clone();
262
263 if let Some(override_style) = self.breakpoints.get(breakpoint) {
264 merge_styles(&mut merged, override_style);
266 }
267
268 merged
269 }
270}
271
272fn merge_styles(dest: &mut Style, source: &Style) {
274 if source.font_family.is_some() {
275 dest.font_family.clone_from(&source.font_family);
276 }
277 if source.font_size.is_some() {
278 dest.font_size.clone_from(&source.font_size);
279 }
280 if source.font_weight.is_some() {
281 dest.font_weight.clone_from(&source.font_weight);
282 }
283 if source.font_style.is_some() {
284 dest.font_style.clone_from(&source.font_style);
285 }
286 if source.line_height.is_some() {
287 dest.line_height.clone_from(&source.line_height);
288 }
289 if source.letter_spacing.is_some() {
290 dest.letter_spacing.clone_from(&source.letter_spacing);
291 }
292 if source.text_align.is_some() {
293 dest.text_align = source.text_align;
294 }
295 if source.text_decoration.is_some() {
296 dest.text_decoration.clone_from(&source.text_decoration);
297 }
298 if source.text_transform.is_some() {
299 dest.text_transform.clone_from(&source.text_transform);
300 }
301 if source.color.is_some() {
302 dest.color.clone_from(&source.color);
303 }
304 if source.margin_top.is_some() {
305 dest.margin_top.clone_from(&source.margin_top);
306 }
307 if source.margin_right.is_some() {
308 dest.margin_right.clone_from(&source.margin_right);
309 }
310 if source.margin_bottom.is_some() {
311 dest.margin_bottom.clone_from(&source.margin_bottom);
312 }
313 if source.margin_left.is_some() {
314 dest.margin_left.clone_from(&source.margin_left);
315 }
316 if source.padding_top.is_some() {
317 dest.padding_top.clone_from(&source.padding_top);
318 }
319 if source.padding_right.is_some() {
320 dest.padding_right.clone_from(&source.padding_right);
321 }
322 if source.padding_bottom.is_some() {
323 dest.padding_bottom.clone_from(&source.padding_bottom);
324 }
325 if source.padding_left.is_some() {
326 dest.padding_left.clone_from(&source.padding_left);
327 }
328 if source.border_width.is_some() {
329 dest.border_width.clone_from(&source.border_width);
330 }
331 if source.border_style.is_some() {
332 dest.border_style.clone_from(&source.border_style);
333 }
334 if source.border_color.is_some() {
335 dest.border_color.clone_from(&source.border_color);
336 }
337 if source.background_color.is_some() {
338 dest.background_color.clone_from(&source.background_color);
339 }
340 if source.width.is_some() {
341 dest.width.clone_from(&source.width);
342 }
343 if source.height.is_some() {
344 dest.height.clone_from(&source.height);
345 }
346 if source.max_width.is_some() {
347 dest.max_width.clone_from(&source.max_width);
348 }
349 if source.max_height.is_some() {
350 dest.max_height.clone_from(&source.max_height);
351 }
352 if source.page_break_before.is_some() {
353 dest.page_break_before.clone_from(&source.page_break_before);
354 }
355 if source.page_break_after.is_some() {
356 dest.page_break_after.clone_from(&source.page_break_after);
357 }
358 if source.extends.is_some() {
359 dest.extends.clone_from(&source.extends);
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::presentation::style::FontWeight;
367
368 #[test]
369 fn test_responsive_default() {
370 let r = Responsive::default();
371 assert_eq!(r.presentation_type, "responsive");
372 assert_eq!(r.defaults.breakpoints.len(), 3);
373 }
374
375 #[test]
376 fn test_breakpoint_constructors() {
377 let mobile = Breakpoint::mobile();
378 assert_eq!(mobile.name, "mobile");
379 assert!(mobile.min_width.is_none());
380 assert_eq!(mobile.max_width, Some("599px".to_string()));
381
382 let tablet = Breakpoint::tablet();
383 assert_eq!(tablet.name, "tablet");
384 assert_eq!(tablet.min_width, Some("600px".to_string()));
385 assert_eq!(tablet.max_width, Some("1023px".to_string()));
386
387 let desktop = Breakpoint::desktop();
388 assert_eq!(desktop.name, "desktop");
389 assert_eq!(desktop.min_width, Some("1024px".to_string()));
390 assert!(desktop.max_width.is_none());
391 }
392
393 #[test]
394 fn test_standard_breakpoints() {
395 let breakpoints = Breakpoint::standard();
396 assert_eq!(breakpoints.len(), 3);
397 assert_eq!(breakpoints[0].name, "mobile");
398 assert_eq!(breakpoints[1].name, "tablet");
399 assert_eq!(breakpoints[2].name, "desktop");
400 }
401
402 #[test]
403 fn test_media_query_generation() {
404 assert_eq!(
405 Breakpoint::mobile().to_media_query(),
406 "@media (max-width: 599px)"
407 );
408 assert_eq!(
409 Breakpoint::tablet().to_media_query(),
410 "@media (min-width: 600px) and (max-width: 1023px)"
411 );
412 assert_eq!(
413 Breakpoint::desktop().to_media_query(),
414 "@media (min-width: 1024px)"
415 );
416 }
417
418 #[test]
419 fn test_responsive_style_with_breakpoints() {
420 let base_style = Style {
421 font_size: Some(CssValue::String("16px".to_string())),
422 ..Default::default()
423 };
424
425 let mobile_style = Style {
426 font_size: Some(CssValue::String("14px".to_string())),
427 ..Default::default()
428 };
429
430 let style = ResponsiveStyle::new(base_style).with_mobile(mobile_style);
431
432 assert!(style.breakpoints.contains_key("mobile"));
433 }
434
435 #[test]
436 fn test_style_for_breakpoint() {
437 let base = Style {
438 font_size: Some(CssValue::String("16px".to_string())),
439 font_weight: Some(FontWeight::Number(400)),
440 ..Default::default()
441 };
442
443 let mobile_override = Style {
444 font_size: Some(CssValue::String("14px".to_string())),
445 ..Default::default()
446 };
447
448 let style = ResponsiveStyle::new(base).with_mobile(mobile_override);
449
450 let merged = style.style_for_breakpoint("mobile");
451 assert_eq!(merged.font_size, Some(CssValue::String("14px".to_string())));
452 assert_eq!(merged.font_weight, Some(FontWeight::Number(400)));
453
454 let base_only = style.style_for_breakpoint("desktop");
456 assert_eq!(
457 base_only.font_size,
458 Some(CssValue::String("16px".to_string()))
459 );
460 }
461
462 #[test]
463 fn test_serialization() {
464 let r = Responsive::with_standard_breakpoints();
465 let json = serde_json::to_string_pretty(&r).unwrap();
466 assert!(json.contains("\"type\": \"responsive\""));
467 assert!(json.contains("\"mobile\""));
468 assert!(json.contains("\"tablet\""));
469 assert!(json.contains("\"desktop\""));
470 }
471
472 #[test]
473 fn test_deserialization() {
474 let json = r#"{
475 "version": "0.1",
476 "type": "responsive",
477 "defaults": {
478 "rootFontSize": "16px",
479 "breakpoints": [
480 {"name": "mobile", "maxWidth": "599px"},
481 {"name": "tablet", "minWidth": "600px", "maxWidth": "1023px"},
482 {"name": "desktop", "minWidth": "1024px"}
483 ],
484 "maxWidth": "1200px",
485 "lineHeight": 1.6
486 },
487 "styles": {
488 "heading1": {
489 "fontSize": "2.5rem",
490 "fontWeight": 700,
491 "breakpoints": {
492 "mobile": {
493 "fontSize": "1.75rem"
494 }
495 }
496 }
497 }
498 }"#;
499
500 let r: Responsive = serde_json::from_str(json).unwrap();
501 assert_eq!(r.presentation_type, "responsive");
502 assert_eq!(r.defaults.breakpoints.len(), 3);
503 assert!(r.styles.contains_key("heading1"));
504
505 let h1_style = r.styles.get("heading1").unwrap();
506 assert!(h1_style.breakpoints.contains_key("mobile"));
507 }
508
509 #[test]
510 fn test_round_trip() {
511 let base = Style {
512 font_size: Some(CssValue::String("2rem".to_string())),
513 font_weight: Some(FontWeight::Number(700)),
514 margin_bottom: Some(CssValue::String("1rem".to_string())),
515 ..Default::default()
516 };
517
518 let mobile = Style {
519 font_size: Some(CssValue::String("1.5rem".to_string())),
520 ..Default::default()
521 };
522
523 let heading_style = ResponsiveStyle::new(base).with_mobile(mobile);
524
525 let responsive =
526 Responsive::with_standard_breakpoints().with_style("heading1", heading_style);
527
528 let json = serde_json::to_string(&responsive).unwrap();
529 let parsed: Responsive = serde_json::from_str(&json).unwrap();
530
531 assert_eq!(parsed.defaults.breakpoints.len(), 3);
532 assert!(parsed.styles.contains_key("heading1"));
533 }
534}