Skip to main content

rustmotion_core/css/
units.rs

1//! CSS length/percentage units.
2//!
3//! Supports: `px`, `%`, `em`, `rem`, `vw`, `vh`, `fr`, `auto`, plus a bare
4//! number (interpreted as `px`).
5//!
6//! Resolution to absolute pixels happens through [`LengthContext`], which
7//! carries the viewport dimensions, parent size (for `%`), and font sizes
8//! (for `em` / `rem`).
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// A pure CSS length, no percentage allowed (e.g. `font-size`, `box-shadow`).
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
15#[serde(untagged)]
16pub enum Length {
17    /// Bare number, treated as pixels.
18    Px(f32),
19    /// String form: `"24px"`, `"1.5em"`, `"100vw"`, `"0"`.
20    String(String),
21}
22
23impl Default for Length {
24    fn default() -> Self {
25        Length::Px(0.0)
26    }
27}
28
29/// A CSS length OR percentage (e.g. `width`, `padding`, `top`).
30#[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/// Internal parsed representation. Once parsed we know which unit it is.
44#[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    /// Resolve to absolute pixels. Returns `None` for `Auto` / `Fr` (caller decides).
79    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    /// True for units whose absolute pixel value depends on a
93    /// [`LengthContext`] (`%`, `em`, `rem`, `vw`, `vh`) — i.e. everything
94    /// [`Length::px`] / [`LengthPercentage::px`] cannot resolve on their own
95    /// and silently (issue #125 §2) or loudly (post-fix) fall back to `0.0`
96    /// for. `Px` needs no context; `Auto`/`Fr` are not scalar lengths at all
97    /// (the caller decides what they mean) so they are not "relative" in
98    /// this sense.
99    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
107/// Parse a CSS length/percentage string that may also contain transform-origin
108/// axis keywords (`left`, `center`, `right`, `top`, `bottom`).
109///
110/// Keywords are normalised to `ParsedLength::Percent` so that the regular
111/// `resolve` machinery handles them — the caller must still choose the correct
112/// axis dimension (width for x, height for y) in the `LengthContext`.
113pub 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
124/// Parse a CSS length/percentage string. Whitespace tolerated.
125pub 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    /// Parse into a resolved unit. Falls back to `Px(0.0)` on unparseable
154    /// input, for the many existing call sites across the codebase that
155    /// expect an infallible result — but unlike the previous behaviour,
156    /// this now logs a warning so the fallback is a detectable signal
157    /// instead of a silent, indistinguishable zero. Callers that can
158    /// surface the failure themselves (e.g. a validator) should prefer
159    /// [`Length::try_parse`], which returns `None` instead of guessing.
160    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    /// Fallible counterpart of [`Length::parse`]: `None` on unparseable
168    /// input rather than a silent `Px(0.0)`, and never logs. Prefer this
169    /// when the failure can be reported to the caller (e.g. schema
170    /// validation) instead of swallowed.
171    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    /// Quick px resolution without context (treats em/rem/%/vw/vh as 0). Used
183    /// by painters that only need the px value of an explicit length and
184    /// have no [`LengthContext`] available (see [`CssStyle::font_size_px_or`]
185    /// / `letter_spacing_px` / `line_height_for` in `css::style`, all built
186    /// on this — issue #125 §2).
187    ///
188    /// A relative unit here cannot be resolved correctly no matter what:
189    /// `em`/`rem` need a font-size base, `vw`/`vh` need the real viewport,
190    /// `%` needs a parent box — none of which this context-free accessor
191    /// has. Rather than silently returning `0.0` indistinguishably from a
192    /// deliberate `0px` (the previous behaviour — the "15.6vw renders a
193    /// completely black frame with no signal" bug), this now warns loudly
194    /// when the dropped value was a relative unit, same fail-loud contract
195    /// as [`parse_length_or_warn`] uses for genuinely unparseable input.
196    /// Callers with a real `LengthContext` should call [`Length::resolve`]
197    /// instead, which resolves these correctly.
198    pub fn px(&self) -> f32 {
199        px_or_warn(self.parse())
200    }
201}
202
203impl LengthPercentage {
204    /// See [`Length::parse`] — same infallible-with-a-warning contract.
205    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    /// See [`Length::try_parse`] — fallible, silent, `None` on failure.
213    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    /// Quick px resolution without context (treats em/rem/%/vw/vh as 0). See
225    /// [`Length::px`] — same fail-loud contract.
226    pub fn px(&self) -> f32 {
227        px_or_warn(self.parse())
228    }
229}
230
231/// Shared by `Length::px` / `LengthPercentage::px`: return the pixel value
232/// for `Px`, or `0.0` for anything else — but if the anything-else is a
233/// *relative* unit (issue #125 §2: `%`/`em`/`rem`/`vw`/`vh`), warn loudly
234/// first, since silently returning `0.0` here is indistinguishable from a
235/// deliberate `0px` and was the exact defect reported (`font-size: "15.6vw"`
236/// rendering a fully black frame with `render` exiting 0). `Auto`/`Fr`
237/// aren't scalar lengths (the caller decides what they mean), so they don't
238/// warn — a context-free accessor asking for their "px value" is a caller
239/// bug, not a units bug, and predates this fix.
240fn 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
258/// Shared fallback used by both `Length::parse` and `LengthPercentage::parse`:
259/// parse `s`, or log a warning and return `Px(0.0)` if it doesn't match any
260/// known length form. This is what closes the "silently fold into 0px with
261/// no signal" gap — the previous `unwrap_or(ParsedLength::Px(0.0))` produced
262/// an indistinguishable, unreported zero for a typo'd unit exactly like a
263/// deliberate `0px`.
264fn 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    // ---- unparseable length: detectable signal, not a silent 0px ----
368
369    #[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        // `.parse()` keeps its infallible signature (existing call sites
391        // across the codebase depend on it) — it still resolves to 0px for
392        // unparseable input, but `try_parse` above proves the failure is
393        // now independently detectable rather than indistinguishable from
394        // a deliberate `0px`.
395        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    // ---- issue #125 §2: relative units on the context-free `.px()` path ----
407
408    #[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    /// `.px()` cannot correctly resolve relative units (no context is
428    /// available at this call site) — this locks in that it still falls
429    /// back to `0.0` for them post-fix, same numeric behaviour as before.
430    /// What changed is that this fallback is now loud (see
431    /// `px_or_warn`/`is_relative`): distinguishing "the value really is
432    /// relative, and got dropped" from "the value was genuinely `0px`" is
433    /// exactly what `is_relative` (tested above) exists to let a caller —
434    /// or the `px_or_warn` eprintln — detect, since asserting on stderr
435    /// text isn't practical from a unit test.
436    #[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            // And `is_relative` on the same parsed value proves *why*: a
446            // caller (or px_or_warn) can tell this apart from a real 0px.
447            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        // Auto/Fr aren't scalar lengths — a context-free `.px()` call on
454        // them is a caller bug predating this fix, not a relative-unit
455        // silent-zero. They fall back to 0.0 too, but `is_relative` is
456        // false for both so `px_or_warn` does not treat them as the
457        // issue #125 §2 case.
458        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}