From ca551a996bf7d656e9ccb663eb009f2556d4d4ac Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:09:49 +0000 Subject: [PATCH] render: resolve contextual positioned lengths --- crates/obscura-render/src/dom.rs | 57 ++++++++ crates/obscura-render/src/lib.rs | 2 + crates/obscura-render/src/style.rs | 152 +++++++++++++++------ crates/obscura-render/tests/layout_test.rs | 70 ++++++++++ 4 files changed, 243 insertions(+), 38 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index d320900..b7ec2a6 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -676,11 +676,68 @@ pub fn layout_dom_with_images( style.height, crate::Dimension::Px(_) | crate::Dimension::Percent(_) ); + for index in 0..4 { + let Some(expression) = + style.inset_expressions[index].as_deref() + else { + continue; + }; + let percent_base = if matches!(index, 1 | 3) { + cb_w + } else { + viewport.1 + }; + style.inset[index] = + crate::style::resolve_contextual_length( + expression, + em_px, + root_fs, + vw, + vh, + percent_base, + ) + .map(crate::Dimension::Px); + } for i in style.inset.iter_mut() { if let Some(d) = i { *i = Some(d.resolve(em_px, root_fs, vw, vh)); } } + // A fixed box is positioned against the initial containing + // block, whose dimensions are the viewport, not the full + // scrollable root element. Taffy attaches out-of-flow boxes to + // a layout node, and the root node can grow to the document's + // full content height. Make the CSS 2.1 stretch equation + // explicit for the ubiquitous `position:fixed; inset:0` case + // so overlays/canvases remain exactly viewport-sized. + if style.position_fixed { + if style.width == crate::Dimension::Auto { + if let ( + Some(crate::Dimension::Px(right)), + Some(crate::Dimension::Px(left)), + ) = (style.inset[1], style.inset[3]) + { + style.width = crate::Dimension::Px( + (viewport.0 - left - right).max(0.0), + ); + } + } + if style.height == crate::Dimension::Auto { + if let ( + Some(crate::Dimension::Px(top)), + Some(crate::Dimension::Px(bottom)), + ) = (style.inset[0], style.inset[2]) + { + style.height = crate::Dimension::Px( + (viewport.1 - top - bottom).max(0.0), + ); + } + } + child_cb_height_definite = matches!( + style.height, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ); + } match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() } match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() } let is_table = tree diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 864f329..9a5f259 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -375,6 +375,8 @@ pub struct LayoutStyle { /// layout mode, but fixed boxes use the initial containing block. pub position_fixed: bool, pub inset: [Option; 4], // top, right, bottom, left + /// Deferred functional inset expressions in top/right/bottom/left order. + pub inset_expressions: [Option; 4], /// `overflow`/-x/-y other than `visible`: clips this element's descendants /// to its border box during paint. This is what makes the ubiquitous diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index ceb5f22..96a7db6 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -625,13 +625,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { _ => {} } }, - "top" => style.inset[0] = inset_dim(value), - "right" => style.inset[1] = inset_dim(value), - "bottom" => style.inset[2] = inset_dim(value), - "left" => style.inset[3] = inset_dim(value), + "top" => set_inset_side(style, 0, value), + "right" => set_inset_side(style, 1, value), + "bottom" => set_inset_side(style, 2, value), + "left" => set_inset_side(style, 3, value), "inset" => { // 1-4 values, CSS shorthand order: all / v h / t h b / t r b l. - let parts: Vec = value.split_whitespace().map(dimension_value).collect(); + let parts = split_ws_paren(value); let (t, r, b, l) = match parts.as_slice() { [a] => (*a, *a, *a, *a), [v, h] => (*v, *h, *v, *h), @@ -639,7 +639,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { [t, r, b, l, ..] => (*t, *r, *b, *l), [] => return, }; - style.inset = [Some(t), Some(r), Some(b), Some(l)]; + set_inset_side(style, 0, t); + set_inset_side(style, 1, r); + set_inset_side(style, 2, b); + set_inset_side(style, 3, l); } "overflow" | "overflow-x" | "overflow-y" => { style.overflow_hidden = value != "visible"; @@ -1876,6 +1879,12 @@ struct LengthContext { fn resolve_contextual(value: &str, context: &LengthContext) -> Option { let value = value.trim(); + if let Some(rest) = value.strip_prefix('(') { + let end = find_matching_paren(rest)?; + if end + 2 == value.len() { + return eval_contextual_calc(&rest[..end], context); + } + } if let Some(rest) = value.strip_prefix("var(") { let end = find_matching_paren(rest)?; let inner = &rest[..end]; @@ -1895,7 +1904,7 @@ fn resolve_contextual(value: &str, context: &LengthContext) -> Option { let args = split_top_level(&rest[..end], ','); let mut values = args .iter() - .filter_map(|arg| resolve_contextual(arg, context)); + .filter_map(|arg| eval_contextual_calc(arg, context)); let mut best = values.next()?; for candidate in values { if (is_max && candidate > best) || (!is_max && candidate < best) { @@ -1908,9 +1917,9 @@ fn resolve_contextual(value: &str, context: &LengthContext) -> Option { let end = find_matching_paren(rest)?; let args = split_top_level(&rest[..end], ','); if args.len() == 3 { - let low = resolve_contextual(args[0], context)?; - let preferred = resolve_contextual(args[1], context)?; - let high = resolve_contextual(args[2], context)?; + let low = eval_contextual_calc(args[0], context)?; + let preferred = eval_contextual_calc(args[1], context)?; + let high = eval_contextual_calc(args[2], context)?; return Some(preferred.min(high).max(low)); } } @@ -1963,6 +1972,22 @@ fn contextual_atom(value: &str, context: &LengthContext) -> Option { if let Some(value) = lower.strip_suffix("vmax").and_then(parse) { return Some(value * context.vw.max(context.vh)); } + if let Some(value) = lower + .strip_suffix("dvw") + .or_else(|| lower.strip_suffix("svw")) + .or_else(|| lower.strip_suffix("lvw")) + .and_then(parse) + { + return Some(value * context.vw); + } + if let Some(value) = lower + .strip_suffix("dvh") + .or_else(|| lower.strip_suffix("svh")) + .or_else(|| lower.strip_suffix("lvh")) + .and_then(parse) + { + return Some(value * context.vh); + } if let Some(value) = lower.strip_suffix("vw").and_then(parse) { return Some(value * context.vw); } @@ -2019,11 +2044,11 @@ fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option { } fn eval_contextual_product(term: &str, context: &LengthContext) -> Option { - let mut result = None; + let mut result: Option = None; let mut operator = '*'; let mut depth = 0i32; let mut current = String::new(); - let mut factors = Vec::new(); + let mut factors: Vec<(char, String)> = Vec::new(); for character in term.chars() { match character { '(' => { @@ -2034,26 +2059,25 @@ fn eval_contextual_product(term: &str, context: &LengthContext) -> Option { depth -= 1; current.push(character); } - ' ' if depth == 0 => { - if !current.is_empty() { - factors.push(std::mem::take(&mut current)); + '*' | '/' if depth == 0 => { + if current.trim().is_empty() { + return None; } + factors.push((operator, std::mem::take(&mut current))); + operator = character; } _ => current.push(character), } } - if !current.is_empty() { - factors.push(current); + if current.trim().is_empty() { + return None; } - for factor in &factors { - if factor == "*" || factor == "/" { - operator = factor.chars().next()?; - continue; - } + factors.push((operator, current)); + for (operator, factor) in &factors { let value = resolve_contextual(factor, context)?; result = Some(match result { None => value, - Some(previous) if operator == '/' => previous / value, + Some(previous) if *operator == '/' => previous / value, Some(previous) => previous * value, }); } @@ -2069,6 +2093,12 @@ fn eval_contextual_product(term: &str, context: &LengthContext) -> Option { /// this function rather than assuming a flat expression. fn resolve_length(value: &str) -> Option { let v = value.trim(); + if let Some(rest) = v.strip_prefix('(') { + let end = find_matching_paren(rest)?; + if end + 2 == v.len() { + return eval_calc(&rest[..end]); + } + } if let Some(rest) = v.strip_prefix("var(") { let end = find_matching_paren(rest)?; let inner = &rest[..end]; @@ -2083,7 +2113,7 @@ fn resolve_length(value: &str) -> Option { let is_max = v.starts_with("max("); let end = find_matching_paren(rest)?; let args = split_top_level(&rest[..end], ','); - let mut values = args.iter().filter_map(|a| resolve_length(a)); + let mut values = args.iter().filter_map(|a| eval_calc(a)); let mut best = values.next()?; for val in values { if (is_max && val > best) || (!is_max && val < best) { @@ -2099,9 +2129,9 @@ fn resolve_length(value: &str) -> Option { let end = find_matching_paren(rest)?; let args = split_top_level(&rest[..end], ','); if args.len() == 3 { - let lo = resolve_length(args[0].trim())?; - let mid = resolve_length(args[1].trim())?; - let hi = resolve_length(args[2].trim())?; + let lo = eval_calc(args[0].trim())?; + let mid = eval_calc(args[1].trim())?; + let hi = eval_calc(args[2].trim())?; return Some(mid.min(hi).max(lo)); } } @@ -2206,28 +2236,31 @@ fn eval_product(term: &str) -> Option { let mut op = '*'; let mut depth = 0i32; let mut cur = String::new(); - let mut factors = Vec::new(); + let mut factors: Vec<(char, String)> = Vec::new(); for c in term.chars() { match c { '(' => { depth += 1; cur.push(c); } ')' => { depth -= 1; cur.push(c); } - ' ' if depth == 0 => { - if !cur.is_empty() { factors.push(std::mem::take(&mut cur)); } + '*' | '/' if depth == 0 => { + if cur.trim().is_empty() { + return None; + } + factors.push((op, std::mem::take(&mut cur))); + op = c; } _ => cur.push(c), } } - if !cur.is_empty() { factors.push(cur); } + if cur.trim().is_empty() { + return None; + } + factors.push((op, cur)); - for tok in &factors { - if tok == "*" || tok == "/" { - op = tok.chars().next()?; - continue; - } + for (op, tok) in &factors { let v = resolve_length(tok)?; result = Some(match result { None => v, - Some(r) if op == '/' => r / v, + Some(r) if *op == '/' => r / v, Some(r) => r * v, }); } @@ -2258,6 +2291,17 @@ fn inset_dim(value: &str) -> Option { } } +fn set_inset_side(style: &mut LayoutStyle, index: usize, value: &str) { + let value = value.trim(); + if let Some(expression) = deferred_length_expression(value) { + style.inset[index] = None; + style.inset_expressions[index] = Some(expression); + } else { + style.inset[index] = inset_dim(value); + style.inset_expressions[index] = None; + } +} + /// Absolute keyword font-sizes (the `medium`-anchored scale), for the handful /// of pages that still use them. fn font_size_keyword(v: &str) -> Option { @@ -2383,7 +2427,10 @@ fn is_font_size_token(value: &str) -> bool { { return true; } - ["px", "pt", "em", "ex", "rem", "vw", "vh", "vmin", "vmax", "%"] + [ + "px", "pt", "em", "ex", "rem", "vw", "vh", "dvw", "dvh", "svw", + "svh", "lvw", "lvh", "vmin", "vmax", "%", + ] .iter() .any(|unit| lower.strip_suffix(unit).and_then(|number| number.parse::().ok()).is_some()) } @@ -2410,6 +2457,22 @@ fn dimension_value(tok: &str) -> crate::Dimension { if let Some(v) = lower.strip_suffix("ex").and_then(parse) { return Dimension::Ex(v); } if let Some(v) = lower.strip_suffix("vmin").and_then(parse) { return Dimension::Vmin(v); } if let Some(v) = lower.strip_suffix("vmax").and_then(parse) { return Dimension::Vmax(v); } + if let Some(v) = lower + .strip_suffix("dvw") + .or_else(|| lower.strip_suffix("svw")) + .or_else(|| lower.strip_suffix("lvw")) + .and_then(parse) + { + return Dimension::Vw(v); + } + if let Some(v) = lower + .strip_suffix("dvh") + .or_else(|| lower.strip_suffix("svh")) + .or_else(|| lower.strip_suffix("lvh")) + .and_then(parse) + { + return Dimension::Vh(v); + } if let Some(v) = lower.strip_suffix("vw").and_then(parse) { return Dimension::Vw(v); } if let Some(v) = lower.strip_suffix("vh").and_then(parse) { return Dimension::Vh(v); } if let Some(v) = lower.strip_suffix("px").and_then(parse) { return Dimension::Px(v); } @@ -3407,6 +3470,19 @@ mod tests { ), Some(250.0) ); + let grouped = resolve_contextual_length( + "calc(clamp(128px,92px + 7vw,188px) + (100vw - 48px)*43/440)", + context.0, + context.1, + context.2, + context.3, + context.4, + ) + .unwrap(); + assert!( + (grouped - 238.263_64).abs() < 0.001, + "grouped calc should preserve the nested subtraction: {grouped}" + ); } #[test] diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 5ed3d71..61b5ec7 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -102,6 +102,45 @@ fn relative_units_resolve_against_viewport_and_font_size() { assert!(hit, "expected a 500x160 box from 50vw/10em, rects: {:?}", layout.rects.values().map(|r| (r.width, r.height)).collect::>()); } +#[test] +fn dynamic_viewport_units_use_the_live_viewport() { + let tree = parse_html( + r#"
"#, + ); + let layout = layout_dom(&tree, (1000.0, 800.0)); + assert!( + layout + .rects + .values() + .any(|rect| (rect.width - 250.0).abs() < 0.01 + && (rect.height - 120.0).abs() < 0.01), + "dvw/dvh should resolve against the capture viewport" + ); +} + +#[test] +fn fixed_inset_box_uses_viewport_not_document_height() { + let tree = parse_html( + r#" + +
+
+ +
+ + "#, + ); + let layout = layout_dom(&tree, (900.0, 1000.0)); + for id in ["overlay", "surface"] { + let rect = layout.rects[&tree.get_element_by_id(id).unwrap()]; + assert!( + (rect.width - 900.0).abs() < 0.01 + && (rect.height - 1000.0).abs() < 0.01, + "{id} should fill the viewport rather than the 5000px document: {rect:?}" + ); + } +} + #[test] fn percentage_padding_top_reserves_aspect_ratio_box() { // The responsive aspect-ratio trick: an empty box with padding-top:56.25% @@ -999,3 +1038,34 @@ fn length_line_height_computes_before_inheritance() { Some(obscura_render::LineHeight::Ratio(2.0)) ); } + +#[test] +fn contextual_right_inset_uses_root_font_tokens() { + let tree = parse_html( + r#" + + + "#, + ); + let layout = layout_dom(&tree, (900.0, 1000.0)); + let search = tree.get_element_by_id("search").unwrap(); + assert_eq!( + layout.styles[&search].inset[1], + Some(obscura_render::Dimension::Px(56.0)) + ); + let rect = layout.rects[&search]; + assert!( + (rect.x - 744.0).abs() < 0.01, + "right inset should place 100px box at 900 - 56 - 100: {rect:?}" + ); +}