diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 0001991dc..25ad4f29e 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const Frame = @import("Frame.zig"); +const units = @import("css/units.zig"); const CssParser = @import("css/Parser.zig"); const MediaQuery = @import("css/MediaQuery.zig"); const Element = @import("webapi/Element.zig"); @@ -1214,6 +1215,56 @@ pub fn inlineStyleValue(self: *StyleManager, el: *Element, property_name: String return property._value.str(); } +/// Bounds computedFontSize's ancestor recursion (the parent walk and +/// `inherit`/relative-unit chains share it). +const MAX_FONT_ANCESTOR_DEPTH = 32; + +/// Computed font-size in CSS pixels. We do what we can, namely inline styles +/// font-related attributes, up the parent chain. +pub fn computedFontSize(self: *StyleManager, element: ?*Element) f64 { + return self.computedFontSizeAt(element, 0); +} + +fn computedFontSizeAt(self: *StyleManager, element: ?*Element, depth: u8) f64 { + if (depth >= MAX_FONT_ANCESTOR_DEPTH) { + return 16; + } + const current = element orelse return 16; + const parent = current.parentElement(); + + if (self.inlineStyleValue(current, comptime .wrap("font-size"))) |raw| { + if (self.parseFontSize(raw, parent, depth + 1)) |size| { + return size; + } + } + if (current.getAttributeSafe(comptime .wrap("font-size"))) |raw| { + if (self.parseFontSize(raw, parent, depth + 1)) |size| { + return size; + } + } + return self.computedFontSizeAt(parent, depth + 1); +} + +fn parseFontSize(self: *StyleManager, raw: []const u8, parent: ?*Element, depth: u8) ?f64 { + const value = std.mem.trim(u8, raw, " \t\r\n\x0c"); + if (std.ascii.eqlIgnoreCase(value, "inherit") or std.ascii.eqlIgnoreCase(value, "unset")) { + return self.computedFontSizeAt(parent, depth); + } + if (std.ascii.eqlIgnoreCase(value, "initial") or std.ascii.eqlIgnoreCase(value, "medium")) { + return 16; + } + + const parsed = units.parse(value) catch return null; + const factor = units.absoluteLengthFactor(parsed.unit) orelse switch (parsed.unit) { + .percentage => self.computedFontSizeAt(parent, depth) / 100.0, + .em => self.computedFontSizeAt(parent, depth), + .ex => self.computedFontSizeAt(parent, depth) / 2.0, + else => return null, + }; + const size = parsed.value * factor; + return if (size >= 0 and std.math.isFinite(size)) size else null; +} + const testing = @import("../testing.zig"); test "StyleManager: computeSpecificity: element selector" { // div -> (0, 0, 1) diff --git a/src/browser/css/units.zig b/src/browser/css/units.zig new file mode 100644 index 000000000..a87cffdb9 --- /dev/null +++ b/src/browser/css/units.zig @@ -0,0 +1,168 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Shared lexing and conversion for CSS/SVG dimension values: one suffix +//! table and one set of absolute-length factors. Consumers (svg/Length, +//! svg/Angle, webapi/CSS, StyleManager's font-size resolution) map the +//! result onto their own domain, rejecting units that don't apply. +//! +//! css/MediaQuery keeps its own parser on purpose: MQ4 is fail-closed and +//! deliberately more restrictive than this lenient attribute-value lexer. + +const std = @import("std"); + +pub const Unit = enum { + none, // bare number, no suffix + percentage, + em, + ex, + px, + cm, + mm, + in, + pt, + pc, + vh, + vw, + deg, + rad, + grad, + turn, +}; + +pub const Parsed = struct { + value: f64, + unit: Unit, +}; + +const WHITESPACE = " \t\r\n\x0c"; + +// Longer suffixes first: "5grad" must not match the "rad" entry. +const suffixes = [_]struct { []const u8, Unit }{ + .{ "turn", .turn }, + .{ "grad", .grad }, + .{ "deg", .deg }, + .{ "rad", .rad }, + .{ "%", .percentage }, + .{ "em", .em }, + .{ "ex", .ex }, + .{ "px", .px }, + .{ "cm", .cm }, + .{ "mm", .mm }, + .{ "in", .in }, + .{ "pt", .pt }, + .{ "pc", .pc }, + .{ "vh", .vh }, + .{ "vw", .vw }, +}; + +pub fn parse(input: []const u8) error{SyntaxError}!Parsed { + const value = std.mem.trim(u8, input, WHITESPACE); + if (value.len == 0) { + return error.SyntaxError; + } + + for (suffixes) |entry| { + const sfx, const unit = entry; + if (value.len <= sfx.len) { + continue; + } + if (!std.ascii.eqlIgnoreCase(value[value.len - sfx.len ..], sfx)) { + continue; + } + const number = std.mem.trim(u8, value[0 .. value.len - sfx.len], WHITESPACE); + return .{ .value = try parseNumber(number), .unit = unit }; + } + return .{ .value = try parseNumber(value), .unit = .none }; +} + +fn parseNumber(value: []const u8) !f64 { + const number = std.fmt.parseFloat(f64, value) catch return error.SyntaxError; + if (!std.math.isFinite(number)) { + return error.SyntaxError; + } + return number; +} + +/// CSS pixels per unit for the absolute length units (CSS Values §6.2, +/// 96px per inch). Null for relative and angle units, which need context. +pub fn absoluteLengthFactor(unit: Unit) ?f64 { + return switch (unit) { + .none, .px => 1, + .cm => 96.0 / 2.54, + .mm => 96.0 / 25.4, + .in => 96, + .pt => 96.0 / 72.0, + .pc => 16, + else => null, + }; +} + +pub fn suffix(unit: Unit) []const u8 { + return switch (unit) { + .none => "", + .percentage => "%", + else => @tagName(unit), + }; +} + +const testing = @import("../../testing.zig"); + +test "css units: parse" { + try testing.expectEqual(Parsed{ .value = 12, .unit = .none }, try parse("12")); + try testing.expectEqual(Parsed{ .value = -3.5, .unit = .px }, try parse("-3.5px")); + try testing.expectEqual(Parsed{ .value = 50, .unit = .percentage }, try parse("50%")); + try testing.expectEqual(Parsed{ .value = 2.54, .unit = .cm }, try parse("2.54cm")); + try testing.expectEqual(Parsed{ .value = 1.5, .unit = .em }, try parse("1.5EM")); + try testing.expectEqual(Parsed{ .value = 90, .unit = .deg }, try parse("90deg")); + try testing.expectEqual(Parsed{ .value = 100, .unit = .grad }, try parse("100grad")); + try testing.expectEqual(Parsed{ .value = 0.5, .unit = .turn }, try parse("0.5turn")); + try testing.expectEqual(Parsed{ .value = 50, .unit = .vh }, try parse("50vh")); + + // surrounding whitespace, and the historical leniency between number + // and suffix, are both accepted + try testing.expectEqual(Parsed{ .value = 10, .unit = .px }, try parse(" 10px ")); + try testing.expectEqual(Parsed{ .value = 10, .unit = .px }, try parse("10 px")); + + try testing.expectError(error.SyntaxError, parse("")); + try testing.expectError(error.SyntaxError, parse(" ")); + try testing.expectError(error.SyntaxError, parse("px")); + try testing.expectError(error.SyntaxError, parse("abc")); + try testing.expectError(error.SyntaxError, parse("10furlong")); + try testing.expectError(error.SyntaxError, parse("inf")); + try testing.expectError(error.SyntaxError, parse("nan")); + try testing.expectError(error.SyntaxError, parse("infpx")); +} + +test "css units: absoluteLengthFactor" { + try testing.expectEqual(1, absoluteLengthFactor(.none).?); + try testing.expectEqual(1, absoluteLengthFactor(.px).?); + try testing.expectEqual(96, absoluteLengthFactor(.in).?); + try testing.expectEqual(16, absoluteLengthFactor(.pc).?); + try testing.expectEqual(null, absoluteLengthFactor(.percentage)); + try testing.expectEqual(null, absoluteLengthFactor(.em)); + try testing.expectEqual(null, absoluteLengthFactor(.vh)); + try testing.expectEqual(null, absoluteLengthFactor(.deg)); +} + +test "css units: suffix" { + try testing.expectEqual("", suffix(.none)); + try testing.expectEqual("%", suffix(.percentage)); + try testing.expectEqual("px", suffix(.px)); + try testing.expectEqual("turn", suffix(.turn)); +} diff --git a/src/browser/tests/element/svg/svgsvg.html b/src/browser/tests/element/svg/svgsvg.html index c81845a9b..111883e4e 100644 --- a/src/browser/tests/element/svg/svgsvg.html +++ b/src/browser/tests/element/svg/svgsvg.html @@ -44,10 +44,13 @@ testing.expectEqual(96, Math.round(length.value)); testing.expectEqual('2.54cm', length.valueAsString); - // `value` is an absolute user-unit setter and resets the unit to NUMBER. + // `value` is an absolute user-unit setter; the unit is preserved and + // valueInSpecifiedUnits converts (12 user units = 0.3175cm). length.value = 12; - testing.expectEqual(SVGLength.SVG_LENGTHTYPE_NUMBER, length.unitType); - testing.expectEqual('12', length.valueAsString); + testing.expectEqual(SVGLength.SVG_LENGTHTYPE_CM, length.unitType); + testing.expectEqual(0.3175, length.valueInSpecifiedUnits); + testing.expectEqual(12, length.value); + testing.expectEqual('0.3175cm', length.valueAsString); length.newValueSpecifiedUnits(SVGLength.SVG_LENGTHTYPE_PX, 4.5); testing.expectEqual('4.5px', length.valueAsString); testing.expectError('NotSupportedError', () => length.newValueSpecifiedUnits(0, 1)); @@ -66,8 +69,9 @@ testing.expectEqual(180, Math.round(angle.valueInSpecifiedUnits)); testing.expectEqual('180deg', angle.valueAsString); angle.value = 45; - testing.expectEqual(SVGAngle.SVG_ANGLETYPE_UNSPECIFIED, angle.unitType); - testing.expectEqual('45', angle.valueAsString); + testing.expectEqual(SVGAngle.SVG_ANGLETYPE_DEG, angle.unitType); + testing.expectEqual(45, angle.valueInSpecifiedUnits); + testing.expectEqual('45deg', angle.valueAsString); testing.expectError('NotSupportedError', () => angle.convertToSpecifiedUnits(0)); testing.expectError('TypeError', () => { angle.value = NaN; }); @@ -155,8 +159,8 @@ svg.x.baseVal.valueInSpecifiedUnits = 4.5; testing.expectEqual('4.5px', svg.getAttribute('x')); svg.x.baseVal.value = 7; - testing.expectEqual('7', svg.getAttribute('x')); - testing.expectEqual(SVGLength.SVG_LENGTHTYPE_NUMBER, svg.x.animVal.unitType); + testing.expectEqual('7px', svg.getAttribute('x')); + testing.expectEqual(SVGLength.SVG_LENGTHTYPE_PX, svg.x.animVal.unitType); testing.expectError('NoModificationAllowedError', () => { svg.x.animVal.value = 3; }); testing.expectError('NoModificationAllowedError', () => { svg.x.animVal.valueAsString = '3px'; }); diff --git a/src/browser/webapi/CSS.zig b/src/browser/webapi/CSS.zig index 0df73dcad..d93970074 100644 --- a/src/browser/webapi/CSS.zig +++ b/src/browser/webapi/CSS.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Frame = @import("../Frame.zig"); +const units = @import("../css/units.zig"); const CSS = @This(); _pad: bool = false, @@ -26,39 +27,23 @@ _pad: bool = false, pub const init: CSS = .{}; pub fn parseDimension(value: []const u8) ?f64 { - if (value.len == 0) { - return null; - } - return parseNonEmptyDimension(value); + const parsed = units.parse(value) catch return null; + return switch (parsed.unit) { + .none, .px => parsed.value, + else => null, + }; } // parseDimension plus viewport-relative units, which the faux layout // resolves against the page viewport. pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 { - if (value.len == 0) { - return null; - } - - if (std.mem.endsWith(u8, value, "vh")) { - const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; - return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0; - } - - if (std.mem.endsWith(u8, value, "vw")) { - const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; - return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0; - } - - return parseNonEmptyDimension(value); -} - -fn parseNonEmptyDimension(value: []const u8) ?f64 { - var num_str = value; - if (std.mem.endsWith(u8, value, "px")) { - num_str = value[0 .. value.len - 2]; - } - - return std.fmt.parseFloat(f64, num_str) catch null; + 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, + }; } /// Escapes a CSS identifier string diff --git a/src/browser/webapi/element/svg/TextContent.zig b/src/browser/webapi/element/svg/TextContent.zig index 0c5a6f3cc..1e52e3ddd 100644 --- a/src/browser/webapi/element/svg/TextContent.zig +++ b/src/browser/webapi/element/svg/TextContent.zig @@ -25,7 +25,6 @@ const Element = @import("../../Element.zig"); const AnimatedEnumeration = @import("../../svg/AnimatedEnumeration.zig"); const AnimatedLength = @import("../../svg/AnimatedLength.zig"); -const Length = @import("../../svg/Length.zig"); const Graphics = @import("Graphics.zig"); @@ -65,7 +64,7 @@ fn text(self: *TextContent, frame: *Frame) []const u8 { } fn fontSize(self: *TextContent, frame: *Frame) f64 { - return Length.fontSizeForElement(self.asElement(), frame); + return frame._style_manager.computedFontSize(self.asElement()); } fn getTextLength(self: *TextContent, frame: *Frame) !*AnimatedLength { diff --git a/src/browser/webapi/svg/Angle.zig b/src/browser/webapi/svg/Angle.zig index 4cedaf54e..163ba6e39 100644 --- a/src/browser/webapi/svg/Angle.zig +++ b/src/browser/webapi/svg/Angle.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); const Page = @import("../../Page.zig"); +const units = @import("../../css/units.zig"); const Element = @import("../Element.zig"); const String = lp.String; @@ -77,11 +78,16 @@ pub fn getValue(self: *Angle) f64 { return toDegrees(self._value, self._unit); } +// Sets the value in degrees; the stored unit is preserved and +// valueInSpecifiedUnits converts, matching Blink and Gecko. pub fn setValue(self: *Angle, value: f64, frame: *Frame) !void { try self.ensureWritable(); try ensureFinite(value); - self._value = value; - self._unit = .unspecified; + self.syncFromAttribute(); + if (self._unit == .unknown) { + self._unit = .unspecified; + } + self._value = fromDegrees(value, self._unit); try self.writeBack(frame); } @@ -176,37 +182,16 @@ const Parsed = struct { }; fn parse(input: []const u8) !Parsed { - const value = std.mem.trim(u8, input, " \t\r\n\x0c"); - if (value.len == 0) { - return error.SyntaxError; - } - - const suffixes = [_]struct { []const u8, Unit }{ - .{ "turn", .turn }, - .{ "grad", .grad }, - .{ "deg", .deg }, - .{ "rad", .rad }, + const parsed = try units.parse(input); + const unit: Unit = switch (parsed.unit) { + .none => .unspecified, + .deg => .deg, + .rad => .rad, + .grad => .grad, + .turn => .turn, + else => return error.SyntaxError, }; - for (suffixes) |entry| { - const suffix, const unit = entry; - if (value.len <= suffix.len) { - continue; - } - if (!std.ascii.eqlIgnoreCase(value[value.len - suffix.len ..], suffix)) { - continue; - } - const number = std.mem.trim(u8, value[0 .. value.len - suffix.len], " \t\r\n\x0c"); - return .{ .value = try parseNumber(number), .unit = unit }; - } - return .{ .value = try parseNumber(value), .unit = .unspecified }; -} - -fn parseNumber(value: []const u8) !f64 { - const number = std.fmt.parseFloat(f64, value) catch return error.SyntaxError; - if (!std.math.isFinite(number)) { - return error.SyntaxError; - } - return number; + return .{ .value = parsed.value, .unit = unit }; } fn checkedUnit(value: u16) !Unit { diff --git a/src/browser/webapi/svg/Length.zig b/src/browser/webapi/svg/Length.zig index ede68e82a..6bb0dd817 100644 --- a/src/browser/webapi/svg/Length.zig +++ b/src/browser/webapi/svg/Length.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); const Page = @import("../../Page.zig"); +const units = @import("../../css/units.zig"); const Element = @import("../Element.zig"); const String = lp.String; @@ -119,11 +120,17 @@ pub fn getValue(self: *Length, frame: *Frame) f64 { return self._value * self.unitToUserUnits(self._unit, frame); } +// Sets the value in user units; the stored unit is preserved and +// valueInSpecifiedUnits converts, matching Blink and Gecko. pub fn setValue(self: *Length, value: f64, frame: *Frame) !void { try self.ensureWritable(); try ensureFinite(value); - self._value = value; - self._unit = .number; + self.syncFromAttribute(); + if (self._unit == .unknown) { + self._unit = .number; + } + const factor = self.unitToUserUnits(self._unit, frame); + self._value = if (factor == 0) 0 else value / factor; try self.writeBack(frame); } @@ -210,21 +217,17 @@ fn writeBack(self: *Length, frame: *Frame) !void { } fn serialize(self: *const Length, frame: *Frame) ![]const u8 { - return std.fmt.allocPrint(frame.local_arena, "{d}{s}", .{ self._value, unitSuffix(self._unit) }); + return std.fmt.allocPrint(frame.local_arena, "{d}{s}", .{ self._value, units.suffix(toShared(self._unit)) }); } fn unitToUserUnits(self: *const Length, unit: Unit, frame: *Frame) f64 { - if (absoluteUnitFactor(unit)) |factor| { - return factor; - } return switch (unit) { - .unknown => 1, .percentage => self.percentageBasis(frame) / 100.0, .em => self.fontSize(frame), // Lightpanda does not load font metrics for DOM-only SVG values. CSS // defines 0.5em as the fallback when the x-height is unavailable. .ex => self.fontSize(frame) / 2.0, - else => unreachable, + else => units.absoluteLengthFactor(toShared(unit)).?, }; } @@ -285,83 +288,17 @@ fn pageViewportDimension(direction: Direction, frame: *Frame) f64 { } fn resolveParsedLength(parsed: Parsed, element: *Element, direction: Direction, frame: *Frame, depth: u8) f64 { - const factor = absoluteUnitFactor(parsed.unit) orelse switch (parsed.unit) { - .unknown => 1, + const factor = switch (parsed.unit) { .percentage => ancestorViewportDimensionAt(element, direction, frame, depth) / 100.0, - .em => resolvedFontSizeAt(element, frame, depth), - .ex => resolvedFontSizeAt(element, frame, depth) / 2.0, - else => unreachable, + .em => frame._style_manager.computedFontSize(element), + .ex => frame._style_manager.computedFontSize(element) / 2.0, + else => units.absoluteLengthFactor(toShared(parsed.unit)).?, }; return parsed.value * factor; } fn fontSize(self: *const Length, frame: *Frame) f64 { - return resolvedFontSize(self._element, frame); -} - -// The style engine currently exposes inline declarations but does not compute -// stylesheet font inheritance. Resolve the sources it can represent exactly, -// then fall back to CSS's initial medium size (16px). -fn resolvedFontSize(element: ?*Element, frame: *Frame) f64 { - return resolvedFontSizeAt(element, frame, 0); -} - -fn resolvedFontSizeAt(element: ?*Element, frame: *Frame, depth: u8) f64 { - if (depth >= MAX_ANCESTOR_DEPTH) { - return 16; - } - const current = element orelse return 16; - const parent = current.parentElement(); - - if (frame._style_manager.inlineStyleValue(current, comptime .wrap("font-size"))) |raw| { - if (parseFontSize(raw, parent, frame, depth + 1)) |size| { - return size; - } - } - if (current.getAttributeSafe(comptime .wrap("font-size"))) |raw| { - if (parseFontSize(raw, parent, frame, depth + 1)) |size| { - return size; - } - } - return resolvedFontSizeAt(parent, frame, depth + 1); -} - -pub fn fontSizeForElement(element: *Element, frame: *Frame) f64 { - return resolvedFontSize(element, frame); -} - -fn parseFontSize(raw: []const u8, parent: ?*Element, frame: *Frame, depth: u8) ?f64 { - const value = std.mem.trim(u8, raw, " \t\r\n\x0c"); - if (std.ascii.eqlIgnoreCase(value, "inherit") or std.ascii.eqlIgnoreCase(value, "unset")) { - return resolvedFontSizeAt(parent, frame, depth); - } - if (std.ascii.eqlIgnoreCase(value, "initial") or std.ascii.eqlIgnoreCase(value, "medium")) { - return 16; - } - - const parsed = parse(value) catch return null; - const parent_size = resolvedFontSizeAt(parent, frame, depth); - const factor = absoluteUnitFactor(parsed.unit) orelse switch (parsed.unit) { - .unknown => return null, - .percentage => parent_size / 100.0, - .em => parent_size, - .ex => parent_size / 2.0, - else => unreachable, - }; - const size = parsed.value * factor; - return if (size >= 0 and std.math.isFinite(size)) size else null; -} - -fn absoluteUnitFactor(unit: Unit) ?f64 { - return switch (unit) { - .number, .px => 1, - .cm => 96.0 / 2.54, - .mm => 96.0 / 25.4, - .in => 96, - .pt => 96.0 / 72.0, - .pc => 16, - .unknown, .percentage, .em, .ex => null, - }; + return frame._style_manager.computedFontSize(self._element); } const Parsed = struct { @@ -370,44 +307,40 @@ const Parsed = struct { }; fn parse(input: []const u8) !Parsed { - const value = std.mem.trim(u8, input, " \t\r\n\x0c"); - if (value.len == 0) { - return error.SyntaxError; - } - - const suffixes = [_]struct { []const u8, Unit }{ - .{ "%", .percentage }, - .{ "em", .em }, - .{ "ex", .ex }, - .{ "px", .px }, - .{ "cm", .cm }, - .{ "mm", .mm }, - .{ "in", .in }, - .{ "pt", .pt }, - .{ "pc", .pc }, - }; - - for (suffixes) |entry| { - const suffix, const unit = entry; - if (value.len <= suffix.len) { - continue; - } - if (!std.ascii.eqlIgnoreCase(value[value.len - suffix.len ..], suffix)) { - continue; - } - const number = std.mem.trim(u8, value[0 .. value.len - suffix.len], " \t\r\n\x0c"); - return .{ .value = try parseNumber(number), .unit = unit }; - } - - return .{ .value = try parseNumber(value), .unit = .number }; + const parsed = try units.parse(input); + const unit = fromShared(parsed.unit) orelse return error.SyntaxError; + return .{ .value = parsed.value, .unit = unit }; } -fn parseNumber(value: []const u8) !f64 { - const number = std.fmt.parseFloat(f64, value) catch return error.SyntaxError; - if (!std.math.isFinite(number)) { - return error.SyntaxError; - } - return number; +fn fromShared(unit: units.Unit) ?Unit { + return switch (unit) { + .none => .number, + .percentage => .percentage, + .em => .em, + .ex => .ex, + .px => .px, + .cm => .cm, + .mm => .mm, + .in => .in, + .pt => .pt, + .pc => .pc, + .vh, .vw, .deg, .rad, .grad, .turn => null, + }; +} + +fn toShared(unit: Unit) units.Unit { + return switch (unit) { + .unknown, .number => .none, + .percentage => .percentage, + .em => .em, + .ex => .ex, + .px => .px, + .cm => .cm, + .mm => .mm, + .in => .in, + .pt => .pt, + .pc => .pc, + }; } fn checkedUnit(value: u16) !Unit { @@ -426,21 +359,6 @@ fn checkedUnit(value: u16) !Unit { }; } -fn unitSuffix(unit: Unit) []const u8 { - return switch (unit) { - .unknown, .number => "", - .percentage => "%", - .em => "em", - .ex => "ex", - .px => "px", - .cm => "cm", - .mm => "mm", - .in => "in", - .pt => "pt", - .pc => "pc", - }; -} - pub const JsApi = struct { pub const bridge = js.Bridge(Length); diff --git a/src/browser/webapi/svg/PointList.zig b/src/browser/webapi/svg/PointList.zig index 689dc78cd..7d77ee7b6 100644 --- a/src/browser/webapi/svg/PointList.zig +++ b/src/browser/webapi/svg/PointList.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); @@ -24,6 +25,7 @@ const Page = @import("../../Page.zig"); const DOMPoint = @import("../DOMPoint.zig"); const DOMPointReadOnly = @import("../DOMPointReadOnly.zig"); const Element = @import("../Element.zig"); +const reflected_list = @import("reflected_list.zig"); const PointList = @This(); @@ -35,6 +37,16 @@ _snapshot: std.ArrayList(u8) = .empty, _items: std.ArrayList(*DOMPoint) = .empty, _retired: std.ArrayList(*DOMPoint) = .empty, +const M = reflected_list.Mixin(PointList, DOMPoint, .{ + .attrName = attrName, + .parse = parse, + .writeItem = writeItem, + .prepareItem = prepareItem, + .attach = attach, + .detachItem = detachItem, + .releaseItem = releaseItem, +}); + pub const Kind = enum { base, animated }; pub const Key = struct { @@ -61,114 +73,19 @@ pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*PointList { return gop.value_ptr.*; } -pub fn deinit(self: *PointList, page: *Page) void { - for (self._items.items) |point| { - point._proto.detach(self); - point._proto.releaseRef(page); - } - self._items.clearRetainingCapacity(); - self.releaseRetired(page); -} +pub const deinit = M.deinit; +pub const getLength = M.getLength; +pub const getNumberOfItems = M.getNumberOfItems; +pub const clear = M.clear; +pub const initialize = M.initialize; +pub const getItem = M.getItem; +pub const insertItemBefore = M.insertItemBefore; +pub const replaceItem = M.replaceItem; +pub const removeItem = M.removeItem; +pub const appendItem = M.appendItem; -pub fn getLength(self: *PointList, frame: *Frame) !u32 { - try self.sync(frame); - return @intCast(self._items.items.len); -} - -pub fn getNumberOfItems(self: *PointList, frame: *Frame) !u32 { - return self.getLength(frame); -} - -pub fn clear(self: *PointList, frame: *Frame) !void { - try self.requireMutable(); - try self.sync(frame); - try self.retireAll(frame); - try self.setAttribute(&.{}, frame); -} - -pub fn initialize(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint { - try self.requireMutable(); - try self.sync(frame); - - const prepared = try self.prepareItem(item, frame); - errdefer prepared._proto.releaseRef(frame._page); - - try self.retireAll(frame); - try self._items.ensureTotalCapacity(frame.arena, 1); - try self.setAttribute(&.{prepared}, frame); - self._items.appendAssumeCapacity(prepared); - self.attach(prepared); - return prepared; -} - -pub fn getItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint { - try self.sync(frame); - if (index >= self._items.items.len) return error.IndexSizeError; - return self._items.items[index]; -} - -pub fn insertItemBefore(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint { - try self.requireMutable(); - try self.sync(frame); - - const prepared = try self.prepareItem(item, frame); - errdefer prepared._proto.releaseRef(frame._page); - const at = @min(@as(usize, index), self._items.items.len); - const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len + 1); - @memcpy(next[0..at], self._items.items[0..at]); - next[at] = prepared; - @memcpy(next[at + 1 ..], self._items.items[at..]); - - try self._items.ensureUnusedCapacity(frame.arena, 1); - try self.setAttribute(next, frame); - self._items.insertAssumeCapacity(at, prepared); - self.attach(prepared); - return prepared; -} - -pub fn replaceItem(self: *PointList, item: *DOMPoint, index: u32, frame: *Frame) !*DOMPoint { - try self.requireMutable(); - try self.sync(frame); - if (index >= self._items.items.len) return error.IndexSizeError; - - const prepared = try self.prepareItem(item, frame); - errdefer prepared._proto.releaseRef(frame._page); - const next = try frame.local_arena.dupe(*DOMPoint, self._items.items); - next[index] = prepared; - - try self._retired.ensureUnusedCapacity(frame.arena, 1); - try self.setAttribute(next, frame); - const replaced = self._items.items[index]; - replaced._proto.detach(self); - self._retired.appendAssumeCapacity(replaced); - self._items.items[index] = prepared; - self.attach(prepared); - return prepared; -} - -pub fn removeItem(self: *PointList, index: u32, frame: *Frame) !*DOMPoint { - try self.requireMutable(); - try self.sync(frame); - if (index >= self._items.items.len) return error.IndexSizeError; - - const next = try frame.local_arena.alloc(*DOMPoint, self._items.items.len - 1); - @memcpy(next[0..index], self._items.items[0..index]); - @memcpy(next[index..], self._items.items[index + 1 ..]); - - try self._retired.ensureUnusedCapacity(frame.arena, 1); - try self.setAttribute(next, frame); - const removed = self._items.orderedRemove(index); - removed._proto.detach(self); - self._retired.appendAssumeCapacity(removed); - return removed; -} - -pub fn appendItem(self: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint { - return self.insertItemBefore(item, std.math.maxInt(u32), frame); -} - -fn requireMutable(self: *const PointList) !void { - if (self._read_only) return error.NoModificationAllowed; +fn attrName(_: *const PointList) lp.String { + return .wrap("points"); } fn prepareItem(_: *PointList, item: *DOMPoint, frame: *Frame) !*DOMPoint { @@ -189,6 +106,14 @@ fn attach(self: *PointList, point: *DOMPoint) void { }, self._read_only); } +fn detachItem(point: *DOMPoint, owner: *PointList) void { + point._proto.detach(owner); +} + +fn releaseItem(point: *DOMPoint, page: *Page) void { + point._proto.releaseRef(page); +} + fn mutatePoint( context: *anyopaque, point: *DOMPointReadOnly, @@ -197,7 +122,7 @@ fn mutatePoint( ) anyerror!void { const self: *PointList = @ptrCast(@alignCast(context)); const frame = self._frame; - try self.sync(frame); + try M.sync(self, frame); // An external attribute mutation detaches the old item during sync. The // caller still owns that DOMPoint identity, but it no longer mutates the list. @@ -220,43 +145,6 @@ fn mutatePoint( point.setCoordinateRaw(coordinate, value); } -fn sync(self: *PointList, frame: *Frame) !void { - self.releaseRetired(frame._page); - - const raw = self._element.getAttributeSafe(comptime .wrap("points")) orelse ""; - if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) { - return; - } - - self._synced = false; - var parsed = parse(raw, frame) catch |err| switch (err) { - error.SyntaxError => std.ArrayList(*DOMPoint).empty, - else => return err, - }; - errdefer for (parsed.items) |point| point._proto.releaseRef(frame._page); - - self._snapshot.clearRetainingCapacity(); - try self._snapshot.appendSlice(frame.arena, raw); - try self.retireAll(frame); - try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); - for (parsed.items) |point| { - self._items.appendAssumeCapacity(point); - self.attach(point); - } - parsed.clearRetainingCapacity(); - self._synced = true; -} - -// A retired item must outlive the operation that retired it: removeItem's -// return value has no JS wrapper until the bridge wraps it after we return. -// By the next operation, anything still reachable holds its own ref. -fn releaseRetired(self: *PointList, page: *Page) void { - for (self._retired.items) |point| { - point._proto.releaseRef(page); - } - self._retired.clearRetainingCapacity(); -} - fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) { var scanner = NumberScanner{ .input = raw }; var parsed: std.ArrayList(*DOMPoint) = .empty; @@ -276,24 +164,8 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*DOMPoint) { return parsed; } -fn retireAll(self: *PointList, frame: *Frame) !void { - self._synced = false; - try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len); - for (self._items.items) |point| { - point._proto.detach(self); - self._retired.appendAssumeCapacity(point); - } - self._items.clearRetainingCapacity(); -} - -fn setAttribute(self: *PointList, items: []const *DOMPoint, frame: *Frame) !void { - var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); - const writer = &serialized.writer; - for (items, 0..) |point, i| { - if (i != 0) try writer.writeByte(' '); - try writer.print("{d} {d}", .{ point._proto._x, point._proto._y }); - } - try self.commitAttribute(serialized.written(), frame); +fn writeItem(point: *DOMPoint, writer: *std.Io.Writer) !void { + try writer.print("{d} {d}", .{ point._proto._x, point._proto._y }); } fn setAttributeWithOverride( @@ -311,15 +183,7 @@ fn setAttributeWithOverride( const y = if (i == index and coordinate == .y) value else point._proto._y; try writer.print("{d} {d}", .{ x, y }); } - try self.commitAttribute(serialized.written(), frame); -} - -fn commitAttribute(self: *PointList, serialized: []const u8, frame: *Frame) !void { - self._synced = false; - try self._element.setAttributeSafe(comptime .wrap("points"), .wrap(serialized), frame); - self._snapshot.clearRetainingCapacity(); - try self._snapshot.appendSlice(frame.arena, serialized); - self._synced = true; + try M.commitAttribute(self, serialized.written(), frame); } const NumberScanner = struct { diff --git a/src/browser/webapi/svg/TransformList.zig b/src/browser/webapi/svg/TransformList.zig index 15a8ae9a5..d79f29743 100644 --- a/src/browser/webapi/svg/TransformList.zig +++ b/src/browser/webapi/svg/TransformList.zig @@ -25,6 +25,7 @@ const Page = @import("../../Page.zig"); const DOMMatrixReadOnly = @import("../DOMMatrixReadOnly.zig"); const Element = @import("../Element.zig"); const Transform = @import("Transform.zig"); +const reflected_list = @import("reflected_list.zig"); const TransformList = @This(); @@ -37,6 +38,16 @@ _snapshot: std.ArrayList(u8) = .empty, _items: std.ArrayList(*Transform) = .empty, _retired: std.ArrayList(*Transform) = .empty, +const M = reflected_list.Mixin(TransformList, Transform, .{ + .attrName = attrName, + .parse = parse, + .writeItem = writeItem, + .prepareItem = prepareItem, + .attach = attach, + .detachItem = detachItem, + .releaseItem = releaseItem, +}); + pub fn createForAttribute(element: *Element, attr_name: lp.String, read_only: bool, frame: *Frame) !*TransformList { return frame._factory.create(TransformList{ ._frame = frame, @@ -46,111 +57,20 @@ pub fn createForAttribute(element: *Element, attr_name: lp.String, read_only: bo }); } -pub fn deinit(self: *TransformList, page: *Page) void { - for (self._items.items) |transform| { - transform.detach(self); - transform.releaseRef(page); - } - self._items.clearRetainingCapacity(); - self.releaseRetired(page); -} - -pub fn getLength(self: *TransformList, frame: *Frame) !u32 { - try self.sync(frame); - return @intCast(self._items.items.len); -} - -pub fn getNumberOfItems(self: *TransformList, frame: *Frame) !u32 { - return self.getLength(frame); -} - -pub fn clear(self: *TransformList, frame: *Frame) !void { - try self.requireMutable(); - try self.sync(frame); - try self.retireAll(frame); - try self.setAttribute(&.{}, frame); -} - -pub fn initialize(self: *TransformList, item: *Transform, frame: *Frame) !*Transform { - try self.requireMutable(); - try self.sync(frame); - const prepared = try self.prepareItem(item, frame); - errdefer prepared.releaseRef(frame._page); - - try self.retireAll(frame); - try self._items.ensureTotalCapacity(frame.arena, 1); - try self.setAttribute(&.{prepared}, frame); - self._items.appendAssumeCapacity(prepared); - self.attach(prepared); - return prepared; -} - -pub fn getItem(self: *TransformList, index: u32, frame: *Frame) !*Transform { - try self.sync(frame); - if (index >= self._items.items.len) return error.IndexSizeError; - return self._items.items[index]; -} - -pub fn insertItemBefore(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform { - try self.requireMutable(); - try self.sync(frame); - const prepared = try self.prepareItem(item, frame); - errdefer prepared.releaseRef(frame._page); - const at = @min(@as(usize, index), self._items.items.len); - const next = try frame.local_arena.alloc(*Transform, self._items.items.len + 1); - @memcpy(next[0..at], self._items.items[0..at]); - next[at] = prepared; - @memcpy(next[at + 1 ..], self._items.items[at..]); - - try self._items.ensureUnusedCapacity(frame.arena, 1); - try self.setAttribute(next, frame); - self._items.insertAssumeCapacity(at, prepared); - self.attach(prepared); - return prepared; -} - -pub fn replaceItem(self: *TransformList, item: *Transform, index: u32, frame: *Frame) !*Transform { - try self.requireMutable(); - try self.sync(frame); - if (index >= self._items.items.len) return error.IndexSizeError; - const prepared = try self.prepareItem(item, frame); - errdefer prepared.releaseRef(frame._page); - const next = try frame.local_arena.dupe(*Transform, self._items.items); - next[index] = prepared; - - try self._retired.ensureUnusedCapacity(frame.arena, 1); - try self.setAttribute(next, frame); - const replaced = self._items.items[index]; - replaced.detach(self); - self._retired.appendAssumeCapacity(replaced); - self._items.items[index] = prepared; - self.attach(prepared); - return prepared; -} - -pub fn removeItem(self: *TransformList, index: u32, frame: *Frame) !*Transform { - try self.requireMutable(); - try self.sync(frame); - if (index >= self._items.items.len) return error.IndexSizeError; - const next = try frame.local_arena.alloc(*Transform, self._items.items.len - 1); - @memcpy(next[0..index], self._items.items[0..index]); - @memcpy(next[index..], self._items.items[index + 1 ..]); - - try self._retired.ensureUnusedCapacity(frame.arena, 1); - try self.setAttribute(next, frame); - const removed = self._items.orderedRemove(index); - removed.detach(self); - self._retired.appendAssumeCapacity(removed); - return removed; -} - -pub fn appendItem(self: *TransformList, item: *Transform, frame: *Frame) !*Transform { - return self.insertItemBefore(item, std.math.maxInt(u32), frame); -} +pub const deinit = M.deinit; +pub const getLength = M.getLength; +pub const getNumberOfItems = M.getNumberOfItems; +pub const clear = M.clear; +pub const initialize = M.initialize; +pub const getItem = M.getItem; +pub const insertItemBefore = M.insertItemBefore; +pub const replaceItem = M.replaceItem; +pub const removeItem = M.removeItem; +pub const appendItem = M.appendItem; pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform { - try self.requireMutable(); - try self.sync(frame); + try M.requireMutable(self); + try M.sync(self, frame); if (self._items.items.len == 0) return null; var matrix = DOMMatrixReadOnly.identity(); @@ -173,16 +93,16 @@ pub fn consolidate(self: *TransformList, frame: *Frame) !?*Transform { consolidated.acquireRef(); errdefer consolidated.releaseRef(frame._page); - try self.retireAll(frame); + try M.retireAll(self, frame); try self._items.ensureTotalCapacity(frame.arena, 1); - try self.setAttribute(&.{consolidated}, frame); + try M.setAttribute(self, &.{consolidated}, frame); self._items.appendAssumeCapacity(consolidated); - self.attach(consolidated); + attach(self, consolidated); return consolidated; } -fn requireMutable(self: *const TransformList) !void { - if (self._read_only) return error.NoModificationAllowed; +fn attrName(self: *const TransformList) lp.String { + return self._attr_name; } fn prepareItem(_: *TransformList, item: *Transform, frame: *Frame) !*Transform { @@ -198,10 +118,18 @@ fn attach(self: *TransformList, transform: *Transform) void { }, self._read_only); } +fn detachItem(transform: *Transform, owner: *TransformList) void { + transform.detach(owner); +} + +fn releaseItem(transform: *Transform, page: *Page) void { + transform.releaseRef(page); +} + fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform.State) anyerror!void { const self: *TransformList = @ptrCast(@alignCast(context)); const frame = self._frame; - try self.sync(frame); + try M.sync(self, frame); if (!transform.isAttachedTo(self)) { transform.applyStateRaw(state); return; @@ -213,41 +141,6 @@ fn mutateTransform(context: *anyopaque, transform: *Transform, state: Transform. transform.applyStateRaw(state); } -fn sync(self: *TransformList, frame: *Frame) !void { - self.releaseRetired(frame._page); - - const raw = self._element.getAttributeSafe(self._attr_name) orelse ""; - if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) return; - - self._synced = false; - var parsed = parse(raw, frame) catch |err| switch (err) { - error.SyntaxError => std.ArrayList(*Transform).empty, - else => return err, - }; - errdefer for (parsed.items) |transform| transform.releaseRef(frame._page); - - self._snapshot.clearRetainingCapacity(); - try self._snapshot.appendSlice(frame.arena, raw); - try self.retireAll(frame); - try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); - for (parsed.items) |transform| { - self._items.appendAssumeCapacity(transform); - self.attach(transform); - } - parsed.clearRetainingCapacity(); - self._synced = true; -} - -// A retired item must outlive the operation that retired it: removeItem's -// return value has no JS wrapper until the bridge wraps it after we return. -// By the next operation, anything still reachable holds its own ref. -fn releaseRetired(self: *TransformList, page: *Page) void { - for (self._retired.items) |transform| { - transform.releaseRef(page); - } - self._retired.clearRetainingCapacity(); -} - fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) { var parsed: std.ArrayList(*Transform) = .empty; errdefer for (parsed.items) |transform| transform.releaseRef(frame._page); @@ -267,24 +160,8 @@ fn parse(raw: []const u8, frame: *Frame) !std.ArrayList(*Transform) { return parsed; } -fn retireAll(self: *TransformList, frame: *Frame) !void { - self._synced = false; - try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len); - for (self._items.items) |transform| { - transform.detach(self); - self._retired.appendAssumeCapacity(transform); - } - self._items.clearRetainingCapacity(); -} - -fn setAttribute(self: *TransformList, items: []const *Transform, frame: *Frame) !void { - var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); - const writer = &serialized.writer; - for (items, 0..) |transform, i| { - if (i != 0) try writer.writeByte(' '); - try Transform.writeState(transform.getState(), writer); - } - try self.commitAttribute(serialized.written(), frame); +fn writeItem(transform: *Transform, writer: *std.Io.Writer) !void { + try Transform.writeState(transform.getState(), writer); } fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform.State, frame: *Frame) !void { @@ -294,15 +171,7 @@ fn setAttributeWithOverride(self: *TransformList, index: usize, state: Transform if (i != 0) try writer.writeByte(' '); try Transform.writeState(if (i == index) state else transform.getState(), writer); } - try self.commitAttribute(serialized.written(), frame); -} - -fn commitAttribute(self: *TransformList, serialized: []const u8, frame: *Frame) !void { - self._synced = false; - try self._element.setAttributeSafe(self._attr_name, .wrap(serialized), frame); - self._snapshot.clearRetainingCapacity(); - try self._snapshot.appendSlice(frame.arena, serialized); - self._synced = true; + try M.commitAttribute(self, serialized.written(), frame); } pub const JsApi = struct { diff --git a/src/browser/webapi/svg/reflected_list.zig b/src/browser/webapi/svg/reflected_list.zig new file mode 100644 index 000000000..9138ccdc4 --- /dev/null +++ b/src/browser/webapi/svg/reflected_list.zig @@ -0,0 +1,226 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Shared machinery for the attribute-reflecting SVG list interfaces whose +//! items are live, ref-counted objects (SVGPointList, SVGTransformList): +//! attribute-snapshot sync, the retire/release item lifetime protocol, and +//! the DOM list operations. The concrete list supplies the item specifics +//! via `hooks` and re-exports the operations as decl aliases. +//! +//! StringList reflects an attribute too but is deliberately not built on +//! this: its items are plain slices (no ref counting, no attach/retire) and +//! its commit path is list-authoritative rather than reparse-based. +//! +//! `List` must carry the `_frame`, `_element`, `_read_only`, `_synced`, +//! `_snapshot`, `_items` and `_retired` fields. `hooks` must provide: +//! attrName(*const List) String +//! parse([]const u8, *Frame) !std.ArrayList(*Item) (SyntaxError -> empty list) +//! writeItem(*Item, *std.Io.Writer) !void +//! prepareItem(*List, *Item, *Frame) !*Item +//! attach(*List, *Item) void +//! detachItem(*Item, *List) void +//! releaseItem(*Item, *Page) void +//! +//! The operations keep a strict commit order: reserve list capacity, write +//! the attribute, then apply the infallible list update — so a failed +//! setAttribute never leaves the mirror out of step with the attribute. + +const std = @import("std"); + +const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); + +pub fn Mixin(comptime List: type, comptime Item: type, comptime hooks: anytype) type { + return struct { + pub fn deinit(self: *List, page: *Page) void { + for (self._items.items) |item| { + hooks.detachItem(item, self); + hooks.releaseItem(item, page); + } + self._items.clearRetainingCapacity(); + releaseRetired(self, page); + } + + pub fn getLength(self: *List, frame: *Frame) !u32 { + try sync(self, frame); + return @intCast(self._items.items.len); + } + + pub fn getNumberOfItems(self: *List, frame: *Frame) !u32 { + return getLength(self, frame); + } + + pub fn clear(self: *List, frame: *Frame) !void { + try requireMutable(self); + try sync(self, frame); + try retireAll(self, frame); + try setAttribute(self, &.{}, frame); + } + + pub fn initialize(self: *List, item: *Item, frame: *Frame) !*Item { + try requireMutable(self); + try sync(self, frame); + + const prepared = try hooks.prepareItem(self, item, frame); + errdefer hooks.releaseItem(prepared, frame._page); + + try retireAll(self, frame); + try self._items.ensureTotalCapacity(frame.arena, 1); + try setAttribute(self, &.{prepared}, frame); + self._items.appendAssumeCapacity(prepared); + hooks.attach(self, prepared); + return prepared; + } + + pub fn getItem(self: *List, index: u32, frame: *Frame) !*Item { + try sync(self, frame); + if (index >= self._items.items.len) return error.IndexSizeError; + return self._items.items[index]; + } + + pub fn insertItemBefore(self: *List, item: *Item, index: u32, frame: *Frame) !*Item { + try requireMutable(self); + try sync(self, frame); + + const prepared = try hooks.prepareItem(self, item, frame); + errdefer hooks.releaseItem(prepared, frame._page); + const at = @min(@as(usize, index), self._items.items.len); + const next = try frame.local_arena.alloc(*Item, self._items.items.len + 1); + @memcpy(next[0..at], self._items.items[0..at]); + next[at] = prepared; + @memcpy(next[at + 1 ..], self._items.items[at..]); + + try self._items.ensureUnusedCapacity(frame.arena, 1); + try setAttribute(self, next, frame); + self._items.insertAssumeCapacity(at, prepared); + hooks.attach(self, prepared); + return prepared; + } + + pub fn replaceItem(self: *List, item: *Item, index: u32, frame: *Frame) !*Item { + try requireMutable(self); + try sync(self, frame); + if (index >= self._items.items.len) return error.IndexSizeError; + + const prepared = try hooks.prepareItem(self, item, frame); + errdefer hooks.releaseItem(prepared, frame._page); + const next = try frame.local_arena.dupe(*Item, self._items.items); + next[index] = prepared; + + try self._retired.ensureUnusedCapacity(frame.arena, 1); + try setAttribute(self, next, frame); + const replaced = self._items.items[index]; + hooks.detachItem(replaced, self); + self._retired.appendAssumeCapacity(replaced); + self._items.items[index] = prepared; + hooks.attach(self, prepared); + return prepared; + } + + pub fn removeItem(self: *List, index: u32, frame: *Frame) !*Item { + try requireMutable(self); + try sync(self, frame); + if (index >= self._items.items.len) return error.IndexSizeError; + + const next = try frame.local_arena.alloc(*Item, self._items.items.len - 1); + @memcpy(next[0..index], self._items.items[0..index]); + @memcpy(next[index..], self._items.items[index + 1 ..]); + + try self._retired.ensureUnusedCapacity(frame.arena, 1); + try setAttribute(self, next, frame); + const removed = self._items.orderedRemove(index); + hooks.detachItem(removed, self); + self._retired.appendAssumeCapacity(removed); + return removed; + } + + pub fn appendItem(self: *List, item: *Item, frame: *Frame) !*Item { + return insertItemBefore(self, item, std.math.maxInt(u32), frame); + } + + pub fn requireMutable(self: *const List) !void { + if (self._read_only) return error.NoModificationAllowed; + } + + pub fn sync(self: *List, frame: *Frame) !void { + releaseRetired(self, frame._page); + + const raw = self._element.getAttributeSafe(hooks.attrName(self)) orelse ""; + if (self._synced and std.mem.eql(u8, self._snapshot.items, raw)) { + return; + } + + self._synced = false; + var parsed = hooks.parse(raw, frame) catch |err| switch (err) { + error.SyntaxError => std.ArrayList(*Item).empty, + else => return err, + }; + errdefer for (parsed.items) |item| hooks.releaseItem(item, frame._page); + + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, raw); + try retireAll(self, frame); + try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); + for (parsed.items) |item| { + self._items.appendAssumeCapacity(item); + hooks.attach(self, item); + } + parsed.clearRetainingCapacity(); + self._synced = true; + } + + // A retired item must outlive the operation that retired it: + // removeItem's return value has no JS wrapper until the bridge wraps + // it after we return. By the next operation, anything still reachable + // holds its own ref. + pub fn releaseRetired(self: *List, page: *Page) void { + for (self._retired.items) |item| { + hooks.releaseItem(item, page); + } + self._retired.clearRetainingCapacity(); + } + + pub fn retireAll(self: *List, frame: *Frame) !void { + self._synced = false; + try self._retired.ensureUnusedCapacity(frame.arena, self._items.items.len); + for (self._items.items) |item| { + hooks.detachItem(item, self); + self._retired.appendAssumeCapacity(item); + } + self._items.clearRetainingCapacity(); + } + + pub fn setAttribute(self: *List, items: []const *Item, frame: *Frame) !void { + var serialized: std.Io.Writer.Allocating = .init(frame.local_arena); + const writer = &serialized.writer; + for (items, 0..) |item, i| { + if (i != 0) try writer.writeByte(' '); + try hooks.writeItem(item, writer); + } + try commitAttribute(self, serialized.written(), frame); + } + + pub fn commitAttribute(self: *List, serialized: []const u8, frame: *Frame) !void { + self._synced = false; + try self._element.setAttributeSafe(hooks.attrName(self), .wrap(serialized), frame); + self._snapshot.clearRetainingCapacity(); + try self._snapshot.appendSlice(frame.arena, serialized); + self._synced = true; + } + }; +}