Skip to main content

hjkl_css_gui/
lib.rs

1//! Adapter that maps a [`hjkl_css::Stylesheet`] onto floem View styling.
2//!
3//! Usage:
4//!
5//! ```rust,ignore
6//! use hjkl_css::parse;
7//! use hjkl_css_gui::ViewCssExt;
8//!
9//! let sheet = parse("label.prompt { color: #21d1d3; padding: 4px 8px; }")
10//!     .expect("stylesheet parses");
11//! let view = floem::views::label(|| "hello")
12//!     .css(&sheet, "label", &["prompt"]);
13//! ```
14//!
15//! Use `.css_in(...)` when the view is inside a hierarchy and combinator
16//! selectors (` `, `>`, `+`, `~`) need to fire:
17//!
18//! ```rust,ignore
19//! use hjkl_css::{Node, parse};
20//! use hjkl_css_gui::ViewCssExt;
21//!
22//! let sheet = parse(".row .label { color: #fff; }").expect("parses");
23//! let target = Node { element: "label", classes: &["prompt"] };
24//! let ancestors = [Node { element: "row", classes: &[] }];
25//! let view = floem::views::label(|| "hello")
26//!     .css_in(&sheet, target, &ancestors, &[]);
27//! ```
28//!
29//! The trait resolves the stylesheet eagerly for the base state and each
30//! supported pseudo (`:hover`, `:focus`, `:active`, `:disabled`,
31//! `:selected`), capturing the resolved property bags into the floem
32//! `.style(|s| ...)` closure. Pseudo-state blocks delegate to floem's
33//! own `.hover()` / `.focus()` / etc. chain so floem handles the
34//! interaction wiring.
35//!
36//! # Workspace setup
37//!
38//! floem's Wayland layer-shell support lives on a fork (`mxaddict/floem`,
39//! `layer-shell` branch) that requires a patched `floem-winit`. Cargo
40//! only honours `[patch.crates-io]` declared at the workspace root, so
41//! `hjkl-css-floem` cannot ship the patch transitively. Downstream
42//! consumers building on Wayland with layer-shell **must** add the
43//! following to their own workspace `Cargo.toml`:
44//!
45//! ```toml
46//! [patch.crates-io]
47//! floem       = { git = "https://github.com/mxaddict/floem.git", branch = "layer-shell" }
48//! floem-winit = { git = "https://github.com/mxaddict/winit.git", branch = "layer-shell" }
49//! ```
50//!
51//! Without this block the build succeeds against stock `floem 0.2` from
52//! crates.io — there is no compile-time signal — and the layer-shell
53//! features fail silently at runtime.
54
55use floem::peniko::{Brush, Color};
56use floem::style::Style;
57use floem::taffy::style::{AlignItems, Display, FlexDirection, JustifyContent};
58use floem::text::Weight;
59use floem::unit::{Px, PxPct, PxPctAuto};
60use floem::views::Decorators;
61use hjkl_css::{
62    Length, Node, PseudoClass, ResolvedStyle, SideValue, Stylesheet, Value, expand_side_set,
63    expand_sides,
64};
65
66/// Extension trait that consumes a [`Stylesheet`] and applies its
67/// resolved properties — base plus every supported pseudo-state — to a
68/// floem view in one call.
69///
70/// Two entry points are provided:
71///
72/// - `.css(sheet, element, classes)` — flat call for top-level views with no
73///   combinator context. Builds a [`Node`] internally and calls `.css_in` with
74///   empty ancestor / sibling slices.
75///
76/// - `.css_in(sheet, target, ancestors, prev_siblings)` — context-aware call
77///   for views that live inside a hierarchy. Enables descendant (` `), child
78///   (`>`), adjacent-sibling (`+`), and general-sibling (`~`) selectors to
79///   match.
80///
81/// A `CssContext` builder is deliberately *not* introduced here: the two-method
82/// surface keeps the API minimal. If a third variant is needed in the future
83/// (e.g. per-property filtering or lazy resolution), `CssContext` is the right
84/// abstraction to reach for then.
85///
86/// **Sealed by the blanket impl.** Downstream crates cannot add their own
87/// `impl ViewCssExt for SomeWrapper` because the orphan rule + the blanket impl
88/// would conflict. Consumers needing custom property application should write a
89/// free function or their own extension trait that delegates to this one.
90pub trait ViewCssExt: Decorators + Sized {
91    /// Resolve and apply styles for `element` with `classes`, no combinator
92    /// context. Equivalent to `.css_in(sheet, Node { element, classes },
93    /// &[], &[])`.
94    #[must_use = "css() returns a styled view; bind it or chain further"]
95    fn css(self, sheet: &Stylesheet, element: &str, classes: &[&str]) -> Self::DV {
96        let target = Node { element, classes };
97        self.css_in(sheet, target, &[], &[])
98    }
99
100    /// Resolve and apply styles for `target` inside a given tree context.
101    /// `ancestors` is root→parent (exclusive of target); `prev_siblings` is
102    /// oldest→immediately-preceding sibling (exclusive of target).
103    #[must_use = "css_in() returns a styled view; bind it or chain further"]
104    fn css_in(
105        self,
106        sheet: &Stylesheet,
107        target: Node<'_>,
108        ancestors: &[Node<'_>],
109        prev_siblings: &[Node<'_>],
110    ) -> Self::DV {
111        let states = StateStyles::resolve(sheet, target, ancestors, prev_siblings);
112        self.style(move |s| {
113            let mut s = apply(s, &states.base);
114            s = s.hover(|hs| apply(hs, &states.hover));
115            s = s.focus(|fs| apply(fs, &states.focus));
116            s = s.active(|act| apply(act, &states.active));
117            s = s.disabled(|ds| apply(ds, &states.disabled));
118            s = s.selected(|sel| apply(sel, &states.selected));
119            s
120        })
121    }
122}
123
124impl<V: Decorators + Sized> ViewCssExt for V {}
125
126/// Pre-resolved property bags for each state. Eager resolution avoids
127/// running the cascade on every floem style closure invocation.
128///
129/// floem's `.style(...)` takes `impl Fn(Style) -> Style` and re-invokes
130/// the closure whenever any captured reactive signal changes. The
131/// adapter closure accesses each state bag by reference (`&states.base`,
132/// `&states.hover`, …) so the move-captured `StateStyles` is reused
133/// across invocations without needing to be cloned. `Clone` is kept for
134/// defensive completeness — useful if a caller wants to fork the bag
135/// for inspection or composition — but is not required for the
136/// `.style(...)` call path.
137#[derive(Clone)]
138struct StateStyles {
139    base: ResolvedStyle,
140    hover: ResolvedStyle,
141    focus: ResolvedStyle,
142    active: ResolvedStyle,
143    disabled: ResolvedStyle,
144    selected: ResolvedStyle,
145}
146
147impl StateStyles {
148    fn resolve(
149        sheet: &Stylesheet,
150        target: Node<'_>,
151        ancestors: &[Node<'_>],
152        prev_siblings: &[Node<'_>],
153    ) -> Self {
154        Self {
155            base: sheet.resolve(&target, ancestors, prev_siblings, None),
156            hover: sheet.resolve(&target, ancestors, prev_siblings, Some(PseudoClass::Hover)),
157            focus: sheet.resolve(&target, ancestors, prev_siblings, Some(PseudoClass::Focus)),
158            active: sheet.resolve(&target, ancestors, prev_siblings, Some(PseudoClass::Active)),
159            disabled: sheet.resolve(
160                &target,
161                ancestors,
162                prev_siblings,
163                Some(PseudoClass::Disabled),
164            ),
165            selected: sheet.resolve(
166                &target,
167                ancestors,
168                prev_siblings,
169                Some(PseudoClass::Selected),
170            ),
171        }
172    }
173}
174
175/// Walk every property in `resolved` and chain the matching floem
176/// `Style` setter. Unknown properties (or values of the wrong shape for
177/// a known property — though the parser already filters most of those)
178/// are silently skipped.
179fn apply(mut s: Style, resolved: &ResolvedStyle) -> Style {
180    for (prop, value) in resolved.iter() {
181        s = apply_one(s, prop, value);
182    }
183    s
184}
185
186#[allow(clippy::too_many_lines)]
187fn apply_one(s: Style, prop: &str, value: &Value) -> Style {
188    match prop {
189        // ── Color ─────────────────────────────────────────────────────────────
190        "color" => match value {
191            Value::Color(c) => s.color(to_peniko_color(*c)),
192            _ => s,
193        },
194        "background-color" => match value {
195            Value::Color(c) => s.background(to_peniko_color(*c)),
196            _ => s,
197        },
198
199        // ── Sizing ────────────────────────────────────────────────────────────
200        "width" => match value {
201            Value::Length(l) => s.width(to_pct_auto(*l)),
202            Value::Auto => s.width(PxPctAuto::Auto),
203            _ => s,
204        },
205        "height" => match value {
206            Value::Length(l) => s.height(to_pct_auto(*l)),
207            Value::Auto => s.height(PxPctAuto::Auto),
208            _ => s,
209        },
210        "flex-basis" => match value {
211            Value::Length(l) => s.flex_basis(to_pct_auto(*l)),
212            Value::Auto => s.flex_basis(PxPctAuto::Auto),
213            _ => s,
214        },
215
216        // ── Box spacing ───────────────────────────────────────────────────────
217        "padding" | "border-radius" => apply_padding_or_border_radius(s, prop, value),
218        "margin" => apply_margin(s, value),
219        "gap" => match value {
220            Value::Length(l) => s.gap(to_pct(*l)),
221            _ => s,
222        },
223        "row-gap" => match value {
224            Value::Length(l) => s.row_gap(to_pct(*l)),
225            _ => s,
226        },
227        "column-gap" => match value {
228            Value::Length(l) => s.column_gap(to_pct(*l)),
229            _ => s,
230        },
231
232        // ── Layout ────────────────────────────────────────────────────────────
233        "display" => match value {
234            Value::Keyword(kw) => match kw.as_str() {
235                "flex" => s.display(Display::Flex),
236                "block" => s.display(Display::Block),
237                "none" => s.display(Display::None),
238                _ => s,
239            },
240            _ => s,
241        },
242        "flex-direction" => match value {
243            Value::Keyword(kw) => match kw.as_str() {
244                "row" => s.flex_direction(FlexDirection::Row),
245                "column" => s.flex_direction(FlexDirection::Column),
246                "row-reverse" => s.flex_direction(FlexDirection::RowReverse),
247                "column-reverse" => s.flex_direction(FlexDirection::ColumnReverse),
248                _ => s,
249            },
250            _ => s,
251        },
252        "align-items" => match value {
253            Value::Keyword(kw) => match kw.as_str() {
254                "start" => s.align_items(Some(AlignItems::Start)),
255                "end" => s.align_items(Some(AlignItems::End)),
256                "center" => s.align_items(Some(AlignItems::Center)),
257                "stretch" => s.align_items(Some(AlignItems::Stretch)),
258                "baseline" => s.align_items(Some(AlignItems::Baseline)),
259                _ => s,
260            },
261            _ => s,
262        },
263        "justify-content" => match value {
264            Value::Keyword(kw) => match kw.as_str() {
265                "start" => s.justify_content(Some(JustifyContent::Start)),
266                "end" => s.justify_content(Some(JustifyContent::End)),
267                "center" => s.justify_content(Some(JustifyContent::Center)),
268                "space-between" => s.justify_content(Some(JustifyContent::SpaceBetween)),
269                "space-around" => s.justify_content(Some(JustifyContent::SpaceAround)),
270                "space-evenly" => s.justify_content(Some(JustifyContent::SpaceEvenly)),
271                _ => s,
272            },
273            _ => s,
274        },
275        "flex-grow" => match value {
276            Value::Number(n) => s.flex_grow(*n as f32),
277            _ => s,
278        },
279        "flex-shrink" => match value {
280            Value::Number(n) => s.flex_shrink(*n as f32),
281            _ => s,
282        },
283
284        // ── Border shorthands ─────────────────────────────────────────────────
285        "border" => apply_border(s, value, BorderSide::All),
286        "border-top" => apply_border(s, value, BorderSide::Top),
287        "border-right" => apply_border(s, value, BorderSide::Right),
288        "border-bottom" => apply_border(s, value, BorderSide::Bottom),
289        "border-left" => apply_border(s, value, BorderSide::Left),
290        // `outline` maps to floem's outline + outline_color setters.
291        "outline" => match value {
292            Value::Border { width, color } => {
293                let Some(px) = width.as_px() else { return s };
294                s.outline(px).outline_color(to_peniko_brush(*color))
295            }
296            _ => s,
297        },
298        // `border-width` is a 1..=4 side shorthand — set all four sides.
299        "border-width" => apply_border_width(s, value),
300        "border-color" => match value {
301            Value::Color(c) => s.border_color(to_peniko_brush(*c)),
302            _ => s,
303        },
304        // floem 0.2 has a single global border_color brush; per-side colors
305        // collapse to last-write-wins (see apply_border doc for details).
306        "border-top-color" | "border-right-color" | "border-bottom-color" | "border-left-color" => {
307            match value {
308                Value::Color(c) => s.border_color(to_peniko_brush(*c)),
309                _ => s,
310            }
311        }
312
313        // ── Typography ────────────────────────────────────────────────────────
314        "font-size" => match value {
315            Value::Length(l) => {
316                if let Some(px) = l.as_px() {
317                    s.font_size(Px(px))
318                } else {
319                    // Percent font-sizes have no direct floem setter.
320                    // Gap: floem 0.2 font_size takes Px only, no % support.
321                    s
322                }
323            }
324            _ => s,
325        },
326        "font-weight" => match value {
327            Value::Number(n) => s.font_weight(Weight(*n as u16)),
328            Value::Keyword(kw) => match kw.as_str() {
329                "normal" => s.font_weight(Weight::NORMAL),
330                "bold" => s.font_weight(Weight::BOLD),
331                _ => s,
332            },
333            _ => s,
334        },
335        "font-style" => match value {
336            Value::Keyword(kw) => match kw.as_str() {
337                "italic" => s.font_style(floem::text::Style::Italic),
338                "oblique" => s.font_style(floem::text::Style::Oblique),
339                "normal" => s.font_style(floem::text::Style::Normal),
340                _ => s,
341            },
342            _ => s,
343        },
344        "font-family" => match value {
345            // Use the first family name; floem 0.2 font_family takes a
346            // single String. A future floem version may accept a list.
347            Value::FontFamilyList(list) => {
348                if let Some(first) = list.first() {
349                    s.font_family(first.clone())
350                } else {
351                    s
352                }
353            }
354            _ => s,
355        },
356        "line-height" => match value {
357            Value::Number(n) => s.line_height(*n as f32),
358            // Gap: floem 0.2 line_height takes f32 (normal multiplier); no
359            // pixel-value setter is exposed. Length variant is silently
360            // skipped.
361            _ => s,
362        },
363        // Gap: floem 0.2 has no text_align setter. The CSS property is
364        // parsed and resolved by hjkl-css but cannot be forwarded.
365        "text-align" => s,
366
367        _ => s,
368    }
369}
370
371// ── Border helpers ────────────────────────────────────────────────────────────
372
373#[derive(Clone, Copy)]
374enum BorderSide {
375    All,
376    Top,
377    Right,
378    Bottom,
379    Left,
380}
381
382/// Apply a `Value::Border` (width + color) to the requested side(s).
383///
384/// **Per-side color limitation.** floem 0.2 exposes a single global
385/// `border_color` brush — there is no per-side border color. When the CSS
386/// declares `border-top: 2px solid red; border-left: 2px solid blue`, the
387/// adapter calls `border_color(red)` then `border_color(blue)` and the
388/// last write wins for every side. This is a floem limitation surfaced
389/// here, not a parser bug: the resolved bag contains distinct colors,
390/// but the renderer cannot honour them. Authors targeting v0.x should
391/// either keep all per-side colors identical or set one global
392/// `border-color`.
393///
394/// **Source order honoured.** hjkl-css v0.4.0's `iter()` returns properties
395/// in CSS source order, so the adapter walks properties in the order they
396/// appear in the stylesheet. Shorthand (`border`) and longhand
397/// (`border-color`) are applied in the same sequence the author wrote them —
398/// no more alphabetical-order surprises.
399///
400/// **Always emits `border_color`.** Every `border` / `border-{side}` shorthand
401/// declaration emits both a width call and a `border_color` call, even when
402/// the author wrote only a width-and-style pair. This is intentional: a
403/// shorthand resets all its sub-properties per CSS spec. If a later
404/// `border-color` longhand follows in source order, it overrides via the
405/// single-brush model documented above.
406fn apply_border(s: Style, value: &Value, side: BorderSide) -> Style {
407    let Value::Border { width, color } = value else {
408        return s;
409    };
410    let Some(px) = width.as_px() else { return s };
411    let s = match side {
412        BorderSide::All => s.border(px),
413        BorderSide::Top => s.border_top(px),
414        BorderSide::Right => s.border_right(px),
415        BorderSide::Bottom => s.border_bottom(px),
416        BorderSide::Left => s.border_left(px),
417    };
418    s.border_color(to_peniko_brush(*color))
419}
420
421fn apply_border_width(s: Style, value: &Value) -> Style {
422    let Value::LengthSet(set) = value else {
423        return s;
424    };
425    let Some([top, right, bottom, left]) = expand_sides(set) else {
426        return s;
427    };
428    let (t, r, b, l) = (top.as_px(), right.as_px(), bottom.as_px(), left.as_px());
429    let Some((t, r, b, l)) = t
430        .zip(r)
431        .and_then(|(t, r)| b.zip(l).map(|(b, l)| (t, r, b, l)))
432    else {
433        // Percent border widths have no floem equivalent; skip.
434        return s;
435    };
436    s.border_top(t)
437        .border_right(r)
438        .border_bottom(b)
439        .border_left(l)
440}
441
442// ── Padding / border-radius helper ────────────────────────────────────────────
443
444fn apply_padding_or_border_radius(s: Style, prop: &str, value: &Value) -> Style {
445    let Value::LengthSet(set) = value else {
446        return s;
447    };
448    let Some([top, right, bottom, left]) = expand_sides(set) else {
449        return s;
450    };
451    if prop == "border-radius" {
452        // floem 0.2 exposes a single border_radius(PxPct) — use the top
453        // value (first in the shorthand) as the uniform radius.
454        // Gap: floem 0.2 has no per-corner border-radius setters.
455        s.border_radius(to_pct(top))
456    } else {
457        s.padding_top(to_pct(top))
458            .padding_right(to_pct(right))
459            .padding_bottom(to_pct(bottom))
460            .padding_left(to_pct(left))
461    }
462}
463
464// ── Margin helper ─────────────────────────────────────────────────────────────
465
466fn apply_margin(s: Style, value: &Value) -> Style {
467    match value {
468        Value::LengthSet(set) => {
469            let Some([top, right, bottom, left]) = expand_sides(set) else {
470                return s;
471            };
472            s.margin_top(to_pct_auto(top))
473                .margin_right(to_pct_auto(right))
474                .margin_bottom(to_pct_auto(bottom))
475                .margin_left(to_pct_auto(left))
476        }
477        Value::Auto => s
478            .margin_top(PxPctAuto::Auto)
479            .margin_right(PxPctAuto::Auto)
480            .margin_bottom(PxPctAuto::Auto)
481            .margin_left(PxPctAuto::Auto),
482        Value::SideSet(set) => {
483            let Some([top, right, bottom, left]) = expand_side_set(set) else {
484                return s;
485            };
486            s.margin_top(to_pct_auto_side(top))
487                .margin_right(to_pct_auto_side(right))
488                .margin_bottom(to_pct_auto_side(bottom))
489                .margin_left(to_pct_auto_side(left))
490        }
491        _ => s,
492    }
493}
494
495// ── Conversion helpers ────────────────────────────────────────────────────────
496
497fn to_peniko_color(c: hjkl_css::Color) -> Color {
498    Color::rgba8(c.r, c.g, c.b, c.a)
499}
500
501fn to_peniko_brush(c: hjkl_css::Color) -> Brush {
502    Brush::Solid(to_peniko_color(c))
503}
504
505fn to_pct(l: Length) -> PxPct {
506    match l {
507        Length::Px(v) => PxPct::Px(v),
508        Length::Percent(v) => PxPct::Pct(v),
509    }
510}
511
512fn to_pct_auto(l: Length) -> PxPctAuto {
513    match l {
514        Length::Px(v) => PxPctAuto::Px(v),
515        Length::Percent(v) => PxPctAuto::Pct(v),
516    }
517}
518
519/// Convert a single `SideValue` to `PxPctAuto` for margin shorthands that
520/// mix lengths with `auto` (e.g. `margin: 4px auto`).
521fn to_pct_auto_side(v: SideValue) -> PxPctAuto {
522    match v {
523        SideValue::Length(l) => to_pct_auto(l),
524        SideValue::Auto => PxPctAuto::Auto,
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use hjkl_css::{Color, Node, PseudoClass, Value};
531
532    use super::*;
533
534    // ── Conversion helpers ────────────────────────────────────────────────────
535
536    /// Smoke test: every conversion helper round-trips a representative
537    /// value without panicking. Compile-time evidence that the hjkl-css
538    /// AST types line up with floem's expected input shapes.
539    #[test]
540    fn conversions_are_total() {
541        let c = to_peniko_color(hjkl_css::Color::rgba(0x21, 0xd1, 0xd3, 0xff));
542        assert_eq!((c.r, c.g, c.b, c.a), (0x21, 0xd1, 0xd3, 0xff));
543        assert!(matches!(to_pct(Length::Px(10.0)), PxPct::Px(_)));
544        assert!(matches!(to_pct(Length::Percent(50.0)), PxPct::Pct(_)));
545        assert!(matches!(to_pct_auto(Length::Px(10.0)), PxPctAuto::Px(_)));
546        assert!(matches!(
547            to_pct_auto(Length::Percent(50.0)),
548            PxPctAuto::Pct(_)
549        ));
550        assert!(matches!(to_pct_auto_side(SideValue::Auto), PxPctAuto::Auto));
551        assert!(matches!(
552            to_pct_auto_side(SideValue::Length(Length::Px(4.0))),
553            PxPctAuto::Px(_)
554        ));
555    }
556
557    /// Resolving a stylesheet for an empty class list against a property
558    /// nobody set is a no-op — apply returns the input Style untouched.
559    #[test]
560    fn empty_resolved_does_not_panic() {
561        let sheet = hjkl_css::parse(".unrelated { color: #fff; }").unwrap();
562        let target = Node {
563            element: "nothing",
564            classes: &[],
565        };
566        let resolved = sheet.resolve(&target, &[], &[], None);
567        let s = Style::new();
568        let _ = apply(s, &resolved);
569    }
570
571    // ── Full property surface — no panic ─────────────────────────────────────
572
573    /// Parse a stylesheet that exercises every Value variant the parser can
574    /// emit, resolve it, and run apply() — confirming no panics across the
575    /// full property surface.
576    #[test]
577    fn all_value_variants_apply_without_panic() {
578        let css = r#"
579            x {
580                color: #ff0000;
581                background-color: rgba(0, 128, 0, 0.5);
582                width: 100px;
583                height: 50%;
584                width: auto;
585                padding: 4px 8px;
586                margin: 4px auto;
587                gap: 8px;
588                row-gap: 4px;
589                column-gap: 2px;
590                display: flex;
591                flex-direction: column;
592                align-items: center;
593                justify-content: space-between;
594                flex-grow: 2;
595                flex-shrink: 0;
596                flex-basis: 200px;
597                border: 1px solid #000;
598                border-top: 2px solid #fff;
599                border-right: 2px solid #fff;
600                border-bottom: 2px solid #fff;
601                border-left: 2px solid #fff;
602                border-width: 1px 2px 3px 4px;
603                border-color: blue;
604                border-radius: 4px;
605                outline: 1px solid #000;
606                font-size: 16px;
607                font-weight: 700;
608                font-weight: bold;
609                font-style: italic;
610                font-family: "Hack Nerd Font", monospace;
611                line-height: 1.5;
612                text-align: center;
613            }
614        "#;
615        let sheet = hjkl_css::parse(css).unwrap();
616        let target = Node {
617            element: "x",
618            classes: &[],
619        };
620        let resolved = sheet.resolve(&target, &[], &[], None);
621        // Must not panic; result is discarded.
622        let _ = apply(Style::new(), &resolved);
623    }
624
625    // ── Combinator: descendant selector ──────────────────────────────────────
626
627    /// `.parent .child { color: red }` resolved with a matching ancestor sets
628    /// the property; resolved without ancestors does not.
629    #[test]
630    fn descendant_combinator_with_ancestors_sets_property() {
631        let sheet = hjkl_css::parse(".parent .child { color: #ff0000; }").unwrap();
632        let child = Node {
633            element: "div",
634            classes: &["child"],
635        };
636        let parent = Node {
637            element: "div",
638            classes: &["parent"],
639        };
640
641        // With matching ancestor — must resolve.
642        let with_ancestor = sheet.resolve(&child, &[parent], &[], None);
643        assert_eq!(
644            with_ancestor.get("color"),
645            Some(&Value::Color(Color::rgb(0xff, 0x00, 0x00))),
646            "expected color when ancestor matches"
647        );
648
649        // Without ancestors — must not resolve.
650        let without_ancestor = sheet.resolve(&child, &[], &[], None);
651        assert!(
652            without_ancestor.get("color").is_none(),
653            "must not match without ancestor"
654        );
655    }
656
657    // ── Pseudo-state buckets ─────────────────────────────────────────────────
658
659    /// `:hover` declarations land only in the hover-state bag.
660    #[test]
661    fn hover_declaration_only_in_hover_state() {
662        let sheet = hjkl_css::parse(".btn { color: #000; } .btn:hover { color: #fff; }").unwrap();
663        let target = Node {
664            element: "button",
665            classes: &["btn"],
666        };
667
668        let base = sheet.resolve(&target, &[], &[], None);
669        let hover = sheet.resolve(&target, &[], &[], Some(PseudoClass::Hover));
670
671        assert_eq!(
672            base.get("color"),
673            Some(&Value::Color(Color::rgb(0x00, 0x00, 0x00))),
674            "base must use non-pseudo color"
675        );
676        assert_eq!(
677            hover.get("color"),
678            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff))),
679            "hover must use :hover color"
680        );
681    }
682
683    /// `:disabled` declarations land only in the disabled-state bag.
684    #[test]
685    fn disabled_declaration_only_in_disabled_state() {
686        let sheet = hjkl_css::parse(".btn:disabled { color: #aaa; }").unwrap();
687        let target = Node {
688            element: "button",
689            classes: &["btn"],
690        };
691
692        let base = sheet.resolve(&target, &[], &[], None);
693        let disabled = sheet.resolve(&target, &[], &[], Some(PseudoClass::Disabled));
694
695        assert!(
696            base.get("color").is_none(),
697            "base must not have :disabled color"
698        );
699        assert_eq!(
700            disabled.get("color"),
701            Some(&Value::Color(Color::rgb(0xaa, 0xaa, 0xaa))),
702            "disabled must use :disabled color"
703        );
704    }
705
706    // ── margin: auto and SideSet ─────────────────────────────────────────────
707
708    /// `margin: auto` must not panic and must reach the Auto branch.
709    #[test]
710    fn margin_auto_applies_without_panic() {
711        let sheet = hjkl_css::parse("x { margin: auto; }").unwrap();
712        let target = Node {
713            element: "x",
714            classes: &[],
715        };
716        let resolved = sheet.resolve(&target, &[], &[], None);
717        // Confirm the parser emits Auto for margin: auto.
718        assert_eq!(resolved.get("margin"), Some(&Value::Auto));
719        let _ = apply(Style::new(), &resolved);
720    }
721
722    /// `margin: 4px auto` → SideSet, apply must not panic.
723    #[test]
724    fn margin_side_set_applies_without_panic() {
725        let sheet = hjkl_css::parse("x { margin: 4px auto; }").unwrap();
726        let target = Node {
727            element: "x",
728            classes: &[],
729        };
730        let resolved = sheet.resolve(&target, &[], &[], None);
731        assert!(
732            matches!(resolved.get("margin"), Some(Value::SideSet(_))),
733            "expected SideSet for mixed margin"
734        );
735        let _ = apply(Style::new(), &resolved);
736    }
737
738    // ── Border: per-side colors collide (documented floem limitation) ────────
739
740    /// floem 0.2 has a single `border_color` brush, so per-side CSS colors
741    /// silently unify to last-write-wins. Test guarantees `apply` does not
742    /// panic on mixed per-side colors and pins the limitation under test —
743    /// if a future floem gains per-side colors, this test should be
744    /// re-expressed to assert per-side preservation.
745    #[test]
746    fn per_side_border_colors_resolve_without_panic() {
747        let css = r#"
748            x {
749                border-top: 2px solid #ff0000;
750                border-left: 2px solid #0000ff;
751            }
752        "#;
753        let sheet = hjkl_css::parse(css).unwrap();
754        let target = Node {
755            element: "x",
756            classes: &[],
757        };
758        let resolved = sheet.resolve(&target, &[], &[], None);
759        // Both declarations resolve into the bag; only the renderer collapses.
760        assert!(resolved.get("border-top").is_some());
761        assert!(resolved.get("border-left").is_some());
762        let _ = apply(Style::new(), &resolved);
763    }
764
765    // ── flex-basis routes to flex_basis, not width ───────────────────────────
766
767    /// Regression: `flex-basis` must resolve to its own bag entry, not get
768    /// silently re-mapped to `width`. Pins the routing contract — actual
769    /// floem setter selection is verified at compile time by `apply_one`.
770    #[test]
771    fn flex_basis_resolves_independently_from_width() {
772        let sheet = hjkl_css::parse("x { flex-basis: 200px; }").unwrap();
773        let target = Node {
774            element: "x",
775            classes: &[],
776        };
777        let resolved = sheet.resolve(&target, &[], &[], None);
778        assert_eq!(
779            resolved.get("flex-basis"),
780            Some(&Value::Length(Length::Px(200.0)))
781        );
782        assert!(resolved.get("width").is_none(), "must not also set width");
783        let _ = apply(Style::new(), &resolved);
784    }
785
786    // ── css_in API smoke test ────────────────────────────────────────────────
787
788    /// StateStyles::resolve accepts a non-empty ancestor slice without panic.
789    #[test]
790    fn state_styles_resolve_with_ancestors() {
791        let sheet = hjkl_css::parse(".row .label { color: #fff; }").unwrap();
792        let target = Node {
793            element: "span",
794            classes: &["label"],
795        };
796        let row = Node {
797            element: "div",
798            classes: &["row"],
799        };
800        let states = StateStyles::resolve(&sheet, target, &[row], &[]);
801        // base must have color resolved via the descendant combinator.
802        assert_eq!(
803            states.base.get("color"),
804            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
805        );
806        // Resolving with empty ancestors must yield nothing.
807        let states_no_ctx = StateStyles::resolve(&sheet, target, &[], &[]);
808        assert!(states_no_ctx.base.is_empty());
809    }
810
811    // ── font-style: oblique ──────────────────────────────────────────────────
812
813    /// `font-style: oblique` resolves to a Keyword value and apply runs
814    /// without panic. The Oblique → `floem::text::Style::Oblique` routing is
815    /// checked at compile time via the match arm; floem 0.2 exposes no
816    /// `Style` introspection API so the resulting setter call cannot be
817    /// asserted at runtime.
818    #[test]
819    fn font_style_oblique_resolves() {
820        let sheet = hjkl_css::parse("x { font-style: oblique; }").unwrap();
821        let target = Node {
822            element: "x",
823            classes: &[],
824        };
825        let resolved = sheet.resolve(&target, &[], &[], None);
826        assert_eq!(
827            resolved.get("font-style"),
828            Some(&Value::Keyword("oblique".into())),
829            "expected oblique keyword from parser"
830        );
831        let _ = apply(Style::new(), &resolved);
832    }
833
834    // ── border per-side color longhands ──────────────────────────────────────
835
836    /// Each of the four per-side border color longhands resolves to
837    /// `Value::Color` in hjkl-css v0.4.0 and apply runs without panic.
838    /// floem 0.2 collapses all four to a single brush (last-write-wins).
839    #[test]
840    fn border_side_color_longhands_resolve() {
841        let css = r#"
842            x {
843                border-top-color: #ff0000;
844                border-right-color: #00ff00;
845                border-bottom-color: #0000ff;
846                border-left-color: #ffff00;
847            }
848        "#;
849        let sheet = hjkl_css::parse(css).unwrap();
850        let target = Node {
851            element: "x",
852            classes: &[],
853        };
854        let resolved = sheet.resolve(&target, &[], &[], None);
855        assert!(
856            matches!(resolved.get("border-top-color"), Some(Value::Color(_))),
857            "border-top-color must resolve to Color"
858        );
859        assert!(
860            matches!(resolved.get("border-right-color"), Some(Value::Color(_))),
861            "border-right-color must resolve to Color"
862        );
863        assert!(
864            matches!(resolved.get("border-bottom-color"), Some(Value::Color(_))),
865            "border-bottom-color must resolve to Color"
866        );
867        assert!(
868            matches!(resolved.get("border-left-color"), Some(Value::Color(_))),
869            "border-left-color must resolve to Color"
870        );
871        // apply must not panic even though floem collapses to last-write-wins.
872        let _ = apply(Style::new(), &resolved);
873    }
874
875    // ── Integration: .css() and .css_in() compile and run without panic ──────
876
877    /// Approach (a): construct a label, stack, and container view, chain
878    /// `.css(...)` and `.css_in(...)` on each, and confirm the calls
879    /// typecheck and do not panic. No headless floem runtime is required —
880    /// the style closure is captured but not driven by a reactor.
881    ///
882    /// **Coverage scope.** This test catches compile-time regressions in
883    /// the `ViewCssExt` trait surface and the `apply_one` dispatch
884    /// signature. It does NOT catch runtime routing mistakes (e.g. a
885    /// width property accidentally routed to a height setter) because
886    /// floem 0.2 exposes no `Style` introspection API to assert on. Per-
887    /// property routing correctness is enforced by the per-property
888    /// resolver tests above and by visual inspection in adopter crates.
889    #[test]
890    fn integration_label_view_with_css() {
891        use hjkl_css::Node as CssNode;
892
893        let sheet = hjkl_css::parse(
894            r#"
895            label { color: #21d1d3; padding: 4px 8px; font-style: oblique; }
896            label.prompt { font-weight: bold; }
897            .row label { color: #ffffff; }
898            "#,
899        )
900        .unwrap();
901
902        // Flat call: label with a class.
903        let _label = floem::views::label(|| "hello").css(&sheet, "label", &["prompt"]);
904
905        // Context-aware call: label inside a row.
906        let row = CssNode {
907            element: "div",
908            classes: &["row"],
909        };
910        let target = CssNode {
911            element: "label",
912            classes: &[],
913        };
914        let _label_in = floem::views::label(|| "world").css_in(&sheet, target, &[row], &[]);
915
916        // Stack with css.
917        let _stack = floem::views::stack((floem::views::label(|| "a"),)).css(&sheet, "stack", &[]);
918
919        // Container with css.
920        let _container =
921            floem::views::container(floem::views::label(|| "b")).css(&sheet, "container", &[]);
922    }
923}