1use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
15#[serde(untagged)]
16pub enum Length {
17 Px(f32),
19 String(String),
21}
22
23impl Default for Length {
24 fn default() -> Self {
25 Length::Px(0.0)
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
31#[serde(untagged)]
32pub enum LengthPercentage {
33 Px(f32),
34 String(String),
35}
36
37impl Default for LengthPercentage {
38 fn default() -> Self {
39 LengthPercentage::Px(0.0)
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum ParsedLength {
46 Px(f32),
47 Percent(f32),
48 Em(f32),
49 Rem(f32),
50 Vw(f32),
51 Vh(f32),
52 Fr(f32),
53 Auto,
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct LengthContext {
58 pub viewport_width: f32,
59 pub viewport_height: f32,
60 pub parent_size: f32,
61 pub font_size: f32,
62 pub root_font_size: f32,
63}
64
65impl Default for LengthContext {
66 fn default() -> Self {
67 Self {
68 viewport_width: 1920.0,
69 viewport_height: 1080.0,
70 parent_size: 0.0,
71 font_size: 16.0,
72 root_font_size: 16.0,
73 }
74 }
75}
76
77impl ParsedLength {
78 pub fn resolve(&self, ctx: &LengthContext) -> Option<f32> {
80 match self {
81 Self::Px(v) => Some(*v),
82 Self::Percent(v) => Some(*v / 100.0 * ctx.parent_size),
83 Self::Em(v) => Some(*v * ctx.font_size),
84 Self::Rem(v) => Some(*v * ctx.root_font_size),
85 Self::Vw(v) => Some(*v / 100.0 * ctx.viewport_width),
86 Self::Vh(v) => Some(*v / 100.0 * ctx.viewport_height),
87 Self::Fr(_) => None,
88 Self::Auto => None,
89 }
90 }
91
92 pub fn is_relative(&self) -> bool {
100 matches!(
101 self,
102 Self::Percent(_) | Self::Em(_) | Self::Rem(_) | Self::Vw(_) | Self::Vh(_)
103 )
104 }
105}
106
107pub fn parse_origin_component(s: &str) -> Option<ParsedLength> {
114 let lower = s.trim().to_ascii_lowercase();
115 match lower.as_str() {
116 "left" | "top" => return Some(ParsedLength::Percent(0.0)),
117 "center" => return Some(ParsedLength::Percent(50.0)),
118 "right" | "bottom" => return Some(ParsedLength::Percent(100.0)),
119 _ => {}
120 }
121 parse_length(s)
122}
123
124pub fn parse_length(s: &str) -> Option<ParsedLength> {
126 let s = s.trim();
127 if s.eq_ignore_ascii_case("auto") {
128 return Some(ParsedLength::Auto);
129 }
130 if let Ok(n) = s.parse::<f32>() {
131 return Some(ParsedLength::Px(n));
132 }
133 let suffixes: &[(&str, fn(f32) -> ParsedLength)] = &[
134 ("px", ParsedLength::Px),
135 ("%", ParsedLength::Percent),
136 ("em", ParsedLength::Em),
137 ("rem", ParsedLength::Rem),
138 ("vw", ParsedLength::Vw),
139 ("vh", ParsedLength::Vh),
140 ("fr", ParsedLength::Fr),
141 ];
142 for (suf, ctor) in suffixes {
143 if let Some(num_part) = s.strip_suffix(suf) {
144 if let Ok(n) = num_part.trim().parse::<f32>() {
145 return Some(ctor(n));
146 }
147 }
148 }
149 None
150}
151
152impl Length {
153 pub fn parse(&self) -> ParsedLength {
161 match self {
162 Length::Px(v) => ParsedLength::Px(*v),
163 Length::String(s) => parse_length_or_warn(s),
164 }
165 }
166
167 pub fn try_parse(&self) -> Option<ParsedLength> {
172 match self {
173 Length::Px(v) => Some(ParsedLength::Px(*v)),
174 Length::String(s) => parse_length(s),
175 }
176 }
177
178 pub fn resolve(&self, ctx: &LengthContext) -> f32 {
179 self.parse().resolve(ctx).unwrap_or(0.0)
180 }
181
182 pub fn px(&self) -> f32 {
199 px_or_warn(self.parse())
200 }
201}
202
203impl LengthPercentage {
204 pub fn parse(&self) -> ParsedLength {
206 match self {
207 LengthPercentage::Px(v) => ParsedLength::Px(*v),
208 LengthPercentage::String(s) => parse_length_or_warn(s),
209 }
210 }
211
212 pub fn try_parse(&self) -> Option<ParsedLength> {
214 match self {
215 LengthPercentage::Px(v) => Some(ParsedLength::Px(*v)),
216 LengthPercentage::String(s) => parse_length(s),
217 }
218 }
219
220 pub fn resolve(&self, ctx: &LengthContext) -> f32 {
221 self.parse().resolve(ctx).unwrap_or(0.0)
222 }
223
224 pub fn px(&self) -> f32 {
227 px_or_warn(self.parse())
228 }
229}
230
231fn px_or_warn(parsed: ParsedLength) -> f32 {
241 match parsed {
242 ParsedLength::Px(v) => v,
243 other => {
244 if other.is_relative() {
245 eprintln!(
246 "Warning: relative unit {other:?} used where only an absolute px value is \
247 supported (no LengthContext available here) — resolving to 0px instead of \
248 the intended value. Use an absolute px length, or resolve through \
249 LengthContext::resolve() / CssStyle's *_ctx accessors where a context is \
250 available."
251 );
252 }
253 0.0
254 }
255 }
256}
257
258fn parse_length_or_warn(s: &str) -> ParsedLength {
265 match parse_length(s) {
266 Some(p) => p,
267 None => {
268 eprintln!(
269 "Warning: unparseable length '{s}' — falling back to 0px instead of silently \
270 resolving with no signal"
271 );
272 ParsedLength::Px(0.0)
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn parse_px_with_unit() {
283 assert_eq!(parse_length("24px"), Some(ParsedLength::Px(24.0)));
284 assert_eq!(parse_length(" 24 px"), Some(ParsedLength::Px(24.0)));
285 }
286
287 #[test]
288 fn parse_percent() {
289 assert_eq!(parse_length("50%"), Some(ParsedLength::Percent(50.0)));
290 }
291
292 #[test]
293 fn parse_em_rem() {
294 assert_eq!(parse_length("1.5em"), Some(ParsedLength::Em(1.5)));
295 assert_eq!(parse_length("2rem"), Some(ParsedLength::Rem(2.0)));
296 }
297
298 #[test]
299 fn parse_vw_vh() {
300 assert_eq!(parse_length("100vw"), Some(ParsedLength::Vw(100.0)));
301 assert_eq!(parse_length("50vh"), Some(ParsedLength::Vh(50.0)));
302 }
303
304 #[test]
305 fn parse_fr() {
306 assert_eq!(parse_length("1fr"), Some(ParsedLength::Fr(1.0)));
307 assert_eq!(parse_length("2.5fr"), Some(ParsedLength::Fr(2.5)));
308 }
309
310 #[test]
311 fn parse_bare_number_is_px() {
312 assert_eq!(parse_length("16"), Some(ParsedLength::Px(16.0)));
313 }
314
315 #[test]
316 fn parse_auto() {
317 assert_eq!(parse_length("auto"), Some(ParsedLength::Auto));
318 assert_eq!(parse_length("AUTO"), Some(ParsedLength::Auto));
319 }
320
321 #[test]
322 fn parse_invalid() {
323 assert_eq!(parse_length("foo"), None);
324 assert_eq!(parse_length(""), None);
325 }
326
327 #[test]
328 fn resolve_percent_against_parent() {
329 let ctx = LengthContext {
330 parent_size: 200.0,
331 ..Default::default()
332 };
333 let p = ParsedLength::Percent(50.0);
334 assert_eq!(p.resolve(&ctx), Some(100.0));
335 }
336
337 #[test]
338 fn resolve_em_uses_font_size() {
339 let ctx = LengthContext {
340 font_size: 20.0,
341 ..Default::default()
342 };
343 assert_eq!(ParsedLength::Em(1.5).resolve(&ctx), Some(30.0));
344 }
345
346 #[test]
347 fn resolve_vw_uses_viewport() {
348 let ctx = LengthContext {
349 viewport_width: 1000.0,
350 ..Default::default()
351 };
352 assert_eq!(ParsedLength::Vw(50.0).resolve(&ctx), Some(500.0));
353 }
354
355 #[test]
356 fn length_struct_resolves_string() {
357 let l = Length::String("24px".into());
358 assert_eq!(l.resolve(&LengthContext::default()), 24.0);
359 }
360
361 #[test]
362 fn length_struct_resolves_bare_px() {
363 let l = Length::Px(10.0);
364 assert_eq!(l.resolve(&LengthContext::default()), 10.0);
365 }
366
367 #[test]
370 fn length_try_parse_returns_none_for_garbage() {
371 let l = Length::String("not-a-length".into());
372 assert_eq!(l.try_parse(), None);
373 }
374
375 #[test]
376 fn length_percentage_try_parse_returns_none_for_garbage() {
377 let l = LengthPercentage::String("wat".into());
378 assert_eq!(l.try_parse(), None);
379 }
380
381 #[test]
382 fn length_try_parse_agrees_with_parse_on_valid_input() {
383 let l = Length::String("24px".into());
384 assert_eq!(l.try_parse(), Some(ParsedLength::Px(24.0)));
385 assert_eq!(l.parse(), ParsedLength::Px(24.0));
386 }
387
388 #[test]
389 fn length_parse_still_infallible_fallback_for_garbage() {
390 let l = Length::String("not-a-length".into());
396 assert_eq!(l.parse(), ParsedLength::Px(0.0));
397 assert_eq!(l.resolve(&LengthContext::default()), 0.0);
398 }
399
400 #[test]
401 fn length_percentage_parse_still_infallible_fallback_for_garbage() {
402 let l = LengthPercentage::String("wat".into());
403 assert_eq!(l.parse(), ParsedLength::Px(0.0));
404 }
405
406 #[test]
409 fn is_relative_classifies_every_variant() {
410 assert!(!ParsedLength::Px(1.0).is_relative());
411 assert!(ParsedLength::Percent(1.0).is_relative());
412 assert!(ParsedLength::Em(1.0).is_relative());
413 assert!(ParsedLength::Rem(1.0).is_relative());
414 assert!(ParsedLength::Vw(1.0).is_relative());
415 assert!(ParsedLength::Vh(1.0).is_relative());
416 assert!(!ParsedLength::Fr(1.0).is_relative());
417 assert!(!ParsedLength::Auto.is_relative());
418 }
419
420 #[test]
421 fn px_still_resolves_absolute_px_correctly() {
422 assert_eq!(Length::String("24px".into()).px(), 24.0);
423 assert_eq!(Length::Px(10.0).px(), 10.0);
424 assert_eq!(LengthPercentage::String("24px".into()).px(), 24.0);
425 }
426
427 #[test]
437 fn px_falls_back_to_zero_for_every_relative_unit() {
438 for s in ["50%", "1.5em", "2rem", "100vw", "50vh"] {
439 assert_eq!(Length::String(s.into()).px(), 0.0, "Length::px() for {s:?}");
440 assert_eq!(
441 LengthPercentage::String(s.into()).px(),
442 0.0,
443 "LengthPercentage::px() for {s:?}"
444 );
445 assert!(parse_length(s).unwrap().is_relative());
448 }
449 }
450
451 #[test]
452 fn px_does_not_warn_for_auto_or_fr_context_free_use() {
453 assert!(!ParsedLength::Auto.is_relative());
459 assert!(!ParsedLength::Fr(2.0).is_relative());
460 assert_eq!(Length::String("auto".into()).px(), 0.0);
461 }
462}