use ratatui::{
layout::{Alignment, Constraint, Rect},
style::Style as RStyle,
widgets::Block,
};
use crate::box_model::Length;
use crate::color::Color;
use crate::node::{Classes, StyledNode};
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);
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(area);
frame.render_widget(block, shrunk);
frame.render_widget(make(inner, style), inner);
}
pub struct CascadeContext<'s> {
sheet: &'s Stylesheet,
scratch: ComputeScratch,
stack: Vec<ComputedStyle>,
}
impl<'s> CascadeContext<'s> {
pub fn new(sheet: &'s Stylesheet) -> Self {
Self {
sheet,
scratch: ComputeScratch::new(),
stack: Vec::new(),
}
}
pub fn enter(&mut self, node: &dyn StyledNode) -> ComputedStyle {
let parent = self.stack.last();
let computed = self.sheet.compute_with(node, parent, &mut self.scratch);
self.stack.push(computed.clone());
computed
}
pub fn leave(&mut self) -> Option<ComputedStyle> {
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 {
let type_name = node.type_name();
let id = node.id();
let classes: Classes<'_> = node.classes();
let state = node.state();
let rules = self.rules();
scratch.matching.clear();
for (i, r) in rules.iter().enumerate() {
if r.selector.matches_values(type_name, id, &classes, state) {
scratch.matching.push(i);
}
}
scratch.matching.sort_unstable_by_key(|&i| {
let r = &rules[i];
(r.origin, r.selector.specificity(), r.order)
});
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());
ComputedStyle::new(own)
}
}
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) {
resolve_color_field(&mut style.color, tokens);
resolve_color_field(&mut style.background, tokens);
resolve_color_field(&mut style.underline_color, tokens);
if let Some(border) = style.border.as_mut() {
resolve_color_field(&mut border.color, tokens);
}
resolve_length_field(&mut style.width, tokens);
resolve_length_field(&mut style.height, tokens);
}
fn resolve_color_field(field: &mut Option<Color>, tokens: &ThemeTokens) {
if let Some(inner) = field {
match inner {
Color::Literal(_) | Color::Reset => {} Color::Var { .. } | Color::Inherit => {
*field = Some(Color::Literal(token::resolve(inner, tokens)));
}
}
}
}
fn resolve_length_field(field: &mut Option<Length>, tokens: &ThemeTokens) {
if let Some(inner) = field {
if let Length::Var { .. } = inner {
*field = Some(token::resolve_length(inner, tokens));
}
}
}
#[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 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::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 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)));
}
}