use floem::peniko::{Brush, Color};
use floem::style::Style;
use floem::taffy::style::{AlignItems, Display, FlexDirection, JustifyContent};
use floem::text::Weight;
use floem::unit::{Px, PxPct, PxPctAuto};
use floem::views::Decorators;
use hjkl_css::{
Length, Node, PseudoClass, ResolvedStyle, SideValue, Stylesheet, Value, expand_side_set,
expand_sides,
};
pub trait ViewCssExt: Decorators + Sized {
#[must_use = "css() returns a styled view; bind it or chain further"]
fn css(self, sheet: &Stylesheet, element: &str, classes: &[&str]) -> Self::DV {
let target = Node { element, classes };
self.css_in(sheet, target, &[], &[])
}
#[must_use = "css_in() returns a styled view; bind it or chain further"]
fn css_in(
self,
sheet: &Stylesheet,
target: Node<'_>,
ancestors: &[Node<'_>],
prev_siblings: &[Node<'_>],
) -> Self::DV {
let states = StateStyles::resolve(sheet, target, ancestors, prev_siblings);
self.style(move |s| {
let mut s = apply(s, &states.base);
s = s.hover(|hs| apply(hs, &states.hover));
s = s.focus(|fs| apply(fs, &states.focus));
s = s.active(|act| apply(act, &states.active));
s = s.disabled(|ds| apply(ds, &states.disabled));
s = s.selected(|sel| apply(sel, &states.selected));
s
})
}
}
impl<V: Decorators + Sized> ViewCssExt for V {}
#[derive(Clone)]
struct StateStyles {
base: ResolvedStyle,
hover: ResolvedStyle,
focus: ResolvedStyle,
active: ResolvedStyle,
disabled: ResolvedStyle,
selected: ResolvedStyle,
}
impl StateStyles {
fn resolve(
sheet: &Stylesheet,
target: Node<'_>,
ancestors: &[Node<'_>],
prev_siblings: &[Node<'_>],
) -> Self {
Self {
base: sheet.resolve(&target, ancestors, prev_siblings, None),
hover: sheet.resolve(&target, ancestors, prev_siblings, Some(PseudoClass::Hover)),
focus: sheet.resolve(&target, ancestors, prev_siblings, Some(PseudoClass::Focus)),
active: sheet.resolve(&target, ancestors, prev_siblings, Some(PseudoClass::Active)),
disabled: sheet.resolve(
&target,
ancestors,
prev_siblings,
Some(PseudoClass::Disabled),
),
selected: sheet.resolve(
&target,
ancestors,
prev_siblings,
Some(PseudoClass::Selected),
),
}
}
}
fn apply(mut s: Style, resolved: &ResolvedStyle) -> Style {
for (prop, value) in resolved.iter() {
s = apply_one(s, prop, value);
}
s
}
#[allow(clippy::too_many_lines)]
fn apply_one(s: Style, prop: &str, value: &Value) -> Style {
match prop {
"color" => match value {
Value::Color(c) => s.color(to_peniko_color(*c)),
_ => s,
},
"background-color" => match value {
Value::Color(c) => s.background(to_peniko_color(*c)),
_ => s,
},
"width" => match value {
Value::Length(l) => s.width(to_pct_auto(*l)),
Value::Auto => s.width(PxPctAuto::Auto),
_ => s,
},
"height" => match value {
Value::Length(l) => s.height(to_pct_auto(*l)),
Value::Auto => s.height(PxPctAuto::Auto),
_ => s,
},
"flex-basis" => match value {
Value::Length(l) => s.flex_basis(to_pct_auto(*l)),
Value::Auto => s.flex_basis(PxPctAuto::Auto),
_ => s,
},
"padding" | "border-radius" => apply_padding_or_border_radius(s, prop, value),
"margin" => apply_margin(s, value),
"gap" => match value {
Value::Length(l) => s.gap(to_pct(*l)),
_ => s,
},
"row-gap" => match value {
Value::Length(l) => s.row_gap(to_pct(*l)),
_ => s,
},
"column-gap" => match value {
Value::Length(l) => s.column_gap(to_pct(*l)),
_ => s,
},
"display" => match value {
Value::Keyword(kw) => match kw.as_str() {
"flex" => s.display(Display::Flex),
"block" => s.display(Display::Block),
"none" => s.display(Display::None),
_ => s,
},
_ => s,
},
"flex-direction" => match value {
Value::Keyword(kw) => match kw.as_str() {
"row" => s.flex_direction(FlexDirection::Row),
"column" => s.flex_direction(FlexDirection::Column),
"row-reverse" => s.flex_direction(FlexDirection::RowReverse),
"column-reverse" => s.flex_direction(FlexDirection::ColumnReverse),
_ => s,
},
_ => s,
},
"align-items" => match value {
Value::Keyword(kw) => match kw.as_str() {
"start" => s.align_items(Some(AlignItems::Start)),
"end" => s.align_items(Some(AlignItems::End)),
"center" => s.align_items(Some(AlignItems::Center)),
"stretch" => s.align_items(Some(AlignItems::Stretch)),
"baseline" => s.align_items(Some(AlignItems::Baseline)),
_ => s,
},
_ => s,
},
"justify-content" => match value {
Value::Keyword(kw) => match kw.as_str() {
"start" => s.justify_content(Some(JustifyContent::Start)),
"end" => s.justify_content(Some(JustifyContent::End)),
"center" => s.justify_content(Some(JustifyContent::Center)),
"space-between" => s.justify_content(Some(JustifyContent::SpaceBetween)),
"space-around" => s.justify_content(Some(JustifyContent::SpaceAround)),
"space-evenly" => s.justify_content(Some(JustifyContent::SpaceEvenly)),
_ => s,
},
_ => s,
},
"flex-grow" => match value {
Value::Number(n) => s.flex_grow(*n as f32),
_ => s,
},
"flex-shrink" => match value {
Value::Number(n) => s.flex_shrink(*n as f32),
_ => s,
},
"border" => apply_border(s, value, BorderSide::All),
"border-top" => apply_border(s, value, BorderSide::Top),
"border-right" => apply_border(s, value, BorderSide::Right),
"border-bottom" => apply_border(s, value, BorderSide::Bottom),
"border-left" => apply_border(s, value, BorderSide::Left),
"outline" => match value {
Value::Border { width, color } => {
let Some(px) = width.as_px() else { return s };
s.outline(px).outline_color(to_peniko_brush(*color))
}
_ => s,
},
"border-width" => apply_border_width(s, value),
"border-color" => match value {
Value::Color(c) => s.border_color(to_peniko_brush(*c)),
_ => s,
},
"border-top-color" | "border-right-color" | "border-bottom-color" | "border-left-color" => {
match value {
Value::Color(c) => s.border_color(to_peniko_brush(*c)),
_ => s,
}
}
"font-size" => match value {
Value::Length(l) => {
if let Some(px) = l.as_px() {
s.font_size(Px(px))
} else {
s
}
}
_ => s,
},
"font-weight" => match value {
Value::Number(n) => s.font_weight(Weight(*n as u16)),
Value::Keyword(kw) => match kw.as_str() {
"normal" => s.font_weight(Weight::NORMAL),
"bold" => s.font_weight(Weight::BOLD),
_ => s,
},
_ => s,
},
"font-style" => match value {
Value::Keyword(kw) => match kw.as_str() {
"italic" => s.font_style(floem::text::Style::Italic),
"oblique" => s.font_style(floem::text::Style::Oblique),
"normal" => s.font_style(floem::text::Style::Normal),
_ => s,
},
_ => s,
},
"font-family" => match value {
Value::FontFamilyList(list) => {
if let Some(first) = list.first() {
s.font_family(first.clone())
} else {
s
}
}
_ => s,
},
"line-height" => match value {
Value::Number(n) => s.line_height(*n as f32),
_ => s,
},
"text-align" => s,
_ => s,
}
}
#[derive(Clone, Copy)]
enum BorderSide {
All,
Top,
Right,
Bottom,
Left,
}
fn apply_border(s: Style, value: &Value, side: BorderSide) -> Style {
let Value::Border { width, color } = value else {
return s;
};
let Some(px) = width.as_px() else { return s };
let s = match side {
BorderSide::All => s.border(px),
BorderSide::Top => s.border_top(px),
BorderSide::Right => s.border_right(px),
BorderSide::Bottom => s.border_bottom(px),
BorderSide::Left => s.border_left(px),
};
s.border_color(to_peniko_brush(*color))
}
fn apply_border_width(s: Style, value: &Value) -> Style {
let Value::LengthSet(set) = value else {
return s;
};
let Some([top, right, bottom, left]) = expand_sides(set) else {
return s;
};
let (t, r, b, l) = (top.as_px(), right.as_px(), bottom.as_px(), left.as_px());
let Some((t, r, b, l)) = t
.zip(r)
.and_then(|(t, r)| b.zip(l).map(|(b, l)| (t, r, b, l)))
else {
return s;
};
s.border_top(t)
.border_right(r)
.border_bottom(b)
.border_left(l)
}
fn apply_padding_or_border_radius(s: Style, prop: &str, value: &Value) -> Style {
let Value::LengthSet(set) = value else {
return s;
};
let Some([top, right, bottom, left]) = expand_sides(set) else {
return s;
};
if prop == "border-radius" {
s.border_radius(to_pct(top))
} else {
s.padding_top(to_pct(top))
.padding_right(to_pct(right))
.padding_bottom(to_pct(bottom))
.padding_left(to_pct(left))
}
}
fn apply_margin(s: Style, value: &Value) -> Style {
match value {
Value::LengthSet(set) => {
let Some([top, right, bottom, left]) = expand_sides(set) else {
return s;
};
s.margin_top(to_pct_auto(top))
.margin_right(to_pct_auto(right))
.margin_bottom(to_pct_auto(bottom))
.margin_left(to_pct_auto(left))
}
Value::Auto => s
.margin_top(PxPctAuto::Auto)
.margin_right(PxPctAuto::Auto)
.margin_bottom(PxPctAuto::Auto)
.margin_left(PxPctAuto::Auto),
Value::SideSet(set) => {
let Some([top, right, bottom, left]) = expand_side_set(set) else {
return s;
};
s.margin_top(to_pct_auto_side(top))
.margin_right(to_pct_auto_side(right))
.margin_bottom(to_pct_auto_side(bottom))
.margin_left(to_pct_auto_side(left))
}
_ => s,
}
}
fn to_peniko_color(c: hjkl_css::Color) -> Color {
Color::rgba8(c.r, c.g, c.b, c.a)
}
fn to_peniko_brush(c: hjkl_css::Color) -> Brush {
Brush::Solid(to_peniko_color(c))
}
fn to_pct(l: Length) -> PxPct {
match l {
Length::Px(v) => PxPct::Px(v),
Length::Percent(v) => PxPct::Pct(v),
}
}
fn to_pct_auto(l: Length) -> PxPctAuto {
match l {
Length::Px(v) => PxPctAuto::Px(v),
Length::Percent(v) => PxPctAuto::Pct(v),
}
}
fn to_pct_auto_side(v: SideValue) -> PxPctAuto {
match v {
SideValue::Length(l) => to_pct_auto(l),
SideValue::Auto => PxPctAuto::Auto,
}
}
#[cfg(test)]
mod tests {
use hjkl_css::{Color, Node, PseudoClass, Value};
use super::*;
#[test]
fn conversions_are_total() {
let c = to_peniko_color(hjkl_css::Color::rgba(0x21, 0xd1, 0xd3, 0xff));
assert_eq!((c.r, c.g, c.b, c.a), (0x21, 0xd1, 0xd3, 0xff));
assert!(matches!(to_pct(Length::Px(10.0)), PxPct::Px(_)));
assert!(matches!(to_pct(Length::Percent(50.0)), PxPct::Pct(_)));
assert!(matches!(to_pct_auto(Length::Px(10.0)), PxPctAuto::Px(_)));
assert!(matches!(
to_pct_auto(Length::Percent(50.0)),
PxPctAuto::Pct(_)
));
assert!(matches!(to_pct_auto_side(SideValue::Auto), PxPctAuto::Auto));
assert!(matches!(
to_pct_auto_side(SideValue::Length(Length::Px(4.0))),
PxPctAuto::Px(_)
));
}
#[test]
fn empty_resolved_does_not_panic() {
let sheet = hjkl_css::parse(".unrelated { color: #fff; }").unwrap();
let target = Node {
element: "nothing",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
let s = Style::new();
let _ = apply(s, &resolved);
}
#[test]
fn all_value_variants_apply_without_panic() {
let css = r#"
x {
color: #ff0000;
background-color: rgba(0, 128, 0, 0.5);
width: 100px;
height: 50%;
width: auto;
padding: 4px 8px;
margin: 4px auto;
gap: 8px;
row-gap: 4px;
column-gap: 2px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
flex-grow: 2;
flex-shrink: 0;
flex-basis: 200px;
border: 1px solid #000;
border-top: 2px solid #fff;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
border-left: 2px solid #fff;
border-width: 1px 2px 3px 4px;
border-color: blue;
border-radius: 4px;
outline: 1px solid #000;
font-size: 16px;
font-weight: 700;
font-weight: bold;
font-style: italic;
font-family: "Hack Nerd Font", monospace;
line-height: 1.5;
text-align: center;
}
"#;
let sheet = hjkl_css::parse(css).unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
let _ = apply(Style::new(), &resolved);
}
#[test]
fn descendant_combinator_with_ancestors_sets_property() {
let sheet = hjkl_css::parse(".parent .child { color: #ff0000; }").unwrap();
let child = Node {
element: "div",
classes: &["child"],
};
let parent = Node {
element: "div",
classes: &["parent"],
};
let with_ancestor = sheet.resolve(&child, &[parent], &[], None);
assert_eq!(
with_ancestor.get("color"),
Some(&Value::Color(Color::rgb(0xff, 0x00, 0x00))),
"expected color when ancestor matches"
);
let without_ancestor = sheet.resolve(&child, &[], &[], None);
assert!(
without_ancestor.get("color").is_none(),
"must not match without ancestor"
);
}
#[test]
fn hover_declaration_only_in_hover_state() {
let sheet = hjkl_css::parse(".btn { color: #000; } .btn:hover { color: #fff; }").unwrap();
let target = Node {
element: "button",
classes: &["btn"],
};
let base = sheet.resolve(&target, &[], &[], None);
let hover = sheet.resolve(&target, &[], &[], Some(PseudoClass::Hover));
assert_eq!(
base.get("color"),
Some(&Value::Color(Color::rgb(0x00, 0x00, 0x00))),
"base must use non-pseudo color"
);
assert_eq!(
hover.get("color"),
Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff))),
"hover must use :hover color"
);
}
#[test]
fn disabled_declaration_only_in_disabled_state() {
let sheet = hjkl_css::parse(".btn:disabled { color: #aaa; }").unwrap();
let target = Node {
element: "button",
classes: &["btn"],
};
let base = sheet.resolve(&target, &[], &[], None);
let disabled = sheet.resolve(&target, &[], &[], Some(PseudoClass::Disabled));
assert!(
base.get("color").is_none(),
"base must not have :disabled color"
);
assert_eq!(
disabled.get("color"),
Some(&Value::Color(Color::rgb(0xaa, 0xaa, 0xaa))),
"disabled must use :disabled color"
);
}
#[test]
fn margin_auto_applies_without_panic() {
let sheet = hjkl_css::parse("x { margin: auto; }").unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
assert_eq!(resolved.get("margin"), Some(&Value::Auto));
let _ = apply(Style::new(), &resolved);
}
#[test]
fn margin_side_set_applies_without_panic() {
let sheet = hjkl_css::parse("x { margin: 4px auto; }").unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
assert!(
matches!(resolved.get("margin"), Some(Value::SideSet(_))),
"expected SideSet for mixed margin"
);
let _ = apply(Style::new(), &resolved);
}
#[test]
fn per_side_border_colors_resolve_without_panic() {
let css = r#"
x {
border-top: 2px solid #ff0000;
border-left: 2px solid #0000ff;
}
"#;
let sheet = hjkl_css::parse(css).unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
assert!(resolved.get("border-top").is_some());
assert!(resolved.get("border-left").is_some());
let _ = apply(Style::new(), &resolved);
}
#[test]
fn flex_basis_resolves_independently_from_width() {
let sheet = hjkl_css::parse("x { flex-basis: 200px; }").unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
assert_eq!(
resolved.get("flex-basis"),
Some(&Value::Length(Length::Px(200.0)))
);
assert!(resolved.get("width").is_none(), "must not also set width");
let _ = apply(Style::new(), &resolved);
}
#[test]
fn state_styles_resolve_with_ancestors() {
let sheet = hjkl_css::parse(".row .label { color: #fff; }").unwrap();
let target = Node {
element: "span",
classes: &["label"],
};
let row = Node {
element: "div",
classes: &["row"],
};
let states = StateStyles::resolve(&sheet, target, &[row], &[]);
assert_eq!(
states.base.get("color"),
Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
);
let states_no_ctx = StateStyles::resolve(&sheet, target, &[], &[]);
assert!(states_no_ctx.base.is_empty());
}
#[test]
fn font_style_oblique_resolves() {
let sheet = hjkl_css::parse("x { font-style: oblique; }").unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
assert_eq!(
resolved.get("font-style"),
Some(&Value::Keyword("oblique".into())),
"expected oblique keyword from parser"
);
let _ = apply(Style::new(), &resolved);
}
#[test]
fn border_side_color_longhands_resolve() {
let css = r#"
x {
border-top-color: #ff0000;
border-right-color: #00ff00;
border-bottom-color: #0000ff;
border-left-color: #ffff00;
}
"#;
let sheet = hjkl_css::parse(css).unwrap();
let target = Node {
element: "x",
classes: &[],
};
let resolved = sheet.resolve(&target, &[], &[], None);
assert!(
matches!(resolved.get("border-top-color"), Some(Value::Color(_))),
"border-top-color must resolve to Color"
);
assert!(
matches!(resolved.get("border-right-color"), Some(Value::Color(_))),
"border-right-color must resolve to Color"
);
assert!(
matches!(resolved.get("border-bottom-color"), Some(Value::Color(_))),
"border-bottom-color must resolve to Color"
);
assert!(
matches!(resolved.get("border-left-color"), Some(Value::Color(_))),
"border-left-color must resolve to Color"
);
let _ = apply(Style::new(), &resolved);
}
#[test]
fn integration_label_view_with_css() {
use hjkl_css::Node as CssNode;
let sheet = hjkl_css::parse(
r#"
label { color: #21d1d3; padding: 4px 8px; font-style: oblique; }
label.prompt { font-weight: bold; }
.row label { color: #ffffff; }
"#,
)
.unwrap();
let _label = floem::views::label(|| "hello").css(&sheet, "label", &["prompt"]);
let row = CssNode {
element: "div",
classes: &["row"],
};
let target = CssNode {
element: "label",
classes: &[],
};
let _label_in = floem::views::label(|| "world").css_in(&sheet, target, &[row], &[]);
let _stack = floem::views::stack((floem::views::label(|| "a"),)).css(&sheet, "stack", &[]);
let _container =
floem::views::container(floem::views::label(|| "b")).css(&sheet, "container", &[]);
}
}