use ratatui::{
layout::{Alignment, Constraint, Rect},
style::Style as RStyle,
widgets::Block,
};
use crate::box_model::{BorderStyle, BorderStyleValue, BoxEdges, BoxEdgesValue, Length};
use crate::cache::{node_signature, ComputeCache};
use crate::color::Color;
use crate::media::MediaContext;
use crate::node::{Classes, StyledNode};
use crate::selector::NodeIdentity;
use crate::style::CssStyle;
use crate::stylesheet::Stylesheet;
use crate::token::{self, ThemeTokens};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ComputedStyle {
pub style: CssStyle,
}
impl ComputedStyle {
pub fn new(style: CssStyle) -> Self {
Self { style }
}
pub fn to_style(&self) -> RStyle {
self.style.to_style()
}
pub fn to_block(&self) -> Block<'_> {
self.style.to_block()
}
pub fn apply_margin(&self, area: Rect) -> Rect {
self.style.apply_margin(area)
}
pub fn constraints(&self) -> Option<(Constraint, Constraint)> {
self.style.constraints()
}
pub fn alignment(&self) -> Option<Alignment> {
self.style.alignment()
}
pub fn apply_inline(&mut self, inline: &CssStyle) {
self.style.overlay(inline);
}
pub fn with_inline(mut self, inline: &CssStyle) -> Self {
self.apply_inline(inline);
self
}
pub fn layout(&self, area: Rect) -> (Block<'_>, RStyle, Rect) {
let shrunk = self.apply_margin(area);
self.layout_with_shrunk(shrunk)
}
fn layout_with_shrunk(&self, shrunk: Rect) -> (Block<'_>, RStyle, Rect) {
let block = self.to_block();
let inner = block.inner(shrunk);
let style = self.to_style();
(block, style, inner)
}
}
pub fn render_computed<W, F>(
frame: &mut ratatui::Frame<'_>,
computed: &ComputedStyle,
area: Rect,
make: F,
) where
F: FnOnce(Rect, RStyle) -> W,
W: ratatui::widgets::Widget,
{
let shrunk = computed.apply_margin(area);
let (block, style, inner) = computed.layout_with_shrunk(shrunk);
frame.render_widget(block, shrunk);
frame.render_widget(make(inner, style), inner);
}
pub struct CascadeContext<'s> {
sheet: &'s Stylesheet,
scratch: ComputeScratch,
stack: Vec<ComputedStyle>,
identity_stack: Vec<NodeIdentity>,
siblings: Vec<Vec<NodeIdentity>>,
media: MediaContext,
cache: Option<ComputeCache>,
sig_stack: Vec<u64>,
}
impl<'s> CascadeContext<'s> {
pub fn new(sheet: &'s Stylesheet) -> Self {
Self {
sheet,
scratch: ComputeScratch::new(),
stack: Vec::new(),
identity_stack: Vec::new(),
siblings: Vec::new(),
media: MediaContext::default(),
cache: None,
sig_stack: Vec::new(),
}
}
pub fn set_media(&mut self, media: MediaContext) -> &mut Self {
self.media = media;
self
}
pub fn with_media(mut self, media: MediaContext) -> Self {
self.media = media;
self
}
pub fn with_cache(mut self, capacity: usize) -> Self {
self.cache = Some(ComputeCache::new(capacity));
self
}
pub fn cache(&self) -> Option<&ComputeCache> {
self.cache.as_ref()
}
pub fn media(&self) -> &MediaContext {
&self.media
}
pub fn enter(&mut self, node: &dyn StyledNode) -> ComputedStyle {
let parent = self.stack.last();
let has_comb = self.sheet.has_combinators();
let depth = self.stack.len();
let (computed, sig) = if let Some(cache) = self.cache.as_mut() {
let parent_sig = self.sig_stack.last().copied();
if has_comb {
let prev_sibs: &[NodeIdentity] = self
.siblings
.get(depth)
.map(Vec::as_slice)
.unwrap_or(&[]);
self.sheet.compute_cached_ancestors(
node,
parent,
parent_sig,
&self.identity_stack,
prev_sibs,
&self.media,
&mut self.scratch,
cache,
)
} else {
self.sheet.compute_cached(
node,
parent,
parent_sig,
&self.media,
&mut self.scratch,
cache,
)
}
} else {
let c = if has_comb {
let prev_sibs: &[NodeIdentity] = self
.siblings
.get(depth)
.map(Vec::as_slice)
.unwrap_or(&[]);
self.sheet.compute_with_ancestors_media(
node,
parent,
&mut self.scratch,
&self.identity_stack,
prev_sibs,
&self.media,
)
} else {
self.sheet
.compute_with_media(node, parent, &mut self.scratch, &self.media)
};
(c, 0)
};
self.stack.push(computed.clone());
if self.cache.is_some() {
self.sig_stack.push(sig);
}
if has_comb {
self.identity_stack.push(NodeIdentity::from_node(node));
let child_depth = depth + 1;
if self.siblings.len() <= child_depth {
self.siblings.resize_with(child_depth + 1, Vec::new);
}
self.siblings[child_depth].clear();
}
computed
}
pub fn leave(&mut self) -> Option<ComputedStyle> {
if self.sheet.has_combinators() && !self.identity_stack.is_empty() {
let popped = self.identity_stack.pop().expect("identity stack non-empty");
let depth = self.stack.len() - 1;
if self.siblings.len() <= depth {
self.siblings.resize_with(depth + 1, Vec::new);
}
self.siblings[depth].push(popped);
}
if self.cache.is_some() && !self.sig_stack.is_empty() {
self.sig_stack.pop();
}
self.stack.pop()
}
pub fn current(&self) -> Option<&ComputedStyle> {
self.stack.last()
}
pub fn depth(&self) -> usize {
self.stack.len()
}
pub fn sheet(&self) -> &Stylesheet {
self.sheet
}
}
pub struct ComputeScratch {
matching: Vec<usize>,
}
impl ComputeScratch {
pub fn new() -> Self {
Self {
matching: Vec::new(),
}
}
}
impl Default for ComputeScratch {
fn default() -> Self {
Self::new()
}
}
impl Stylesheet {
pub fn compute(&self, node: &dyn StyledNode, parent: Option<&ComputedStyle>) -> ComputedStyle {
let mut scratch = ComputeScratch::new();
self.compute_with(node, parent, &mut scratch)
}
pub fn compute_with(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
scratch: &mut ComputeScratch,
) -> ComputedStyle {
self.compute_with_media(node, parent, scratch, &MediaContext::default())
}
pub fn compute_with_media(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
scratch: &mut ComputeScratch,
media: &MediaContext,
) -> ComputedStyle {
self.compute_inner(node, parent, scratch, None, None, media)
}
pub(crate) fn compute_with_ancestors_media(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
scratch: &mut ComputeScratch,
ancestors: &[NodeIdentity],
siblings: &[NodeIdentity],
media: &MediaContext,
) -> ComputedStyle {
self.compute_inner(node, parent, scratch, Some(ancestors), Some(siblings), media)
}
pub fn compute_cached(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
parent_sig: Option<u64>,
media: &MediaContext,
scratch: &mut ComputeScratch,
cache: &mut ComputeCache,
) -> (ComputedStyle, u64) {
let node_id = NodeIdentity::from_node(node);
let sig = node_signature(&node_id, parent_sig, &[], media);
if let Some(hit) = cache.get(sig, self.generation()) {
return (hit, sig);
}
let computed = self.compute_with_media(node, parent, scratch, media);
cache.insert(sig, computed.clone(), self.generation());
(computed, sig)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn compute_cached_ancestors(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
parent_sig: Option<u64>,
ancestors: &[NodeIdentity],
siblings: &[NodeIdentity],
media: &MediaContext,
scratch: &mut ComputeScratch,
cache: &mut ComputeCache,
) -> (ComputedStyle, u64) {
let node_id = NodeIdentity::from_node(node);
let sig = node_signature(&node_id, parent_sig, siblings, media);
if let Some(hit) = cache.get(sig, self.generation()) {
return (hit, sig);
}
let computed =
self.compute_with_ancestors_media(node, parent, scratch, ancestors, siblings, media);
cache.insert(sig, computed.clone(), self.generation());
(computed, sig)
}
fn compute_inner(
&self,
node: &dyn StyledNode,
parent: Option<&ComputedStyle>,
scratch: &mut ComputeScratch,
ancestors: Option<&[NodeIdentity]>,
siblings: Option<&[NodeIdentity]>,
media: &MediaContext,
) -> ComputedStyle {
let rules = self.rules();
scratch.matching.clear();
match ancestors {
None => {
let type_name = node.type_name();
let id = node.id();
let classes: Classes<'_> = node.classes();
let state = node.state();
let position = node.position();
for (i, r) in rules.iter().enumerate() {
if r.selector.matches_values(type_name, id, &classes, state, &position)
&& rule_media_matches(&r.media, media)
&& rule_supports_matches(&r.supports, media)
{
scratch.matching.push(i);
}
}
}
Some(stack) => {
let node_id = NodeIdentity::from_node(node);
let sibs: &[NodeIdentity] = siblings.unwrap_or(&[]);
for (i, r) in rules.iter().enumerate() {
if r.selector.matches_chain(&node_id, stack, sibs)
&& rule_media_matches(&r.media, media)
&& rule_supports_matches(&r.supports, media)
{
scratch.matching.push(i);
}
}
}
}
let mut own = CssStyle::new();
for &i in &scratch.matching {
own.overlay(&rules[i].style);
}
if let Some(parent) = parent {
resolve_explicit_inherit(&mut own, &parent.style);
own.inherit_from(&parent.style);
}
resolve_vars_in_place(&mut own, self.tokens(), media);
ComputedStyle::new(own)
}
}
#[inline]
fn rule_media_matches(query: &Option<crate::media::MediaQuery>, ctx: &MediaContext) -> bool {
match query {
None => true,
Some(q) => q.matches(ctx),
}
}
#[inline]
fn rule_supports_matches(query: &Option<crate::supports::SupportsQuery>, ctx: &MediaContext) -> bool {
match query {
None => true,
Some(q) => q.matches(ctx),
}
}
fn resolve_explicit_inherit(own: &mut CssStyle, parent: &CssStyle) {
if matches!(own.color, Some(Color::Inherit)) {
own.color = parent.color.clone();
}
if matches!(own.background, Some(Color::Inherit)) {
own.background = parent.background.clone();
}
if matches!(own.underline_color, Some(Color::Inherit)) {
own.underline_color = parent.underline_color.clone();
}
}
fn resolve_vars_in_place(style: &mut CssStyle, tokens: &ThemeTokens, media: &MediaContext) {
resolve_color_field(&mut style.color, tokens, media);
resolve_color_field(&mut style.background, tokens, media);
resolve_color_field(&mut style.underline_color, tokens, media);
if let Some(border) = style.border.as_mut() {
resolve_color_field(&mut border.color, tokens, media);
resolve_border_style_field(&mut border.style, tokens, media);
}
resolve_length_field(&mut style.width, tokens, media);
resolve_length_field(&mut style.height, tokens, media);
resolve_box_edges_field(&mut style.padding, tokens, media);
resolve_box_edges_field(&mut style.margin, tokens, media);
}
fn resolve_color_field(field: &mut Option<Color>, tokens: &ThemeTokens, media: &MediaContext) {
if let Some(inner) = field {
match inner {
Color::Literal(_) | Color::Reset => {} Color::Var { .. } | Color::Inherit => {
*field = Some(Color::Literal(token::resolve_with_media(inner, tokens, media)));
}
}
}
}
fn resolve_length_field(field: &mut Option<Length>, tokens: &ThemeTokens, media: &MediaContext) {
if let Some(inner) = field {
if let Length::Var { .. } = inner {
*field = Some(token::resolve_length_with_media(inner, tokens, media));
}
}
}
fn resolve_box_edges_field(
field: &mut Option<BoxEdgesValue>,
tokens: &ThemeTokens,
media: &MediaContext,
) {
if let Some(inner) = field.take() {
*field = Some(resolve_box_edges_value(inner, tokens, media, 0));
}
}
fn resolve_box_edges_value(
value: BoxEdgesValue,
tokens: &ThemeTokens,
media: &MediaContext,
depth: u8,
) -> BoxEdgesValue {
if depth > 32 {
return BoxEdgesValue::Edges(BoxEdges::zero());
}
match value {
BoxEdgesValue::Edges(_) => value,
BoxEdgesValue::Var { name, fallback } => {
match tokens.get_box_edges_with(&name, media) {
Some(edges) => BoxEdgesValue::Edges(edges),
None => match fallback {
Some(fb) => resolve_box_edges_value(*fb, tokens, media, depth + 1),
None => BoxEdgesValue::Edges(BoxEdges::zero()),
},
}
}
}
}
fn resolve_border_style_field(
field: &mut BorderStyleValue,
tokens: &ThemeTokens,
media: &MediaContext,
) {
let owned = std::mem::take(field);
*field = resolve_border_style_value(owned, tokens, media, 0);
}
fn resolve_border_style_value(
value: BorderStyleValue,
tokens: &ThemeTokens,
media: &MediaContext,
depth: u8,
) -> BorderStyleValue {
if depth > 32 {
return BorderStyleValue::Fixed(BorderStyle::None);
}
match value {
BorderStyleValue::Fixed(_) => value,
BorderStyleValue::Var { name, fallback } => {
match tokens.get_border_style_with(&name, media) {
Some(style) => BorderStyleValue::Fixed(style),
None => match fallback {
Some(fb) => resolve_border_style_value(*fb, tokens, media, depth + 1),
None => BorderStyleValue::Fixed(BorderStyle::None),
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::{NodeRef, OwnedNode, State};
use crate::stylesheet::Origin;
use ratatui::style::Color as RC;
fn sheet() -> Stylesheet {
let mut s = Stylesheet::with_tokens(
crate::token::ThemeTokens::new().set("accent", Color::literal(RC::Cyan)),
);
s.add("Button", CssStyle::new().color(RC::Gray), Origin::User)
.unwrap();
s.add(
"Button.primary",
CssStyle::new().background(RC::Blue),
Origin::User,
)
.unwrap();
s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
.unwrap();
s.add(
"Button:focus",
CssStyle::new().background(RC::Green),
Origin::User,
)
.unwrap();
s.add(
".accented",
CssStyle::new().color(Color::var("accent")),
Origin::User,
)
.unwrap();
s
}
#[test]
fn specificity_wins() {
let s = sheet();
let n = OwnedNode::new("Button")
.with_id("save")
.with_classes(["primary"]);
let c = s.compute(&n, None);
assert_eq!(c.style.color, Some(Color::literal(RC::Yellow)));
assert_eq!(c.style.background, Some(Color::literal(RC::Blue)));
}
#[test]
fn pseudo_state_matches() {
let s = sheet();
let n = OwnedNode::new("Button").with_state(State::focus());
let c = s.compute(&n, None);
assert_eq!(c.style.background, Some(Color::literal(RC::Green)));
}
#[test]
fn nth_child_cascade_end_to_end() {
let mut s = Stylesheet::new();
s.add(
"Item:nth-child(odd)",
CssStyle::new().color(RC::Red),
Origin::User,
)
.unwrap();
let first = OwnedNode::new("Item").with_position(crate::node::Position::new(0, 3));
let second = OwnedNode::new("Item").with_position(crate::node::Position::new(1, 3));
let third = OwnedNode::new("Item").with_position(crate::node::Position::new(2, 3));
assert_eq!(
s.compute(&first, None).style.color,
Some(Color::literal(RC::Red))
);
assert_eq!(s.compute(&second, None).style.color, None);
assert_eq!(
s.compute(&third, None).style.color,
Some(Color::literal(RC::Red))
);
}
#[test]
fn nth_child_default_position_does_not_match() {
let mut s = Stylesheet::new();
s.add(
"Item:nth-child(odd)",
CssStyle::new().color(RC::Red),
Origin::User,
)
.unwrap();
let n = OwnedNode::new("Item"); assert_eq!(n.position().sibling_count, 0);
let c = s.compute(&n, None);
assert_eq!(c.style.color, None);
}
#[test]
fn var_resolves_from_tokens() {
let s = sheet();
let n = OwnedNode::new("Text").with_classes(["accented"]);
let c = s.compute(&n, None);
assert_eq!(c.style.color, Some(Color::literal(RC::Cyan)));
}
#[test]
fn border_color_var_resolves_from_tokens() {
let sheet = Stylesheet::parse(
":root{--border-dim:#003237} .panel { border: rounded var(--border-dim); }",
)
.unwrap();
let n = OwnedNode::new("Div").with_classes(["panel"]);
let c = sheet.compute(&n, None);
let border = c.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
);
assert_eq!(
border.color,
Some(Color::literal(RC::Rgb(0x00, 0x32, 0x37)))
);
}
#[test]
fn border_color_var_via_subdeclaration_resolves() {
let sheet = Stylesheet::parse(
":root{--rim:#ff0000} .b { border-style: single; border-color: var(--rim); }",
)
.unwrap();
let n = OwnedNode::new("Div").with_classes(["b"]);
let c = sheet.compute(&n, None);
let border = c.style.border.expect("border present");
assert_eq!(
border.color,
Some(Color::literal(RC::Rgb(0xff, 0x00, 0x00)))
);
}
#[test]
fn border_color_var_fallback_resolves() {
let sheet = Stylesheet::parse(".b { border: rounded var(--nope, #00ff00); }").unwrap();
let n = OwnedNode::new("Div").with_classes(["b"]);
let c = sheet.compute(&n, None);
let border = c.style.border.expect("border present");
assert_eq!(
border.color,
Some(Color::literal(RC::Rgb(0x00, 0xff, 0x00)))
);
}
#[test]
fn inheritance_from_parent() {
let s = sheet();
let parent_node = OwnedNode::new("Button").with_classes(["primary"]);
let parent = s.compute(&parent_node, None);
let child = OwnedNode::new("Text");
let computed = s.compute(&child, Some(&parent));
assert_eq!(computed.style.color, Some(Color::literal(RC::Gray)));
}
#[test]
fn origin_overrides_specificity() {
let mut s = Stylesheet::new();
s.add("Button", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
s.add("Button", CssStyle::new().color(RC::Blue), Origin::Inline)
.unwrap();
let n = OwnedNode::new("Button");
let c = s.compute(&n, None);
assert_eq!(c.style.color, Some(Color::literal(RC::Blue)));
}
#[test]
fn rules_stored_in_cascade_sorted_order() {
let mut s = Stylesheet::new();
s.add("Button", CssStyle::new(), Origin::User).unwrap();
s.add("#save", CssStyle::new(), Origin::User).unwrap();
s.add(".primary", CssStyle::new(), Origin::User).unwrap();
s.add("Button", CssStyle::new(), Origin::Inline).unwrap();
s.add(".primary", CssStyle::new(), Origin::UserAgent)
.unwrap();
let rules = s.rules();
for w in rules.windows(2) {
let a = &w[0];
let b = &w[1];
let ka = (a.origin, a.selector.specificity(), a.order);
let kb = (b.origin, b.selector.specificity(), b.order);
assert!(ka <= kb, "rules not sorted: {ka:?} > {kb:?}");
}
assert_eq!(rules.first().unwrap().origin, Origin::UserAgent);
assert_eq!(rules.last().unwrap().origin, Origin::Inline);
}
#[test]
fn compute_unchanged_after_sort_removal_scrambled_insertion() {
let mut s = Stylesheet::new();
s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
.unwrap();
s.add(
"Button.primary",
CssStyle::new().background(RC::Blue),
Origin::User,
)
.unwrap();
s.add("Button", CssStyle::new().color(RC::Gray), Origin::User)
.unwrap();
let n = OwnedNode::new("Button")
.with_id("save")
.with_classes(["primary"]);
let c = s.compute(&n, None);
assert_eq!(c.style.color, Some(Color::literal(RC::Yellow)));
assert_eq!(c.style.background, Some(Color::literal(RC::Blue)));
}
#[test]
fn inline_origin_wins_in_scrambled_insertion_order() {
let mut s = Stylesheet::new();
s.add("Button", CssStyle::new().color(RC::Blue), Origin::Inline)
.unwrap();
s.add("Button", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let n = OwnedNode::new("Button");
let c = s.compute(&n, None);
assert_eq!(c.style.color, Some(Color::literal(RC::Blue)));
}
#[test]
fn render_computed_applies_margin_once() {
let computed = ComputedStyle::new(
CssStyle::new()
.margin("2")
.padding("1")
.border("rounded #00d4ff"),
);
let area = Rect::new(0, 0, 44, 8);
let shrunk = computed.apply_margin(area);
let (_block, _style, inner) = computed.layout_with_shrunk(shrunk);
assert_eq!(shrunk, Rect::new(2, 2, 40, 4));
assert_eq!(inner, Rect::new(4, 4, 36, 0));
}
#[test]
fn with_inline_overrides_specificity() {
let mut s = Stylesheet::new();
s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
.unwrap();
let n = OwnedNode::new("Button").with_id("save");
let c = s
.compute(&n, None)
.with_inline(&CssStyle::new().color("red"));
assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn apply_inline_in_place_overrides() {
let mut s = Stylesheet::new();
s.add(
"Button.primary",
CssStyle::new().color(RC::Blue),
Origin::User,
)
.unwrap();
let n = OwnedNode::new("Button").with_classes(["primary"]);
let mut c = s.compute(&n, None);
c.apply_inline(&CssStyle::new().color("red"));
assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn layout_inner_matches_handwritten_sequence() {
let computed = ComputedStyle::new(
CssStyle::new()
.margin("2")
.padding("1")
.border("rounded #00d4ff"),
);
let area = Rect::new(0, 0, 44, 8);
let (_block, _style, inner_from_layout) = computed.layout(area);
let shrunk = computed.apply_margin(area);
let block = computed.to_block();
let inner_from_hand = block.inner(shrunk);
assert_eq!(inner_from_layout, inner_from_hand);
assert_eq!(inner_from_layout, Rect::new(4, 4, 36, 0));
}
#[test]
fn layout_inner_equals_area_with_no_box_model() {
let computed = ComputedStyle::new(CssStyle::new());
let area = Rect::new(0, 0, 30, 10);
let (_block, _style, inner) = computed.layout(area);
assert_eq!(inner, area);
}
#[test]
fn layout_content_style_matches_to_style() {
let computed = ComputedStyle::new(CssStyle::new().color(RC::Cyan).bold().padding("1"));
let area = Rect::new(0, 0, 20, 5);
let (_block, style, _inner) = computed.layout(area);
assert_eq!(style, computed.to_style());
}
fn parity_sheet() -> Stylesheet {
let mut s = Stylesheet::new();
s.add("Button", CssStyle::new().color(RC::Gray), Origin::User)
.unwrap();
s.add(
"Button.primary",
CssStyle::new().background(RC::Blue),
Origin::User,
)
.unwrap();
s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
.unwrap();
s.add(
"Button:focus",
CssStyle::new().background(RC::Green),
Origin::User,
)
.unwrap();
s
}
#[test]
fn noderef_behavioral_parity() {
let sheet = parity_sheet();
let owned = OwnedNode::new("Button")
.with_id("save")
.with_classes(["primary"])
.with_state(State::focus());
let borrowed = NodeRef::new("Button")
.id("save")
.classes(&["primary"])
.state(State::focus());
let c_owned = sheet.compute(&owned, None);
let c_borrowed = sheet.compute(&borrowed, None);
assert_eq!(c_owned, c_borrowed);
}
#[test]
fn noderef_zero_string_construction() {
let sheet = parity_sheet();
let node = NodeRef::new("Button")
.classes(&["primary"])
.state(State::focus());
let c = sheet.compute(&node, None);
assert_eq!(c.style.background, Some(Color::literal(RC::Green)));
assert_eq!(c.style.color, Some(Color::literal(RC::Gray)));
}
#[test]
fn compute_with_matches_compute() {
let sheet = parity_sheet();
let mut scratch = ComputeScratch::new();
let cases: [(&str, OwnedNode); 5] = [
("plain", OwnedNode::new("Button")),
(
"primary",
OwnedNode::new("Button").with_classes(["primary"]),
),
("id", OwnedNode::new("Button").with_id("save")),
("focus", OwnedNode::new("Button").with_state(State::focus())),
(
"combo",
OwnedNode::new("Button")
.with_id("save")
.with_classes(["primary"])
.with_state(State::focus()),
),
];
for (name, node) in cases {
let via_compute = sheet.compute(&node, None);
let via_compute_with = sheet.compute_with(&node, None, &mut scratch);
assert_eq!(via_compute, via_compute_with, "mismatch for case `{name}`");
}
}
#[test]
fn scratch_reuse_no_panic() {
let sheet = parity_sheet();
let mut scratch = ComputeScratch::new();
let big = NodeRef::new("Button")
.id("save")
.classes(&["primary"])
.state(State::focus());
let none = NodeRef::new("NoSuchType");
let c1 = sheet.compute_with(&big, None, &mut scratch);
let c_none = sheet.compute_with(&none, None, &mut scratch);
let c2 = sheet.compute_with(&big, None, &mut scratch);
assert_eq!(c_none.style.color, None);
assert_eq!(c1, c2);
assert_eq!(c1.style.color, Some(Color::literal(RC::Yellow)));
}
fn context_sheet() -> Stylesheet {
let mut s = Stylesheet::new();
s.add("Panel", CssStyle::new().color("#cdd6f4"), Origin::User)
.unwrap();
s
}
#[test]
fn context_inherits_without_manual_threading() {
let sheet = context_sheet();
let mut ctx = CascadeContext::new(&sheet);
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(
text.style.color,
Some(Color::literal(RC::Rgb(0xcd, 0xd6, 0xf4)))
);
}
#[test]
fn context_parity_with_manual_compute() {
let mut sheet = Stylesheet::new();
sheet
.add("Root", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
sheet
.add("Panel", CssStyle::new().padding("1"), Origin::User)
.unwrap();
sheet.add("Text", CssStyle::new(), Origin::User).unwrap();
let mut ctx = CascadeContext::new(&sheet);
let ctx_root = ctx.enter(&OwnedNode::new("Root"));
let ctx_panel = ctx.enter(&OwnedNode::new("Panel"));
let ctx_text = ctx.enter(&OwnedNode::new("Text"));
let man_root = sheet.compute(&OwnedNode::new("Root"), None);
let man_panel = sheet.compute(&OwnedNode::new("Panel"), Some(&man_root));
let man_text = sheet.compute(&OwnedNode::new("Text"), Some(&man_panel));
assert_eq!(ctx_root, man_root);
assert_eq!(ctx_panel, man_panel);
assert_eq!(ctx_text, man_text);
}
#[test]
fn context_leave_restores_parent() {
let mut sheet = Stylesheet::new();
sheet
.add("A", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
sheet
.add("B", CssStyle::new().color(RC::Blue), Origin::User)
.unwrap();
sheet.add("C", CssStyle::new(), Origin::User).unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _a = ctx.enter(&OwnedNode::new("A"));
let _b = ctx.enter(&OwnedNode::new("B"));
ctx.leave(); let c = ctx.enter(&OwnedNode::new("C"));
assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn context_depth() {
let sheet = context_sheet();
let mut ctx = CascadeContext::new(&sheet);
assert_eq!(ctx.depth(), 0);
ctx.enter(&OwnedNode::new("Panel"));
assert_eq!(ctx.depth(), 1);
ctx.enter(&OwnedNode::new("Text"));
assert_eq!(ctx.depth(), 2);
ctx.leave();
assert_eq!(ctx.depth(), 1);
ctx.leave();
assert_eq!(ctx.depth(), 0);
assert!(ctx.leave().is_none());
}
#[test]
fn context_scratch_reused() {
let mut sheet = Stylesheet::new();
sheet
.add("A", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
sheet
.add("A.child", CssStyle::new().bold(), Origin::User)
.unwrap();
sheet
.add("NoMatch", CssStyle::new().color(RC::Green), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let child = ctx.enter(&OwnedNode::new("A").with_classes(["child"]));
assert_eq!(child.style.color, Some(Color::literal(RC::Red)));
let none = ctx.enter(&OwnedNode::new("TotallyUnknown"));
assert_eq!(none.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
let child2 = ctx.enter(&OwnedNode::new("A").with_classes(["child"]));
assert_eq!(child2.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn width_var_resolves() {
let sheet = Stylesheet::parse(":root{--w:50%} .col { width: var(--w);}").unwrap();
let node = OwnedNode::new("Div").with_classes(["col"]);
let c = sheet.compute(&node, None);
assert_eq!(c.style.width, Some(crate::box_model::Length::Percent(50)));
}
#[test]
fn width_var_chain() {
let sheet =
Stylesheet::parse(":root{--w: var(--w2); --w2: 10;} .x { width: var(--w); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(c.style.width, Some(crate::box_model::Length::Cells(10)));
}
#[test]
fn width_var_undefined_degrades_to_auto() {
let sheet = Stylesheet::parse(".x { width: var(--nope); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(c.style.width, Some(crate::box_model::Length::Auto));
}
#[test]
fn width_var_mistype_degrades_to_auto() {
let sheet = Stylesheet::parse(":root{--c:#fff} .x { width: var(--c); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(c.style.width, Some(crate::box_model::Length::Auto));
}
#[test]
fn height_var_resolves() {
let sheet = Stylesheet::parse(":root{--h:max(8)} .row { height: var(--h); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["row"]);
let c = sheet.compute(&node, None);
assert_eq!(c.style.height, Some(crate::box_model::Length::Max(8)));
}
#[test]
fn width_var_undefined_uses_fallback() {
let sheet = Stylesheet::parse(".x { width: var(--nope, 7); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(c.style.width, Some(crate::box_model::Length::Cells(7)));
}
#[test]
fn padding_var_resolves_from_token() {
let sheet = Stylesheet::parse(
":root{--pad: 1 2} .x { padding: var(--pad); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(
c.style.padding,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges {
top: 1,
right: 2,
bottom: 1,
left: 2,
}))
);
}
#[test]
fn padding_var_resolved_into_block() {
let sheet = Stylesheet::parse(
":root{--pad: 1 2} .x { padding: var(--pad); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
let block = c.to_block();
let area = Rect::new(0, 0, 10, 10);
let inner = block.inner(area);
assert_eq!(inner, Rect::new(2, 1, 6, 8));
}
#[test]
fn margin_var_resolves_from_token() {
let sheet = Stylesheet::parse(
":root{--m: 3} .x { margin: var(--m); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(
c.style.margin,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(3)))
);
let area = Rect::new(0, 0, 10, 10);
let shrunk = c.apply_margin(area);
assert_eq!((shrunk.x, shrunk.y, shrunk.width, shrunk.height), (3, 3, 4, 4));
}
#[test]
fn border_style_var_resolves() {
let sheet = Stylesheet::parse(
":root{--bs: rounded} .x { border-style: var(--bs); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
let border = c.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
);
}
#[test]
fn border_style_var_via_shorthand_resolves() {
let sheet = Stylesheet::parse(
":root{--bs: double} .x { border: var(--bs); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
let border = c.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Double)
);
}
#[test]
fn padding_var_undefined_degrades_to_zero() {
let sheet = Stylesheet::parse(".x { padding: var(--nope); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(
c.style.padding,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::zero()))
);
let block = c.to_block();
let area = Rect::new(0, 0, 10, 10);
assert_eq!(block.inner(area), area);
}
#[test]
fn padding_var_undefined_uses_fallback() {
let sheet = Stylesheet::parse(".x { padding: var(--nope, 3); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(
c.style.padding,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(3)))
);
}
#[test]
fn border_style_var_undefined_degrades_to_none() {
let sheet = Stylesheet::parse(".x { border-style: var(--nope); }").unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
let border = c.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::None)
);
}
#[test]
fn box_edges_var_mistype_degrades() {
let sheet = Stylesheet::parse(
":root{--c:#fff} .x { padding: var(--c); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let c = sheet.compute(&node, None);
assert_eq!(
c.style.padding,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::zero()))
);
}
#[test]
fn box_edges_media_gated_token_resolves() {
let sheet = Stylesheet::parse(
":root{--pad:1} @media (min-width:80){:root{--pad:2}} .x{padding:var(--pad)}",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let mut scratch = ComputeScratch::new();
let large = MediaContext { cols: 100, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &large);
assert_eq!(
c.style.padding,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(2)))
);
let small = MediaContext { cols: 40, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &small);
assert_eq!(
c.style.padding,
Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(1)))
);
}
#[test]
fn has_combinators_flag() {
let mut plain = Stylesheet::new();
plain.add("Button", CssStyle::new(), Origin::User).unwrap();
assert!(!plain.has_combinators());
let mut with_comb = Stylesheet::new();
with_comb
.add("Panel Button", CssStyle::new(), Origin::User)
.unwrap();
assert!(with_comb.has_combinators());
let mut merged = Stylesheet::new();
merged.add("Text", CssStyle::new(), Origin::User).unwrap();
assert!(!merged.has_combinators());
merged.extend(&with_comb);
assert!(merged.has_combinators());
}
#[test]
fn descendant_combinator_matches_in_context() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel Text", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn child_combinator_direct_child_matches() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel > Text", CssStyle::new().color(RC::Blue), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text.style.color, Some(Color::literal(RC::Blue)));
}
#[test]
fn child_combinator_indirect_child_does_not_match() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel > Text", CssStyle::new().color(RC::Blue), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let _other = ctx.enter(&OwnedNode::new("Other"));
let text = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text.style.color, None);
}
#[test]
fn descendant_vs_child_distinction() {
let mut child_sheet = Stylesheet::new();
child_sheet
.add("Root > Text", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let mut desc_sheet = Stylesheet::new();
desc_sheet
.add("Root Text", CssStyle::new().color(RC::Green), Origin::User)
.unwrap();
let mut ctx_c = CascadeContext::new(&child_sheet);
let _r = ctx_c.enter(&OwnedNode::new("Root"));
let _p = ctx_c.enter(&OwnedNode::new("Panel"));
let t_c = ctx_c.enter(&OwnedNode::new("Text"));
assert_eq!(t_c.style.color, None, "Root > Text must not match a grandchild");
let mut ctx_d = CascadeContext::new(&desc_sheet);
let _r = ctx_d.enter(&OwnedNode::new("Root"));
let _p = ctx_d.enter(&OwnedNode::new("Panel"));
let t_d = ctx_d.enter(&OwnedNode::new("Text"));
assert_eq!(t_d.style.color, Some(Color::literal(RC::Green)));
}
#[test]
fn non_combinator_rules_match_in_context() {
let mut sheet = Stylesheet::new();
sheet
.add("Button", CssStyle::new().color(RC::Yellow), Origin::User)
.unwrap();
sheet
.add("Panel Button", CssStyle::new().bold(), Origin::User)
.unwrap();
assert!(sheet.has_combinators());
let mut ctx = CascadeContext::new(&sheet);
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let btn = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn.style.color, Some(Color::literal(RC::Yellow)));
assert!(btn.style.weight.is_some());
}
#[test]
fn combinator_rule_does_not_match_one_shot() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel Text", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let node = OwnedNode::new("Text");
let c = sheet.compute(&node, None);
assert_eq!(c.style.color, None);
}
#[test]
fn context_leave_keeps_stacks_in_sync() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel > Text", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text1 = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text1.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
let text2 = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text2.style.color, Some(Color::literal(RC::Red)));
ctx.leave(); ctx.leave();
let text3 = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text3.style.color, None);
}
#[test]
fn adjacent_combinator_matches_preceding_sibling() {
let mut sheet = Stylesheet::new();
sheet
.add("Item + Item", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
assert!(sheet.has_combinators());
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let first = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(first.style.color, None, "first Item has no preceding sibling");
ctx.leave();
let second = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(
second.style.color,
Some(Color::literal(RC::Red)),
"second Item follows a sibling Item"
);
ctx.leave();
let third = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(
third.style.color,
Some(Color::literal(RC::Red)),
"third Item follows a sibling Item"
);
}
#[test]
fn general_sibling_combinator_matches_any_preceding() {
let mut sheet = Stylesheet::new();
sheet
.add("Item ~ Item", CssStyle::new().color(RC::Blue), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let first = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(first.style.color, None);
ctx.leave();
let second = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(second.style.color, Some(Color::literal(RC::Blue)));
ctx.leave();
let third = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(third.style.color, Some(Color::literal(RC::Blue)));
}
#[test]
fn adjacent_combinator_requires_immediate_predecessor_type() {
let mut sheet = Stylesheet::new();
sheet
.add("Header + Content", CssStyle::new().color(RC::Green), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _sidebar = ctx.enter(&OwnedNode::new("Sidebar"));
ctx.leave();
let content = ctx.enter(&OwnedNode::new("Content"));
assert_eq!(content.style.color, None);
ctx.leave();
let _header = ctx.enter(&OwnedNode::new("Header"));
ctx.leave();
let content2 = ctx.enter(&OwnedNode::new("Content"));
assert_eq!(content2.style.color, Some(Color::literal(RC::Green)));
}
#[test]
fn sibling_plus_descendant_combinator() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel Item + Item", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let first = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(first.style.color, None);
ctx.leave();
let second = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(second.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn sibling_lists_reset_on_new_parent() {
let mut sheet = Stylesheet::new();
sheet
.add("Item + Item", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _pa = ctx.enter(&OwnedNode::new("ParentA"));
let _pa_first = ctx.enter(&OwnedNode::new("Item"));
ctx.leave();
let _pa_second = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(_pa_second.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
ctx.leave();
let _pb = ctx.enter(&OwnedNode::new("ParentB"));
let pb_first = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(pb_first.style.color, None, "ParentB's first item has no prior sibling");
}
#[test]
fn sibling_combinator_does_not_match_one_shot() {
let mut sheet = Stylesheet::new();
sheet
.add("Item + Item", CssStyle::new().color(RC::Red), Origin::User)
.unwrap();
let node = OwnedNode::new("Item");
let c = sheet.compute(&node, None);
assert_eq!(c.style.color, None);
}
#[test]
fn descendant_combinator_still_matches_via_context() {
let mut sheet = Stylesheet::new();
sheet
.add("Panel Button", CssStyle::new().color(RC::Yellow), Origin::User)
.unwrap();
sheet
.add("Panel > Button", CssStyle::new().bold(), Origin::User)
.unwrap();
let mut ctx = CascadeContext::new(&sheet);
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let btn = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn.style.color, Some(Color::literal(RC::Yellow)));
assert!(btn.style.weight.is_some());
}
fn media_sheet() -> Stylesheet {
Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap()
}
#[test]
fn media_rule_applies_when_context_matches() {
let sheet = media_sheet();
let mut scratch = ComputeScratch::new();
let media = MediaContext { cols: 100, rows: 24, ..Default::default() };
let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn media_rule_skipped_when_context_does_not_match() {
let sheet = media_sheet();
let mut scratch = ComputeScratch::new();
let media = MediaContext { cols: 60, rows: 24, ..Default::default() };
let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
assert_eq!(c.style.color, None, "media-gated rule must not apply when cols < 80");
}
#[test]
fn media_rule_skipped_by_default_context() {
let sheet = media_sheet();
let c = sheet.compute(&OwnedNode::new("Button"), None);
assert_eq!(c.style.color, None, "default-context compute does not apply media-gated rules");
}
#[test]
fn plain_and_media_rules_coexist() {
let sheet = Stylesheet::parse(
"Button { color: blue; } @media (min-width: 80) { Button { color: red; } }",
)
.unwrap();
let mut scratch = ComputeScratch::new();
let small = MediaContext { cols: 40, ..Default::default() };
let c_small = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &small);
assert_eq!(c_small.style.color, Some(Color::literal(RC::Blue)));
let large = MediaContext { cols: 120, ..Default::default() };
let c_large = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &large);
assert_eq!(c_large.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn cascade_context_with_media_applies_gated_rule() {
let sheet = media_sheet();
let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
cols: 100,
rows: 24,
..Default::default()
});
let btn = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn.style.color, Some(Color::literal(RC::Red)));
ctx.set_media(MediaContext { cols: 40, ..Default::default() });
ctx.leave();
let btn2 = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn2.style.color, None);
}
#[test]
fn cascade_context_media_combinator_path() {
let sheet = Stylesheet::parse(
"@media (min-width: 80) { Panel Button { color: green; } }",
)
.unwrap();
assert!(sheet.has_combinators());
let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
cols: 100,
rows: 24,
..Default::default()
});
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let btn = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn.style.color, Some(Color::literal(RC::Green)));
ctx.set_media(MediaContext { cols: 40, ..Default::default() });
ctx.leave();
ctx.leave();
let _panel2 = ctx.enter(&OwnedNode::new("Panel"));
let btn2 = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn2.style.color, None);
}
fn media_token_sheet() -> Stylesheet {
Stylesheet::parse(
":root { --accent: red } @media (min-width: 80) { :root { --accent: blue } } .a { color: var(--accent); }",
)
.unwrap()
}
#[test]
fn media_gated_token_resolves_blue_under_matching_context() {
let sheet = media_token_sheet();
let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
cols: 100,
..Default::default()
});
let a = ctx.enter(&OwnedNode::new("Div").with_classes(["a"]));
assert_eq!(a.style.color, Some(Color::literal(RC::Blue)));
}
#[test]
fn media_gated_token_resolves_red_under_non_matching_context() {
let sheet = media_token_sheet();
let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
cols: 60,
..Default::default()
});
let a = ctx.enter(&OwnedNode::new("Div").with_classes(["a"]));
assert_eq!(a.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn media_gated_token_resolves_default_via_one_shot_compute() {
let sheet = media_token_sheet();
let a = sheet.compute(&OwnedNode::new("Div").with_classes(["a"]), None);
assert_eq!(a.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn media_gated_token_via_compute_with_media() {
let sheet = media_token_sheet();
let mut scratch = ComputeScratch::new();
let node = OwnedNode::new("Div").with_classes(["a"]);
let large = MediaContext { cols: 100, ..Default::default() };
let c_large = sheet.compute_with_media(&node, None, &mut scratch, &large);
assert_eq!(c_large.style.color, Some(Color::literal(RC::Blue)));
let small = MediaContext { cols: 60, ..Default::default() };
let c_small = sheet.compute_with_media(&node, None, &mut scratch, &small);
assert_eq!(c_small.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn non_media_tokens_still_resolve_as_before() {
let sheet = Stylesheet::parse(
":root { --c: #abcdef } .x { color: var(--c); }",
)
.unwrap();
let node = OwnedNode::new("Div").with_classes(["x"]);
let one_shot = sheet.compute(&node, None);
assert_eq!(
one_shot.style.color,
Some(Color::literal(RC::Rgb(0xab, 0xcd, 0xef)))
);
let mut ctx = CascadeContext::new(&sheet);
let via_ctx = ctx.enter(&node);
assert_eq!(via_ctx.style.color, one_shot.style.color);
}
fn walk_tree_cached(ctx: &mut CascadeContext<'_>, out: &mut Vec<ComputedStyle>) {
out.push(ctx.enter(&OwnedNode::new("Root")));
out.push(ctx.enter(&OwnedNode::new("Panel")));
out.push(ctx.enter(&OwnedNode::new("Text")));
ctx.leave();
ctx.leave();
ctx.leave();
}
#[test]
fn cache_warm_walk_produces_identical_styles() {
let mut sheet = Stylesheet::new();
sheet.add("Root", CssStyle::new().color(RC::Red), Origin::User).unwrap();
sheet.add("Panel", CssStyle::new().padding("1"), Origin::User).unwrap();
sheet.add("Text", CssStyle::new().bold(), Origin::User).unwrap();
let mut ctx = CascadeContext::new(&sheet).with_cache(16);
let mut cold = Vec::new();
walk_tree_cached(&mut ctx, &mut cold);
assert_eq!(ctx.cache().unwrap().len(), 3);
let mut warm = Vec::new();
walk_tree_cached(&mut ctx, &mut warm);
assert_eq!(warm.len(), cold.len());
for (i, (w, c)) in warm.iter().zip(cold.iter()).enumerate() {
assert_eq!(w, c, "warm walk node {i} differs from cold walk");
}
assert_eq!(ctx.cache().unwrap().len(), 3);
}
#[test]
fn cache_invalidated_by_stylesheet_mutation() {
let mut sheet = Stylesheet::new();
sheet.add("Text", CssStyle::new().color(RC::Red), Origin::User).unwrap();
let mut scratch = ComputeScratch::new();
let mut cache = ComputeCache::new(8);
let media = MediaContext::default();
let node = OwnedNode::new("Text");
let (text1, sig1) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
assert_eq!(text1.style.color, Some(Color::literal(RC::Red)));
assert_eq!(cache.len(), 1);
sheet.add("Text", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
let (text2, sig2) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
assert_eq!(
text2.style.color,
Some(Color::literal(RC::Blue)),
"mutation must invalidate the cache"
);
assert_eq!(sig1, sig2);
assert_eq!(cache.len(), 1);
}
#[test]
fn cache_invalidated_by_tokens_mut() {
let mut sheet = Stylesheet::with_tokens(
crate::token::ThemeTokens::new().set("accent", Color::literal(RC::Red)),
);
sheet.add(".a", CssStyle::new().color(Color::var("accent")), Origin::User).unwrap();
let mut scratch = ComputeScratch::new();
let mut cache = ComputeCache::new(8);
let media = MediaContext::default();
let node = OwnedNode::new("Div").with_classes(["a"]);
let (a1, _) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
assert_eq!(a1.style.color, Some(Color::literal(RC::Red)));
sheet.tokens_mut().insert("accent", Color::literal(RC::Blue));
let (a2, _) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
assert_eq!(
a2.style.color,
Some(Color::literal(RC::Blue)),
"tokens_mut must invalidate the cache so the var re-resolves"
);
}
#[test]
fn cache_invalidated_by_media_change() {
let sheet = Stylesheet::parse(
"@media (min-width: 80) { Button { color: red; } }",
)
.unwrap();
let mut ctx = CascadeContext::new(&sheet).with_cache(8).with_media(MediaContext {
cols: 100,
..Default::default()
});
let big = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(big.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
ctx.set_media(MediaContext { cols: 40, ..Default::default() });
let small = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(small.style.color, None);
}
#[test]
fn cache_parent_dependency_different_parents() {
let mut sheet = Stylesheet::new();
sheet.add("Red", CssStyle::new().color(RC::Red), Origin::User).unwrap();
sheet.add("Blue", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
sheet.add("Child", CssStyle::new(), Origin::User).unwrap();
let mut ctx = CascadeContext::new(&sheet).with_cache(8);
let _red = ctx.enter(&OwnedNode::new("Red"));
let child_a = ctx.enter(&OwnedNode::new("Child"));
assert_eq!(child_a.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
ctx.leave();
let _blue = ctx.enter(&OwnedNode::new("Blue"));
let child_b = ctx.enter(&OwnedNode::new("Child"));
assert_eq!(
child_b.style.color,
Some(Color::literal(RC::Blue)),
"identical Child node with a different parent must produce a different result"
);
}
#[test]
fn cache_works_with_combinator_sheet_descendant() {
let mut sheet = Stylesheet::new();
sheet.add("Panel Text", CssStyle::new().color(RC::Green), Origin::User).unwrap();
assert!(sheet.has_combinators());
let mut ctx = CascadeContext::new(&sheet).with_cache(8);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text.style.color, Some(Color::literal(RC::Green)));
ctx.leave();
ctx.leave();
ctx.leave();
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text2 = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text2.style.color, Some(Color::literal(RC::Green)));
assert_eq!(text2, text, "warm cached walk == cold walk for combinators");
}
#[test]
fn cache_works_with_combinator_sheet_child() {
let mut sheet = Stylesheet::new();
sheet.add("Panel > Text", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
assert!(sheet.has_combinators());
let mut ctx = CascadeContext::new(&sheet).with_cache(8);
let _root = ctx.enter(&OwnedNode::new("Root"));
let _panel = ctx.enter(&OwnedNode::new("Panel"));
let text = ctx.enter(&OwnedNode::new("Text"));
assert_eq!(text.style.color, Some(Color::literal(RC::Blue)));
}
#[test]
fn cache_works_with_sibling_combinator() {
let mut sheet = Stylesheet::new();
sheet.add("Item + Item", CssStyle::new().color(RC::Red), Origin::User).unwrap();
assert!(sheet.has_combinators());
let mut ctx = CascadeContext::new(&sheet).with_cache(16);
let _root = ctx.enter(&OwnedNode::new("Root"));
let first = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(first.style.color, None);
ctx.leave();
let second = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(second.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
ctx.leave();
let _root = ctx.enter(&OwnedNode::new("Root"));
let first2 = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(first2.style.color, None);
ctx.leave();
let second2 = ctx.enter(&OwnedNode::new("Item"));
assert_eq!(second2.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn cache_off_context_behaves_identically() {
let mut sheet = Stylesheet::new();
sheet.add("Root", CssStyle::new().color(RC::Red), Origin::User).unwrap();
sheet.add("Panel", CssStyle::new().padding("1"), Origin::User).unwrap();
sheet.add("Text", CssStyle::new().bold(), Origin::User).unwrap();
let mut ctx = CascadeContext::new(&sheet);
let ctx_root = ctx.enter(&OwnedNode::new("Root"));
let ctx_panel = ctx.enter(&OwnedNode::new("Panel"));
let ctx_text = ctx.enter(&OwnedNode::new("Text"));
let man_root = sheet.compute(&OwnedNode::new("Root"), None);
let man_panel = sheet.compute(&OwnedNode::new("Panel"), Some(&man_root));
let man_text = sheet.compute(&OwnedNode::new("Text"), Some(&man_panel));
assert_eq!(ctx_root, man_root);
assert_eq!(ctx_panel, man_panel);
assert_eq!(ctx_text, man_text);
assert!(ctx.cache().is_none());
}
#[test]
fn cache_recomputes_correctly_after_mixed_tree_walks() {
let mut sheet = Stylesheet::new();
sheet.add("A", CssStyle::new().color(RC::Red), Origin::User).unwrap();
sheet.add("B", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
let mut ctx = CascadeContext::new(&sheet).with_cache(32);
let _ = ctx.enter(&OwnedNode::new("A"));
let b1 = ctx.enter(&OwnedNode::new("B"));
assert_eq!(b1.style.color, Some(Color::literal(RC::Blue)));
ctx.leave();
ctx.leave();
let _ = ctx.enter(&OwnedNode::new("B"));
let a1 = ctx.enter(&OwnedNode::new("A"));
assert_eq!(a1.style.color, Some(Color::literal(RC::Red)));
ctx.leave();
ctx.leave();
let _ = ctx.enter(&OwnedNode::new("A"));
let b2 = ctx.enter(&OwnedNode::new("B"));
assert_eq!(b2.style.color, Some(Color::literal(RC::Blue)));
assert_eq!(b2, b1, "re-walked subtree is identical to the first");
}
fn supports_sheet() -> Stylesheet {
Stylesheet::parse("@supports (truecolor) { Button { color: red; } }").unwrap()
}
#[test]
fn supports_rule_applies_when_capability_matches() {
let sheet = supports_sheet();
let mut scratch = ComputeScratch::new();
let media = MediaContext { truecolor: true, ..Default::default() };
let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
}
#[test]
fn supports_rule_skipped_when_capability_does_not_match() {
let sheet = supports_sheet();
let mut scratch = ComputeScratch::new();
let media = MediaContext { truecolor: false, ..Default::default() };
let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
assert_eq!(c.style.color, None, "supports-gated rule must not apply when truecolor is off");
}
#[test]
fn supports_rule_skipped_by_default_context() {
let sheet = supports_sheet();
let c = sheet.compute(&OwnedNode::new("Button"), None);
assert_eq!(c.style.color, None, "default-context compute does not apply supports-gated rules");
}
#[test]
fn supports_property_known_applies() {
let sheet = Stylesheet::parse("@supports (border-style) { .x { border-style: rounded; } }").unwrap();
let mut scratch = ComputeScratch::new();
let media = MediaContext::default();
let c = sheet.compute_with_media(&OwnedNode::new("Div").with_classes(["x"]), None, &mut scratch, &media);
let border = c.style.border.expect("border present");
assert_eq!(
border.style,
crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
);
}
#[test]
fn supports_property_unknown_does_not_apply() {
let sheet = Stylesheet::parse("@supports (future-thing) { .x { color: red; } }").unwrap();
let mut scratch = ComputeScratch::new();
let media = MediaContext::default();
let c = sheet.compute_with_media(&OwnedNode::new("Div").with_classes(["x"]), None, &mut scratch, &media);
assert_eq!(c.style.color, None, "supports rule with unknown property must not apply");
}
#[test]
fn supports_combined_with_media_requires_both() {
let sheet = Stylesheet::parse(
"@media (min-width: 80) { @supports (truecolor) { Button { color: green; } } }",
)
.unwrap();
let mut scratch = ComputeScratch::new();
let node = OwnedNode::new("Button");
let both = MediaContext { cols: 100, truecolor: true, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &both);
assert_eq!(c.style.color, Some(Color::literal(RC::Green)), "both match → applies");
let media_only = MediaContext { cols: 100, truecolor: false, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &media_only);
assert_eq!(c.style.color, None, "supports fails → no apply");
let supports_only = MediaContext { cols: 40, truecolor: true, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &supports_only);
assert_eq!(c.style.color, None, "media fails → no apply");
}
#[test]
fn supports_inside_media_applies_when_both_match_via_context() {
let sheet = Stylesheet::parse(
"@media (min-width: 80) { @supports (truecolor) { Button { color: green; } } }",
)
.unwrap();
let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
cols: 100,
truecolor: true,
..Default::default()
});
let btn = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn.style.color, Some(Color::literal(RC::Green)));
ctx.set_media(MediaContext { cols: 100, truecolor: false, ..Default::default() });
ctx.leave();
let btn2 = ctx.enter(&OwnedNode::new("Button"));
assert_eq!(btn2.style.color, None);
}
#[test]
fn plain_and_supports_rules_coexist() {
let sheet = Stylesheet::parse(
"Button { color: blue; } @supports (truecolor) { Button { color: red; } }",
)
.unwrap();
let mut scratch = ComputeScratch::new();
let node = OwnedNode::new("Button");
let tc_off = MediaContext { truecolor: false, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &tc_off);
assert_eq!(c.style.color, Some(Color::literal(RC::Blue)));
let tc_on = MediaContext { truecolor: true, ..Default::default() };
let c = sheet.compute_with_media(&node, None, &mut scratch, &tc_on);
assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
}
}