1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6#[serde(untagged)]
7pub enum Length {
8 Px(f32),
10 String(String),
12}
13
14impl Default for Length {
15 fn default() -> Self {
16 Length::Px(0.0)
17 }
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
22#[serde(untagged)]
23pub enum LengthPercentage {
24 Px(f32),
25 String(String),
26}
27
28impl Default for LengthPercentage {
29 fn default() -> Self {
30 LengthPercentage::Px(0.0)
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub enum ParsedLength {
36 Px(f32),
37 Percent(f32),
38 Em(f32),
39 Rem(f32),
40 Vw(f32),
41 Vh(f32),
42 Fr(f32),
43 Auto,
44}
45
46#[derive(Debug, Clone, Copy)]
47pub struct LengthContext {
48 pub viewport_width: f32,
49 pub viewport_height: f32,
50 pub parent_size: f32,
51 pub font_size: f32,
52 pub root_font_size: f32,
53}
54
55impl Default for LengthContext {
56 fn default() -> Self {
57 Self {
58 viewport_width: 1920.0,
59 viewport_height: 1080.0,
60 parent_size: 0.0,
61 font_size: 16.0,
62 root_font_size: 16.0,
63 }
64 }
65}
66
67impl ParsedLength {
68 pub fn resolve(&self, ctx: &LengthContext) -> Option<f32> {
69 match self {
70 Self::Px(v) => Some(*v),
71 Self::Percent(v) => Some(*v / 100.0 * ctx.parent_size),
72 Self::Em(v) => Some(*v * ctx.font_size),
73 Self::Rem(v) => Some(*v * ctx.root_font_size),
74 Self::Vw(v) => Some(*v / 100.0 * ctx.viewport_width),
75 Self::Vh(v) => Some(*v / 100.0 * ctx.viewport_height),
76 Self::Fr(_) => None,
77 Self::Auto => None,
78 }
79 }
80
81 pub fn is_relative(&self) -> bool {
82 matches!(
83 self,
84 Self::Percent(_) | Self::Em(_) | Self::Rem(_) | Self::Vw(_) | Self::Vh(_)
85 )
86 }
87}
88
89pub fn parse_origin_component(s: &str) -> Option<ParsedLength> {
90 let lower = s.trim().to_ascii_lowercase();
91 match lower.as_str() {
92 "left" | "top" => return Some(ParsedLength::Percent(0.0)),
93 "center" => return Some(ParsedLength::Percent(50.0)),
94 "right" | "bottom" => return Some(ParsedLength::Percent(100.0)),
95 _ => {}
96 }
97 parse_length(s)
98}
99
100pub fn parse_length(s: &str) -> Option<ParsedLength> {
101 let s = s.trim();
102 if s.eq_ignore_ascii_case("auto") {
103 return Some(ParsedLength::Auto);
104 }
105 if let Ok(n) = s.parse::<f32>() {
106 return Some(ParsedLength::Px(n));
107 }
108 let suffixes: &[(&str, fn(f32) -> ParsedLength)] = &[
109 ("px", ParsedLength::Px),
110 ("%", ParsedLength::Percent),
111 ("em", ParsedLength::Em),
112 ("rem", ParsedLength::Rem),
113 ("vw", ParsedLength::Vw),
114 ("vh", ParsedLength::Vh),
115 ("fr", ParsedLength::Fr),
116 ];
117 for (suf, ctor) in suffixes {
118 if let Some(num_part) = s.strip_suffix(suf) {
119 if let Ok(n) = num_part.trim().parse::<f32>() {
120 return Some(ctor(n));
121 }
122 }
123 }
124 None
125}
126
127impl Length {
128 pub fn parse(&self) -> ParsedLength {
129 match self {
130 Length::Px(v) => ParsedLength::Px(*v),
131 Length::String(s) => parse_length_or_warn(s),
132 }
133 }
134
135 pub fn try_parse(&self) -> Option<ParsedLength> {
136 match self {
137 Length::Px(v) => Some(ParsedLength::Px(*v)),
138 Length::String(s) => parse_length(s),
139 }
140 }
141
142 pub fn resolve(&self, ctx: &LengthContext) -> f32 {
143 self.parse().resolve(ctx).unwrap_or(0.0)
144 }
145
146 pub fn px(&self) -> f32 {
147 px_or_warn(self.parse())
148 }
149}
150
151impl LengthPercentage {
152 pub fn parse(&self) -> ParsedLength {
153 match self {
154 LengthPercentage::Px(v) => ParsedLength::Px(*v),
155 LengthPercentage::String(s) => parse_length_or_warn(s),
156 }
157 }
158
159 pub fn try_parse(&self) -> Option<ParsedLength> {
160 match self {
161 LengthPercentage::Px(v) => Some(ParsedLength::Px(*v)),
162 LengthPercentage::String(s) => parse_length(s),
163 }
164 }
165
166 pub fn resolve(&self, ctx: &LengthContext) -> f32 {
167 self.parse().resolve(ctx).unwrap_or(0.0)
168 }
169
170 pub fn px(&self) -> f32 {
171 px_or_warn(self.parse())
172 }
173}
174
175fn px_or_warn(parsed: ParsedLength) -> f32 {
176 match parsed {
177 ParsedLength::Px(v) => v,
178 other => {
179 if other.is_relative() {
180 eprintln!(
181 "Warning: relative unit {other:?} used where only an absolute px value is \
182 supported (no LengthContext available here) — resolving to 0px instead of \
183 the intended value. Use an absolute px length, or resolve through \
184 LengthContext::resolve() / CssStyle's *_ctx accessors where a context is \
185 available."
186 );
187 }
188 0.0
189 }
190 }
191}
192
193fn parse_length_or_warn(s: &str) -> ParsedLength {
194 match parse_length(s) {
195 Some(p) => p,
196 None => {
197 eprintln!(
198 "Warning: unparseable length '{s}' — falling back to 0px instead of silently \
199 resolving with no signal"
200 );
201 ParsedLength::Px(0.0)
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn parse_px_with_unit() {
212 assert_eq!(parse_length("24px"), Some(ParsedLength::Px(24.0)));
213 assert_eq!(parse_length(" 24 px"), Some(ParsedLength::Px(24.0)));
214 }
215
216 #[test]
217 fn parse_percent() {
218 assert_eq!(parse_length("50%"), Some(ParsedLength::Percent(50.0)));
219 }
220
221 #[test]
222 fn parse_em_rem() {
223 assert_eq!(parse_length("1.5em"), Some(ParsedLength::Em(1.5)));
224 assert_eq!(parse_length("2rem"), Some(ParsedLength::Rem(2.0)));
225 }
226
227 #[test]
228 fn parse_vw_vh() {
229 assert_eq!(parse_length("100vw"), Some(ParsedLength::Vw(100.0)));
230 assert_eq!(parse_length("50vh"), Some(ParsedLength::Vh(50.0)));
231 }
232
233 #[test]
234 fn parse_fr() {
235 assert_eq!(parse_length("1fr"), Some(ParsedLength::Fr(1.0)));
236 assert_eq!(parse_length("2.5fr"), Some(ParsedLength::Fr(2.5)));
237 }
238
239 #[test]
240 fn parse_bare_number_is_px() {
241 assert_eq!(parse_length("16"), Some(ParsedLength::Px(16.0)));
242 }
243
244 #[test]
245 fn parse_auto() {
246 assert_eq!(parse_length("auto"), Some(ParsedLength::Auto));
247 assert_eq!(parse_length("AUTO"), Some(ParsedLength::Auto));
248 }
249
250 #[test]
251 fn parse_invalid() {
252 assert_eq!(parse_length("foo"), None);
253 assert_eq!(parse_length(""), None);
254 }
255
256 #[test]
257 fn resolve_percent_against_parent() {
258 let ctx = LengthContext {
259 parent_size: 200.0,
260 ..Default::default()
261 };
262 let p = ParsedLength::Percent(50.0);
263 assert_eq!(p.resolve(&ctx), Some(100.0));
264 }
265
266 #[test]
267 fn resolve_em_uses_font_size() {
268 let ctx = LengthContext {
269 font_size: 20.0,
270 ..Default::default()
271 };
272 assert_eq!(ParsedLength::Em(1.5).resolve(&ctx), Some(30.0));
273 }
274
275 #[test]
276 fn resolve_vw_uses_viewport() {
277 let ctx = LengthContext {
278 viewport_width: 1000.0,
279 ..Default::default()
280 };
281 assert_eq!(ParsedLength::Vw(50.0).resolve(&ctx), Some(500.0));
282 }
283
284 #[test]
285 fn length_struct_resolves_string() {
286 let l = Length::String("24px".into());
287 assert_eq!(l.resolve(&LengthContext::default()), 24.0);
288 }
289
290 #[test]
291 fn length_struct_resolves_bare_px() {
292 let l = Length::Px(10.0);
293 assert_eq!(l.resolve(&LengthContext::default()), 10.0);
294 }
295
296 #[test]
297 fn length_try_parse_returns_none_for_garbage() {
298 let l = Length::String("not-a-length".into());
299 assert_eq!(l.try_parse(), None);
300 }
301
302 #[test]
303 fn length_percentage_try_parse_returns_none_for_garbage() {
304 let l = LengthPercentage::String("wat".into());
305 assert_eq!(l.try_parse(), None);
306 }
307
308 #[test]
309 fn length_try_parse_agrees_with_parse_on_valid_input() {
310 let l = Length::String("24px".into());
311 assert_eq!(l.try_parse(), Some(ParsedLength::Px(24.0)));
312 assert_eq!(l.parse(), ParsedLength::Px(24.0));
313 }
314
315 #[test]
316 fn length_parse_still_infallible_fallback_for_garbage() {
317 let l = Length::String("not-a-length".into());
318 assert_eq!(l.parse(), ParsedLength::Px(0.0));
319 assert_eq!(l.resolve(&LengthContext::default()), 0.0);
320 }
321
322 #[test]
323 fn length_percentage_parse_still_infallible_fallback_for_garbage() {
324 let l = LengthPercentage::String("wat".into());
325 assert_eq!(l.parse(), ParsedLength::Px(0.0));
326 }
327
328 #[test]
329 fn is_relative_classifies_every_variant() {
330 assert!(!ParsedLength::Px(1.0).is_relative());
331 assert!(ParsedLength::Percent(1.0).is_relative());
332 assert!(ParsedLength::Em(1.0).is_relative());
333 assert!(ParsedLength::Rem(1.0).is_relative());
334 assert!(ParsedLength::Vw(1.0).is_relative());
335 assert!(ParsedLength::Vh(1.0).is_relative());
336 assert!(!ParsedLength::Fr(1.0).is_relative());
337 assert!(!ParsedLength::Auto.is_relative());
338 }
339
340 #[test]
341 fn px_still_resolves_absolute_px_correctly() {
342 assert_eq!(Length::String("24px".into()).px(), 24.0);
343 assert_eq!(Length::Px(10.0).px(), 10.0);
344 assert_eq!(LengthPercentage::String("24px".into()).px(), 24.0);
345 }
346
347 #[test]
348 fn px_falls_back_to_zero_for_every_relative_unit() {
349 for s in ["50%", "1.5em", "2rem", "100vw", "50vh"] {
350 assert_eq!(Length::String(s.into()).px(), 0.0, "Length::px() for {s:?}");
351 assert_eq!(
352 LengthPercentage::String(s.into()).px(),
353 0.0,
354 "LengthPercentage::px() for {s:?}"
355 );
356 assert!(parse_length(s).unwrap().is_relative());
357 }
358 }
359
360 #[test]
361 fn px_does_not_warn_for_auto_or_fr_context_free_use() {
362 assert!(!ParsedLength::Auto.is_relative());
363 assert!(!ParsedLength::Fr(2.0).is_relative());
364 assert_eq!(Length::String("auto".into()).px(), 0.0);
365 }
366}