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.
273fn 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        // Base style for unknown breakpoint
455        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}