Skip to main content

alpha2_model/
layout_values.rs

1//! The CSS value forms a layout reads: lengths, the four-side shorthand, grid track lists and
2//! grid lines.
3//!
4//! A JSON number is a fraction of the picture's shorter side. A string is CSS: `"12px"` is
5//! project canvas pixels, `"50%"` a percent of the parent's content box where allowed.
6//! Keywords inside these strings use CSS spelling (`min-content`, `auto-fill`); elsewhere
7//! layout keywords are `snake_case`. Whitespace is ASCII spaces. Unparsable strings are
8//! refused with the offending token and, where likely, the intended spelling.
9//!
10//! Each form serializes canonically (a number, `"<n>px"`, `"<n>%"`, the sides object, a track
11//! list as CSS) and round-trips.
12
13use serde::de::{self, Deserializer, MapAccess, Visitor};
14use serde::ser::{SerializeStruct, Serializer};
15use serde::{Deserialize, Serialize};
16use std::fmt::{self, Write as _};
17
18/// A CSS number without an exponent, `-?(\d+\.?\d*|\.\d+)`, finite. Read through 64 bits as
19/// a JSON number is, so `"0.1px"` holds the same `f32` as `0.1`.
20pub(crate) fn parse_number(text: &str) -> Option<f32> {
21    let digits = text.strip_prefix('-').unwrap_or(text);
22    let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
23    let all_digits = |part: &str| part.bytes().all(|byte| byte.is_ascii_digit());
24    if !(all_digits(whole) && all_digits(fraction)) || (whole.is_empty() && fraction.is_empty()) {
25        return None;
26    }
27    #[allow(clippy::cast_possible_truncation)]
28    let value = text.parse::<f64>().ok()? as f32;
29    value.is_finite().then_some(value)
30}
31
32fn tokens(text: &str) -> impl Iterator<Item = &str> {
33    text.split(' ').filter(|token| !token.is_empty())
34}
35
36fn write_length(out: &mut impl fmt::Write, value: f32, unit: &str) -> fmt::Result {
37    write!(out, "{value}{unit}")
38}
39
40/// A layout length that may be a percent: a size or an inset side.
41#[derive(Copy, Clone, Debug, PartialEq)]
42pub enum LayoutLength {
43    /// A fraction of the layout picture's shorter side, written as a number.
44    Fraction(f32),
45    /// Pixels of the project canvas, written `"<n>px"`. A plan converts it to a fraction of the
46    /// canvas's shorter side.
47    Px(f32),
48    /// Percent (0 to 100) of the parent's content box along the field's axis, written `"<n>%"`.
49    Percent(f32),
50}
51
52impl Default for LayoutLength {
53    fn default() -> Self {
54        Self::Fraction(0.0)
55    }
56}
57
58impl LayoutLength {
59    /// The number, whatever its unit.
60    #[must_use]
61    pub const fn value(self) -> f32 {
62        match self {
63            Self::Fraction(value) | Self::Px(value) | Self::Percent(value) => value,
64        }
65    }
66
67    /// Whether it is a percent.
68    #[must_use]
69    pub const fn is_percent(self) -> bool {
70        matches!(self, Self::Percent(_))
71    }
72
73    /// The same length with pixels converted to a fraction of a picture `shorter_side` pixels on
74    /// its shorter side.
75    #[must_use]
76    pub fn without_px(self, shorter_side: f32) -> Self {
77        match self {
78            Self::Px(value) => Self::Fraction(px_fraction(value, shorter_side)),
79            other => other,
80        }
81    }
82
83    /// The same kind of length with `value`.
84    #[must_use]
85    pub const fn with_value(self, value: f32) -> Self {
86        match self {
87            Self::Fraction(_) => Self::Fraction(value),
88            Self::Px(_) => Self::Px(value),
89            Self::Percent(_) => Self::Percent(value),
90        }
91    }
92
93    fn parse(text: &str) -> Result<Self, String> {
94        let size = |value: LayoutSize| match value {
95            LayoutSize::Fraction(value) => Self::Fraction(value),
96            LayoutSize::Px(value) => Self::Px(value),
97        };
98        match text.trim_matches(' ').strip_suffix('%') {
99            Some(number) => parse_number(number)
100                .map(Self::Percent)
101                .ok_or_else(|| invalid_length(text, true)),
102            None => LayoutSize::parse_token(text.trim_matches(' '), true)
103                .map(size)
104                .map_err(|_| invalid_length(text, true)),
105        }
106    }
107}
108
109/// `n` canvas pixels as a fraction of a shorter side of `shorter_side` pixels.
110#[allow(clippy::cast_possible_truncation)]
111fn px_fraction(value: f32, shorter_side: f32) -> f32 {
112    (f64::from(value) / f64::from(shorter_side)) as f32
113}
114
115impl fmt::Display for LayoutLength {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match *self {
118            Self::Fraction(value) => write!(formatter, "{value}"),
119            Self::Px(value) => write_length(formatter, value, "px"),
120            Self::Percent(value) => write_length(formatter, value, "%"),
121        }
122    }
123}
124
125/// A layout length that may not be a percent: a gap, radius, border width, font size,
126/// padding or margin side, or translation.
127#[derive(Copy, Clone, Debug, PartialEq)]
128pub enum LayoutSize {
129    /// A fraction of the layout picture's shorter side, written as a number.
130    Fraction(f32),
131    /// Pixels of the project canvas, written `"<n>px"`.
132    Px(f32),
133}
134
135impl Default for LayoutSize {
136    fn default() -> Self {
137        Self::Fraction(0.0)
138    }
139}
140
141impl LayoutSize {
142    /// The number, whatever its unit.
143    #[must_use]
144    pub const fn value(self) -> f32 {
145        match self {
146            Self::Fraction(value) | Self::Px(value) => value,
147        }
148    }
149
150    /// The same size with pixels converted to a fraction of a picture `shorter_side` pixels on
151    /// its shorter side.
152    #[must_use]
153    pub fn without_px(self, shorter_side: f32) -> Self {
154        match self {
155            Self::Px(value) => Self::Fraction(px_fraction(value, shorter_side)),
156            other @ Self::Fraction(_) => other,
157        }
158    }
159
160    /// Its fraction of a picture `shorter_side` pixels on its shorter side.
161    #[must_use]
162    pub fn fraction(self, shorter_side: f32) -> f32 {
163        self.without_px(shorter_side).value()
164    }
165
166    /// The same kind of size with `value`.
167    #[must_use]
168    pub const fn with_value(self, value: f32) -> Self {
169        match self {
170            Self::Fraction(_) => Self::Fraction(value),
171            Self::Px(_) => Self::Px(value),
172        }
173    }
174
175    /// One token: a number or `<n>px`. `percent` says whether the field takes a percent, for the
176    /// error message.
177    fn parse_token(token: &str, percent: bool) -> Result<Self, String> {
178        if let Some(number) = token.strip_suffix("px") {
179            return parse_number(number)
180                .map(Self::Px)
181                .ok_or_else(|| invalid_length(token, percent));
182        }
183        if !percent && token.ends_with('%') && parse_number(&token[..token.len() - 1]).is_some() {
184            return Err(format!("percent is not allowed here: \"{token}\""));
185        }
186        parse_number(token)
187            .map(Self::Fraction)
188            .ok_or_else(|| invalid_length(token, percent))
189    }
190
191    fn parse(text: &str) -> Result<Self, String> {
192        Self::parse_token(text.trim_matches(' '), false)
193    }
194}
195
196impl fmt::Display for LayoutSize {
197    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
198        match *self {
199            Self::Fraction(value) => write!(formatter, "{value}"),
200            Self::Px(value) => write_length(formatter, value, "px"),
201        }
202    }
203}
204
205fn invalid_length(token: &str, percent: bool) -> String {
206    let hint = if token.contains(' ') {
207        format!(" (write {})", token.replace(' ', ""))
208    } else if token.ends_with("em") {
209        " (em and rem are not supported: write px or a number)".to_owned()
210    } else {
211        String::new()
212    };
213    let forms = if percent {
214        "a number, \"<n>px\", or \"<n>%\""
215    } else {
216        "a number or \"<n>px\""
217    };
218    format!("invalid length \"{token}\"{hint}: write {forms}")
219}
220
221/// A visitor for a value written as a number or a string.
222struct NumberOrString<T> {
223    expecting: &'static str,
224    number: fn(f32) -> T,
225    string: fn(&str) -> Result<T, String>,
226}
227
228impl<T> Visitor<'_> for NumberOrString<T> {
229    type Value = T;
230
231    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        formatter.write_str(self.expecting)
233    }
234
235    #[allow(clippy::cast_possible_truncation)]
236    fn visit_f64<E: de::Error>(self, value: f64) -> Result<T, E> {
237        Ok((self.number)(value as f32))
238    }
239
240    #[allow(clippy::cast_precision_loss)]
241    fn visit_u64<E: de::Error>(self, value: u64) -> Result<T, E> {
242        Ok((self.number)(value as f32))
243    }
244
245    #[allow(clippy::cast_precision_loss)]
246    fn visit_i64<E: de::Error>(self, value: i64) -> Result<T, E> {
247        Ok((self.number)(value as f32))
248    }
249
250    fn visit_str<E: de::Error>(self, value: &str) -> Result<T, E> {
251        (self.string)(value).map_err(E::custom)
252    }
253}
254
255impl<'de> Deserialize<'de> for LayoutLength {
256    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
257        deserializer.deserialize_any(NumberOrString {
258            expecting: "a length: a number, \"<n>px\", or \"<n>%\"",
259            number: Self::Fraction,
260            string: Self::parse,
261        })
262    }
263}
264
265impl Serialize for LayoutLength {
266    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
267        match self {
268            Self::Fraction(value) => serializer.serialize_f32(*value),
269            other => serializer.collect_str(other),
270        }
271    }
272}
273
274impl<'de> Deserialize<'de> for LayoutSize {
275    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
276        deserializer.deserialize_any(NumberOrString {
277            expecting: "a length: a number or \"<n>px\"",
278            number: Self::Fraction,
279            string: Self::parse,
280        })
281    }
282}
283
284impl Serialize for LayoutSize {
285    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
286        match self {
287            Self::Fraction(value) => serializer.serialize_f32(*value),
288            other @ Self::Px(_) => serializer.collect_str(other),
289        }
290    }
291}
292
293/// A length on each side of a box (padding or margin). Missing sides are 0.
294///
295/// Written as one length, as the CSS shorthand (`"8px 16px"`: one to four lengths, top first,
296/// clockwise, missing ones repeating the opposite side), or as the sides object.
297#[derive(Copy, Clone, Debug, Default, PartialEq)]
298pub struct Insets {
299    /// Top side.
300    pub top: LayoutSize,
301    /// Right side.
302    pub right: LayoutSize,
303    /// Bottom side.
304    pub bottom: LayoutSize,
305    /// Left side.
306    pub left: LayoutSize,
307}
308
309impl Insets {
310    /// The sides, top first, clockwise.
311    #[must_use]
312    pub const fn sides(self) -> [LayoutSize; 4] {
313        [self.top, self.right, self.bottom, self.left]
314    }
315
316    /// Insets from sides, top first, clockwise.
317    #[must_use]
318    pub const fn from_sides([top, right, bottom, left]: [LayoutSize; 4]) -> Self {
319        Self {
320            top,
321            right,
322            bottom,
323            left,
324        }
325    }
326
327    /// The same insets with pixels converted to fractions.
328    #[must_use]
329    pub fn without_px(self, shorter_side: f32) -> Self {
330        Self::from_sides(self.sides().map(|side| side.without_px(shorter_side)))
331    }
332
333    fn parse(text: &str) -> Result<Self, String> {
334        let sides = tokens(text)
335            .map(|token| LayoutSize::parse_token(token, false))
336            .collect::<Result<Vec<_>, _>>()?;
337        let [top, right, bottom, left] = shorthand(&sides)?;
338        Ok(Self {
339            top,
340            right,
341            bottom,
342            left,
343        })
344    }
345}
346
347/// The four sides of a CSS shorthand of one to four values.
348fn shorthand<T: Copy>(values: &[T]) -> Result<[T; 4], String> {
349    match *values {
350        [all] => Ok([all; 4]),
351        [vertical, horizontal] => Ok([vertical, horizontal, vertical, horizontal]),
352        [top, horizontal, bottom] => Ok([top, horizontal, bottom, horizontal]),
353        [top, right, bottom, left] => Ok([top, right, bottom, left]),
354        _ => Err(format!("expected 1 to 4 lengths, got {}", values.len())),
355    }
356}
357
358#[derive(Deserialize)]
359#[serde(deny_unknown_fields)]
360struct InsetsObject {
361    #[serde(default)]
362    top: LayoutSize,
363    #[serde(default)]
364    right: LayoutSize,
365    #[serde(default)]
366    bottom: LayoutSize,
367    #[serde(default)]
368    left: LayoutSize,
369}
370
371struct InsetsVisitor;
372
373impl<'de> Visitor<'de> for InsetsVisitor {
374    type Value = Insets;
375
376    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
377        formatter.write_str("insets: a length, one to four lengths, or the sides object")
378    }
379
380    #[allow(clippy::cast_possible_truncation)]
381    fn visit_f64<E: de::Error>(self, value: f64) -> Result<Insets, E> {
382        Ok(Insets::from_sides([LayoutSize::Fraction(value as f32); 4]))
383    }
384
385    #[allow(clippy::cast_precision_loss)]
386    fn visit_u64<E: de::Error>(self, value: u64) -> Result<Insets, E> {
387        Ok(Insets::from_sides([LayoutSize::Fraction(value as f32); 4]))
388    }
389
390    #[allow(clippy::cast_precision_loss)]
391    fn visit_i64<E: de::Error>(self, value: i64) -> Result<Insets, E> {
392        Ok(Insets::from_sides([LayoutSize::Fraction(value as f32); 4]))
393    }
394
395    fn visit_str<E: de::Error>(self, value: &str) -> Result<Insets, E> {
396        Insets::parse(value).map_err(E::custom)
397    }
398
399    fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Insets, A::Error> {
400        let sides = InsetsObject::deserialize(de::value::MapAccessDeserializer::new(map))?;
401        Ok(Insets {
402            top: sides.top,
403            right: sides.right,
404            bottom: sides.bottom,
405            left: sides.left,
406        })
407    }
408}
409
410impl<'de> Deserialize<'de> for Insets {
411    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
412        deserializer.deserialize_any(InsetsVisitor)
413    }
414}
415
416impl Serialize for Insets {
417    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
418        let mut sides = serializer.serialize_struct("Insets", 4)?;
419        sides.serialize_field("top", &self.top)?;
420        sides.serialize_field("right", &self.right)?;
421        sides.serialize_field("bottom", &self.bottom)?;
422        sides.serialize_field("left", &self.left)?;
423        sides.end()
424    }
425}
426
427/// A box's offset after layout: `x` right, `y` down.
428///
429/// Written as the object, or as the CSS `translate` value: one or two lengths, `y` 0 when
430/// missing.
431#[derive(Copy, Clone, Debug, Default, PartialEq)]
432pub struct LayoutOffset {
433    /// Rightward.
434    pub x: LayoutSize,
435    /// Downward.
436    pub y: LayoutSize,
437}
438
439impl LayoutOffset {
440    /// The same offset with pixels converted to fractions.
441    #[must_use]
442    pub fn without_px(self, shorter_side: f32) -> Self {
443        Self {
444            x: self.x.without_px(shorter_side),
445            y: self.y.without_px(shorter_side),
446        }
447    }
448
449    fn parse(text: &str) -> Result<Self, String> {
450        let values = tokens(text)
451            .map(|token| LayoutSize::parse_token(token, false))
452            .collect::<Result<Vec<_>, _>>()?;
453        match *values {
454            [x] => Ok(Self {
455                x,
456                y: LayoutSize::default(),
457            }),
458            [x, y] => Ok(Self { x, y }),
459            _ => Err(format!("expected 1 or 2 lengths, got {}", values.len())),
460        }
461    }
462}
463
464#[derive(Deserialize)]
465#[serde(deny_unknown_fields)]
466struct OffsetObject {
467    x: LayoutSize,
468    y: LayoutSize,
469}
470
471struct OffsetVisitor;
472
473impl<'de> Visitor<'de> for OffsetVisitor {
474    type Value = LayoutOffset;
475
476    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
477        formatter.write_str("an offset: one or two lengths, or {x, y}")
478    }
479
480    fn visit_str<E: de::Error>(self, value: &str) -> Result<LayoutOffset, E> {
481        LayoutOffset::parse(value).map_err(E::custom)
482    }
483
484    fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<LayoutOffset, A::Error> {
485        let OffsetObject { x, y } =
486            OffsetObject::deserialize(de::value::MapAccessDeserializer::new(map))?;
487        Ok(LayoutOffset { x, y })
488    }
489}
490
491impl<'de> Deserialize<'de> for LayoutOffset {
492    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
493        deserializer.deserialize_any(OffsetVisitor)
494    }
495}
496
497impl Serialize for LayoutOffset {
498    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
499        let mut offset = serializer.serialize_struct("LayoutOffset", 2)?;
500        offset.serialize_field("x", &self.x)?;
501        offset.serialize_field("y", &self.y)?;
502        offset.end()
503    }
504}
505
506/// One side of `minmax()`: how large a grid track may be at least or at most.
507#[derive(Copy, Clone, Debug, PartialEq)]
508pub enum TrackBreadth {
509    /// A length or a percent of the grid's content box.
510    Length(LayoutLength),
511    /// A share of the free space, `<n>fr`; only as the largest size.
512    Fr(f32),
513    /// `auto`.
514    Auto,
515    /// `min-content`.
516    MinContent,
517    /// `max-content`.
518    MaxContent,
519}
520
521impl TrackBreadth {
522    const fn is_fixed(self) -> bool {
523        matches!(self, Self::Length(_))
524    }
525
526    fn without_px(self, shorter_side: f32) -> Self {
527        match self {
528            Self::Length(length) => Self::Length(length.without_px(shorter_side)),
529            other => other,
530        }
531    }
532
533    fn parse(token: &str, max: bool) -> Result<Self, String> {
534        match token {
535            "auto" => Ok(Self::Auto),
536            "min-content" => Ok(Self::MinContent),
537            "max-content" => Ok(Self::MaxContent),
538            _ => {
539                if let Some(share) = token.strip_suffix("fr").and_then(parse_number) {
540                    return if max {
541                        Ok(Self::Fr(share))
542                    } else {
543                        Err(format!("a minimum size cannot be fr: \"{token}\""))
544                    };
545                }
546                LayoutLength::parse(token)
547                    .map(Self::Length)
548                    .map_err(|_| unknown_track(token))
549            }
550        }
551    }
552}
553
554impl fmt::Display for TrackBreadth {
555    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
556        match self {
557            Self::Length(length) => write!(formatter, "{length}"),
558            Self::Fr(share) => write_length(formatter, *share, "fr"),
559            Self::Auto => formatter.write_str("auto"),
560            Self::MinContent => formatter.write_str("min-content"),
561            Self::MaxContent => formatter.write_str("max-content"),
562        }
563    }
564}
565
566/// The size of one grid track.
567#[derive(Copy, Clone, Debug, PartialEq)]
568pub enum TrackSize {
569    /// One breadth, as CSS reads it: a length is fixed, `<n>fr` is `minmax(auto, <n>fr)`, a
570    /// keyword sizes by content.
571    Breadth(TrackBreadth),
572    /// `minmax(min, max)`.
573    MinMax(TrackBreadth, TrackBreadth),
574    /// `fit-content(limit)`.
575    FitContent(LayoutLength),
576}
577
578impl TrackSize {
579    /// Whether it is a CSS fixed size, as `auto-fill` and `auto-fit` need: a length, or
580    /// `minmax()` with a length on one side and no flexible or content minimum.
581    #[must_use]
582    pub const fn is_fixed(self) -> bool {
583        match self {
584            Self::Breadth(breadth) => breadth.is_fixed(),
585            Self::MinMax(min, max) => {
586                min.is_fixed() || (max.is_fixed() && !matches!(min, TrackBreadth::Fr(_)))
587            }
588            Self::FitContent(_) => false,
589        }
590    }
591
592    fn without_px(self, shorter_side: f32) -> Self {
593        match self {
594            Self::Breadth(breadth) => Self::Breadth(breadth.without_px(shorter_side)),
595            Self::MinMax(min, max) => {
596                Self::MinMax(min.without_px(shorter_side), max.without_px(shorter_side))
597            }
598            Self::FitContent(limit) => Self::FitContent(limit.without_px(shorter_side)),
599        }
600    }
601
602    fn lengths(self) -> impl Iterator<Item = LayoutLength> {
603        let breadth = |breadth: TrackBreadth| match breadth {
604            TrackBreadth::Length(length) => Some(length),
605            _ => None,
606        };
607        let (first, second) = match self {
608            Self::Breadth(single) => (breadth(single), None),
609            Self::MinMax(min, max) => (breadth(min), breadth(max)),
610            Self::FitContent(limit) => (Some(limit), None),
611        };
612        first.into_iter().chain(second)
613    }
614
615    fn fr(self) -> Option<f32> {
616        match self {
617            Self::Breadth(TrackBreadth::Fr(share)) | Self::MinMax(_, TrackBreadth::Fr(share)) => {
618                Some(share)
619            }
620            _ => None,
621        }
622    }
623}
624
625impl fmt::Display for TrackSize {
626    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
627        match self {
628            Self::Breadth(breadth) => write!(formatter, "{breadth}"),
629            Self::MinMax(min, max) => write!(formatter, "minmax({min}, {max})"),
630            Self::FitContent(limit) => write!(formatter, "fit-content({limit})"),
631        }
632    }
633}
634
635/// How often `repeat()` repeats its tracks.
636#[derive(Copy, Clone, Debug, Eq, PartialEq)]
637pub enum RepeatCount {
638    /// That many times.
639    Count(u16),
640    /// `auto-fill`: as many as fit.
641    AutoFill,
642    /// `auto-fit`: as many as fit, empty ones collapsed.
643    AutoFit,
644}
645
646impl fmt::Display for RepeatCount {
647    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
648        match self {
649            Self::Count(count) => write!(formatter, "{count}"),
650            Self::AutoFill => formatter.write_str("auto-fill"),
651            Self::AutoFit => formatter.write_str("auto-fit"),
652        }
653    }
654}
655
656/// One entry of a track list: a track, or `repeat()` of some.
657#[derive(Clone, Debug, PartialEq)]
658pub enum TrackComponent {
659    /// One track.
660    Single(TrackSize),
661    /// `repeat(count, tracks)`.
662    Repeat(RepeatCount, Vec<TrackSize>),
663}
664
665/// A grid track list as CSS writes it: `"1fr 2fr 120px"`, `"repeat(3, minmax(100px, 1fr))"`,
666/// `"repeat(auto-fill, 160px)"`. Named lines are not supported.
667#[derive(Clone, Debug, Default, PartialEq)]
668pub struct TrackList(pub Vec<TrackComponent>);
669
670impl TrackList {
671    /// The explicit track count, with each repeat count applied (an automatic repeat counts
672    /// once).
673    #[must_use]
674    pub fn track_count(&self) -> usize {
675        self.0
676            .iter()
677            .map(|component| match component {
678                TrackComponent::Single(_) => 1,
679                TrackComponent::Repeat(RepeatCount::Count(count), tracks) => {
680                    usize::from(*count) * tracks.len()
681                }
682                TrackComponent::Repeat(_, tracks) => tracks.len(),
683            })
684            .sum()
685    }
686
687    /// Every track size, once each, repeats included once.
688    pub fn sizes(&self) -> impl Iterator<Item = TrackSize> + '_ {
689        self.0.iter().flat_map(|component| match component {
690            TrackComponent::Single(size) => std::slice::from_ref(size).iter().copied(),
691            TrackComponent::Repeat(_, tracks) => tracks.iter().copied(),
692        })
693    }
694
695    /// Every length in it, for range checks.
696    pub fn lengths(&self) -> impl Iterator<Item = LayoutLength> + '_ {
697        self.sizes().flat_map(TrackSize::lengths)
698    }
699
700    /// Every `fr` share in it.
701    pub fn shares(&self) -> impl Iterator<Item = f32> + '_ {
702        self.sizes().filter_map(TrackSize::fr)
703    }
704
705    /// The same list with pixels converted to fractions.
706    #[must_use]
707    pub fn without_px(&self, shorter_side: f32) -> Self {
708        Self(
709            self.0
710                .iter()
711                .map(|component| match component {
712                    TrackComponent::Single(size) => {
713                        TrackComponent::Single(size.without_px(shorter_side))
714                    }
715                    TrackComponent::Repeat(count, tracks) => TrackComponent::Repeat(
716                        *count,
717                        tracks
718                            .iter()
719                            .map(|size| size.without_px(shorter_side))
720                            .collect(),
721                    ),
722                })
723                .collect(),
724        )
725    }
726
727    fn parse(text: &str) -> Result<Self, String> {
728        let mut parser = TrackParser { text, at: 0 };
729        let mut components = Vec::new();
730        while parser.skip_spaces() {
731            if parser.rest().starts_with("repeat(") {
732                parser.at += "repeat(".len();
733                components.push(parser.repeat()?);
734            } else {
735                components.push(TrackComponent::Single(parser.track()?));
736            }
737        }
738        if components.is_empty() {
739            return Err("expected at least one track".to_owned());
740        }
741        Ok(Self(components))
742    }
743}
744
745impl fmt::Display for TrackList {
746    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
747        for (index, component) in self.0.iter().enumerate() {
748            if index > 0 {
749                formatter.write_char(' ')?;
750            }
751            match component {
752                TrackComponent::Single(size) => write!(formatter, "{size}")?,
753                TrackComponent::Repeat(count, tracks) => {
754                    write!(formatter, "repeat({count},")?;
755                    for size in tracks {
756                        write!(formatter, " {size}")?;
757                    }
758                    formatter.write_char(')')?;
759                }
760            }
761        }
762        Ok(())
763    }
764}
765
766/// The message for a track that is not one, with the spelling likely meant.
767fn unknown_track(token: &str) -> String {
768    let hint = match token {
769        "min_content" => Some("min-content".to_owned()),
770        "max_content" => Some("max-content".to_owned()),
771        "auto_fill" => Some("auto-fill".to_owned()),
772        "auto_fit" => Some("auto-fit".to_owned()),
773        _ if token.starts_with('[') => {
774            return format!("named grid lines are not supported: \"{token}\"");
775        }
776        _ if token.contains(' ') => Some(token.replace(' ', "")),
777        _ => None,
778    };
779    match hint {
780        Some(hint) => format!("unknown track \"{token}\" (write {hint})"),
781        None => format!("unknown track \"{token}\""),
782    }
783}
784
785struct TrackParser<'a> {
786    text: &'a str,
787    at: usize,
788}
789
790impl<'a> TrackParser<'a> {
791    fn rest(&self) -> &'a str {
792        &self.text[self.at..]
793    }
794
795    /// Skips spaces; whether anything is left.
796    fn skip_spaces(&mut self) -> bool {
797        while self.rest().starts_with(' ') {
798            self.at += 1;
799        }
800        !self.rest().is_empty()
801    }
802
803    /// The next token: up to a space, a comma or a parenthesis.
804    fn token(&mut self) -> &'a str {
805        let rest = &self.text[self.at..];
806        let end = rest.find([' ', ',', '(', ')']).unwrap_or(rest.len());
807        self.at += end;
808        &rest[..end]
809    }
810
811    /// `expected` after optional spaces.
812    fn expect(&mut self, expected: char) -> Result<(), String> {
813        self.skip_spaces();
814        if self.rest().starts_with(expected) {
815            self.at += 1;
816            Ok(())
817        } else {
818            Err(format!("expected \"{expected}\" in \"{}\"", self.text))
819        }
820    }
821
822    fn track(&mut self) -> Result<TrackSize, String> {
823        let start = self.at;
824        let token = self.token();
825        match token {
826            "minmax" | "fit-content" if self.rest().starts_with('(') => {
827                self.at += 1;
828                if token == "fit-content" {
829                    let limit = self.breadth(false)?;
830                    self.expect(')')?;
831                    return match limit {
832                        TrackBreadth::Length(limit) => Ok(TrackSize::FitContent(limit)),
833                        _ => Err(format!(
834                            "fit-content takes a length: \"{}\"",
835                            &self.text[start..self.at]
836                        )),
837                    };
838                }
839                let min = self.breadth(false)?;
840                self.expect(',')?;
841                let max = self.breadth(true)?;
842                self.expect(')')?;
843                Ok(TrackSize::MinMax(min, max))
844            }
845            "" => Err(format!("expected a track in \"{}\"", self.text)),
846            "fit_content" | "min_max" => Err(format!(
847                "unknown track \"{token}\" (write {})",
848                token.replace('_', "-").replace("min-max", "minmax")
849            )),
850            _ => {
851                // A unit standing apart from its number: "1 fr".
852                let after = self.at;
853                if parse_number(token).is_some() && self.skip_spaces() {
854                    let mark = self.at;
855                    let unit = self.token();
856                    if matches!(unit, "fr" | "px" | "%") {
857                        return Err(unknown_track(&self.text[start..self.at]));
858                    }
859                    self.at = mark;
860                }
861                self.at = after;
862                TrackBreadth::parse(token, true).map(TrackSize::Breadth)
863            }
864        }
865    }
866
867    fn breadth(&mut self, max: bool) -> Result<TrackBreadth, String> {
868        self.skip_spaces();
869        let token = self.token();
870        if token.is_empty() {
871            return Err(format!("expected a track size in \"{}\"", self.text));
872        }
873        TrackBreadth::parse(token, max)
874    }
875
876    fn repeat(&mut self) -> Result<TrackComponent, String> {
877        self.skip_spaces();
878        let token = self.token();
879        let count = match token {
880            "auto-fill" => RepeatCount::AutoFill,
881            "auto-fit" => RepeatCount::AutoFit,
882            "auto_fill" | "auto_fit" => {
883                return Err(format!(
884                    "unknown repeat count \"{token}\" (write {})",
885                    token.replace('_', "-")
886                ));
887            }
888            _ => token
889                .parse::<u16>()
890                .ok()
891                .filter(|_| token.bytes().all(|byte| byte.is_ascii_digit()))
892                .map(RepeatCount::Count)
893                .ok_or_else(|| format!("unknown repeat count \"{token}\""))?,
894        };
895        self.expect(',')?;
896        let mut tracks = Vec::new();
897        loop {
898            if !self.skip_spaces() {
899                return Err(format!("expected \")\" in \"{}\"", self.text));
900            }
901            if self.rest().starts_with(')') {
902                self.at += 1;
903                break;
904            }
905            if self.rest().starts_with("repeat(") {
906                return Err("repeat() cannot hold repeat()".to_owned());
907            }
908            tracks.push(self.track()?);
909        }
910        if tracks.is_empty() {
911            return Err("repeat() needs at least one track".to_owned());
912        }
913        Ok(TrackComponent::Repeat(count, tracks))
914    }
915}
916
917impl<'de> Deserialize<'de> for TrackList {
918    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
919        let text = String::deserialize(deserializer)?;
920        Self::parse(&text).map_err(de::Error::custom)
921    }
922}
923
924impl Serialize for TrackList {
925    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
926        serializer.collect_str(self)
927    }
928}
929
930/// One edge of a grid item's placement.
931#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
932pub enum GridEdge {
933    /// Placed automatically.
934    #[default]
935    Auto,
936    /// At a line: 1 is the first, -1 the last explicit line.
937    Line(i16),
938    /// So many tracks from the other edge.
939    Span(u16),
940}
941
942impl GridEdge {
943    fn parse(text: &str) -> Result<Self, String> {
944        let words = tokens(text).collect::<Vec<_>>();
945        let integer = |word: &str| {
946            let digits = word.strip_prefix('-').unwrap_or(word);
947            (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
948                .then(|| word.parse::<i16>().ok())
949                .flatten()
950        };
951        match *words {
952            ["auto"] => Ok(Self::Auto),
953            ["span", count] => integer(count)
954                .and_then(|count| u16::try_from(count).ok())
955                .map(Self::Span)
956                .ok_or_else(|| invalid_line(text)),
957            [line] => integer(line)
958                .map(Self::Line)
959                .ok_or_else(|| invalid_line(text)),
960            _ => Err(invalid_line(text)),
961        }
962    }
963}
964
965fn invalid_line(text: &str) -> String {
966    let text = text.trim_matches(' ');
967    let named = text
968        .split([' ', '/'])
969        .filter(|word| !word.is_empty())
970        .any(|word| {
971            word != "span"
972                && word != "auto"
973                && word
974                    .bytes()
975                    .next()
976                    .is_some_and(|byte| byte.is_ascii_alphabetic())
977        });
978    if named {
979        format!("named grid lines are not supported: \"{text}\"")
980    } else {
981        format!("invalid grid line \"{text}\": write a line number, \"span <n>\", or \"auto\"")
982    }
983}
984
985impl fmt::Display for GridEdge {
986    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
987        match self {
988            Self::Auto => formatter.write_str("auto"),
989            Self::Line(line) => write!(formatter, "{line}"),
990            Self::Span(count) => write!(formatter, "span {count}"),
991        }
992    }
993}
994
995/// Where a grid item lies on one axis, as CSS `grid_column` writes it: `2`, `"1 / 3"`,
996/// `"span 2"`, `"2 / span 3"`, `"auto"`. A missing end is `auto`.
997#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
998pub struct GridPlacement {
999    /// The start edge.
1000    pub start: GridEdge,
1001    /// The end edge.
1002    pub end: GridEdge,
1003}
1004
1005impl GridPlacement {
1006    /// Every edge of it.
1007    #[must_use]
1008    pub const fn edges(self) -> [GridEdge; 2] {
1009        [self.start, self.end]
1010    }
1011
1012    fn parse(text: &str) -> Result<Self, String> {
1013        let (start, end) = match text.split_once('/') {
1014            Some((start, end)) => (start, Some(end)),
1015            None => (text, None),
1016        };
1017        if end.is_some_and(|end| end.contains('/')) {
1018            return Err(invalid_line(text));
1019        }
1020        let edge = |text| GridEdge::parse(text).map_err(|_| invalid_line(text));
1021        Ok(Self {
1022            start: edge(start)?,
1023            end: end.map_or(Ok(GridEdge::Auto), edge)?,
1024        })
1025    }
1026}
1027
1028impl fmt::Display for GridPlacement {
1029    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1030        match self.end {
1031            GridEdge::Auto => write!(formatter, "{}", self.start),
1032            end => write!(formatter, "{} / {end}", self.start),
1033        }
1034    }
1035}
1036
1037struct PlacementVisitor;
1038
1039impl Visitor<'_> for PlacementVisitor {
1040    type Value = GridPlacement;
1041
1042    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1043        formatter.write_str("a grid line: a line number, \"1 / 3\", \"span 2\", or \"auto\"")
1044    }
1045
1046    fn visit_u64<E: de::Error>(self, value: u64) -> Result<GridPlacement, E> {
1047        i16::try_from(value)
1048            .map(|line| GridPlacement {
1049                start: GridEdge::Line(line),
1050                end: GridEdge::Auto,
1051            })
1052            .map_err(|_| E::custom(invalid_line(&value.to_string())))
1053    }
1054
1055    fn visit_i64<E: de::Error>(self, value: i64) -> Result<GridPlacement, E> {
1056        i16::try_from(value)
1057            .map(|line| GridPlacement {
1058                start: GridEdge::Line(line),
1059                end: GridEdge::Auto,
1060            })
1061            .map_err(|_| E::custom(invalid_line(&value.to_string())))
1062    }
1063
1064    /// A JSON number with a fraction is a line when it is whole, as in JavaScript.
1065    #[allow(clippy::cast_possible_truncation)]
1066    fn visit_f64<E: de::Error>(self, value: f64) -> Result<GridPlacement, E> {
1067        if value.fract() == 0.0 && (f64::from(i16::MIN)..=f64::from(i16::MAX)).contains(&value) {
1068            return Ok(GridPlacement {
1069                start: GridEdge::Line(value as i16),
1070                end: GridEdge::Auto,
1071            });
1072        }
1073        Err(E::custom(invalid_line(&value.to_string())))
1074    }
1075
1076    fn visit_str<E: de::Error>(self, value: &str) -> Result<GridPlacement, E> {
1077        GridPlacement::parse(value).map_err(E::custom)
1078    }
1079}
1080
1081impl<'de> Deserialize<'de> for GridPlacement {
1082    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1083        deserializer.deserialize_any(PlacementVisitor)
1084    }
1085}
1086
1087impl Serialize for GridPlacement {
1088    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1089        match (self.start, self.end) {
1090            (GridEdge::Line(line), GridEdge::Auto) => serializer.serialize_i16(line),
1091            _ => serializer.collect_str(self),
1092        }
1093    }
1094}
1095
1096#[cfg(feature = "schema")]
1097mod schema {
1098    //! The schema of each form. String grammar is the parser's; patterns state it where a
1099    //! regular expression can.
1100
1101    use super::{GridPlacement, Insets, LayoutLength, LayoutOffset, LayoutSize, TrackList};
1102    use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
1103    use std::borrow::Cow;
1104
1105    const NUMBER: &str = r"-?(\d+\.?\d*|\.\d+)";
1106
1107    fn size_pattern() -> String {
1108        format!("^ *{NUMBER}(px)? *$")
1109    }
1110
1111    fn length_pattern() -> String {
1112        format!("^ *{NUMBER}(px|%)? *$")
1113    }
1114
1115    fn sides_pattern() -> String {
1116        format!("^ *{NUMBER}(px)?( +{NUMBER}(px)?){{0,3}} *$")
1117    }
1118
1119    impl JsonSchema for LayoutLength {
1120        fn schema_name() -> Cow<'static, str> {
1121            "LayoutLength".into()
1122        }
1123
1124        fn json_schema(_: &mut SchemaGenerator) -> Schema {
1125            json_schema!({
1126                "description": "A length: a number is a fraction of the layout picture's shorter side, \"<n>px\" pixels of the project canvas, \"<n>%\" a percent of the parent's content box along the field's axis.",
1127                "anyOf": [
1128                    { "type": "number", "format": "float" },
1129                    { "type": "string", "pattern": length_pattern() },
1130                ],
1131            })
1132        }
1133    }
1134
1135    impl JsonSchema for LayoutSize {
1136        fn schema_name() -> Cow<'static, str> {
1137            "LayoutSize".into()
1138        }
1139
1140        fn json_schema(_: &mut SchemaGenerator) -> Schema {
1141            json_schema!({
1142                "description": "A length that may not be a percent: a number is a fraction of the layout picture's shorter side, \"<n>px\" pixels of the project canvas.",
1143                "anyOf": [
1144                    { "type": "number", "format": "float" },
1145                    { "type": "string", "pattern": size_pattern() },
1146                ],
1147            })
1148        }
1149    }
1150
1151    impl JsonSchema for Insets {
1152        fn schema_name() -> Cow<'static, str> {
1153            "Insets".into()
1154        }
1155
1156        fn json_schema(generator: &mut SchemaGenerator) -> Schema {
1157            let side = generator.subschema_for::<LayoutSize>();
1158            json_schema!({
1159                "description": "A length on each side of a box: one length for every side, the CSS shorthand of one to four lengths (\"8px 16px\": top first, clockwise), or the sides object, missing sides 0.",
1160                "anyOf": [
1161                    { "type": "number", "format": "float" },
1162                    { "type": "string", "pattern": sides_pattern() },
1163                    {
1164                        "type": "object",
1165                        "properties": {
1166                            "top": side,
1167                            "right": side,
1168                            "bottom": side,
1169                            "left": side,
1170                        },
1171                        "additionalProperties": false,
1172                    },
1173                ],
1174            })
1175        }
1176    }
1177
1178    impl JsonSchema for LayoutOffset {
1179        fn schema_name() -> Cow<'static, str> {
1180            "LayoutOffset".into()
1181        }
1182
1183        fn json_schema(generator: &mut SchemaGenerator) -> Schema {
1184            let side = generator.subschema_for::<LayoutSize>();
1185            json_schema!({
1186                "description": "An offset, x right and y down: one or two lengths as CSS translate writes them (y 0 when missing), or {x, y}.",
1187                "anyOf": [
1188                    {
1189                        "type": "string",
1190                        "pattern": format!("^ *{NUMBER}(px)?( +{NUMBER}(px)?)? *$"),
1191                    },
1192                    {
1193                        "type": "object",
1194                        "properties": { "x": side, "y": side },
1195                        "required": ["x", "y"],
1196                        "additionalProperties": false,
1197                    },
1198                ],
1199            })
1200        }
1201    }
1202
1203    impl JsonSchema for TrackList {
1204        fn schema_name() -> Cow<'static, str> {
1205            "TrackList".into()
1206        }
1207
1208        fn json_schema(_: &mut SchemaGenerator) -> Schema {
1209            json_schema!({
1210                "description": "A grid track list as CSS writes it: \"1fr 2fr 120px\", \"repeat(3, minmax(100px, 1fr))\", \"repeat(auto-fill, 160px)\", \"auto min-content max-content\", \"fit-content(200px)\". A bare number is a fraction of the picture's shorter side. Named lines are not supported.",
1211                "type": "string",
1212            })
1213        }
1214    }
1215
1216    impl JsonSchema for GridPlacement {
1217        fn schema_name() -> Cow<'static, str> {
1218            "GridPlacement".into()
1219        }
1220
1221        fn json_schema(_: &mut SchemaGenerator) -> Schema {
1222            json_schema!({
1223                "description": "Where a grid item lies on one axis, as CSS writes it: 2, \"1 / 3\", \"span 2\", \"2 / span 3\", \"auto\". Lines count from 1, or from -1 at the end.",
1224                "anyOf": [
1225                    { "type": "integer", "minimum": -32768, "maximum": 32767 },
1226                    { "type": "string" },
1227                ],
1228            })
1229        }
1230    }
1231}
1232
1233#[cfg(test)]
1234#[path = "layout_values_tests.rs"]
1235mod tests;