Merge pull request #3626 from lightpanda-io/style-groups

Split the style cascade into property groups
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-25 09:30:42 +08:00
commit 140b0bc908
1 file changed
+418 -318
+418 -318
View File
@@ -46,21 +46,13 @@ const StyleManager = @This();
const Tag = Element.Tag;
const Input = Element.Html.Input;
const RuleList = std.MultiArrayList(TrackedRule);
frame: *Frame,
arena: *lp.Arena,
// Bucketed rules for fast lookup - keyed by rightmost selector part
id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
// Own-element results, valid while Page.style_version == memo_version.
memo: Memo = .empty,
memo_version: usize = 0,
visibility: Group(Visibility) = .{},
geometry: Group(Geometry) = .{},
// Keyed by property name, pruning to what is (hopefully) one or few rules
custom_rules: std.StringHashMapUnmanaged(CustomProperty) = .empty,
@@ -373,14 +365,14 @@ fn isValidLayerComponent(component: []const u8) bool {
return true;
}
/// Compute every layer's rank and apply it to every TrackedRule we have.
/// Compute every layer's rank and apply it to every group Rule we have.
/// We can only do this now that we've parsed every parsed every sheet since
/// @layer statement can change the ordering/
fn finalizeLayerRanks(self: *StyleManager, build_arena: Allocator) Allocator.Error!void {
const layers = self.layers.items;
if (layers.len == 0) {
// No layers. Every TrackedRule already has the correct layerless
// No layers. Every group Rule already has the correct layerless
// priority, and with no layers, there's nothing to adjust.
return;
}
@@ -441,21 +433,8 @@ fn finalizeLayerRanks(self: *StyleManager, build_arena: Allocator) Allocator.Err
}
}
self.stampRuleList(&self.other_rules);
var id_it = self.id_rules.valueIterator();
while (id_it.next()) |rules| {
self.stampRuleList(rules);
}
var class_it = self.class_rules.valueIterator();
while (class_it.next()) |rules| {
self.stampRuleList(rules);
}
var tag_it = self.tag_rules.valueIterator();
while (tag_it.next()) |rules| {
self.stampRuleList(rules);
inline for (group_fields) |field| {
@field(self, field).stampRules(self);
}
// Custom rules parse their selectors lazily, but the layer ranks are only
@@ -468,12 +447,6 @@ fn finalizeLayerRanks(self: *StyleManager, build_arena: Allocator) Allocator.Err
}
}
fn stampRuleList(self: *StyleManager, rules: *RuleList) void {
for (rules.items(.priority)) |*priority| {
priority.* |= self.layerRank(priority.*) << RANK_SHIFT;
}
}
/// The rank finalizeLayerRanks still has to stamp for a packed priority, via
/// its doc_order: 0 for an unlayered rule, which packed UNLAYERED_RANK at
/// creation.
@@ -491,18 +464,18 @@ fn addRawRule(self: *StyleManager, build_arena: Allocator, selector_text: []cons
return;
}
var customs: CustomSink = .{ .allocator = build_arena };
const props = try foldDeclarations(block_text, &customs);
const props = try foldDeclarations(Declarations, block_text, &customs);
_ = try self.addSelectorRules(selector_text, props, customs.map.values(), layer);
}
// Tracked rules get one TrackedRule per selector (not per selector list)
// Tracked rules get one group Rule per selector (not per selector list)
// so each has correct specificity, bucketed by their rightmost selector part.
// Custom properties are keyed by name instead, and lazily parse the selector.
// Returns whether the rule set any tracked property.
fn addSelectorRules(
self: *StyleManager,
selector_text: []const u8,
props: TrackedProperties,
props: Declarations,
customs: []const CustomDeclaration,
layer: u16,
) !bool {
@@ -540,14 +513,22 @@ fn addSelectorRules(
return self.next_doc_order != before;
}
fn addTrackedRule(self: *StyleManager, selector: Selector.Selector, props: TrackedProperties, layer: u16) !void {
// One priority per selector, shared by every group it lands in: doc_order
// indexes rule_layers.
fn addTrackedRule(self: *StyleManager, selector: Selector.Selector, props: Declarations, layer: u16) !void {
const key = getBucketKey(selector.rightmost()) orelse return;
const priority = try self.nextPriority(layer);
try self.bucket(key, .{
.props = props,
.selector = selector,
.priority = priority | (@as(u64, computeSpecificity(selector)) << SPEC_SHIFT),
});
const rank_and_order = try self.nextPriority(layer);
const priority = rank_and_order | (@as(u64, computeSpecificity(selector)) << SPEC_SHIFT);
inline for (group_fields) |field| {
const declared = @field(props, field);
if (declaresAny(declared)) {
try @field(self, field).bucket(self.arena.allocator(), key, .{
.props = declared,
.selector = selector,
.priority = priority,
});
}
}
}
/// Rank and document-order bits of the next rule. A rule outside any layer
@@ -561,17 +542,6 @@ fn nextPriority(self: *StyleManager, layer: u16) !u64 {
return priority;
}
fn bucket(self: *StyleManager, key: BucketKey, rule: TrackedRule) !void {
const allocator = self.arena.allocator();
const list: *RuleList = switch (key) {
.id => |id| (try self.id_rules.getOrPutValue(allocator, id, .empty)).value_ptr,
.class => |class| (try self.class_rules.getOrPutValue(allocator, class, .empty)).value_ptr,
.tag => |tag| (try self.tag_rules.getOrPutValue(allocator, tag, .empty)).value_ptr,
.other => &self.other_rules,
};
try list.append(allocator, rule);
}
fn isCustomProperty(name: []const u8) bool {
return std.mem.startsWith(u8, name, "--");
}
@@ -606,33 +576,21 @@ fn rebuildIfDirty(self: *StyleManager) !void {
self.dirty = false;
errdefer self.dirty = true;
const id_rules_count = self.id_rules.count();
const class_rules_count = self.class_rules.count();
const tag_rules_count = self.tag_rules.count();
const other_rules_count = self.other_rules.len;
const memo_count = self.memo.count();
var caps: [group_fields.len]Capacities = undefined;
inline for (group_fields, &caps) |field, *c| {
c.* = @field(self, field).capacities();
}
const custom_rules_count = self.custom_rules.count();
self.arena.resetRetain();
self.memo = .empty;
try self.memo.ensureTotalCapacity(self.arena.allocator(), memo_count);
self.next_doc_order = 1;
self.rule_layers = .empty;
self.last_rule_sheet = null;
self.id_rules = .empty;
try self.id_rules.ensureTotalCapacity(self.arena.allocator(), id_rules_count);
self.class_rules = .empty;
try self.class_rules.ensureTotalCapacity(self.arena.allocator(), class_rules_count);
self.tag_rules = .empty;
try self.tag_rules.ensureTotalCapacity(self.arena.allocator(), tag_rules_count);
self.other_rules = .{};
try self.other_rules.ensureTotalCapacity(self.arena.allocator(), other_rules_count);
inline for (group_fields, caps) |field, c| {
try @field(self, field).reset(self.arena.allocator(), c);
}
self.custom_rules = .empty;
try self.custom_rules.ensureTotalCapacity(self.arena.allocator(), custom_rules_count);
@@ -652,41 +610,11 @@ fn rebuildIfDirty(self: *StyleManager) !void {
try self.finalizeLayerRanks(build_arena.allocator());
}
/// Own-element cascade result, resolved for every property at once so one
/// entry serves any probe.
const Props = packed struct(u10) {
// Author value (inline or sheet). Without `author_display` it's the UA
// fallback: .none when matchesUaDisplayNoneRule, else .other.
display: Display = .other,
author_display: bool = false,
visibility_hidden: bool = false,
opacity_zero: bool = false,
pointer_events_none: bool = false,
overflow_x_scrolls: bool = false,
overflow_y_scrolls: bool = false,
overscroll_x_contains: bool = false,
overscroll_y_contains: bool = false,
fn probe(self: Props, comptime what: Probe, options: CheckVisibilityOptions) bool {
return switch (what) {
.hidden => self.display == .none or
(options.check_visibility and self.visibility_hidden) or
(options.check_opacity and self.opacity_zero),
.visibility => self.visibility_hidden,
.pointer_events => self.pointer_events_none,
};
}
};
const Probe = enum { hidden, visibility, pointer_events };
const Memo = std.AutoHashMapUnmanaged(*Element, Props);
pub fn isHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
self.assertOwns(el);
self.rebuildIfDirty() catch return false;
if (!options.ancestors) {
return self.ownProps(el).probe(.hidden, options);
return self.visibilityProps(el).probe(.hidden, options);
}
return self.anyInChain(el, .hidden, options);
}
@@ -701,7 +629,7 @@ pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool {
pub fn display(self: *StyleManager, el: *Element) Display {
self.assertOwns(el);
self.rebuildIfDirty() catch return .other;
return self.ownProps(el).display;
return self.visibilityProps(el).display;
}
/// Computed display:none coming only from inline style or an author stylesheet
@@ -711,7 +639,7 @@ pub fn display(self: *StyleManager, el: *Element) Display {
pub fn hasAuthorDisplayNone(self: *StyleManager, el: *Element) bool {
self.assertOwns(el);
self.rebuildIfDirty() catch return false;
const p = self.ownProps(el);
const p = self.visibilityProps(el);
return p.author_display and p.display == .none;
}
@@ -735,7 +663,7 @@ pub fn hasPointerEventsNone(self: *StyleManager, el: *Element) bool {
/// on that axis is auto, scroll or overlay. No ancestor walk.
pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
self.rebuildIfDirty() catch return .{};
const p = self.ownProps(el);
const p = self.geometryProps(el);
return .{ .x = p.overflow_x_scrolls, .y = p.overflow_y_scrolls };
}
@@ -744,123 +672,213 @@ pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
/// walk.
pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
self.rebuildIfDirty() catch return .{};
const p = self.ownProps(el);
const p = self.geometryProps(el);
return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains };
}
fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool {
fn anyInChain(self: *StyleManager, el: *Element, comptime what: Visibility.Probe, options: CheckVisibilityOptions) bool {
var current: ?*Element = el;
while (current) |elem| : (current = elem.parentElement()) {
if (self.ownProps(elem).probe(what, options)) {
if (self.visibilityProps(elem).probe(what, options)) {
return true;
}
}
return false;
}
/// The memoized own-element result. Callers must have run rebuildIfDirty,
/// which resets the memo.
fn ownProps(self: *StyleManager, el: *Element) Props {
const version = self.frame.page.style_version;
if (self.memo_version != version) {
self.memo.clearRetainingCapacity();
self.memo_version = version;
}
const gop = self.memo.getOrPut(self.arena.allocator(), el) catch |err| {
log.warn(.browser, "StyleManager memo", .{ .err = err });
return self.compute(el);
};
if (gop.found_existing) {
return gop.value_ptr.*;
}
gop.value_ptr.* = self.compute(el);
return gop.value_ptr.*;
/// Callers must have run rebuildIfDirty, which resets the memo.
fn visibilityProps(self: *StyleManager, el: *Element) Visibility.Computed {
return self.visibility.ownProps(self.arena.allocator(), el, self.frame);
}
const property_fields = std.meta.fieldNames(TrackedProperties);
/// Callers must have run rebuildIfDirty, which resets the memo.
fn geometryProps(self: *StyleManager, el: *Element) Geometry.Computed {
return self.geometry.ownProps(self.arena.allocator(), el, self.frame);
}
const Priorities = struct {
display: u64 = 0,
visibility_hidden: u64 = 0,
opacity_zero: u64 = 0,
pointer_events_none: u64 = 0,
overflow_x_scrolls: u64 = 0,
overflow_y_scrolls: u64 = 0,
overscroll_x_contains: u64 = 0,
overscroll_y_contains: u64 = 0,
/// Taken before the arena resets, to presize the new containers.
const Capacities = struct {
id_rules: u32,
class_rules: u32,
tag_rules: u32,
other_rules: usize,
memo: u32,
};
fn compute(self: *StyleManager, el: *Element) Props {
const frame = self.frame;
var p: Props = .{};
var priorities: Priorities = .{};
/// One property group's rules and memo. A rule joins only the groups it
/// declares something in.
fn Group(comptime Spec: type) type {
return struct {
const Self = @This();
const Declared = Spec.Declared;
const Computed = Spec.Computed;
const Field = std.meta.FieldEnum(Declared);
const fields = std.meta.fieldNames(Declared);
const inline_props = inlineProps(el, frame);
inline for (property_fields) |field| {
if (@field(inline_props, field)) |value| {
@field(p, field) = value;
@field(priorities, field) = INLINE_PRIORITY;
}
}
if (el.getId()) |id| {
if (self.id_rules.get(id)) |rules| {
checkRules(&rules, &p, &priorities, el, frame);
}
}
if (el.getClassName()) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
checkRules(&rules, &p, &priorities, el, frame);
comptime {
// compute copies each declared value into its Computed namesake
for (fields) |field| {
std.debug.assert(@FieldType(Computed, field) == @typeInfo(@FieldType(Declared, field)).optional.child);
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
checkRules(&rules, &p, &priorities, el, frame);
}
const Rule = struct {
selector: Selector.Selector, // Single selector, not a list
props: Declared,
checkRules(&self.other_rules, &p, &priorities, el, frame);
// Packed priority: layer_rank:12 | specificity:30 | doc_order:22. A
// layered rule's rank bits stay 0 until finalizeLayerRanks knows
// every layer and stamps them from rule_layers[doc_order - 1].
priority: u64,
};
// UA stylesheet display:none fallback (HTML Rendering §15.3.1 "Hidden
// elements"). Applied only when no author rule for `display` matched the
// element — per CSS Cascade §6.1 any normal-origin author rule beats UA
// origin regardless of specificity, so `.x { display: flex }` on a
// `<div class="x" hidden>` must report visible.
p.author_display = priorities.display != 0;
if (!p.author_display and matchesUaDisplayNoneRule(el)) {
p.display = .none;
}
const RuleList = std.MultiArrayList(Rule);
return p;
}
// Bucketed rules for fast lookup - keyed by rightmost selector part
id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
fn checkRules(rules: *const RuleList, p: *Props, priorities: *Priorities, el: *Element, frame: *Frame) void {
for (rules.items(.priority), rules.items(.props), rules.items(.selector)) |priority, rule, selector| {
// Only rules that set a property nothing stronger has set yet are
// worth matching.
var relevant = false;
inline for (property_fields) |field| {
if (@field(rule, field) != null and priority > @field(priorities, field)) {
relevant = true;
}
}
if (!relevant or !matchesSelector(el, selector, frame)) {
continue;
// Own-element results, valid while Page.style_version == memo_version.
memo: std.AutoHashMapUnmanaged(*Element, Computed) = .empty,
memo_version: usize = 0,
fn capacities(self: *const Self) Capacities {
return .{
.id_rules = self.id_rules.count(),
.class_rules = self.class_rules.count(),
.tag_rules = self.tag_rules.count(),
.other_rules = self.other_rules.len,
.memo = self.memo.count(),
};
}
inline for (property_fields) |field| {
if (@field(rule, field)) |value| {
if (priority > @field(priorities, field)) {
@field(p, field) = value;
@field(priorities, field) = priority;
fn reset(self: *Self, allocator: Allocator, caps: Capacities) !void {
self.memo = .empty;
try self.memo.ensureTotalCapacity(allocator, caps.memo);
self.id_rules = .empty;
try self.id_rules.ensureTotalCapacity(allocator, caps.id_rules);
self.class_rules = .empty;
try self.class_rules.ensureTotalCapacity(allocator, caps.class_rules);
self.tag_rules = .empty;
try self.tag_rules.ensureTotalCapacity(allocator, caps.tag_rules);
self.other_rules = .{};
try self.other_rules.ensureTotalCapacity(allocator, caps.other_rules);
}
fn bucket(self: *Self, allocator: Allocator, key: BucketKey, rule: Rule) !void {
const list: *RuleList = switch (key) {
.id => |id| (try self.id_rules.getOrPutValue(allocator, id, .empty)).value_ptr,
.class => |class| (try self.class_rules.getOrPutValue(allocator, class, .empty)).value_ptr,
.tag => |tag| (try self.tag_rules.getOrPutValue(allocator, tag, .empty)).value_ptr,
.other => &self.other_rules,
};
try list.append(allocator, rule);
}
fn stampRules(self: *Self, sm: *const StyleManager) void {
stampRuleList(sm, &self.other_rules);
inline for (.{ &self.id_rules, &self.class_rules, &self.tag_rules }) |map| {
var it = map.valueIterator();
while (it.next()) |rules| {
stampRuleList(sm, rules);
}
}
}
}
fn stampRuleList(sm: *const StyleManager, rules: *RuleList) void {
for (rules.items(.priority)) |*priority| {
priority.* |= sm.layerRank(priority.*) << RANK_SHIFT;
}
}
fn ownProps(self: *Self, allocator: Allocator, el: *Element, frame: *Frame) Computed {
const version = frame.page.style_version;
if (self.memo_version != version) {
self.memo.clearRetainingCapacity();
self.memo_version = version;
}
const gop = self.memo.getOrPut(allocator, el) catch |err| {
log.warn(.browser, "StyleManager memo", .{ .err = err });
return self.compute(el, frame);
};
if (gop.found_existing) {
return gop.value_ptr.*;
}
gop.value_ptr.* = self.compute(el, frame);
return gop.value_ptr.*;
}
fn compute(self: *const Self, el: *Element, frame: *Frame) Computed {
var p: Computed = .{};
var priorities: Priorities(Declared) = .initFill(0);
const inline_declared = inlineDeclared(Declared, el, frame);
inline for (fields) |field| {
if (@field(inline_declared, field)) |value| {
@field(p, field) = value;
priorities.set(@field(Field, field), INLINE_PRIORITY);
}
}
if (el.getId()) |id| {
if (self.id_rules.get(id)) |rules| {
checkRules(&rules, &p, &priorities, el, frame);
}
}
if (el.getClassName()) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
checkRules(&rules, &p, &priorities, el, frame);
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
checkRules(&rules, &p, &priorities, el, frame);
}
checkRules(&self.other_rules, &p, &priorities, el, frame);
if (@hasDecl(Spec, "finish")) {
Spec.finish(&p, el, &priorities);
}
return p;
}
fn checkRules(rules: *const RuleList, p: *Computed, priorities: *Priorities(Declared), el: *Element, frame: *Frame) void {
for (rules.items(.priority), rules.items(.props), rules.items(.selector)) |priority, rule, selector| {
// Only rules that set a property nothing stronger has set yet are
// worth matching.
var relevant = false;
inline for (fields) |field| {
if (@field(rule, field) != null and priority > priorities.get(@field(Field, field))) {
relevant = true;
}
}
if (!relevant or !matchesSelector(el, selector, frame)) {
continue;
}
inline for (fields) |field| {
if (@field(rule, field)) |value| {
if (priority > priorities.get(@field(Field, field))) {
@field(p, field) = value;
priorities.set(@field(Field, field), priority);
}
}
}
}
}
};
}
/// Centralizes UA-stylesheet display:none truth so `getComputedStyle().display`
@@ -905,7 +923,7 @@ fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !bool {
return false;
}
const style = style_rule._style orelse return false;
const props = extractTrackedProperties(style);
const props = extractDeclared(Declarations, style);
const customs = try self.extractCustomDeclarations(style);
// A custom rule holds selector_text until the property is looked up, but it
@@ -1032,14 +1050,11 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey {
return best_key;
}
// The declaration names behind TrackedProperties, in field order.
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" };
/// Extracts the tracked properties from a style declaration. The object holds
/// one entry per name in first-declared order, so folding it in order gives a
/// shorthand and its longhands the same precedence as the source text.
fn extractTrackedProperties(style: *CSSStyleProperties) TrackedProperties {
var slots: Slots = .{};
fn extractDeclared(comptime Declared: type, style: *CSSStyleProperties) Declared {
var slots: Slots(Declared) = .{};
var it = style.asCSSStyleDeclaration().iterator();
while (it.next()) |property| {
slots.apply(property._name.str(), property._value.str(), property._important);
@@ -1119,53 +1134,126 @@ pub const Display = enum(u2) {
}
};
const TrackedProperties = struct {
display: ?Display = null,
visibility_hidden: ?bool = null,
opacity_zero: ?bool = null,
pointer_events_none: ?bool = null,
overflow_x_scrolls: ?bool = null,
overflow_y_scrolls: ?bool = null,
overscroll_x_contains: ?bool = null,
overscroll_y_contains: ?bool = null,
/// Whether an element is rendered and takes pointer input.
const Visibility = struct {
const Declared = struct {
const names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events" };
fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void {
if (std.ascii.eqlIgnoreCase(name, "display")) {
self.display = Display.parse(value);
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
self.visibility_hidden = std.ascii.eqlIgnoreCase(value, "hidden") or std.ascii.eqlIgnoreCase(value, "collapse");
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
self.opacity_zero = std.ascii.eqlIgnoreCase(value, "0");
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
self.pointer_events_none = std.ascii.eqlIgnoreCase(value, "none");
} else if (std.ascii.eqlIgnoreCase(name, "overflow-x")) {
self.overflow_x_scrolls = overflowScrolls(value);
} else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) {
self.overflow_y_scrolls = overflowScrolls(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-x")) {
self.overscroll_x_contains = overscrollContains(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) {
self.overscroll_y_contains = overscrollContains(value);
display: ?Display = null,
visibility_hidden: ?bool = null,
opacity_zero: ?bool = null,
pointer_events_none: ?bool = null,
fn apply(self: *Declared, name: []const u8, value: []const u8) void {
if (std.ascii.eqlIgnoreCase(name, "display")) {
self.display = Display.parse(value);
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
self.visibility_hidden = std.ascii.eqlIgnoreCase(value, "hidden") or std.ascii.eqlIgnoreCase(value, "collapse");
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
self.opacity_zero = std.ascii.eqlIgnoreCase(value, "0");
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
self.pointer_events_none = std.ascii.eqlIgnoreCase(value, "none");
}
}
};
const Computed = packed struct(u6) {
// Author value (inline or sheet). Without `author_display` it's the UA
// fallback: .none when matchesUaDisplayNoneRule, else .other.
display: Display = .other,
author_display: bool = false,
visibility_hidden: bool = false,
opacity_zero: bool = false,
pointer_events_none: bool = false,
fn probe(self: Computed, comptime what: Probe, options: CheckVisibilityOptions) bool {
return switch (what) {
.hidden => self.display == .none or
(options.check_visibility and self.visibility_hidden) or
(options.check_opacity and self.opacity_zero),
.visibility => self.visibility_hidden,
.pointer_events => self.pointer_events_none,
};
}
};
const Probe = enum { hidden, visibility, pointer_events };
// UA stylesheet display:none fallback (HTML Rendering §15.3.1 "Hidden
// elements"). Applied only when no author rule for `display` matched the
// element — per CSS Cascade §6.1 any normal-origin author rule beats UA
// origin regardless of specificity, so `.x { display: flex }` on a
// `<div class="x" hidden>` must report visible.
fn finish(p: *Computed, el: *Element, priorities: *const Priorities(Declared)) void {
p.author_display = priorities.get(.display) != 0;
if (!p.author_display and matchesUaDisplayNoneRule(el)) {
p.display = .none;
}
}
};
/// How an element's box scrolls.
const Geometry = struct {
const Declared = struct {
const names = [_][]const u8{ "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" };
overflow_x_scrolls: ?bool = null,
overflow_y_scrolls: ?bool = null,
overscroll_x_contains: ?bool = null,
overscroll_y_contains: ?bool = null,
fn apply(self: *Declared, name: []const u8, value: []const u8) void {
if (std.ascii.eqlIgnoreCase(name, "overflow-x")) {
self.overflow_x_scrolls = overflowScrolls(value);
} else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) {
self.overflow_y_scrolls = overflowScrolls(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-x")) {
self.overscroll_x_contains = overscrollContains(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) {
self.overscroll_y_contains = overscrollContains(value);
}
}
// `overlay` is Chrome's legacy alias of auto.
fn overflowScrolls(value: []const u8) bool {
return std.ascii.eqlIgnoreCase(value, "auto") or
std.ascii.eqlIgnoreCase(value, "scroll") or
std.ascii.eqlIgnoreCase(value, "overlay");
}
// `contain` keeps the scroll in the box, `none` also kills the bounce we
// don't render anyway; only `auto` lets a scroll chain outward.
fn overscrollContains(value: []const u8) bool {
return std.ascii.eqlIgnoreCase(value, "contain") or
std.ascii.eqlIgnoreCase(value, "none");
}
};
const Computed = packed struct(u4) {
overflow_x_scrolls: bool = false,
overflow_y_scrolls: bool = false,
overscroll_x_contains: bool = false,
overscroll_y_contains: bool = false,
};
};
/// Every group's share of one declaration block, so a sheet's block is folded
/// once. Field names match the StyleManager's group fields.
const Declarations = struct {
const names = Visibility.Declared.names ++ Geometry.Declared.names;
visibility: Visibility.Declared = .{},
geometry: Geometry.Declared = .{},
fn apply(self: *Declarations, name: []const u8, value: []const u8) void {
inline for (group_fields) |field| {
@field(self, field).apply(name, value);
}
}
// `overlay` is Chrome's legacy alias of auto.
fn overflowScrolls(value: []const u8) bool {
return std.ascii.eqlIgnoreCase(value, "auto") or
std.ascii.eqlIgnoreCase(value, "scroll") or
std.ascii.eqlIgnoreCase(value, "overlay");
}
// `contain` keeps the scroll in the box, `none` also kills the bounce we
// don't render anyway; only `auto` lets a scroll chain outward.
fn overscrollContains(value: []const u8) bool {
return std.ascii.eqlIgnoreCase(value, "contain") or
std.ascii.eqlIgnoreCase(value, "none");
}
fn isRelevant(self: TrackedProperties) bool {
inline for (property_fields) |field| {
if (@field(self, field) != null) {
fn isRelevant(self: Declarations) bool {
inline for (group_fields) |field| {
if (declaresAny(@field(self, field))) {
return true;
}
}
@@ -1173,15 +1261,22 @@ const TrackedProperties = struct {
}
};
const TrackedRule = struct {
selector: Selector.Selector, // Single selector, not a list
props: TrackedProperties,
const group_fields = std.meta.fieldNames(Declarations);
// Packed priority: layer_rank:12 | specificity:30 | doc_order:22. A layered
// rule's rank bits stay 0 until finalizeLayerRanks knows every layer and
// stamps them from rule_layers[doc_order - 1].
priority: u64,
};
fn declaresAny(declared: anytype) bool {
inline for (comptime std.meta.fieldNames(@TypeOf(declared))) |field| {
if (@field(declared, field) != null) {
return true;
}
}
return false;
}
/// Per property, the priority of the declaration winning so far; 0 while
/// none has.
fn Priorities(comptime Declared: type) type {
return std.enums.EnumArray(std.meta.FieldEnum(Declared), u64);
}
// custom_rules map is property_name -> CustomProperty, loosely:
// --color => [(:root, "#ff0"), (.card, "#000")]
@@ -1203,7 +1298,7 @@ const CustomRule = struct {
const ParsedCustomRule = struct {
selector: Selector.Selector,
value: []const u8,
priority: u64, // packed the same way as TrackedRule.priority
priority: u64, // packed the same way as a group Rule.priority
};
/// A `--*` declaration on its way from a rule block into custom_rules.
@@ -1238,7 +1333,7 @@ const UNLAYERED_RANK: u32 = std.math.maxInt(u12);
// its 12 bits of VisibleRule.priority
const MAX_LAYERS: usize = 1024;
// TrackedRule.priority field offsets (layer_rank:12 | spec:30 | doc:22).
// Group Rule.priority field offsets (layer_rank:12 | spec:30 | doc:22).
const SPEC_SHIFT: u6 = 22;
const RANK_SHIFT: u6 = 52;
@@ -1260,17 +1355,17 @@ const INLINE_PRIORITY: u64 = std.math.maxInt(u64);
// `frame` must be el's owner frame (el.ownerFrame): that is the map where a
// parsed inline style lives. Without one the attribute text is folded in
// place; layout materializes the object itself when it needs it.
fn inlineProps(el: *Element, frame: *Frame) TrackedProperties {
fn inlineDeclared(comptime Declared: type, el: *Element, frame: *Frame) Declared {
if (!el._flags.has_inline_style) {
// Neither a style object nor a style attribute; skip both lookups.
return .{};
}
if (el.existingStyle(frame)) |style| {
return extractTrackedProperties(style);
return extractDeclared(Declared, style);
}
const attr = el.getAttributeInterned("style") orelse return .{};
// Without a sink nothing allocates
return foldDeclarations(attr, null) catch unreachable;
return foldDeclarations(Declared, attr, null) catch unreachable;
}
/// `--*` declarations of one block, keyed by name so a block declaring the same
@@ -1285,8 +1380,8 @@ const CustomSink = struct {
// earlier !important one, and an empty value removes the property. The tracked
// properties are matched case-insensitively; a custom property's name is
// case-sensitive and goes to `customs` when there is one.
fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties {
var slots: Slots = .{};
fn foldDeclarations(comptime Declared: type, block: []const u8, customs: ?*CustomSink) !Declared {
var slots: Slots(Declared) = .{};
var it = CssParser.parseDeclarationsList(block);
while (it.next()) |declaration| {
if (isCustomProperty(declaration.name)) {
@@ -1303,51 +1398,55 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties
return slots.props();
}
/// One block's winning value per tracked property, folded in declaration
/// order.
const Slots = struct {
const Slot = struct {
value: ?[]const u8 = null,
important: bool = false,
/// One block's winning value per property in `Declared.names`, folded in
/// declaration order.
fn Slots(comptime Declared: type) type {
return struct {
const Self = @This();
fn apply(self: *Slot, value: []const u8, important: bool) void {
if (self.important and !important) {
slots: [Declared.names.len]Slot = @splat(.{}),
fn apply(self: *Self, name: []const u8, value: []const u8, important: bool) void {
if (CssParser.axisShorthand(name)) |shorthand| {
const values = CssParser.splitAxisPair(value) orelse return;
self.apply(shorthand.x, values.x, important);
self.apply(shorthand.y, values.y, important);
return;
}
if (value.len == 0) {
self.* = .{};
return;
for (Declared.names, &self.slots) |tracked, *slot| {
if (std.ascii.eqlIgnoreCase(name, tracked)) {
slot.apply(value, important);
return;
}
}
self.value = value;
self.important = important;
}
fn props(self: Self) Declared {
var p: Declared = .{};
for (Declared.names, self.slots) |name, s| {
if (s.value) |value| {
p.apply(name, value);
}
}
return p;
}
};
}
slots: [property_names.len]Slot = @splat(.{}),
const Slot = struct {
value: ?[]const u8 = null,
important: bool = false,
fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void {
if (CssParser.axisShorthand(name)) |shorthand| {
const values = CssParser.splitAxisPair(value) orelse return;
self.apply(shorthand.x, values.x, important);
self.apply(shorthand.y, values.y, important);
fn apply(self: *Slot, value: []const u8, important: bool) void {
if (self.important and !important) {
return;
}
for (property_names, &self.slots) |tracked, *slot| {
if (std.ascii.eqlIgnoreCase(name, tracked)) {
slot.apply(value, important);
return;
}
if (value.len == 0) {
self.* = .{};
return;
}
}
fn props(self: Slots) TrackedProperties {
var p: TrackedProperties = .{};
for (property_names, self.slots) |name, s| {
if (s.value) |value| {
p.apply(name, value);
}
}
return p;
self.value = value;
self.important = important;
}
};
@@ -1664,7 +1763,7 @@ test "StyleManager: packed priority bounds" {
try testing.expect(MAX_LAYERS < UNLAYERED_RANK);
}
test "StyleManager: inlineProps: scan matches the parsed style object" {
test "StyleManager: inlineDeclared: scan matches the parsed style object" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
@@ -1688,23 +1787,23 @@ test "StyleManager: inlineProps: scan matches the parsed style object" {
\\<i style="overflow-x: overlay"></i>
\\<i style="overflow:"></i>
);
const expected = [_]TrackedProperties{
.{ .display = .none },
.{ .display = .none },
.{ .display = .none },
.{ .display = .other },
.{ .display = .none, .visibility_hidden = true },
.{ .opacity_zero = true, .pointer_events_none = true },
.{ .opacity_zero = false, .pointer_events_none = true },
.{ .visibility_hidden = true },
const expected = [_]Declarations{
.{ .visibility = .{ .display = .none } },
.{ .visibility = .{ .display = .none } },
.{ .visibility = .{ .display = .none } },
.{ .visibility = .{ .display = .other } },
.{ .visibility = .{ .display = .none, .visibility_hidden = true } },
.{ .visibility = .{ .opacity_zero = true, .pointer_events_none = true } },
.{ .visibility = .{ .opacity_zero = false, .pointer_events_none = true } },
.{ .visibility = .{ .visibility_hidden = true } },
.{},
.{},
.{ .overflow_x_scrolls = true, .overflow_y_scrolls = true },
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = true },
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = true },
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = false },
.{ .overflow_x_scrolls = false, .overflow_y_scrolls = true },
.{ .overflow_x_scrolls = true },
.{ .geometry = .{ .overflow_x_scrolls = true, .overflow_y_scrolls = true } },
.{ .geometry = .{ .overflow_x_scrolls = false, .overflow_y_scrolls = true } },
.{ .geometry = .{ .overflow_x_scrolls = false, .overflow_y_scrolls = true } },
.{ .geometry = .{ .overflow_x_scrolls = false, .overflow_y_scrolls = false } },
.{ .geometry = .{ .overflow_x_scrolls = false, .overflow_y_scrolls = true } },
.{ .geometry = .{ .overflow_x_scrolls = true } },
.{},
};
@@ -1712,14 +1811,14 @@ test "StyleManager: inlineProps: scan matches the parsed style object" {
var child = div.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
const el = node.is(Element) orelse continue;
const scanned = inlineProps(el, frame);
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));
// scanning never creates the style object
try testing.expectEqual(null, el.existingStyle(frame));
const materialized = extractTrackedProperties(try el.getOrCreateStyle(frame));
inline for (property_fields) |field| {
try testing.expectEqual(@field(expected[i], field), @field(scanned, field));
try testing.expectEqual(@field(expected[i], field), @field(materialized, field));
}
const materialized = extractDeclared(Declarations, try el.getOrCreateStyle(frame));
try testing.expectEqual(expected[i], materialized);
i += 1;
}
try testing.expectEqual(expected.len, i);
@@ -1739,9 +1838,10 @@ test "StyleManager: memo: reuse and invalidation" {
// The walk memoizes the element and every ancestor
try testing.expectEqual(false, sm.isHidden(b, .{}));
try testing.expectEqual(3, sm.memo.count());
try testing.expectEqual(3, sm.visibility.memo.count());
try testing.expectEqual(0, sm.geometry.memo.count());
try testing.expectEqual(false, sm.isHidden(b, .{}));
try testing.expectEqual(3, sm.memo.count());
try testing.expectEqual(3, sm.visibility.memo.count());
// Probes never create the style object
try testing.expectEqual(null, b.existingStyle(frame));
@@ -1790,7 +1890,7 @@ test "StyleManager: memo: reuse and invalidation" {
// A stylesheet change resets the memo
sm.sheetModified();
try testing.expectEqual(false, sm.isHidden(p, .{}));
try testing.expectEqual(2, sm.memo.count());
try testing.expectEqual(2, sm.visibility.memo.count());
}
test "StyleManager: ruleInserted: append joins the buckets without a rebuild" {