Skip to main content

rustmotion_core/css/
units.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// A pure CSS length, no percentage allowed (e.g. `font-size`, `box-shadow`).
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6#[serde(untagged)]
7pub enum Length {
8    /// Bare number, treated as pixels.
9    Px(f32),
10    /// String form: `"24px"`, `"1.5em"`, `"100vw"`, `"0"`.
11    String(String),
12}
13
14impl Default for Length {
15    fn default() -> Self {
16        Length::Px(0.0)
17    }
18}
19
20/// A CSS length OR percentage (e.g. `width`, `padding`, `top`).
21#[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}