Merge pull request #3612 from lightpanda-io/cascade-box-size

Resolve width and height from stylesheet rules
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-25 09:37:29 +08:00
commit ddda5a4bc6
4 files changed
+133 -38

No files matched your search

+74 -8
View File
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const Frame = @import("Frame.zig");
const Viewport = @import("Viewport.zig");
const units = @import("css/units.zig");
const CssParser = @import("css/Parser.zig");
@@ -40,7 +41,7 @@ const String = lp.String;
const Allocator = std.mem.Allocator;
// Tracks the CSS properties the renderless layout acts on (display, visibility,
// opacity, pointer-events, overflow) from <style> elements.
// opacity, pointer-events, overflow, width, height) from <style> elements.
// Rules are bucketed by their rightmost selector part for fast lookup.
const StyleManager = @This();
@@ -676,6 +677,22 @@ pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAx
return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains };
}
/// Own computed width or height in px, from inline style or a sheet rule.
/// Null when undeclared or when it needs layout to resolve.
pub fn declaredSize(self: *StyleManager, el: *Element, comptime axis: Element.Axis) ?f64 {
self.assertOwns(el);
self.rebuildIfDirty() catch return null;
const length = @field(self.geometryProps(el), @tagName(axis));
return length.resolve(self.frame.page.getViewport());
}
/// Like declaredSize, but only the inline style counts.
pub fn inlineSize(self: *StyleManager, el: *Element, comptime axis: Element.Axis) ?f64 {
self.assertOwns(el);
const length = @field(inlineDeclared(Geometry.Declared, el, self.frame), @tagName(axis)) orelse return null;
return length.resolve(self.frame.page.getViewport());
}
fn anyInChain(self: *StyleManager, el: *Element, comptime what: Visibility.Probe, options: CheckVisibilityOptions) bool {
var current: ?*Element = el;
while (current) |elem| : (current = elem.parentElement()) {
@@ -1134,6 +1151,38 @@ pub const Display = enum(u2) {
}
};
/// A declared width or height. Values that need layout (auto, %, em) are
/// `auto`: they win the cascade but give no size.
pub const Length = packed struct(u34) {
value: f32 = 0,
unit: Unit = .auto,
const Unit = enum(u2) { auto, px, vw, vh };
fn parse(text: []const u8) Length {
const parsed = units.parse(text) catch return .{};
if (parsed.value < 0) {
return .{};
}
return switch (parsed.unit) {
inline .vw, .vh => |unit| .{ .value = @floatCast(parsed.value), .unit = @field(Unit, @tagName(unit)) },
else => .{
.value = @floatCast(parsed.value * (units.absoluteLengthFactor(parsed.unit) orelse return .{})),
.unit = .px,
},
};
}
fn resolve(self: Length, viewport: Viewport) ?f64 {
const value: f64 = self.value;
return switch (self.unit) {
.auto => null,
.px => value,
.vw, .vh => value * @as(f64, @floatFromInt(if (self.unit == .vw) viewport.width else viewport.height)) / 100.0,
};
}
};
/// Whether an element is rendered and takes pointer input.
const Visibility = struct {
const Declared = struct {
@@ -1192,15 +1241,17 @@ const Visibility = struct {
}
};
/// How an element's box scrolls.
/// An element's box: its size and how it scrolls.
const Geometry = struct {
const Declared = struct {
const names = [_][]const u8{ "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" };
const names = [_][]const u8{ "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y", "width", "height" };
overflow_x_scrolls: ?bool = null,
overflow_y_scrolls: ?bool = null,
overscroll_x_contains: ?bool = null,
overscroll_y_contains: ?bool = null,
width: ?Length = null,
height: ?Length = null,
fn apply(self: *Declared, name: []const u8, value: []const u8) void {
if (std.ascii.eqlIgnoreCase(name, "overflow-x")) {
@@ -1211,6 +1262,10 @@ const Geometry = struct {
self.overscroll_x_contains = overscrollContains(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) {
self.overscroll_y_contains = overscrollContains(value);
} else if (std.ascii.eqlIgnoreCase(name, "width")) {
self.width = Length.parse(value);
} else if (std.ascii.eqlIgnoreCase(name, "height")) {
self.height = Length.parse(value);
}
}
@@ -1229,11 +1284,13 @@ const Geometry = struct {
}
};
const Computed = packed struct(u4) {
const Computed = packed struct(u72) {
overflow_x_scrolls: bool = false,
overflow_y_scrolls: bool = false,
overscroll_x_contains: bool = false,
overscroll_y_contains: bool = false,
width: Length = .{},
height: Length = .{},
};
};
@@ -1786,6 +1843,10 @@ test "StyleManager: inlineDeclared: scan matches the parsed style object" {
\\<i style="overflow-y: auto !important; overflow: hidden"></i>
\\<i style="overflow-x: overlay"></i>
\\<i style="overflow:"></i>
\\<i style="width: 100px; height: 1in"></i>
\\<i style="height: 10vh; height: auto"></i>
\\<i style="width: 50%; height: -5px"></i>
\\<i style="width: 2em !important; width: 10vw"></i>
);
const expected = [_]Declarations{
.{ .visibility = .{ .display = .none } },
@@ -1805,20 +1866,25 @@ test "StyleManager: inlineDeclared: scan matches the parsed style object" {
.{ .geometry = .{ .overflow_x_scrolls = false, .overflow_y_scrolls = true } },
.{ .geometry = .{ .overflow_x_scrolls = true } },
.{},
.{ .geometry = .{ .width = .{ .value = 100, .unit = .px }, .height = .{ .value = 96, .unit = .px } } },
.{ .geometry = .{ .height = .{} } },
.{ .geometry = .{ .width = .{}, .height = .{} } },
.{ .geometry = .{ .width = .{} } },
};
var i: usize = 0;
var child = div.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
const el = node.is(Element) orelse continue;
// std's expectEqual: testing's can't compare an optional struct
const scanned = inlineDeclared(Declarations, el, frame);
try testing.expectEqual(expected[i], scanned);
try testing.expectEqual(expected[i].visibility, inlineDeclared(Visibility.Declared, el, frame));
try testing.expectEqual(expected[i].geometry, inlineDeclared(Geometry.Declared, el, frame));
try std.testing.expectEqual(expected[i], scanned);
try std.testing.expectEqual(expected[i].visibility, inlineDeclared(Visibility.Declared, el, frame));
try std.testing.expectEqual(expected[i].geometry, inlineDeclared(Geometry.Declared, el, frame));
// scanning never creates the style object
try testing.expectEqual(null, el.existingStyle(frame));
const materialized = extractDeclared(Declarations, try el.getOrCreateStyle(frame));
try testing.expectEqual(expected[i], materialized);
try std.testing.expectEqual(expected[i], materialized);
i += 1;
}
try testing.expectEqual(expected.len, i);
+35
View File
@@ -629,3 +629,38 @@
testing.expectEqual(0, box.scrollLeft);
}
</script>
<style>
.sheetBox { height: 100px; overflow: auto; }
#sheetAuto { height: auto; }
.sheetVh { height: 10vh; }
</style>
<div id="sheetBox" class="sheetBox"><div style="height: 500px;">content</div></div>
<div id="sheetAuto" class="sheetBox"><div style="height: 500px;">content</div></div>
<div id="inlineAuto" class="sheetBox" style="height: 50%"><div style="height: 500px;">content</div></div>
<div id="sheetVh" class="sheetVh"></div>
<script id="sheetSizedBox">
{
// A sheet size is as explicit as an inline one
const box = $('#sheetBox');
testing.expectEqual(100, box.clientHeight);
testing.expectEqual(500, box.scrollHeight);
testing.expectEqual('100px', getComputedStyle(box).height);
box.scrollTop = 9999;
testing.expectEqual(400, box.scrollTop);
// A stronger rule's `auto` wins, leaving no size to clamp to. Chrome
// clamps to 0; we can't tell auto from max-height, so we don't clamp.
const auto = $('#sheetAuto');
testing.expectEqual(500, auto.clientHeight);
auto.scrollTop = 9999;
testing.expectEqual(9999, auto.scrollTop);
// So does an unresolvable inline value
testing.expectEqual(500, $('#inlineAuto').clientHeight);
testing.expectEqual(window.innerHeight / 10, $('#sheetVh').clientHeight);
}
</script>
-13
View File
@@ -19,7 +19,6 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const units = @import("../css/units.zig");
const Tokenizer = @import("../css/Tokenizer.zig");
const CSS = @This();
@@ -27,18 +26,6 @@ _pad: bool = false,
pub const init: CSS = .{};
// parseDimension plus viewport-relative units, which the faux layout
// resolves against the page viewport.
pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 {
const parsed = units.parse(value) catch return null;
return switch (parsed.unit) {
.none, .px => parsed.value,
.vh => parsed.value * @as(f64, @floatFromInt(frame.page.getViewport().height)) / 100.0,
.vw => parsed.value * @as(f64, @floatFromInt(frame.page.getViewport().width)) / 100.0,
else => null,
};
}
// Extract the X value from a transfrom. This could come from a translate,
// translatex, translate3d or matrix function.
pub fn parseTranslateX(value: []const u8) f64 {
+24 -17
View File
@@ -1497,27 +1497,34 @@ pub const Axis = enum {
};
pub fn getElementAxis(self: *Element, frame: *Frame, comptime axis: Axis) Axis.State {
if (self.inlineStyle(frame)) |style| {
const decl = style.asCSSStyleDeclaration();
if (CSS.parseDimensionViewport(decl.getPropertyValue(@tagName(axis), frame), frame)) |v| {
const tag = self.getTag();
const root = tag == .html or tag == .body;
if (self.ownerFrame(frame)) |owner| {
const style_manager = &owner._style_manager;
// Roots take only an inline size: a sheet's `height: 100vh` on body
// would shrink the box every synthetic position must fit in.
const size = if (root) style_manager.inlineSize(self, axis) else style_manager.declaredSize(self, axis);
if (size) |v| {
return .{ .value = v, .explicit = true };
}
}
switch (self.getTag()) {
// Root containers get large default size to contain descendant positions.
// With calculateDocumentPosition using linear depth scaling (100px per level),
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
.html, .body => return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 },
.img, .iframe => {
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
if (std.fmt.parseFloat(f64, attr)) |parsed| {
return .{ .value = parsed, .explicit = true };
} else |_| {}
}
},
else => {},
// Root containers get large default size to contain descendant positions.
// With calculateDocumentPosition using linear depth scaling (100px per level),
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
if (root) {
return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 };
}
// Presentational attributes lose to CSS sizes.
if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
if (std.fmt.parseFloat(f64, attr)) |parsed| {
return .{ .value = parsed, .explicit = true };
} else |_| {}
}
}
return .{ .value = 5.0 };