Files
obscura/crates/obscura-render/src/lib.rs
T

918 lines
39 KiB
Rust

//! obscura-render: the optional scoped render layer for Obscura.
//!
//! Obscura's default build has no layout or paint engine, which is the source
//! of its speed and low memory. This crate adds a render layer behind a feature
//! flag: real CSS box geometry (so getBoundingClientRect, elementFromPoint, and
//! IntersectionObserver return true values) and, with the paint feature,
//! rasterized PNG screenshots.
//!
//! Phase 1 (this file): the layout core. A `LayoutNode` tree plus a viewport is
//! laid out with taffy and the resulting border-box geometry is returned per
//! node. Later phases build the `LayoutNode` tree from the live DOM + computed
//! styles, feed geometry back to JS, and add text + paint.
use taffy::prelude::*;
pub mod css;
pub use css::Stylesheet;
pub mod style;
pub use style::compute_style;
pub mod dom;
pub use dom::{layout_dom, layout_dom_with_images, DomLayout};
#[cfg(feature = "paint")]
mod paint;
#[cfg(feature = "paint")]
pub use paint::{paint_dom, screenshot_png};
// Real inline text layout (cosmic-text) lives behind the paint feature; the
// layout-only build keeps the lighter word-split geometry. The stub lets
// `dom.rs` name `inline::TextEngine` and call `try_build` unconditionally.
#[cfg(feature = "paint")]
pub mod inline;
#[cfg(not(feature = "paint"))]
pub mod inline {
use obscura_dom::tree::{DomTree, NodeId};
use std::collections::HashMap;
#[derive(Default)]
pub struct TextEngine;
impl TextEngine {
pub fn new() -> Self {
TextEngine
}
/// Layout-only builds have no shaper, so no container is ever treated
/// as a cosmic-text inline formatting context: the word-split path
/// handles text geometry for `getBoundingClientRect`.
pub fn try_build(&mut self, _tree: &DomTree, _id: NodeId, _styles: &HashMap<NodeId, crate::LayoutStyle>) -> Option<usize> {
None
}
/// See `try_build`: inline runs likewise fall back to word-split
/// geometry in layout-only builds.
pub fn try_build_run(
&mut self,
_tree: &DomTree,
_parent: NodeId,
_run: &[NodeId],
_styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Option<usize> {
None
}
}
}
/// An axis-aligned rectangle in CSS pixels, relative to the containing block.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Rect {
/// The overlap of two rects, or `None` if they do not intersect (or the
/// overlap is degenerate). Used to accumulate an ancestor clip chain for
/// `overflow: hidden`.
pub fn intersect(&self, other: &Rect) -> Option<Rect> {
let x0 = self.x.max(other.x);
let y0 = self.y.max(other.y);
let x1 = (self.x + self.width).min(other.x + other.width);
let y1 = (self.y + self.height).min(other.y + other.height);
if x1 > x0 && y1 > y0 {
Some(Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
} else {
None
}
}
/// The smallest rect covering both. Used to derive a table row/section box
/// from its cells, since `<tr>`/`<tbody>` are not laid out as taffy boxes.
pub fn union(&self, other: &Rect) -> Rect {
let x0 = self.x.min(other.x);
let y0 = self.y.min(other.y);
let x1 = (self.x + self.width).max(other.x + other.width);
let y1 = (self.y + self.height).max(other.y + other.height);
Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
}
}
/// Per-edge box values (margin / padding / border) in CSS pixels.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Edges {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
/// The display modes obscura-render cares about for phase 1. Inline text layout
/// arrives with the text/paint phase and is folded in then.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum Display {
#[default]
Block,
Flex,
Grid,
Inline,
#[allow(dead_code)]
None,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum Dimension {
#[default]
Auto,
Px(f32),
/// 0.0-1.0 fraction of the containing block.
Percent(f32),
/// Font-relative and viewport-relative units, kept unresolved at parse
/// time (the element font-size and viewport are not known then) and
/// resolved to `Px` during `dom::layout_dom`'s top-down pass via
/// [`Dimension::resolve`]. Resolving em/rem/vw against a hardcoded 16px at
/// parse time (the old behavior) silently corrupted every relative length.
Em(f32),
Rem(f32),
Vw(f32),
Vh(f32),
Vmin(f32),
Vmax(f32),
}
impl Dimension {
/// Resolve font/viewport-relative units to `Px`. `em_px` is the element's
/// own font-size, `rem_px` the root's, and `vw`/`vh` are one hundredth of
/// the viewport width/height. `Px`, `Percent`, and `Auto` pass through
/// (`Percent` stays for taffy to resolve against the containing block).
pub fn resolve(self, em_px: f32, rem_px: f32, vw: f32, vh: f32) -> Dimension {
match self {
Dimension::Em(v) => Dimension::Px(v * em_px),
Dimension::Rem(v) => Dimension::Px(v * rem_px),
Dimension::Vw(v) => Dimension::Px(v * vw),
Dimension::Vh(v) => Dimension::Px(v * vh),
Dimension::Vmin(v) => Dimension::Px(v * vw.min(vh)),
Dimension::Vmax(v) => Dimension::Px(v * vw.max(vh)),
other => other,
}
}
}
/// The subset of CSS that influences box layout. Expanded in later phases.
#[derive(Debug, Clone, Default)]
pub struct LayoutStyle {
pub display: Display,
pub width: Dimension,
pub height: Dimension,
/// Which box edge `width`/`height` and min/max sizes describe. CSS starts
/// at `content-box`; many modern reset sheets opt into `border-box`.
pub box_sizing: BoxSizing,
/// Whether `width`/`height` was set by an author rule (including an explicit
/// `auto`). Presentational `width`/`height` HTML attributes are a lower
/// priority than author CSS, so they apply only when these are false; an
/// explicit `width:auto` must still suppress a `width="408"` attribute so
/// the element keeps its aspect-ratio size instead of the intrinsic one.
pub width_set: bool,
pub height_set: bool,
pub min_width: Dimension,
pub min_height: Dimension,
pub max_width: Dimension,
pub max_height: Dimension,
/// `aspect-ratio` as width/height, or an image's intrinsic ratio resolved
/// at layout. Lets a replaced element (or a padding-box card) derive the
/// missing dimension from the given one, so a `width:100%` image gets a
/// real height instead of collapsing to zero.
pub aspect_ratio: Option<f32>,
pub margin: Edges,
/// Which margin sides are `auto` (top, right, bottom, left). `margin: 0
/// auto` / `margin-inline: auto` centering needs a real Auto margin, which
/// the f32 `margin` cannot express; this flag drives it at taffy mapping.
pub margin_auto: [bool; 4],
/// Percentage margin per side (top, right, bottom, left) as a 0..1 fraction,
/// `None` when the side is a fixed length. Like padding, every side resolves
/// against the containing block's WIDTH; the f32 `margin` cannot carry a
/// percentage, so this is resolved to px during `dom::layout_dom`'s top-down
/// pass once the containing-block width is known.
pub margin_percent: [Option<f32>; 4],
/// Font- and viewport-relative margin lengths (top, right, bottom, left).
/// These retain their unit until the top-down pass knows the element font
/// size, root font size, and viewport dimensions.
pub margin_relative: [Option<Dimension>; 4],
pub padding: Edges,
/// Percentage padding per side (top, right, bottom, left) as a 0..1
/// fraction, `None` when the side is a fixed length. All four sides resolve
/// against the containing block's WIDTH (per CSS, including top/bottom): this
/// is the responsive aspect-ratio-box trick (`padding-top:56.25%` reserves a
/// 16:9 area). The f32 `padding` cannot carry a percentage, so it is
/// resolved to px in `dom::layout_dom`'s top-down pass and written back into
/// `padding`, which then feeds taffy as a fixed length.
pub padding_percent: [Option<f32>; 4],
/// Font- and viewport-relative padding lengths (top, right, bottom, left),
/// resolved alongside `margin_relative` during the top-down pass.
pub padding_relative: [Option<Dimension>; 4],
pub border: Edges,
/// `border-radius` (uniform; the first value of the shorthand). Rounds the
/// background fill and border. In px after resolution.
pub border_radius: f32,
/// RGBA for the paint step. Parsed always (cheap), used only with `paint`.
pub background_color: Option<[u8; 4]>,
/// `linear-gradient(...)` background: (angle in degrees clockwise from 12
/// o'clock per CSS, list of (rgba, optional 0..1 stop position)). Modern
/// hero sections use gradients heavily; without this they paint white.
pub background_gradient: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
/// The first `url(...)` reference from `background`/`background-image`
/// (gradients and repeat keywords in the same shorthand are ignored: we
/// paint the referenced image, not the gradient layer).
pub background_image: Option<String>,
/// `background-size`, in px, when given as explicit length(s) (a bare
/// `10px` applies to both axes, matching how small square icons are
/// almost always sized). `None` means "fill the whole box" (our fallback
/// when the value is a keyword we don't evaluate, `cover`/`contain`, or
/// absent with an already icon-sized box like the HN vote arrow).
pub background_size: Option<(f32, f32)>,
/// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default
/// top-left; 1,0.5 = "right center"). Only meaningful alongside
/// `background_size`: without a known image size there is no leftover
/// box space to position within.
pub background_position: (f32, f32),
/// `background-clip: text` / `-webkit-background-clip: text`: the background
/// paints only through the element's glyphs, not as a filled box. Combined
/// with a transparent text color this is the common gradient-text technique
/// (hero headings, buttons like astro.build's "Get Started"); without
/// honoring it those labels paint invisible. Consumed in the text paint path
/// (`inline::TextEngine` fills glyphs from the background; `paint` suppresses
/// the box fill so the gradient does not paint as a rectangle).
pub background_clip_text: bool,
/// `mask-image`/`-webkit-mask-image: url(...)`: the ubiquitous "colored,
/// scalable icon" pattern (an SVG shape used as a stencil, tinted by
/// `background-color`/`color` instead of carrying its own colors). Without
/// this, every such icon paints as a solid filled square.
pub mask_image: Option<String>,
/// Foreground (text) color for the paint step.
pub color: Option<[u8; 4]>,
pub border_color: Option<[u8; 4]>,
pub font_size: Option<f32>,
/// `font-size` given in a font/viewport-relative unit, resolved to
/// `font_size` (px) during the inheritance pass against the parent and
/// root font-sizes. `None` when font-size was absolute or unset.
pub font_size_raw: Option<Dimension>,
pub font_weight: Option<String>,
/// The computed `font-family` list, lowercased. Inherited. The text engine
/// resolves it to a bundled face (Liberation Sans/Serif/Mono) the way
/// Chromium picks a generic family on this host.
pub font_family: Option<String>,
pub align_items: Option<taffy::AlignItems>,
pub flex_direction: Option<taffy::FlexDirection>,
pub flex_wrap: Option<taffy::FlexWrap>,
pub justify_content: Option<taffy::JustifyContent>,
pub flex_grow: Option<f32>,
pub flex_shrink: Option<f32>,
/// `flex-basis` (longhand, or the length in a `flex:` shorthand). `Auto`
/// is the default. Fixed-basis sidebars/columns (`flex: 0 0 260px`)
/// collapse to content width without it.
pub flex_basis: Dimension,
// CSS Grid. Tracks are stored as taffy sizing functions; `grid_areas` is the
// parsed `grid-template-areas` matrix (one Vec per row, `.` for a null cell),
// resolved to line placements on children in a later pass.
pub grid_template_columns: Vec<taffy::TrackSizingFunction>,
pub grid_template_rows: Vec<taffy::TrackSizingFunction>,
pub grid_areas: Option<Vec<Vec<String>>>,
pub grid_area_name: Option<String>,
pub grid_column: Option<taffy::Line<taffy::GridPlacement>>,
pub grid_row: Option<taffy::Line<taffy::GridPlacement>>,
/// `[line-name]` -> 1-based grid line number, parsed from
/// `grid-template-columns`/`-rows`. taffy has no native named-line support,
/// so children placed by name (`grid-column: content-start / content-end`,
/// widely used by the Guardian and other editorial grids) are resolved to
/// numeric lines against these maps in `dom::resolve_grid_areas`.
pub grid_col_line_names: Option<std::collections::HashMap<String, i16>>,
pub grid_row_line_names: Option<std::collections::HashMap<String, i16>>,
/// Raw `grid-column`/`grid-row` value when it references a named line (so it
/// cannot be resolved to a `taffy::Line` until the parent's line-name map is
/// known). Resolved in the same later pass; numeric/`span` values still fill
/// `grid_column`/`grid_row` directly at cascade time.
pub grid_column_raw: Option<String>,
pub grid_row_raw: Option<String>,
pub column_gap: Option<f32>,
pub row_gap: Option<f32>,
/// `border-spacing: <horizontal> <vertical>?` (or the `cellspacing`
/// attribute). Only meaningful on a `<table>`; taffy has no native table
/// display mode, so `dom::propagate_border_spacing` distributes this down
/// as the table's own row gap and each descendant `<tr>`'s column gap.
pub border_spacing: Option<(f32, f32)>,
// Positioning. `position: absolute|fixed` takes the box out of normal flow.
pub position: Option<taffy::Position>,
/// Distinguishes `fixed` from `absolute`; both map to taffy's absolute
/// layout mode, but fixed boxes use the initial containing block.
pub position_fixed: bool,
pub inset: [Option<Dimension>; 4], // top, right, bottom, left
/// `overflow`/-x/-y other than `visible`: clips this element's descendants
/// to its border box during paint. This is what makes the ubiquitous
/// "visually-hidden but accessible" pattern (a 1x1 absolutely-positioned,
/// clipped box used for skip-links and screen-reader-only labels) actually
/// invisible instead of painting its text wherever it lands.
pub overflow_hidden: bool,
/// `float: left|right`. True CSS float needs per-line reflow around the
/// float's shape, which taffy's block/flex/grid modes do not do; see
/// `dom::group_float_zone` for the bounded approximation this drives.
pub float: Option<Float>,
/// `visibility: hidden|visible`, own value. `None` means "inherit the
/// ancestor's computed value" (visibility, unlike most box properties, is
/// a real inherited CSS property). Resolved into `effectively_invisible`
/// during `dom::layout_dom`'s inheritance pass.
pub visibility_hidden: Option<bool>,
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
/// default of 1.0.
pub opacity: Option<f32>,
/// `vertical-align` for a table cell's content. Cells effectively default
/// to `middle` in browsers (the HTML UA sheet sets it on row groups and
/// cells inherit it); obscura applies it as main-axis alignment of the
/// cell's flex-column stand-in. `None` on non-cell elements.
pub vertical_align: Option<VerticalAlign>,
/// `z-index` on a positioned element. `None` is `auto` (tree order). A
/// non-zero value lifts the element's whole subtree into a separate paint
/// layer: negatives under the normal flow, positives above it, sorted.
pub z_index: Option<i32>,
/// `clear`, when set: this element moves below preceding floats on the
/// given side(s), ending their float zone.
pub clear: Option<Clear>,
/// Resolved during the inheritance pass: true when this element should
/// not be painted at all, either from its own or an inherited
/// `visibility: hidden`, or because the product of its own and every
/// ancestor's `opacity` has collapsed to (near) zero. Real `opacity`
/// composites as translucent groups, which our paint step does not do;
/// collapsing it to a binary paint/don't-paint decision is enough to
/// make the extremely common "opacity:0 + visibility:hidden" collapsed
/// dropdown/panel pattern actually invisible, without needing full alpha
/// compositing.
pub effectively_invisible: bool,
/// Literal text injected by a `::before`/`::after` rule with a plain
/// string-literal `content` (see `css::Stylesheet::pseudo_content`).
/// Rendered as an extra word-run at the start/end of this element's
/// children, same as if it were real text content.
pub before_content: Option<String>,
pub after_content: Option<String>,
/// True for `inline-block`/`inline-flex`/`inline-grid`: participates in
/// the surrounding inline flow from the outside, like plain `inline`
/// (both currently collapse to `Display::Inline` — this engine has no
/// separate inline-block layout mode), but unlike plain `inline` it must
/// stay a single atomic box rather than have its own content merge into
/// the parent's line-breaking. `dom::is_flattenable_inline` uses this to
/// avoid flattening these away: doing so would lose the element as its
/// own box (including any `::before`/`::after` content attached to it).
pub is_inline_block: bool,
/// `display: contents`: the element generates no box of its own; its
/// children participate in the parent's formatting context directly
/// (`dom::build_any` splices them into the parent's child list). Kept as a
/// flag beside `display` because the element still carries inherited styles
/// for its subtree and `display:none` must still win.
pub display_contents: bool,
/// `list-style-type` (or the `list-style` shorthand). Inherited, like in
/// real CSS; `None` means "not set on this element, inherit". Resolved to
/// a concrete value during the inheritance pass. Only `<li>` elements draw
/// a marker from it, but it is carried on every element because it
/// inherits (a `list-style: none` on a `<ul>` must reach its `<li>`
/// children, which is how nav menus suppress bullets).
pub list_style: Option<ListStyle>,
/// `line-height`. Inherited. `None` means "not set, inherit"; resolved to
/// a concrete value in the inheritance pass. Drives the vertical rhythm of
/// shaped text (a fixed ratio made real-site prose noticeably tighter than
/// Chromium).
pub line_height: Option<LineHeight>,
/// `text-transform`. Inherited. Applied to span text before shaping.
pub text_transform: Option<TextTransform>,
/// `text-decoration-line: underline` (or the `text-decoration` shorthand).
/// Not inherited in CSS, but a decoration visually covers descendant inline
/// text, so it is propagated into the shaped spans of the element's subtree
/// (this is what underlines links, which are underlined by UA default).
pub underline: Option<bool>,
/// `font-style: italic|oblique`. Inherited. Selects the oblique face when
/// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None`
/// means inherit.
pub font_style_italic: Option<bool>,
/// `object-fit` for a replaced element (`<img>`). Controls how the decoded
/// image is scaled into the element's box when their aspect ratios differ;
/// `Fill` (default) stretches to the box, the rest preserve aspect ratio.
/// Only consulted in the image paint path.
pub object_fit: ObjectFit,
/// `transform: translate(x[, y])` / `translateX` / `translateY`, stored
/// unresolved as (dx, dy). Applied at paint time as an offset to the
/// element's own box AND its whole descendant subtree; percentages resolve
/// against the element's own border-box size then. This is what makes the
/// canonical `translate(-50%,-50%)` centering of an absolutely-positioned
/// box land in the right place, and moves a `translate(-9999px,0)`
/// off-screen skip-link out of view instead of painting it on-screen. Not
/// inherited (transform is a non-inherited property).
pub transform_translate: Option<(Dimension, Dimension)>,
/// Independent CSS-property triggers that establish containing blocks for
/// absolute and fixed descendants. Kept as a bitset so `filter:none`
/// cannot clear a transform/containment trigger from another property.
pub containing_block_triggers: u16,
/// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy).
/// Parsed and stored so a value that mixes scale with translate still yields
/// its translate part; scale is not yet folded into paint geometry (doing so
/// correctly would require scaling every descendant's size and text, outside
/// the paint-time translate-offset model used here).
pub transform_scale: Option<(f32, f32)>,
/// `box-shadow` (first layer only). Painted behind the element's own
/// background/border box: cards, buttons, menus, and modals across the
/// modern web rely on it for depth, and without it those elements paint
/// flat. See [`BoxShadow`] and `paint::paint_box_shadow`.
pub box_shadow: Option<BoxShadow>,
}
pub(crate) const CB_TRIGGER_TRANSFORM: u16 = 1 << 0;
pub(crate) const CB_TRIGGER_FILTER: u16 = 1 << 1;
pub(crate) const CB_TRIGGER_BACKDROP_FILTER: u16 = 1 << 2;
pub(crate) const CB_TRIGGER_PERSPECTIVE: u16 = 1 << 3;
pub(crate) const CB_TRIGGER_CONTAIN: u16 = 1 << 4;
pub(crate) const CB_TRIGGER_WILL_CHANGE: u16 = 1 << 5;
pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6;
impl LayoutStyle {
pub(crate) fn establishes_positioning_containing_block(&self) -> bool {
self.containing_block_triggers != 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Float {
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BoxSizing {
#[default]
ContentBox,
BorderBox,
/// A specified CSS-wide `inherit` value. The DOM's top-down computed-style
/// pass resolves this to the parent's computed value before layout.
Inherit,
}
/// One `box-shadow` layer. Offsets, blur, and spread are in CSS px; `color` is
/// the resolved RGBA (falling back to the element's text color, per CSS
/// `currentColor`, when the value omits a color); `inset` distinguishes an
/// inner shadow from the default outer (drop) shadow. Only the first layer of a
/// comma-separated list is modeled.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoxShadow {
pub offset_x: f32,
pub offset_y: f32,
pub blur: f32,
pub spread: f32,
pub color: [u8; 4],
pub inset: bool,
}
/// `object-fit` for replaced elements (`<img>`): how the image's intrinsic
/// content is scaled into its box when their aspect ratios differ. `Fill` (the
/// default) stretches to the whole box; the others preserve the image's aspect
/// ratio, either letterboxing inside the box (`Contain`), cropping to cover it
/// (`Cover`), or using the intrinsic size (`None`, or `ScaleDown` which is
/// `Contain` capped at the intrinsic size so it never upscales).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ObjectFit {
#[default]
Fill,
Contain,
Cover,
ScaleDown,
None,
}
/// `vertical-align` positions for table-cell content. `baseline` (and the
/// text-level values like sub/super, which do not apply to cells) map to
/// `Top` as an approximation: real per-row baseline alignment needs shared
/// ascent metrics across the row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerticalAlign {
Top,
Middle,
Bottom,
}
/// `clear`: which floated side(s) an element moves below. Ends the float
/// zone in `dom::build_children_with_float_zone` (the clearfix idiom).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Clear {
Left,
Right,
Both,
}
/// `line-height`: `normal` (a font-relative default), a unitless multiple of
/// font-size, or an absolute pixel length.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LineHeight {
Normal,
Ratio(f32),
Px(f32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextTransform {
None,
Uppercase,
Lowercase,
Capitalize,
}
/// `list-style-type` values we render a marker for. `Decimal` numbers the
/// item by its position among sibling list items.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListStyle {
None,
Disc,
Circle,
Square,
Decimal,
}
/// A node in the input layout tree. `text` is carried for the paint phase; it
/// does not affect layout in phase 1 (inline/text layout comes later).
#[derive(Debug, Clone)]
pub struct LayoutNode {
pub style: LayoutStyle,
#[allow(dead_code)]
pub text: Option<String>,
pub children: Vec<LayoutNode>,
}
impl LayoutNode {
pub fn leaf(style: LayoutStyle) -> Self {
LayoutNode { style, text: None, children: Vec::new() }
}
}
/// Computed geometry for one node and its subtree.
#[derive(Debug, Clone, Default)]
pub struct NodeRect {
/// Border box, in viewport coordinates.
pub border_box: Rect,
pub children: Vec<NodeRect>,
}
/// Lay out `root` within a `viewport` (width, height) in CSS pixels and return
/// the border-box geometry per node, mirroring the input tree.
pub fn layout(root: &LayoutNode, viewport: (f32, f32)) -> NodeRect {
let mut tree: TaffyTree = TaffyTree::new();
let root_id = build_node(&mut tree, root);
let _ = tree.compute_layout(
root_id,
taffy::Size {
width: taffy::AvailableSpace::Definite(viewport.0),
height: taffy::AvailableSpace::Definite(viewport.1),
},
);
read_node(&tree, root_id)
}
fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId {
let style = to_taffy_style(&node.style);
if node.children.is_empty() {
tree.new_leaf(style).expect("taffy new_leaf")
} else {
let child_ids: Vec<NodeId> =
node.children.iter().map(|c| build_node(tree, c)).collect();
tree.new_with_children(style, &child_ids).expect("taffy new_with_children")
}
}
fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect {
let layout = tree.layout(id).expect("taffy layout");
NodeRect {
border_box: Rect {
x: layout.location.x,
y: layout.location.y,
width: layout.size.width,
height: layout.size.height,
},
children: tree
.children(id)
.unwrap_or_default()
.iter()
.map(|&cid| read_node(tree, cid))
.collect(),
}
}
pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
let mut s = Style::DEFAULT;
s.box_sizing = match style.box_sizing {
BoxSizing::ContentBox => taffy::BoxSizing::ContentBox,
BoxSizing::BorderBox => taffy::BoxSizing::BorderBox,
// Programmatic LayoutStyle users do not have a DOM inheritance pass;
// fall back to the property's CSS initial value in that case.
BoxSizing::Inherit => taffy::BoxSizing::ContentBox,
};
// A block box that wants non-default cross-axis alignment (from
// text-align: center/right, the only way our engine currently sets
// align_items on a block-level element) needs a flex column to have any
// effect at all: taffy's native block algorithm (used for plain
// Display::Block) has no align-items concept whatsoever, it only ever
// places block children at the start edge. Promote such boxes to a flex
// column, matching the same column-flex approximation already used for
// elements like <td> and <center>.
let promote_for_alignment = style.display == Display::Block
&& matches!(style.align_items, Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END));
s.display = match style.display {
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
Display::Block => taffy::style::Display::Block,
Display::Flex => taffy::style::Display::Flex,
Display::Grid => taffy::style::Display::Grid,
Display::Inline => taffy::style::Display::Flex,
Display::None => taffy::style::Display::None,
};
if promote_for_alignment {
s.flex_direction = taffy::FlexDirection::Column;
}
if let Some(fd) = style.flex_direction {
s.flex_direction = fd;
}
if let Some(fw) = style.flex_wrap {
s.flex_wrap = fw;
} else if style.display == Display::Inline {
s.flex_direction = taffy::FlexDirection::Row;
s.flex_wrap = taffy::FlexWrap::Wrap;
} else {
s.flex_wrap = taffy::FlexWrap::NoWrap;
}
s.size = taffy::Size {
width: dimension(style.width),
height: dimension(style.height),
};
// Tell taffy's own layout algorithm about `overflow: hidden`, not just our
// paint-time clip rects: per spec, a flex/grid item's automatic minimum
// size is content-based only when its overflow is `visible`, and `0`
// otherwise. Without this, an explicit `height: 0; overflow: hidden`
// element (the common collapsed-dropdown-panel pattern: hidden until a
// sibling checkbox is checked) still grows to fit its content, since
// taffy has no way to know it should not.
if style.overflow_hidden {
s.overflow = taffy::Point { x: taffy::style::Overflow::Hidden, y: taffy::style::Overflow::Hidden };
}
s.min_size = taffy::Size {
width: dimension(style.min_width),
height: dimension(style.min_height),
};
s.max_size = taffy::Size {
width: dimension(style.max_width),
height: dimension(style.max_height),
};
if let Some(ar) = style.aspect_ratio {
if ar.is_finite() && ar > 0.0 {
s.aspect_ratio = Some(ar);
}
}
if let Some(ai) = style.align_items {
s.align_items = Some(ai);
}
if let Some(jc) = style.justify_content {
s.justify_content = Some(jc);
}
if let Some(fg) = style.flex_grow {
s.flex_grow = fg;
}
if let Some(fs) = style.flex_shrink {
s.flex_shrink = fs;
}
if style.flex_basis != Dimension::Auto {
s.flex_basis = dimension(style.flex_basis);
}
// Grid container tracks and gaps. obscura expands `repeat()` itself during
// parsing, so every stored track is a single (non-repeated) `MinMax`; wrap
// each as a `GridTemplateComponent::Single` for taffy 0.12. The 0.7-era
// fr->Auto row workaround (which stopped `minmax(0,1fr)` image rows from
// collapsing to a sliver) is gone: taffy 0.12 treats an in-flow child's
// vertical available space as indefinite, so fr rows of an auto-height grid
// size to their content the way real CSS does.
if style.display == Display::Grid {
if !style.grid_template_columns.is_empty() {
s.grid_template_columns = style
.grid_template_columns
.iter()
.cloned()
.map(taffy::GridTemplateComponent::Single)
.collect();
}
if !style.grid_template_rows.is_empty() {
s.grid_template_rows = style
.grid_template_rows
.iter()
.cloned()
.map(taffy::GridTemplateComponent::Single)
.collect();
}
}
let cg = style.column_gap.unwrap_or(0.0);
let rg = style.row_gap.unwrap_or(0.0);
s.gap = taffy::Size {
width: taffy::style::LengthPercentage::length(cg),
height: taffy::style::LengthPercentage::length(rg),
};
// Grid item placement (resolved from grid-area names or explicit lines).
// `GridPlacement` is no longer `Copy` in taffy 0.12 (it can carry a named
// line), so clone out of the borrowed style.
if let Some(gc) = &style.grid_column {
s.grid_column = gc.clone();
}
if let Some(gr) = &style.grid_row {
s.grid_row = gr.clone();
}
// Positioning. Absolute/fixed take the box out of flow.
if let Some(pos) = style.position {
s.position = pos;
s.inset = taffy::Rect {
top: inset_lpa(style.inset[0]),
right: inset_lpa(style.inset[1]),
bottom: inset_lpa(style.inset[2]),
left: inset_lpa(style.inset[3]),
};
}
s.margin = rect_auto(style.margin, style.margin_auto);
s.padding = rect_lp(style.padding);
s.border = rect_lp(style.border);
s
}
fn inset_lpa(v: Option<Dimension>) -> taffy::style::LengthPercentageAuto {
match v {
Some(Dimension::Px(px)) => taffy::style::LengthPercentageAuto::length(px),
Some(Dimension::Percent(p)) => taffy::style::LengthPercentageAuto::percent(p),
// Relative units are resolved to Px before layout; unresolved leftovers
// and `auto`/absent both map to Auto.
_ => taffy::style::LengthPercentageAuto::auto(),
}
}
fn dimension(v: Dimension) -> taffy::style::Dimension {
match v {
Dimension::Px(px) => taffy::style::Dimension::length(px),
Dimension::Percent(p) => taffy::style::Dimension::percent(p),
Dimension::Auto => taffy::style::Dimension::auto(),
// Relative units are resolved to Px before layout; if one slips
// through unresolved, fall back to its raw magnitude (em/rem ~16px)
// rather than panicking.
Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0),
Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => taffy::style::Dimension::length(v),
}
}
fn rect_lp(e: Edges) -> taffy::Rect<taffy::style::LengthPercentage> {
taffy::Rect {
top: taffy::style::LengthPercentage::length(e.top),
right: taffy::style::LengthPercentage::length(e.right),
bottom: taffy::style::LengthPercentage::length(e.bottom),
left: taffy::style::LengthPercentage::length(e.left),
}
}
fn rect_auto(e: Edges, auto: [bool; 4]) -> taffy::Rect<taffy::style::LengthPercentageAuto> {
let side = |value, is_auto| {
if is_auto {
taffy::style::LengthPercentageAuto::auto()
} else {
taffy::style::LengthPercentageAuto::length(value)
}
};
taffy::Rect {
top: side(e.top, auto[0]),
right: side(e.right, auto[1]),
bottom: side(e.bottom, auto[2]),
left: side(e.left, auto[3]),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_box(display: Display, w: f32, h: f32) -> LayoutStyle {
LayoutStyle { display, width: Dimension::Px(w), height: Dimension::Px(h), ..Default::default() }
}
#[test]
fn block_children_stack_vertically() {
// A 1000px-wide viewport, two fixed-size block children: they should
// stack top-to-bottom at the expected y offsets.
let root = LayoutNode {
style: make_box(Display::Block, 1000.0, 800.0),
text: None,
children: vec![
LayoutNode::leaf(make_box(Display::Block, 1000.0, 50.0)),
LayoutNode::leaf(make_box(Display::Block, 1000.0, 30.0)),
],
};
let out = layout(&root, (1000.0, 800.0));
assert_eq!(out.border_box.width, 1000.0);
assert_eq!(out.children.len(), 2);
assert_eq!(out.children[0].border_box.height, 50.0);
assert_eq!(out.children[1].border_box.height, 30.0);
// Second block begins where the first ended.
assert!(
(out.children[1].border_box.y - out.children[0].border_box.y).abs()
>= out.children[0].border_box.height - 0.01,
"blocks should stack: c0.y={} c1.y={}",
out.children[0].border_box.y,
out.children[1].border_box.y
);
}
#[test]
fn block_auto_margins_absorb_horizontal_free_space() {
let centered = LayoutStyle {
display: Display::Block,
width: Dimension::Px(300.0),
height: Dimension::Px(40.0),
margin_auto: [false, true, false, true],
..Default::default()
};
let pushed_end = LayoutStyle {
display: Display::Block,
width: Dimension::Px(200.0),
height: Dimension::Px(40.0),
margin: Edges { right: 50.0, ..Default::default() },
margin_auto: [false, false, false, true],
..Default::default()
};
let root = LayoutNode {
style: make_box(Display::Block, 900.0, 200.0),
text: None,
children: vec![LayoutNode::leaf(centered), LayoutNode::leaf(pushed_end)],
};
let out = layout(&root, (900.0, 200.0));
assert!((out.children[0].border_box.x - 300.0).abs() < 0.01);
assert!((out.children[1].border_box.x - 650.0).abs() < 0.01);
}
#[test]
fn flex_row_lays_out_horizontally() {
let root = LayoutNode {
style: LayoutStyle { display: Display::Flex, width: Dimension::Px(600.0), height: Dimension::Px(100.0), ..Default::default() },
text: None,
children: vec![
LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)),
LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)),
],
};
let out = layout(&root, (600.0, 400.0));
assert_eq!(out.border_box.width, 600.0);
assert_eq!(out.children.len(), 2);
// In a row the second child is to the right of the first.
assert!(
out.children[1].border_box.x > out.children[0].border_box.x,
"flex row should place children horizontally: c0.x={} c1.x={}",
out.children[0].border_box.x,
out.children[1].border_box.x
);
}
#[test]
fn padding_expands_content_box_but_not_border_box() {
let content_box = LayoutNode {
style: LayoutStyle {
display: Display::Block,
width: Dimension::Px(100.0),
height: Dimension::Px(100.0),
padding: Edges { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 },
..Default::default()
},
text: None,
children: vec![],
};
let content_out = layout(&content_box, (1000.0, 800.0));
assert_eq!(content_out.border_box.width, 120.0);
let mut border_box = content_box;
border_box.style.box_sizing = BoxSizing::BorderBox;
let border_out = layout(&border_box, (1000.0, 800.0));
assert_eq!(border_out.border_box.width, 100.0);
}
}