Skip to main content

hypersig_style/
sheet.rs

1//! [`Sheet`], which collects styles and writes their rules.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::Write as _;
5
6use crate::condition::Conditions;
7use crate::style::{Atom, Prop, Style};
8
9/// The cascade layer of the classes from [`Sheet::add`].
10pub const LAYER: &str = "hypersig-atoms";
11
12/// The cascade layer of the classes from [`Sheet::add_toggled`], which comes
13/// after [`LAYER`], so a toggled declaration overrides an element's own.
14pub const TOGGLED_LAYER: &str = "hypersig-toggled";
15
16/// The cascade layer of a [`Theme`](crate::Theme)'s values, before [`LAYER`].
17pub const TOKEN_LAYER: &str = "hypersig-tokens";
18
19/// The cascade layer of a [`Global`](crate::Global)'s rules, after
20/// [`TOKEN_LAYER`] and before [`LAYER`], so an element's classes override them.
21pub const BASE_LAYER: &str = "hypersig-base";
22
23/// The statement that puts [`TOKEN_LAYER`], [`BASE_LAYER`], [`LAYER`], and
24/// [`TOGGLED_LAYER`] in that order. Every bucket, theme, and global
25/// stylesheet starts with it, because the first stylesheet to name a layer
26/// fixes its place, and any of them may be the first one a page has.
27pub(crate) const ORDER: &str =
28    "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;";
29
30/// The rules for a set of styles, each declaration written once.
31///
32/// A rule outside any cascade layer beats a layered one, so a plain stylesheet
33/// the page also loads overrides these classes.
34///
35/// ```
36/// use hypersig_style::value::{Color, px};
37/// use hypersig_style::{PseudoClass, Sheet, Style};
38///
39/// let mut sheet = Sheet::new();
40/// sheet.add(&Style::new().padding(px(8)).color(Color::CRIMSON));
41/// sheet.add(&Style::new().on(PseudoClass::Hover, Style::new().color(Color::RED)));
42/// let css: Vec<String> = sheet.buckets().map(|b| b.css().to_owned()).collect();
43/// assert_eq!(
44///     css,
45///     [
46///         "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;\
47///          @layer hypersig-atoms.k-----00{.color_crimson{color:crimson}.padding_8px{padding:8px}}",
48///         "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;\
49///          @layer hypersig-atoms.k----4019-00{.hover\\:color_red:hover{color:red}}",
50///     ]
51/// );
52/// ```
53#[derive(Debug, Clone, Default)]
54pub struct Sheet {
55    rules: BTreeSet<Key>,
56    keyframes: BTreeMap<&'static str, String>,
57}
58
59/// The order rules are written in: toggled after untoggled, then by
60/// conditions, so that a rule under more or later conditions comes after the
61/// rules it overrides, then by shorthand rank, so that `padding` comes before
62/// `padding-top`.
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
64struct Key {
65    toggled: bool,
66    conditions: Conditions,
67    rank: u32,
68    property: Prop,
69    value: String,
70}
71
72/// The rules of one place in the order: one cascade layer, one set of
73/// conditions, and one shorthand rank, or one `@keyframes` rule.
74///
75/// Rules in one bucket never set the same property on the same element, so
76/// their order among themselves does not matter. A page writes each bucket as
77/// its own `<style>` element, so the runtime can add the rules a patch brings
78/// to the right bucket, or insert a new bucket at its place in the order.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Bucket {
81    sort_key: String,
82    css: String,
83}
84
85impl Bucket {
86    /// A string that sorts the way buckets are ordered: `0` for [`LAYER`] or
87    /// `1` for [`TOGGLED_LAYER`], a space, and the sublayer name, whose fixed
88    /// fields compare as strings the way the buckets do. A `@keyframes` rule,
89    /// which no order affects, is `2`, a space, and its name.
90    #[must_use]
91    pub fn sort_key(&self) -> &str {
92        &self.sort_key
93    }
94
95    /// The bucket's rules as a stylesheet of their own: the order of the
96    /// layers, then the rules inside `@layer <layer>.<sublayer>{...}`.
97    #[must_use]
98    pub fn css(&self) -> &str {
99        &self.css
100    }
101}
102
103impl Sheet {
104    /// An empty sheet.
105    #[must_use]
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    fn insert(&mut self, style: &Style, toggled: bool) {
111        for keyframes in style.keyframes_used() {
112            self.keyframes
113                .entry(keyframes.name())
114                .or_insert_with(|| keyframes.to_css());
115        }
116        for atom in style.atoms() {
117            self.rules.insert(Key {
118                toggled,
119                conditions: atom.conditions.clone(),
120                rank: atom.property.rank(),
121                property: atom.property,
122                value: atom.value.clone(),
123            });
124        }
125    }
126
127    /// Adds the rules for the classes [`Style::class_names`] lists.
128    pub fn add(&mut self, style: &Style) {
129        self.insert(style, false);
130    }
131
132    /// Adds the rules for the classes [`Style::toggled_class_names`] lists.
133    pub fn add_toggled(&mut self, style: &Style) {
134        self.insert(style, true);
135    }
136
137    /// Adds every rule of `other`.
138    pub fn extend(&mut self, other: &Self) {
139        self.rules.extend(other.rules.iter().cloned());
140        for (name, css) in &other.keyframes {
141            self.keyframes.entry(name).or_insert_with(|| css.clone());
142        }
143    }
144
145    /// Whether no style has been added.
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.rules.is_empty() && self.keyframes.is_empty()
149    }
150
151    /// The rules grouped into buckets, in the order they apply, then one
152    /// bucket per `@keyframes` rule.
153    pub fn buckets(&self) -> impl Iterator<Item = Bucket> + '_ {
154        let mut rules = self.rules.iter().peekable();
155        let atoms = std::iter::from_fn(move || {
156            let first = rules.next()?;
157            let layer = if first.toggled { TOGGLED_LAYER } else { LAYER };
158            let key = first.bucket();
159            let mut css = format!("{ORDER}@layer {layer}.{key}{{");
160            let open = first.conditions.open(&mut css);
161            first.write_rule(&mut css);
162            while let Some(next) = rules.next_if(|k| k.same_bucket(first)) {
163                next.write_rule(&mut css);
164            }
165            Conditions::close(open, &mut css);
166            css.push('}');
167            Some(Bucket {
168                sort_key: format!("{} {key}", u8::from(first.toggled)),
169                css,
170            })
171        });
172        atoms.chain(self.keyframes.iter().map(|(name, css)| Bucket {
173            sort_key: format!("2 {name}"),
174            css: format!("{ORDER}{css}"),
175        }))
176    }
177
178    /// Writes every bucket, as one stylesheet.
179    pub fn write_css(&self, out: &mut String) {
180        for bucket in self.buckets() {
181            out.push_str(&bucket.css);
182        }
183    }
184
185    /// The stylesheet [`Sheet::write_css`] writes.
186    #[must_use]
187    pub fn to_css(&self) -> String {
188        let mut out = String::new();
189        self.write_css(&mut out);
190        out
191    }
192}
193
194impl Key {
195    fn same_bucket(&self, other: &Self) -> bool {
196        self.toggled == other.toggled
197            && self.conditions == other.conditions
198            && self.rank == other.rank
199    }
200
201    /// The sublayer name: `k`, then the media, container, and pseudo-class
202    /// conditions, the pseudo-element, and the shorthand rank, each in fixed
203    /// width, so that comparing two names as strings compares the buckets.
204    /// Each list of conditions ends with `-`, which sorts before the digits,
205    /// so a list sorts before any longer list it starts.
206    fn bucket(&self) -> String {
207        let mut key = String::from("k");
208        for &m in &self.conditions.media {
209            let _ = write!(key, "{:02}", m as u8);
210        }
211        key.push('-');
212        for &c in &self.conditions.container {
213            let _ = write!(key, "{:02}", c as u8);
214        }
215        key.push('-');
216        for &(rank, p) in &self.conditions.group {
217            let _ = write!(key, "{rank}{:03}", p as u16);
218        }
219        key.push('-');
220        for &(rank, p) in &self.conditions.peer {
221            let _ = write!(key, "{rank}{:03}", p as u16);
222        }
223        key.push('-');
224        for &(rank, p) in &self.conditions.pseudo {
225            let _ = write!(key, "{rank}{:03}", p as u16);
226        }
227        key.push('-');
228        match self.conditions.element {
229            Some(e) => {
230                let _ = write!(key, "1{:03}", e as u16);
231            }
232            None => key.push('0'),
233        }
234        let _ = write!(key, "{}", self.rank);
235        key
236    }
237
238    fn write_rule(&self, out: &mut String) {
239        let atom = Atom {
240            conditions: self.conditions.clone(),
241            property: self.property,
242            value: self.value.clone(),
243        };
244        self.conditions.write_selector(out, |out| {
245            out.push('.');
246            escape_class(&atom.class_name(self.toggled), out);
247        });
248        out.push('{');
249        self.property.write_name(out);
250        let _ = write!(out, ":{}}}", self.value);
251    }
252}
253
254/// Writes `name` as the identifier after `.` in a selector, escaping what
255/// `CSS.escape` escapes: a leading digit as a code point, and any other
256/// character outside letters, digits, `-`, `_`, and non-ASCII with a backslash.
257fn escape_class(name: &str, out: &mut String) {
258    for (i, c) in name.chars().enumerate() {
259        if i == 0 && c.is_ascii_digit() {
260            let _ = write!(out, "\\{:x} ", u32::from(c));
261        } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || !c.is_ascii() {
262            out.push(c);
263        } else {
264            out.push('\\');
265            out.push(c);
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{Sheet, escape_class};
273    use crate::value::{Color, Display, pct, px, quoted, raw, rem};
274    use crate::{Condition, Container, Media, PseudoClass, PseudoElement, Style};
275
276    fn css(sheet: &Sheet) -> Vec<String> {
277        sheet.buckets().map(|b| b.css().to_owned()).collect()
278    }
279
280    #[test]
281    fn escapes_a_leading_digit_and_punctuation() {
282        let mut out = String::new();
283        escape_class("2xl:width_50%", &mut out);
284        assert_eq!(out, "\\32 xl\\:width_50\\%");
285    }
286
287    #[test]
288    fn writes_breakpoints_ascending_after_the_base_rules() {
289        let mut sheet = Sheet::new();
290        sheet.add(
291            &Style::new()
292                .on(Media::Lg, Style::new().width(pct(25)))
293                .on(Media::Md, Style::new().width(pct(50)))
294                .width(pct(100)),
295        );
296        let css = css(&sheet);
297        assert_eq!(css.len(), 3);
298        assert!(css[0].ends_with("{.width_100\\%{width:100%}}"));
299        assert!(css[1].ends_with("{@media (min-width: 48rem){.md\\:width_50\\%{width:50%}}}"));
300        assert!(css[2].ends_with("{@media (min-width: 64rem){.lg\\:width_25\\%{width:25%}}}"));
301    }
302
303    #[test]
304    fn bucket_keys_sort_as_the_buckets_do() {
305        let mut sheet = Sheet::new();
306        sheet.add(
307            &Style::new()
308                .on(
309                    Media::Md,
310                    Style::new().on(PseudoClass::Hover, Style::new().color(Color::RED)),
311                )
312                .on(Media::Md, Style::new().color(Color::BLUE))
313                .on(PseudoClass::Active, Style::new().color(Color::GREEN))
314                .padding_top(px(0))
315                .padding(px(1)),
316        );
317        sheet.add_toggled(&Style::new().color(Color::GRAY));
318        let keys: Vec<String> = sheet.buckets().map(|b| b.sort_key().to_owned()).collect();
319        let mut sorted = keys.clone();
320        sorted.sort();
321        assert_eq!(keys, sorted);
322        assert_eq!(keys.len(), 6);
323    }
324
325    #[test]
326    fn writes_container_queries_inside_media_queries() {
327        let mut sheet = Sheet::new();
328        sheet.add(
329            &Style::new().width(pct(100)).on(
330                Container::Xl3,
331                Style::new()
332                    .width(pct(50))
333                    .on(Media::Print, Style::new().width(pct(25))),
334            ),
335        );
336        let css = css(&sheet);
337        assert_eq!(css.len(), 3);
338        assert!(
339            css[1].ends_with(r"{@container (min-width: 48rem){.\@3xl\:width_50\%{width:50%}}}")
340        );
341        assert!(css[2].ends_with(
342            r"{@media print{@container (min-width: 48rem){.print\:\@3xl\:width_25\%{width:25%}}}}"
343        ));
344    }
345
346    #[test]
347    fn writes_group_and_peer_conditions_before_the_class() {
348        let style = Style::new()
349            .on(
350                Condition::Group(PseudoClass::Hover),
351                Style::new().display(Display::Block),
352            )
353            .on(
354                Condition::Peer(PseudoClass::Checked),
355                Style::new().color(Color::RED),
356            );
357        assert_eq!(
358            style.class_names().collect::<Vec<_>>(),
359            ["group-hover:display_block", "peer-checked:color_red"]
360        );
361        let mut sheet = Sheet::new();
362        sheet.add(&style);
363        let css = sheet.to_css();
364        assert!(css.contains("{.group:hover .group-hover\\:display_block{display:block}}"));
365        assert!(css.contains("{.peer:checked ~ .peer-checked\\:color_red{color:red}}"));
366        assert_eq!(
367            Style::new()
368                .group()
369                .peer()
370                .class_names()
371                .collect::<Vec<_>>(),
372            ["group", "peer"]
373        );
374    }
375
376    #[test]
377    fn writes_shorthands_before_longhands() {
378        let mut sheet = Sheet::new();
379        sheet.add(&Style::new().padding_top(px(0)).padding(rem(1)));
380        let css = css(&sheet);
381        assert!(css[0].ends_with("{.padding_1rem{padding:1rem}}"));
382        assert!(css[1].ends_with("{.padding-top_0{padding-top:0}}"));
383    }
384
385    #[test]
386    fn writes_active_after_hover() {
387        let mut sheet = Sheet::new();
388        sheet.add(
389            &Style::new()
390                .on(PseudoClass::Active, Style::new().color(Color::RED))
391                .on(PseudoClass::Hover, Style::new().color(Color::BLUE)),
392        );
393        let css = css(&sheet);
394        assert!(css[0].contains(":hover{color:blue}") && css[1].contains(":active{color:red}"));
395    }
396
397    #[test]
398    fn toggled_classes_have_their_own_names_and_layer() {
399        let style = Style::new().color(Color::RED);
400        assert_eq!(
401            style.toggled_class_names().collect::<Vec<_>>(),
402            ["when:color_red"]
403        );
404        let mut sheet = Sheet::new();
405        sheet.add_toggled(&style);
406        assert_eq!(
407            sheet.to_css(),
408            "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;\
409             @layer hypersig-toggled.k-----00{.when\\:color_red{color:red}}"
410        );
411    }
412
413    #[test]
414    fn hashes_values_with_spaces_and_writes_pseudo_elements_last() {
415        let style = Style::new()
416            .display(Display::Flex)
417            .margin(raw("0 auto"))
418            .on(
419                PseudoClass::Hover,
420                Style::new().pseudo(PseudoElement::After, Style::new().content(quoted("×"))),
421            );
422        let names: Vec<String> = style.class_names().collect();
423        assert_eq!(names[0], "display_flex");
424        assert!(names[1].starts_with("margin_h") && names[1].len() == "margin_h".len() + 16);
425        let mut sheet = Sheet::new();
426        sheet.add(&style);
427        assert!(sheet.to_css().contains(":hover::after{content:\"×\"}"));
428    }
429
430    #[test]
431    fn escapes_the_parentheses_of_the_select_picker() {
432        let style =
433            Style::new().pseudo(PseudoElement::PickerSelect, Style::new().color(Color::RED));
434        let mut sheet = Sheet::new();
435        sheet.add(&style);
436        assert!(
437            sheet
438                .to_css()
439                .contains(".picker\\(select\\)\\:color_red::picker(select){color:red}")
440        );
441    }
442
443    #[test]
444    fn a_later_value_replaces_an_earlier_one() {
445        let style = Style::new()
446            .color(Color::RED)
447            .merge(Style::new().color(Color::BLUE));
448        assert_eq!(style.class_names().collect::<Vec<_>>(), ["color_blue"]);
449    }
450}