diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 894a73913..a5ed6f4e7 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -654,7 +654,7 @@ fn rebuildIfDirty(self: *StyleManager) !void { /// Own-element cascade result, resolved for every property at once so one /// entry serves any probe. -const Props = packed struct(u8) { +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, @@ -664,6 +664,8 @@ const Props = packed struct(u8) { 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) { @@ -737,6 +739,15 @@ pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes { return .{ .x = p.overflow_x_scrolls, .y = p.overflow_y_scrolls }; } +/// The axes along which `el` keeps a scroll from chaining out of it: its own +/// computed overscroll-behavior on that axis is contain or none. No ancestor +/// walk. +pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAxes { + self.rebuildIfDirty() catch return .{}; + const p = self.ownProps(el); + return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains }; +} + fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool { var current: ?*Element = el; while (current) |elem| : (current = elem.parentElement()) { @@ -776,6 +787,8 @@ const Priorities = struct { 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, }; fn compute(self: *StyleManager, el: *Element) Props { @@ -1020,7 +1033,7 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey { } // The declaration names behind TrackedProperties, in field order. -const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y" }; +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 @@ -1113,6 +1126,8 @@ const TrackedProperties = struct { 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, fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void { if (std.ascii.eqlIgnoreCase(name, "display")) { @@ -1127,6 +1142,10 @@ const TrackedProperties = struct { 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); } } @@ -1137,6 +1156,13 @@ const TrackedProperties = struct { 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) { @@ -1300,10 +1326,10 @@ const Slots = struct { slots: [property_names.len]Slot = @splat(.{}), fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void { - if (std.ascii.eqlIgnoreCase(name, "overflow")) { - const values = CssParser.splitOverflow(value) orelse return; - self.apply("overflow-x", values.x, important); - self.apply("overflow-y", values.y, important); + 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; } for (property_names, &self.slots) |tracked, *slot| { @@ -1752,6 +1778,15 @@ test "StyleManager: memo: reuse and invalidation" { try (try b.getOrCreateStyle(frame)).asCSSStyleDeclaration().setProperty("overflow", "hidden", null, frame); try testing.expectEqual(Element.ScrollAxes{}, sm.overflowAxes(b)); + // overscroll-behavior expands the same way; only `auto` chains outward. + try b.setStyle("overscroll-behavior: contain auto", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = true, .y = false }, sm.overscrollContainAxes(b)); + try testing.expectEqual(Element.ScrollAxes{}, sm.overscrollContainAxes(p)); + try b.setStyle("overscroll-behavior-y: none", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b)); + try b.setStyle("overscroll-behavior: contain; overscroll-behavior-x: auto", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b)); + // A stylesheet change resets the memo sm.sheetModified(); try testing.expectEqual(false, sm.isHidden(p, .{})); diff --git a/src/browser/css/Parser.zig b/src/browser/css/Parser.zig index dc8806dae..5e7f5c12e 100644 --- a/src/browser/css/Parser.zig +++ b/src/browser/css/Parser.zig @@ -25,11 +25,59 @@ pub const Declaration = struct { important: bool, }; -pub const OverflowValues = struct { x: []const u8, y: []const u8 }; +pub const AxisPair = struct { x: []const u8, y: []const u8 }; -/// `overflow: []`; a single value applies to both axes. More than two -/// values is invalid and null, as is an empty declaration. -pub fn splitOverflow(value: []const u8) ?OverflowValues { +pub const AxisShorthand = struct { + name: []const u8, + x: []const u8, + y: []const u8, +}; + +// The ` []` shorthands whose longhands the style cascade tracks. Both the +// CSSOM object and the cascade store these expanded: setting one sets both +// longhands, reading or serializing it recombines them. +pub const axis_shorthands = [_]AxisShorthand{ + .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, + .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, +}; + +/// The axis shorthand `name` names, if it names one. +pub fn axisShorthand(name: []const u8) ?AxisShorthand { + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { + return shorthand; + } + } + return null; +} + +pub const AxisLonghand = struct { + shorthand: AxisShorthand, + is_x: bool, + + /// The longhand on the other axis. + pub fn partner(self: AxisLonghand) []const u8 { + return if (self.is_x) self.shorthand.y else self.shorthand.x; + } +}; + +/// The axis shorthand `name` is a longhand of, if it is one. +pub fn axisLonghand(name: []const u8) ?AxisLonghand { + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.x)) { + return .{ .shorthand = shorthand, .is_x = true }; + } + if (std.ascii.eqlIgnoreCase(name, shorthand.y)) { + return .{ .shorthand = shorthand, .is_x = false }; + } + } + return null; +} + +/// An ` []` axis shorthand such as `overflow` or `overscroll-behavior`; +/// a single value applies to both axes. More than two values is invalid and +/// null, as is an empty declaration. +pub fn splitAxisPair(value: []const u8) ?AxisPair { var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace); const x = it.next() orelse return null; const y = it.next() orelse x; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 92c29d59e..b42241d46 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -487,16 +487,44 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt } // Deltas come from the wire, so guard NaN and saturate the addition. - try wheelScroll(target, deltaToScroll(delta_x), deltaToScroll(delta_y), owner); + try scrollAxis(target, .width, deltaToScroll(delta_x), owner); + try scrollAxis(target, .height, deltaToScroll(delta_y), owner); } -/// Each axis scrolls the nearest ancestor-or-self scroll container along it, -/// else the viewport. Relative deltas may land on different scrollers per -/// axis, unlike an absolute position. -fn wheelScroll(target: *Element, delta_x: i32, delta_y: i32, frame: *Frame) !void { - // A zero delta resolves to .viewport and scrolls it by nothing. - try target.scrollContainer(.{ .x = delta_x != 0 }, frame).scrollBy(delta_x, 0, frame); - try target.scrollContainer(.{ .y = delta_y != 0 }, frame).scrollBy(0, delta_y, frame); +/// A wheel latches to a single scroller and a delta is never split across two, +/// as in Chrome's FindNodeToLatch (cc/input/input_handler.cc): the whole delta +/// goes to the nearest ancestor-or-self container that can still move along +/// this axis. One whose overscroll-behavior doesn't propagate takes the latch +/// even when it can't move, which ends the walk. +fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void { + if (delta == 0) { + return; + } + const axes: Element.ScrollAxes = switch (axis) { + .width => .{ .x = true }, + .height => .{ .y = true }, + }; + + var current: ?*Element = target; + while (current) |el| { + const container = switch (el.scrollContainer(axes, frame)) { + .container => |c| c, + .viewport => break, + }; + if (try container.scrollByAxis(axis, delta, frame)) { + return; + } + if (container.containsOverscroll(axes, frame)) { + return; + } + current = container.parentElement(); + } + + const opts: Element.ScrollToOpts = switch (axis) { + .width => .{ .opts = .{ .left = delta } }, + .height => .{ .opts = .{ .top = delta } }, + }; + return frame.window.scrollBy(opts, null, frame); } fn deltaToScroll(d: f64) i32 { diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 291bf60f3..e6821653f 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -600,3 +600,32 @@ testing.expectEqual(targetY, window.scrollY); } + +
+
content
+
+ + diff --git a/src/browser/tests/element/styles.html b/src/browser/tests/element/styles.html index 043c3c02c..1c30d525d 100644 --- a/src/browser/tests/element/styles.html +++ b/src/browser/tests/element/styles.html @@ -113,6 +113,25 @@ } + +