Skip to main content

whisker_css/
css.rs

1//! The [`Css`] container and its internal [`CssProp`] entries.
2//!
3//! Every typed builder method on [`Css`] resolves its argument to
4//! CSS text via [`ToCss`] and pushes a [`CssProp`] onto an internal
5//! list. Shorthand methods expand to their constituent longhands so
6//! the canonical last-write-wins rule applies per longhand
7//! property — calling `.padding(px(8)).padding_top(px(0))` leaves
8//! `padding-top: 0px; padding-right: 8px; padding-bottom: 8px;
9//! padding-left: 8px;`, exactly as a CSS author would expect.
10
11use core::fmt;
12
13use crate::to_css::ToCss;
14
15/// One CSS declaration stored inside a [`Css`].
16///
17/// Constructed only by [`Css`]'s builder methods; the internal
18/// representation is intentionally opaque so the crate is free to
19/// switch to a typed enum without breaking callers.
20#[derive(Clone, Debug, PartialEq, Eq, Hash)]
21pub struct CssProp {
22    name: &'static str,
23    value: String,
24}
25
26impl CssProp {
27    /// Build a property from a CSS name and an already-serialized
28    /// value. Crate-public; users should go through [`Css`].
29    pub(crate) fn new(name: &'static str, value: String) -> Self {
30        Self { name, value }
31    }
32
33    /// The CSS property name (`"padding-top"`, `"background-color"`).
34    pub fn name(&self) -> &'static str {
35        self.name
36    }
37
38    /// The serialized CSS value (`"8px"`, `"rgb(26, 26, 46)"`).
39    pub fn value(&self) -> &str {
40        &self.value
41    }
42}
43
44impl ToCss for CssProp {
45    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
46        dest.write_str(self.name)?;
47        dest.write_str(": ")?;
48        dest.write_str(&self.value)?;
49        dest.write_char(';')
50    }
51}
52
53/// A type-safe CSS style declaration block.
54///
55/// Build a style by chaining builder methods; every method returns
56/// `Self` so further calls can be appended fluently. The resulting
57/// CSS text is produced by [`ToCss::to_css_string`] or via
58/// [`Display`](core::fmt::Display).
59///
60/// ```ignore
61/// use whisker_css::ext::*;
62/// use whisker_css::{Color, Display, FlexDirection, Css};
63///
64/// let s = Css::new()
65///     .display(Display::Flex)
66///     .flex_direction(FlexDirection::Column)
67///     .padding(px(12))
68///     .background_color(Color::hex(0x1A1A2E));
69/// ```
70#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
71pub struct Css {
72    props: Vec<CssProp>,
73}
74
75impl Css {
76    /// An empty style.
77    pub fn new() -> Self {
78        Self { props: Vec::new() }
79    }
80
81    /// Push a property, taking ownership of `self` to return it. All
82    /// public builder methods funnel through this helper.
83    pub(crate) fn push(mut self, name: &'static str, value: impl ToCss) -> Self {
84        self.props.push(CssProp::new(name, value.to_css_string()));
85        self
86    }
87
88    /// Push a property whose value is an already-serialized string.
89    pub(crate) fn push_raw(mut self, name: &'static str, value: impl Into<String>) -> Self {
90        self.props.push(CssProp::new(name, value.into()));
91        self
92    }
93
94    /// Escape hatch — append a raw CSS declaration without
95    /// type-checking. Use this when Lynx supports a property Whisker
96    /// has not yet wrapped, or when copying a value verbatim from
97    /// hand-written CSS.
98    ///
99    /// `name` should be a `&'static str` because property names are
100    /// part of the CSS grammar, not runtime data. The value is taken
101    /// verbatim and not validated.
102    pub fn raw(self, name: &'static str, value: impl Into<String>) -> Self {
103        self.push_raw(name, value)
104    }
105
106    /// True if no declarations have been added.
107    pub fn is_empty(&self) -> bool {
108        self.props.is_empty()
109    }
110
111    /// Number of declarations currently in the style. Repeats of the
112    /// same property are counted separately; they collapse during
113    /// serialization.
114    pub fn len(&self) -> usize {
115        self.props.len()
116    }
117
118    /// Iterate over every entry in insertion order, including
119    /// duplicates of the same property. Use [`Self::resolved`] for
120    /// last-write-wins iteration.
121    pub fn entries(&self) -> impl Iterator<Item = &CssProp> {
122        self.props.iter()
123    }
124
125    /// Iterate over entries with the last-write-wins rule applied:
126    /// only the final occurrence of each property name is yielded,
127    /// in the position of that final occurrence.
128    pub fn resolved(&self) -> Vec<&CssProp> {
129        // Walk backwards, recording the first time we see each name,
130        // then reverse for forward order.
131        let mut seen: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
132        let mut out: Vec<&CssProp> = Vec::new();
133        for prop in self.props.iter().rev() {
134            if seen.insert(prop.name) {
135                out.push(prop);
136            }
137        }
138        out.reverse();
139        out
140    }
141
142    /// Extend by appending every entry of `other`. Later writes win
143    /// during serialization, so `.merge(other)` lets `other` override
144    /// declarations already set on `self`.
145    pub fn merge(mut self, other: Css) -> Self {
146        self.props.extend(other.props);
147        self
148    }
149}
150
151impl ToCss for Css {
152    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
153        let resolved = self.resolved();
154        for (i, prop) in resolved.iter().enumerate() {
155            if i > 0 {
156                dest.write_char(' ')?;
157            }
158            prop.to_css(dest)?;
159        }
160        Ok(())
161    }
162}
163
164impl fmt::Display for Css {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        ToCss::to_css(self, f)
167    }
168}
169
170impl From<Css> for String {
171    fn from(s: Css) -> Self {
172        s.to_css_string()
173    }
174}
175
176impl From<&Css> for String {
177    fn from(s: &Css) -> Self {
178        s.to_css_string()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn empty_style_serializes_to_empty_string() {
188        assert_eq!(Css::new().to_css_string(), "");
189        assert!(Css::new().is_empty());
190    }
191
192    #[test]
193    fn raw_appends_a_declaration() {
194        let s = Css::new().raw("color", "red");
195        assert_eq!(s.to_css_string(), "color: red;");
196        assert!(!s.is_empty());
197        assert_eq!(s.len(), 1);
198    }
199
200    #[test]
201    fn multiple_distinct_properties_keep_order() {
202        let s = Css::new()
203            .raw("color", "red")
204            .raw("background-color", "blue");
205        assert_eq!(s.to_css_string(), "color: red; background-color: blue;");
206    }
207
208    #[test]
209    fn duplicate_property_uses_last_value() {
210        let s = Css::new()
211            .raw("color", "red")
212            .raw("color", "blue")
213            .raw("color", "green");
214        assert_eq!(s.to_css_string(), "color: green;");
215        // Internal `entries` keeps all three — only `resolved`
216        // collapses.
217        assert_eq!(s.len(), 3);
218        assert_eq!(s.resolved().len(), 1);
219    }
220
221    #[test]
222    fn duplicate_property_preserves_position_of_last() {
223        // color appears at index 0 then again at 2; final order
224        // should place `color: blue` where the last occurrence sits
225        // (after `background-color`).
226        let s = Css::new()
227            .raw("color", "red")
228            .raw("background-color", "white")
229            .raw("color", "blue");
230        assert_eq!(s.to_css_string(), "background-color: white; color: blue;");
231    }
232
233    #[test]
234    fn entries_iterates_all_in_order() {
235        let s = Css::new().raw("color", "red").raw("color", "blue");
236        let names: Vec<&str> = s.entries().map(|p| p.name()).collect();
237        assert_eq!(names, ["color", "color"]);
238    }
239
240    #[test]
241    fn merge_lets_other_win() {
242        let base = Css::new().raw("color", "red");
243        let overlay = Css::new().raw("color", "blue");
244        let merged = base.merge(overlay);
245        assert_eq!(merged.to_css_string(), "color: blue;");
246    }
247
248    #[test]
249    fn merge_preserves_distinct_props() {
250        let base = Css::new().raw("color", "red");
251        let overlay = Css::new().raw("background-color", "yellow");
252        let merged = base.merge(overlay);
253        assert_eq!(
254            merged.to_css_string(),
255            "color: red; background-color: yellow;"
256        );
257    }
258
259    #[test]
260    fn into_string_via_from_owned() {
261        let s = Css::new().raw("color", "red");
262        let css: String = s.into();
263        assert_eq!(css, "color: red;");
264    }
265
266    #[test]
267    fn into_string_via_from_borrowed() {
268        let s = Css::new().raw("color", "red");
269        let css: String = (&s).into();
270        assert_eq!(css, "color: red;");
271    }
272
273    #[test]
274    fn display_matches_to_css_string() {
275        let s = Css::new().raw("color", "red").raw("padding", "8px");
276        assert_eq!(format!("{s}"), s.to_css_string());
277    }
278
279    #[test]
280    fn style_prop_accessors() {
281        let s = Css::new().raw("color", "red");
282        let prop = s.entries().next().unwrap();
283        assert_eq!(prop.name(), "color");
284        assert_eq!(prop.value(), "red");
285        assert_eq!(prop.to_css_string(), "color: red;");
286    }
287}