Skip to main content

cdx_core/presentation/
responsive.rs

1//! Responsive presentation layer.
2//!
3//! The responsive presentation adapts content to different viewport sizes
4//! using breakpoints and responsive styles.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::style::{CssValue, Style};
10
11/// Responsive presentation for adaptive screen layouts.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct Responsive {
14    /// Format version.
15    pub version: String,
16
17    /// Presentation type (always "responsive").
18    #[serde(rename = "type")]
19    pub presentation_type: String,
20
21    /// Default settings.
22    pub defaults: ResponsiveDefaults,
23
24    /// Style definitions with breakpoint overrides.
25    #[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    /// Create a new responsive presentation with default settings.
37    #[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    /// Create a responsive presentation with custom breakpoints.
48    #[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    /// Add a responsive style definition.
62    #[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    /// Create a presentation with standard mobile/tablet/desktop breakpoints.
69    #[must_use]
70    pub fn with_standard_breakpoints() -> Self {
71        Self::with_breakpoints(Breakpoint::standard())
72    }
73}
74
75/// Default settings for responsive presentation.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ResponsiveDefaults {
79    /// Root font size for rem calculations.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub root_font_size: Option<CssValue>,
82
83    /// Viewport breakpoints.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub breakpoints: Vec<Breakpoint>,
86
87    /// Container max-width.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub max_width: Option<CssValue>,
90
91    /// Base content padding.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub padding: Option<CssValue>,
94
95    /// Default font family.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub font_family: Option<String>,
98
99    /// Default line height.
100    #[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/// A viewport width breakpoint.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Breakpoint {
121    /// Breakpoint name (e.g., "mobile", "tablet", "desktop").
122    pub name: String,
123
124    /// Minimum viewport width (inclusive).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub min_width: Option<String>,
127
128    /// Maximum viewport width (inclusive).
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub max_width: Option<String>,
131}
132
133impl Breakpoint {
134    /// Create a new breakpoint with a name and min/max widths.
135    #[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    /// Create a mobile breakpoint (max-width: 599px).
149    #[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    /// Create a tablet breakpoint (600px - 1023px).
159    #[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    /// Create a desktop breakpoint (min-width: 1024px).
169    #[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    /// Create a large desktop breakpoint (min-width: 1440px).
179    #[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    /// Get standard breakpoints (mobile, tablet, desktop).
189    #[must_use]
190    pub fn standard() -> Vec<Self> {
191        vec![Self::mobile(), Self::tablet(), Self::desktop()]
192    }
193
194    /// Convert to a CSS media query.
195    #[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/// A style with optional breakpoint overrides.
209#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
210pub struct ResponsiveStyle {
211    /// Base style (applied at all sizes).
212    #[serde(flatten)]
213    pub base: Style,
214
215    /// Breakpoint-specific style overrides.
216    /// Key is the breakpoint name, value is the style overrides.
217    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
218    pub breakpoints: HashMap<String, Style>,
219}
220
221impl ResponsiveStyle {
222    /// Create a new responsive style with a base style.
223    #[must_use]
224    pub fn new(base: Style) -> Self {
225        Self {
226            base,
227            breakpoints: HashMap::new(),
228        }
229    }
230
231    /// Add a breakpoint override.
232    #[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    /// Add a mobile-specific override.
239    #[must_use]
240    pub fn with_mobile(self, style: Style) -> Self {
241        self.with_breakpoint("mobile", style)
242    }
243
244    /// Add a tablet-specific override.
245    #[must_use]
246    pub fn with_tablet(self, style: Style) -> Self {
247        self.with_breakpoint("tablet", style)
248    }
249
250    /// Add a desktop-specific override.
251    #[must_use]
252    pub fn with_desktop(self, style: Style) -> Self {
253        self.with_breakpoint("desktop", style)
254    }
255
256    /// Get the style for a specific breakpoint, merging with base.
257    ///
258    /// Returns the base style merged with breakpoint overrides.
259    #[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 override into base (override takes precedence)
265            merge_styles(&mut merged, override_style);
266        }
267
268        merged
269    }
270}
271
272/// Merge source style into destination, source values take precedence.
273#[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        // Base style for unknown breakpoint
484        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        // Create a source style with every field set to a non-default value
543        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        // Merge into a default (empty) style
585        let mut dest = Style::default();
586        merge_styles(&mut dest, &source);
587
588        // Assert every field was transferred
589        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}