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
272#[allow(clippy::too_many_lines)]
274fn merge_styles(dest: &mut Style, source: &Style) {
275 if source.font_family.is_some() {
276 dest.font_family.clone_from(&source.font_family);
277 }
278 if source.font_size.is_some() {
279 dest.font_size.clone_from(&source.font_size);
280 }
281 if source.font_weight.is_some() {
282 dest.font_weight.clone_from(&source.font_weight);
283 }
284 if source.font_style.is_some() {
285 dest.font_style.clone_from(&source.font_style);
286 }
287 if source.line_height.is_some() {
288 dest.line_height.clone_from(&source.line_height);
289 }
290 if source.letter_spacing.is_some() {
291 dest.letter_spacing.clone_from(&source.letter_spacing);
292 }
293 if source.text_align.is_some() {
294 dest.text_align = source.text_align;
295 }
296 if source.text_decoration.is_some() {
297 dest.text_decoration.clone_from(&source.text_decoration);
298 }
299 if source.text_transform.is_some() {
300 dest.text_transform.clone_from(&source.text_transform);
301 }
302 if source.color.is_some() {
303 dest.color.clone_from(&source.color);
304 }
305 if source.margin_top.is_some() {
306 dest.margin_top.clone_from(&source.margin_top);
307 }
308 if source.margin_right.is_some() {
309 dest.margin_right.clone_from(&source.margin_right);
310 }
311 if source.margin_bottom.is_some() {
312 dest.margin_bottom.clone_from(&source.margin_bottom);
313 }
314 if source.margin_left.is_some() {
315 dest.margin_left.clone_from(&source.margin_left);
316 }
317 if source.padding_top.is_some() {
318 dest.padding_top.clone_from(&source.padding_top);
319 }
320 if source.padding_right.is_some() {
321 dest.padding_right.clone_from(&source.padding_right);
322 }
323 if source.padding_bottom.is_some() {
324 dest.padding_bottom.clone_from(&source.padding_bottom);
325 }
326 if source.padding_left.is_some() {
327 dest.padding_left.clone_from(&source.padding_left);
328 }
329 if source.border_width.is_some() {
330 dest.border_width.clone_from(&source.border_width);
331 }
332 if source.border_style.is_some() {
333 dest.border_style.clone_from(&source.border_style);
334 }
335 if source.border_color.is_some() {
336 dest.border_color.clone_from(&source.border_color);
337 }
338 if source.background_color.is_some() {
339 dest.background_color.clone_from(&source.background_color);
340 }
341 if source.width.is_some() {
342 dest.width.clone_from(&source.width);
343 }
344 if source.height.is_some() {
345 dest.height.clone_from(&source.height);
346 }
347 if source.max_width.is_some() {
348 dest.max_width.clone_from(&source.max_width);
349 }
350 if source.max_height.is_some() {
351 dest.max_height.clone_from(&source.max_height);
352 }
353 if source.page_break_before.is_some() {
354 dest.page_break_before.clone_from(&source.page_break_before);
355 }
356 if source.page_break_after.is_some() {
357 dest.page_break_after.clone_from(&source.page_break_after);
358 }
359 if source.extends.is_some() {
360 dest.extends.clone_from(&source.extends);
361 }
362 if source.writing_mode.is_some() {
363 dest.writing_mode.clone_from(&source.writing_mode);
364 }
365 if source.z_index.is_some() {
366 dest.z_index = source.z_index;
367 }
368 if source.background_image.is_some() {
369 dest.background_image.clone_from(&source.background_image);
370 }
371 if source.background_size.is_some() {
372 dest.background_size.clone_from(&source.background_size);
373 }
374 if source.background_position.is_some() {
375 dest.background_position
376 .clone_from(&source.background_position);
377 }
378 if source.background_repeat.is_some() {
379 dest.background_repeat.clone_from(&source.background_repeat);
380 }
381 if source.opacity.is_some() {
382 dest.opacity = source.opacity;
383 }
384 if source.border_radius.is_some() {
385 dest.border_radius.clone_from(&source.border_radius);
386 }
387 if source.box_shadow.is_some() {
388 dest.box_shadow.clone_from(&source.box_shadow);
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::presentation::style::FontWeight;
396
397 #[test]
398 fn test_responsive_default() {
399 let r = Responsive::default();
400 assert_eq!(r.presentation_type, "responsive");
401 assert_eq!(r.defaults.breakpoints.len(), 3);
402 }
403
404 #[test]
405 fn test_breakpoint_constructors() {
406 let mobile = Breakpoint::mobile();
407 assert_eq!(mobile.name, "mobile");
408 assert!(mobile.min_width.is_none());
409 assert_eq!(mobile.max_width, Some("599px".to_string()));
410
411 let tablet = Breakpoint::tablet();
412 assert_eq!(tablet.name, "tablet");
413 assert_eq!(tablet.min_width, Some("600px".to_string()));
414 assert_eq!(tablet.max_width, Some("1023px".to_string()));
415
416 let desktop = Breakpoint::desktop();
417 assert_eq!(desktop.name, "desktop");
418 assert_eq!(desktop.min_width, Some("1024px".to_string()));
419 assert!(desktop.max_width.is_none());
420 }
421
422 #[test]
423 fn test_standard_breakpoints() {
424 let breakpoints = Breakpoint::standard();
425 assert_eq!(breakpoints.len(), 3);
426 assert_eq!(breakpoints[0].name, "mobile");
427 assert_eq!(breakpoints[1].name, "tablet");
428 assert_eq!(breakpoints[2].name, "desktop");
429 }
430
431 #[test]
432 fn test_media_query_generation() {
433 assert_eq!(
434 Breakpoint::mobile().to_media_query(),
435 "@media (max-width: 599px)"
436 );
437 assert_eq!(
438 Breakpoint::tablet().to_media_query(),
439 "@media (min-width: 600px) and (max-width: 1023px)"
440 );
441 assert_eq!(
442 Breakpoint::desktop().to_media_query(),
443 "@media (min-width: 1024px)"
444 );
445 }
446
447 #[test]
448 fn test_responsive_style_with_breakpoints() {
449 let base_style = Style {
450 font_size: Some(CssValue::String("16px".to_string())),
451 ..Default::default()
452 };
453
454 let mobile_style = Style {
455 font_size: Some(CssValue::String("14px".to_string())),
456 ..Default::default()
457 };
458
459 let style = ResponsiveStyle::new(base_style).with_mobile(mobile_style);
460
461 assert!(style.breakpoints.contains_key("mobile"));
462 }
463
464 #[test]
465 fn test_style_for_breakpoint() {
466 let base = Style {
467 font_size: Some(CssValue::String("16px".to_string())),
468 font_weight: Some(FontWeight::Number(400)),
469 ..Default::default()
470 };
471
472 let mobile_override = Style {
473 font_size: Some(CssValue::String("14px".to_string())),
474 ..Default::default()
475 };
476
477 let style = ResponsiveStyle::new(base).with_mobile(mobile_override);
478
479 let merged = style.style_for_breakpoint("mobile");
480 assert_eq!(merged.font_size, Some(CssValue::String("14px".to_string())));
481 assert_eq!(merged.font_weight, Some(FontWeight::Number(400)));
482
483 let base_only = style.style_for_breakpoint("desktop");
485 assert_eq!(
486 base_only.font_size,
487 Some(CssValue::String("16px".to_string()))
488 );
489 }
490
491 #[test]
492 fn test_serialization() {
493 let r = Responsive::with_standard_breakpoints();
494 let json = serde_json::to_string_pretty(&r).unwrap();
495 assert!(json.contains("\"type\": \"responsive\""));
496 assert!(json.contains("\"mobile\""));
497 assert!(json.contains("\"tablet\""));
498 assert!(json.contains("\"desktop\""));
499 }
500
501 #[test]
502 fn test_deserialization() {
503 let json = r#"{
504 "version": "0.1",
505 "type": "responsive",
506 "defaults": {
507 "rootFontSize": "16px",
508 "breakpoints": [
509 {"name": "mobile", "maxWidth": "599px"},
510 {"name": "tablet", "minWidth": "600px", "maxWidth": "1023px"},
511 {"name": "desktop", "minWidth": "1024px"}
512 ],
513 "maxWidth": "1200px",
514 "lineHeight": 1.6
515 },
516 "styles": {
517 "heading1": {
518 "fontSize": "2.5rem",
519 "fontWeight": 700,
520 "breakpoints": {
521 "mobile": {
522 "fontSize": "1.75rem"
523 }
524 }
525 }
526 }
527 }"#;
528
529 let r: Responsive = serde_json::from_str(json).unwrap();
530 assert_eq!(r.presentation_type, "responsive");
531 assert_eq!(r.defaults.breakpoints.len(), 3);
532 assert!(r.styles.contains_key("heading1"));
533
534 let h1_style = r.styles.get("heading1").unwrap();
535 assert!(h1_style.breakpoints.contains_key("mobile"));
536 }
537
538 #[test]
539 fn test_merge_styles_all_fields() {
540 use crate::presentation::style::{Color, WritingMode};
541
542 let source = Style {
544 font_family: Some("serif".to_string()),
545 font_size: Some(CssValue::String("18px".to_string())),
546 font_weight: Some(FontWeight::Number(700)),
547 font_style: Some("italic".to_string()),
548 line_height: Some(CssValue::Number(1.8)),
549 letter_spacing: Some(CssValue::String("0.05em".to_string())),
550 text_align: Some(crate::presentation::style::TextAlign::Center),
551 text_decoration: Some("underline".to_string()),
552 text_transform: Some("uppercase".to_string()),
553 color: Some(Color::hex("#ff0000".to_string())),
554 margin_top: Some(CssValue::String("10px".to_string())),
555 margin_right: Some(CssValue::String("11px".to_string())),
556 margin_bottom: Some(CssValue::String("12px".to_string())),
557 margin_left: Some(CssValue::String("13px".to_string())),
558 padding_top: Some(CssValue::String("14px".to_string())),
559 padding_right: Some(CssValue::String("15px".to_string())),
560 padding_bottom: Some(CssValue::String("16px".to_string())),
561 padding_left: Some(CssValue::String("17px".to_string())),
562 border_width: Some(CssValue::String("2px".to_string())),
563 border_style: Some("solid".to_string()),
564 border_color: Some(Color::hex("#000".to_string())),
565 background_color: Some(Color::hex("#fff".to_string())),
566 width: Some(CssValue::String("100%".to_string())),
567 height: Some(CssValue::String("auto".to_string())),
568 max_width: Some(CssValue::String("800px".to_string())),
569 max_height: Some(CssValue::String("600px".to_string())),
570 page_break_before: Some("always".to_string()),
571 page_break_after: Some("avoid".to_string()),
572 extends: Some("base".to_string()),
573 writing_mode: Some(WritingMode::VerticalRl),
574 z_index: Some(42),
575 background_image: Some("url(bg.png)".to_string()),
576 background_size: Some("cover".to_string()),
577 background_position: Some("center".to_string()),
578 background_repeat: Some("no-repeat".to_string()),
579 opacity: Some(0.9),
580 border_radius: Some(CssValue::String("8px".to_string())),
581 box_shadow: Some("0 2px 4px rgba(0,0,0,0.2)".to_string()),
582 };
583
584 let mut dest = Style::default();
586 merge_styles(&mut dest, &source);
587
588 assert_eq!(dest.font_family, source.font_family);
590 assert_eq!(dest.font_size, source.font_size);
591 assert_eq!(dest.font_weight, source.font_weight);
592 assert_eq!(dest.font_style, source.font_style);
593 assert_eq!(dest.line_height, source.line_height);
594 assert_eq!(dest.letter_spacing, source.letter_spacing);
595 assert_eq!(dest.text_align, source.text_align);
596 assert_eq!(dest.text_decoration, source.text_decoration);
597 assert_eq!(dest.text_transform, source.text_transform);
598 assert_eq!(dest.color, source.color);
599 assert_eq!(dest.margin_top, source.margin_top);
600 assert_eq!(dest.margin_right, source.margin_right);
601 assert_eq!(dest.margin_bottom, source.margin_bottom);
602 assert_eq!(dest.margin_left, source.margin_left);
603 assert_eq!(dest.padding_top, source.padding_top);
604 assert_eq!(dest.padding_right, source.padding_right);
605 assert_eq!(dest.padding_bottom, source.padding_bottom);
606 assert_eq!(dest.padding_left, source.padding_left);
607 assert_eq!(dest.border_width, source.border_width);
608 assert_eq!(dest.border_style, source.border_style);
609 assert_eq!(dest.border_color, source.border_color);
610 assert_eq!(dest.background_color, source.background_color);
611 assert_eq!(dest.width, source.width);
612 assert_eq!(dest.height, source.height);
613 assert_eq!(dest.max_width, source.max_width);
614 assert_eq!(dest.max_height, source.max_height);
615 assert_eq!(dest.page_break_before, source.page_break_before);
616 assert_eq!(dest.page_break_after, source.page_break_after);
617 assert_eq!(dest.extends, source.extends);
618 assert_eq!(dest.writing_mode, source.writing_mode);
619 assert_eq!(dest.z_index, source.z_index);
620 assert_eq!(dest.background_image, source.background_image);
621 assert_eq!(dest.background_size, source.background_size);
622 assert_eq!(dest.background_position, source.background_position);
623 assert_eq!(dest.background_repeat, source.background_repeat);
624 assert_eq!(dest.opacity, source.opacity);
625 assert_eq!(dest.border_radius, source.border_radius);
626 assert_eq!(dest.box_shadow, source.box_shadow);
627 }
628
629 #[test]
630 fn test_round_trip() {
631 let base = Style {
632 font_size: Some(CssValue::String("2rem".to_string())),
633 font_weight: Some(FontWeight::Number(700)),
634 margin_bottom: Some(CssValue::String("1rem".to_string())),
635 ..Default::default()
636 };
637
638 let mobile = Style {
639 font_size: Some(CssValue::String("1.5rem".to_string())),
640 ..Default::default()
641 };
642
643 let heading_style = ResponsiveStyle::new(base).with_mobile(mobile);
644
645 let responsive =
646 Responsive::with_standard_breakpoints().with_style("heading1", heading_style);
647
648 let json = serde_json::to_string(&responsive).unwrap();
649 let parsed: Responsive = serde_json::from_str(&json).unwrap();
650
651 assert_eq!(parsed.defaults.breakpoints.len(), 3);
652 assert!(parsed.styles.contains_key("heading1"));
653 }
654}