1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ViewportClass {
9 Compact,
10 Regular,
11 Wide,
12}
13
14impl ViewportClass {
15 #[must_use]
16 pub const fn as_str(self) -> &'static str {
17 match self {
18 Self::Compact => "compact",
19 Self::Regular => "regular",
20 Self::Wide => "wide",
21 }
22 }
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
26pub struct ViewportBreakpoints {
27 compact_max: f64,
28 regular_max: f64,
29}
30
31impl ViewportBreakpoints {
32 pub fn new(compact_max: f64, regular_max: f64) -> Result<Self, ResponsiveError> {
39 if compact_max.is_finite()
40 && regular_max.is_finite()
41 && compact_max > 0.0
42 && regular_max > compact_max
43 {
44 Ok(Self {
45 compact_max,
46 regular_max,
47 })
48 } else {
49 Err(ResponsiveError::InvalidBreakpoints {
50 compact_max,
51 regular_max,
52 })
53 }
54 }
55
56 pub fn classify(self, width: f64) -> Result<ViewportClass, ResponsiveError> {
62 if !width.is_finite() || width < 0.0 {
63 return Err(ResponsiveError::InvalidWidth(width));
64 }
65 Ok(if width <= self.compact_max {
66 ViewportClass::Compact
67 } else if width <= self.regular_max {
68 ViewportClass::Regular
69 } else {
70 ViewportClass::Wide
71 })
72 }
73
74 #[must_use]
75 pub const fn compact_max(self) -> f64 {
76 self.compact_max
77 }
78
79 #[must_use]
80 pub const fn regular_max(self) -> f64 {
81 self.regular_max
82 }
83}
84
85impl Default for ViewportBreakpoints {
86 fn default() -> Self {
87 Self {
88 compact_max: 600.0,
89 regular_max: 1000.0,
90 }
91 }
92}
93
94#[derive(Clone, Debug, Default)]
95pub struct ResponsiveRuntime {
96 breakpoints: ViewportBreakpoints,
97 windows: BTreeMap<String, ViewportClass>,
98}
99
100impl ResponsiveRuntime {
101 #[must_use]
102 pub fn new(breakpoints: ViewportBreakpoints) -> Self {
103 Self {
104 breakpoints,
105 windows: BTreeMap::new(),
106 }
107 }
108
109 pub fn update_window(&mut self, window: &str, width: f64) -> Result<bool, ResponsiveError> {
115 let class = self.breakpoints.classify(width)?;
116 Ok(self.windows.insert(window.to_owned(), class) != Some(class))
117 }
118
119 pub fn would_update_window(&self, window: &str, width: f64) -> Result<bool, ResponsiveError> {
125 let class = self.breakpoints.classify(width)?;
126 Ok(self.windows.get(window).copied() != Some(class))
127 }
128
129 #[must_use]
130 pub fn class(&self, window: &str) -> ViewportClass {
131 self.windows
132 .get(window)
133 .copied()
134 .unwrap_or(ViewportClass::Regular)
135 }
136
137 pub fn remove_window(&mut self, window: &str) -> bool {
138 self.windows.remove(window).is_some()
139 }
140
141 #[must_use]
142 pub const fn breakpoints(&self) -> ViewportBreakpoints {
143 self.breakpoints
144 }
145}
146
147#[derive(Clone, Copy, Debug, Error, PartialEq)]
148pub enum ResponsiveError {
149 #[error(
150 "viewport breakpoints must be finite, positive, and increasing; got {compact_max}, {regular_max}"
151 )]
152 InvalidBreakpoints { compact_max: f64, regular_max: f64 },
153 #[error("viewport width must be finite and non-negative, got {0}")]
154 InvalidWidth(f64),
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn discrete_classes_change_only_at_configured_boundaries() {
163 let breakpoints = ViewportBreakpoints::new(500.0, 900.0).unwrap();
164 let mut runtime = ResponsiveRuntime::new(breakpoints);
165 assert!(runtime.update_window("main", 480.0).unwrap());
166 assert!(!runtime.update_window("main", 499.0).unwrap());
167 assert!(runtime.update_window("main", 700.0).unwrap());
168 assert_eq!(runtime.class("main"), ViewportClass::Regular);
169 assert!(runtime.update_window("main", 1200.0).unwrap());
170 assert_eq!(runtime.class("main"), ViewportClass::Wide);
171 }
172}