Merge pull request #3079 from lightpanda-io/dedupe-svg-css

chore: dedupe SVG / CSS code
This commit is contained in:
Karl Seguin
2026-07-30 07:58:38 +08:00
committed by GitHub
10 changed files with 611 additions and 542 deletions

View File

@@ -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)

168
src/browser/css/units.zig Normal file
View File

@@ -0,0 +1,168 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! 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));
}

View File

@@ -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'; });

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {

View File

@@ -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 {

View File

@@ -0,0 +1,226 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! 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;
}
};
}