use taffy::prelude::*;
use taffy::style::Style as TaffyStyle;
use crate::html_css::css::{parse_property, CalcContext, ComputedStyles, Length, Unit, Value};
use super::box_tree::{BoxId, BoxKind, BoxTree, DisplayInside, DisplayOutside};
pub fn style_to_taffy(
styles: &ComputedStyles<'_>,
ctx: &CalcContext,
outside: DisplayOutside,
inside: DisplayInside,
) -> TaffyStyle {
let mut s = TaffyStyle::DEFAULT;
s.display = match inside {
DisplayInside::Flex => Display::Flex,
DisplayInside::Grid => Display::Grid,
DisplayInside::None => Display::None,
_ => Display::Block,
};
s.box_sizing = match get_keyword(styles, "box-sizing").as_deref() {
Some("border-box") => BoxSizing::BorderBox,
_ => BoxSizing::ContentBox,
};
s.size = Size {
width: dimension(styles, "width", ctx, true),
height: dimension(styles, "height", ctx, false),
};
s.min_size = Size {
width: dimension(styles, "min-width", ctx, true),
height: dimension(styles, "min-height", ctx, false),
};
s.max_size = Size {
width: max_dimension(styles, "max-width", ctx, true),
height: max_dimension(styles, "max-height", ctx, false),
};
let padding_sides = box_shorthand_sides(styles, "padding", ctx);
s.padding = Rect {
left: length_percent(styles, "padding-left", ctx).or_else_default(padding_sides[3]),
right: length_percent(styles, "padding-right", ctx).or_else_default(padding_sides[1]),
top: length_percent(styles, "padding-top", ctx).or_else_default(padding_sides[0]),
bottom: length_percent(styles, "padding-bottom", ctx).or_else_default(padding_sides[2]),
};
s.border = Rect {
left: length_percent(styles, "border-left-width", ctx).or_else_default(None),
right: length_percent(styles, "border-right-width", ctx).or_else_default(None),
top: length_percent(styles, "border-top-width", ctx).or_else_default(None),
bottom: length_percent(styles, "border-bottom-width", ctx).or_else_default(None),
};
let margin_sides = box_shorthand_sides_auto(styles, "margin", ctx);
s.margin = Rect {
left: length_percent_auto(styles, "margin-left", ctx).or_else_default(margin_sides[3]),
right: length_percent_auto(styles, "margin-right", ctx).or_else_default(margin_sides[1]),
top: length_percent_auto(styles, "margin-top", ctx).or_else_default(margin_sides[0]),
bottom: length_percent_auto(styles, "margin-bottom", ctx).or_else_default(margin_sides[2]),
};
if matches!(inside, DisplayInside::Flex) {
s.flex_direction = match get_keyword(styles, "flex-direction").as_deref() {
Some("row-reverse") => FlexDirection::RowReverse,
Some("column") => FlexDirection::Column,
Some("column-reverse") => FlexDirection::ColumnReverse,
_ => FlexDirection::Row,
};
s.flex_wrap = match get_keyword(styles, "flex-wrap").as_deref() {
Some("wrap") => FlexWrap::Wrap,
Some("wrap-reverse") => FlexWrap::WrapReverse,
_ => FlexWrap::NoWrap,
};
s.justify_content = align_content_keyword(styles, "justify-content");
s.align_items = align_items_keyword(styles, "align-items");
s.align_content = align_content_keyword(styles, "align-content");
}
s.gap = Size {
width: length_percent(styles, "column-gap", ctx).or_else_default(None),
height: length_percent(styles, "row-gap", ctx).or_else_default(None),
};
s.flex_grow = number(styles, "flex-grow").unwrap_or(0.0);
s.flex_shrink = number(styles, "flex-shrink").unwrap_or(1.0);
s.flex_basis = match length_value(styles, "flex-basis", ctx) {
Some(v) => v.to_dimension(),
None => Dimension::auto(),
};
let _ = outside; s
}
fn dimension(
styles: &ComputedStyles<'_>,
prop: &str,
ctx: &CalcContext,
_width_axis: bool,
) -> Dimension {
match length_value(styles, prop, ctx) {
Some(v) => v.to_dimension(),
None => Dimension::auto(),
}
}
fn max_dimension(
styles: &ComputedStyles<'_>,
prop: &str,
ctx: &CalcContext,
_width_axis: bool,
) -> Dimension {
match length_value(styles, prop, ctx) {
Some(v) => v.to_dimension(),
None => Dimension::auto(),
}
}
fn length_percent(
styles: &ComputedStyles<'_>,
prop: &str,
ctx: &CalcContext,
) -> Maybe<LengthPercentage> {
match length_value(styles, prop, ctx) {
Some(LengthOrPercent::Length(px)) => Maybe::Some(LengthPercentage::length(px)),
Some(LengthOrPercent::Percent(p)) => Maybe::Some(LengthPercentage::percent(p / 100.0)),
None => Maybe::None,
}
}
enum Maybe<T> {
Some(T),
None,
}
impl Maybe<LengthPercentage> {
fn or_else_default(self, fallback: Option<LengthPercentage>) -> LengthPercentage {
match self {
Maybe::Some(v) => v,
Maybe::None => fallback.unwrap_or_else(|| LengthPercentage::length(0.0)),
}
}
}
impl Maybe<LengthPercentageAuto> {
fn or_else_default(self, fallback: Option<LengthPercentageAuto>) -> LengthPercentageAuto {
match self {
Maybe::Some(v) => v,
Maybe::None => fallback.unwrap_or_else(|| LengthPercentageAuto::length(0.0)),
}
}
}
fn box_shorthand_sides(
styles: &ComputedStyles<'_>,
shorthand: &str,
ctx: &CalcContext,
) -> [Option<LengthPercentage>; 4] {
let Some(rv) = styles.get(shorthand) else {
return [None, None, None, None];
};
let Ok(Value::List(items)) = parse_property(shorthand, &rv.value) else {
return [None, None, None, None];
};
let lp = |v: &Value| -> Option<LengthPercentage> {
if let Value::Length(l) = v {
match l {
Length::Dim {
value,
unit: Unit::Percent,
} => Some(LengthPercentage::percent(*value / 100.0)),
Length::Dim { value, unit } => {
Some(LengthPercentage::length(unit.to_px(*value, ctx)))
},
Length::Auto => None,
Length::Calc { name, body } => Length::Calc {
name: name.clone(),
body: body.clone(),
}
.resolve(ctx)
.map(LengthPercentage::length),
}
} else {
None
}
};
match items.len() {
1 => {
let a = lp(&items[0]);
[a, a, a, a]
},
2 => {
let (tb, lr) = (lp(&items[0]), lp(&items[1]));
[tb, lr, tb, lr]
},
3 => [lp(&items[0]), lp(&items[1]), lp(&items[2]), lp(&items[1])],
4 => [lp(&items[0]), lp(&items[1]), lp(&items[2]), lp(&items[3])],
_ => [None, None, None, None],
}
}
fn box_shorthand_sides_auto(
styles: &ComputedStyles<'_>,
shorthand: &str,
ctx: &CalcContext,
) -> [Option<LengthPercentageAuto>; 4] {
let Some(rv) = styles.get(shorthand) else {
return [None, None, None, None];
};
let Ok(Value::List(items)) = parse_property(shorthand, &rv.value) else {
return [None, None, None, None];
};
let lpa = |v: &Value| -> Option<LengthPercentageAuto> {
if let Value::Length(l) = v {
match l {
Length::Dim {
value,
unit: Unit::Percent,
} => Some(LengthPercentageAuto::percent(*value / 100.0)),
Length::Dim { value, unit } => {
Some(LengthPercentageAuto::length(unit.to_px(*value, ctx)))
},
Length::Auto => Some(LengthPercentageAuto::auto()),
Length::Calc { name, body } => Length::Calc {
name: name.clone(),
body: body.clone(),
}
.resolve(ctx)
.map(LengthPercentageAuto::length),
}
} else {
None
}
};
match items.len() {
1 => {
let a = lpa(&items[0]);
[a, a, a, a]
},
2 => {
let (tb, lr) = (lpa(&items[0]), lpa(&items[1]));
[tb, lr, tb, lr]
},
3 => [
lpa(&items[0]),
lpa(&items[1]),
lpa(&items[2]),
lpa(&items[1]),
],
4 => [
lpa(&items[0]),
lpa(&items[1]),
lpa(&items[2]),
lpa(&items[3]),
],
_ => [None, None, None, None],
}
}
fn length_percent_auto(
styles: &ComputedStyles<'_>,
prop: &str,
ctx: &CalcContext,
) -> Maybe<LengthPercentageAuto> {
if is_auto(styles, prop) {
return Maybe::Some(LengthPercentageAuto::auto());
}
match length_value(styles, prop, ctx) {
Some(LengthOrPercent::Length(px)) => Maybe::Some(LengthPercentageAuto::length(px)),
Some(LengthOrPercent::Percent(p)) => Maybe::Some(LengthPercentageAuto::percent(p / 100.0)),
None => Maybe::None,
}
}
fn is_auto(styles: &ComputedStyles<'_>, prop: &str) -> bool {
matches!(get_keyword(styles, prop).as_deref(), Some("auto"))
|| matches!(
styles
.get(prop)
.and_then(|rv| crate::html_css::css::parse_length(&rv.value, prop).ok()),
Some(Length::Auto)
)
}
#[derive(Clone, Copy)]
enum LengthOrPercent {
Length(f32),
Percent(f32),
}
impl LengthOrPercent {
fn to_dimension(self) -> Dimension {
match self {
LengthOrPercent::Length(px) => Dimension::length(px),
LengthOrPercent::Percent(p) => Dimension::percent(p / 100.0),
}
}
}
fn length_value(
styles: &ComputedStyles<'_>,
prop: &str,
ctx: &CalcContext,
) -> Option<LengthOrPercent> {
let rv = styles.get(prop)?;
let parsed = crate::html_css::css::parse_length(&rv.value, prop).ok()?;
match parsed {
Length::Dim {
value,
unit: Unit::Percent,
} => Some(LengthOrPercent::Percent(value)),
Length::Dim { value, unit } => Some(LengthOrPercent::Length(unit.to_px(value, ctx))),
Length::Auto => None,
Length::Calc { name, body } => {
Length::Calc { name, body }
.resolve(ctx)
.map(LengthOrPercent::Length)
},
}
}
fn number(styles: &ComputedStyles<'_>, prop: &str) -> Option<f32> {
match parse_property(prop, &styles.get(prop)?.value).ok()? {
Value::Number(n) => Some(n),
_ => None,
}
}
fn get_keyword(styles: &ComputedStyles<'_>, prop: &str) -> Option<String> {
let rv = styles.get(prop)?;
match parse_property(prop, &rv.value).ok()? {
Value::Keyword(s) => Some(s),
_ => None,
}
}
fn align_items_keyword(styles: &ComputedStyles<'_>, prop: &str) -> Option<AlignItems> {
Some(match get_keyword(styles, prop)?.as_str() {
"flex-start" | "start" => AlignItems::Start,
"flex-end" | "end" => AlignItems::End,
"center" => AlignItems::Center,
"stretch" => AlignItems::Stretch,
"baseline" => AlignItems::Baseline,
_ => return None,
})
}
fn align_content_keyword(styles: &ComputedStyles<'_>, prop: &str) -> Option<AlignContent> {
Some(match get_keyword(styles, prop)?.as_str() {
"flex-start" | "start" => AlignContent::Start,
"flex-end" | "end" => AlignContent::End,
"center" => AlignContent::Center,
"stretch" => AlignContent::Stretch,
"space-between" => AlignContent::SpaceBetween,
"space-around" => AlignContent::SpaceAround,
"space-evenly" => AlignContent::SpaceEvenly,
_ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutBox {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl LayoutBox {
pub fn zero() -> Self {
Self {
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LayoutResult {
pub boxes: Vec<LayoutBox>,
}
fn collect_text_chars(tree: &BoxTree, id: BoxId) -> usize {
let mut acc = 0usize;
let n = tree.get(id);
if let BoxKind::Text(s) = &n.kind {
let mut last_ws = false;
for c in s.chars() {
let is_ws = c.is_whitespace();
if is_ws {
if !last_ws {
acc += 1;
}
} else {
acc += 1;
}
last_ws = is_ws;
}
}
for &c in &n.children {
acc += collect_text_chars(tree, c);
}
acc
}
fn estimate_block_text_height(
tree: &BoxTree,
box_id: BoxId,
available_width_px: f32,
font_size_px: f32,
) -> f32 {
let chars = collect_text_chars(tree, box_id);
if chars == 0 {
return 0.0;
}
let char_width = (font_size_px * 0.5).max(1.0);
let chars_per_line = (available_width_px / char_width).max(1.0);
let lines = ((chars as f32) / chars_per_line).ceil().max(1.0);
lines * font_size_px * 1.2
}
pub fn run_layout<'sty>(
tree: &BoxTree,
style_for: impl Fn(BoxId) -> ComputedStyles<'sty>,
available: Size<f32>,
ctx: &CalcContext,
body_font_size_px: f32,
) -> LayoutResult {
use taffy::prelude::TaffyTree;
let mut taffy: TaffyTree<()> = TaffyTree::new();
let mut tid: Vec<Option<NodeId>> = vec![None; tree.boxes.len()];
let mut order = tree.iter_ids();
order.reverse();
for id in order {
let node = tree.get(id);
let participates = matches!(node.outside, DisplayOutside::Block | DisplayOutside::ListItem)
&& !matches!(node.kind, BoxKind::Text(_));
if !participates {
continue;
}
let style = if matches!(node.kind, BoxKind::AnonymousBlock) {
let mut s = TaffyStyle::DEFAULT;
s.display = Display::Block;
s.size = Size {
width: Dimension::percent(1.0),
height: Dimension::auto(),
};
let h = estimate_block_text_height(tree, id, available.width, body_font_size_px);
if h > 0.0 {
s.min_size.height = Dimension::length(h);
}
s
} else {
let mut computed = style_to_taffy(&style_for(id), ctx, node.outside, node.inside);
if matches!(node.outside, DisplayOutside::Block | DisplayOutside::ListItem)
&& computed.size.width == Dimension::auto()
{
computed.size.width = Dimension::percent(1.0);
}
if matches!(node.outside, DisplayOutside::Block | DisplayOutside::ListItem)
&& computed.size.height == Dimension::auto()
&& computed.min_size.height == Dimension::auto()
{
let h = estimate_block_text_height(tree, id, available.width, body_font_size_px);
if h > 0.0 {
computed.min_size.height = Dimension::length(h);
}
}
computed
};
let child_taffy_nodes: Vec<NodeId> = node
.children
.iter()
.filter_map(|&c| tid[c as usize])
.collect();
let new_node = if child_taffy_nodes.is_empty() {
taffy.new_leaf(style).expect("taffy leaf")
} else {
taffy
.new_with_children(style, &child_taffy_nodes)
.expect("taffy node")
};
tid[id as usize] = Some(new_node);
}
if let Some(root_node) = tid[BoxTree::ROOT as usize] {
let _ = taffy.compute_layout(
root_node,
Size {
width: AvailableSpace::Definite(available.width),
height: AvailableSpace::MaxContent,
},
);
}
let mut out = LayoutResult {
boxes: vec![LayoutBox::zero(); tree.boxes.len()],
};
walk_absolute(tree, &taffy, &tid, BoxTree::ROOT, 0.0, 0.0, available.width, &mut out);
out
}
fn walk_absolute(
tree: &BoxTree,
taffy: &taffy::TaffyTree<()>,
tid: &[Option<taffy::NodeId>],
box_id: BoxId,
parent_x: f32,
parent_y: f32,
parent_width: f32,
out: &mut LayoutResult,
) {
let mut self_x = parent_x;
let mut self_y = parent_y;
let mut self_width = parent_width;
if let Some(t) = tid[box_id as usize] {
if let Ok(layout) = taffy.layout(t) {
self_x = parent_x + layout.location.x;
self_y = parent_y + layout.location.y;
self_width = layout.size.width;
out.boxes[box_id as usize] = LayoutBox {
x: self_x,
y: self_y,
width: layout.size.width,
height: layout.size.height,
};
}
} else {
out.boxes[box_id as usize] = LayoutBox {
x: self_x,
y: self_y,
width: self_width,
height: 16.0,
};
}
for &child in &tree.get(box_id).children {
walk_absolute(tree, taffy, tid, child, self_x, self_y, self_width, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::html_css::css::{cascade, parse_stylesheet, ComputedStyles};
use crate::html_css::html::parse_document;
use crate::html_css::layout::box_tree::build_box_tree;
fn build(
html: &'static str,
css: &'static str,
) -> (
BoxTree,
&'static crate::html_css::css::Stylesheet<'static>,
&'static crate::html_css::html::Dom,
) {
let dom: &'static _ = Box::leak(Box::new(parse_document(html)));
let ss: &'static _ = Box::leak(Box::new(parse_stylesheet(css).unwrap()));
let tree = build_box_tree(dom, ss).unwrap();
(tree, ss, dom)
}
fn style_for_factory<'s>(
ss: &'s crate::html_css::css::Stylesheet<'s>,
dom: &'s crate::html_css::html::Dom,
tree: &'s BoxTree,
) -> impl Fn(BoxId) -> ComputedStyles<'s> + 's {
move |id: BoxId| {
let node = tree.get(id);
let Some(elem_id) = node.element else {
return ComputedStyles::default();
};
let element = dom.element(elem_id).unwrap();
cascade(ss, element, None)
}
}
#[test]
fn block_takes_full_width() {
let (tree, ss, dom) = build("<div></div>", "");
let style_for = style_for_factory(ss, dom, &tree);
let res = run_layout(
&tree,
style_for,
Size {
width: 600.0,
height: 400.0,
},
&CalcContext::default(),
12.0,
);
let div_id = tree
.iter_ids()
.into_iter()
.find(|&id| matches!(tree.get(id).kind, BoxKind::Element))
.unwrap();
assert_eq!(res.boxes[div_id as usize].width, 600.0);
}
#[test]
fn explicit_width_respected() {
let (tree, ss, dom) = build("<div></div>", "div { width: 200px }");
let style_for = style_for_factory(ss, dom, &tree);
let res = run_layout(
&tree,
style_for,
Size {
width: 600.0,
height: 400.0,
},
&CalcContext::default(),
12.0,
);
let div_id = tree
.iter_ids()
.into_iter()
.find(|&id| matches!(tree.get(id).kind, BoxKind::Element))
.unwrap();
assert_eq!(res.boxes[div_id as usize].width, 200.0);
}
#[test]
fn padding_increases_outer_size_under_content_box() {
let (tree, ss, dom) = build("<div></div>", "div { width: 200px; padding: 10px }");
let style_for = style_for_factory(ss, dom, &tree);
let res = run_layout(
&tree,
style_for,
Size {
width: 600.0,
height: 400.0,
},
&CalcContext::default(),
12.0,
);
let div_id = tree
.iter_ids()
.into_iter()
.find(|&id| matches!(tree.get(id).kind, BoxKind::Element))
.unwrap();
assert_eq!(res.boxes[div_id as usize].width, 220.0);
}
#[test]
fn border_box_includes_padding_in_width() {
let (tree, ss, dom) =
build("<div></div>", "div { width: 200px; padding: 10px; box-sizing: border-box }");
let style_for = style_for_factory(ss, dom, &tree);
let res = run_layout(
&tree,
style_for,
Size {
width: 600.0,
height: 400.0,
},
&CalcContext::default(),
12.0,
);
let div_id = tree
.iter_ids()
.into_iter()
.find(|&id| matches!(tree.get(id).kind, BoxKind::Element))
.unwrap();
assert_eq!(res.boxes[div_id as usize].width, 200.0);
}
#[test]
fn block_stacking() {
let (tree, ss, dom) = build("<div></div><div></div>", "div { width: 100px; height: 50px }");
let style_for = style_for_factory(ss, dom, &tree);
let res = run_layout(
&tree,
style_for,
Size {
width: 600.0,
height: 400.0,
},
&CalcContext::default(),
12.0,
);
let divs: Vec<BoxId> = tree
.iter_ids()
.into_iter()
.filter(|&id| matches!(tree.get(id).kind, BoxKind::Element))
.collect();
assert_eq!(divs.len(), 2);
assert_eq!(res.boxes[divs[0] as usize].y, 0.0);
assert_eq!(res.boxes[divs[1] as usize].y, 50.0);
}
#[test]
fn flex_row_distributes_horizontally() {
let (tree, ss, dom) = build(
"<div><span></span><span></span></div>",
"div { display: flex; width: 600px; height: 100px } \
span { display: block; width: 100px; height: 100px }",
);
let style_for = style_for_factory(ss, dom, &tree);
let res = run_layout(
&tree,
style_for,
Size {
width: 600.0,
height: 400.0,
},
&CalcContext::default(),
12.0,
);
let elements: Vec<BoxId> = tree
.iter_ids()
.into_iter()
.filter(|&id| matches!(tree.get(id).kind, BoxKind::Element))
.collect();
assert_eq!(elements.len(), 3);
let span_xs: Vec<f32> = elements[1..]
.iter()
.map(|&id| res.boxes[id as usize].x)
.collect();
assert!(span_xs.iter().all(|&x| x >= 0.0));
assert_ne!(span_xs[0], span_xs[1]);
}
}