1use 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
66pub trait ViewCssExt: Decorators + Sized {
91 #[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 #[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#[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
175fn 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" => 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 "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 "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 "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" => 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" => 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" => 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 "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 "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 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 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 _ => s,
362 },
363 "text-align" => s,
366
367 _ => s,
368 }
369}
370
371#[derive(Clone, Copy)]
374enum BorderSide {
375 All,
376 Top,
377 Right,
378 Bottom,
379 Left,
380}
381
382fn 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 return s;
435 };
436 s.border_top(t)
437 .border_right(r)
438 .border_bottom(b)
439 .border_left(l)
440}
441
442fn 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 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
464fn 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
495fn 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
519fn 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 #[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 #[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 #[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 let _ = apply(Style::new(), &resolved);
623 }
624
625 #[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 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 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 #[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 #[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 #[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 assert_eq!(resolved.get("margin"), Some(&Value::Auto));
719 let _ = apply(Style::new(), &resolved);
720 }
721
722 #[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 #[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 assert!(resolved.get("border-top").is_some());
761 assert!(resolved.get("border-left").is_some());
762 let _ = apply(Style::new(), &resolved);
763 }
764
765 #[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 #[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 assert_eq!(
803 states.base.get("color"),
804 Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
805 );
806 let states_no_ctx = StateStyles::resolve(&sheet, target, &[], &[]);
808 assert!(states_no_ctx.base.is_empty());
809 }
810
811 #[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 #[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 let _ = apply(Style::new(), &resolved);
873 }
874
875 #[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 let _label = floem::views::label(|| "hello").css(&sheet, "label", &["prompt"]);
904
905 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 let _stack = floem::views::stack((floem::views::label(|| "a"),)).css(&sheet, "stack", &[]);
918
919 let _container =
921 floem::views::container(floem::views::label(|| "b")).css(&sheet, "container", &[]);
922 }
923}