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; + } + }; +}