From 2da300b3bc2fc3810241cb1d7263fbd2a69fddd9 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:43:56 -0500 Subject: [PATCH 1/4] automation: drop:beside resolves per target place Part of fizzyedit/work#311 Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- app/layout/SplitTree.zig | 4 +++- app/layout/State.zig | 17 ++++++++++++---- app/layout/ViewDrag.zig | 2 +- core/widgets/DropZones.zig | 2 +- plugins/workbench/src/Workbench.zig | 22 ++++++++++----------- plugins/workbench/src/Workspace.zig | 2 +- src/editor/Demo.zig | 20 ++++++++++++++----- src/editor/Workspaces.zig | 30 +++++++++++++++++------------ tests/integration.zig | 20 +++++++++++++++++++ 9 files changed, 83 insertions(+), 36 deletions(-) diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index 7f517eeb8..f8c6861d0 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -6,8 +6,10 @@ //! picker fills them. Closing an empty leaf collapses the branch. const std = @import("std"); const dvui = @import("dvui"); +const core = @import("core"); -pub const Side = enum { left, right, top, bottom }; +/// The docking widget's: a pane tree and a place tree name an edge the same way. +pub const Side = core.widgets.DockLayout.Side; pub fn axisOf(side: Side) dvui.enums.Direction { return switch (side) { diff --git a/app/layout/State.zig b/app/layout/State.zig index 96da8e6dc..e1e047fc3 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -38,6 +38,14 @@ pub const StoreOffer = struct { title: []const u8, }; +/// An app's answer to where an open beside splits a place (`beside`). +pub const Beside = struct { + ctx: *anyopaque, + /// The edge of place `name` an open beside splits, or null where it joins a place, becomes a + /// card, or `name` holds no documents. + edge: *const fn (ctx: *anyopaque, name: []const u8) ?SplitTree.Side, +}; + /// How an application gives a document a home outside the slots its plugins made: a view of its /// own that holds documents (fizzy's workspaces). With one, a document carried in the view drag /// lands in any place as a view would, inside a new such view (`ViewDrag.place`). @@ -169,10 +177,11 @@ tree_cleared: bool = false, slide_open: []const u8 = "", /// A place's view being dragged to another place. Empty `name` when idle. view_drag: ViewDrag = .{}, -/// The edge an open beside splits the documents' place on right now, set each frame by the app -/// while a demo plays: that edge's drop zone is also published as the anchor `drop:beside`, so a -/// tape aims at the place's answer, not at an edge it chose when it was written. -beside: ?SplitTree.Side = null, +/// Set each frame by the app while a demo plays: asked for each place a drag may land in, it +/// names the edge an open beside splits that place on. That edge's drop zone is also published as +/// the anchor `drop:beside`, so a tape aims at the target place's own answer, not at an edge it +/// chose when it was written. +beside: ?Beside = null, /// Floats are OS windows of their own (the application says so each frame, where it has them): a /// view let go over no window of the app's opens one there (`ViewDrag.apply`). floats_windowed: bool = false, diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 61500fea1..a01101bff 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1329,7 +1329,7 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void { switch (z) { .edge => |side| { core.anchor.markRect(key, r, true, "drop:{t}", .{side}); - if (l.state.beside) |aimed_side| if (std.mem.eql(u8, @tagName(aimed_side), @tagName(side))) core.anchor.markRect(key, r, true, "drop:beside", .{}); + if (l.state.beside) |aim_at| if (aim_at.edge(aim_at.ctx, name) == side) core.anchor.markRect(key, r, true, "drop:beside", .{}); }, else => core.anchor.markRect(key, r, true, "drop:{t}", .{z}), } diff --git a/core/widgets/DropZones.zig b/core/widgets/DropZones.zig index 0db908e0e..a66ecc084 100644 --- a/core/widgets/DropZones.zig +++ b/core/widgets/DropZones.zig @@ -48,7 +48,7 @@ const motion = @import("glass").motion; const liquid_glass = @import("glass").liquid_glass; const LiquidField = @import("glass").LiquidField; -pub const Side = enum { left, right, top, bottom }; +pub const Side = @import("DockingWidget/Layout.zig").Side; pub const Zone = union(enum) { /// Into the place itself: a trade for a place that shows one view, a new tab for one that diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index ea3ce2a27..e9848c5a6 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -233,27 +233,26 @@ pub fn sideBeside(self: *const Workbench, anchor: u64) Panes.Side { /// What opening beside the person's pane asks of `core.placement.place`: the other pane that is /// there, if any, and the answer over the person's room and that pane's. Null before the person's /// pane has drawn, with no room to judge. -const Beside = struct { other: ?u64, answer: ?core.placement.Result }; +const Beside = struct { other: ?u64, room: ?core.placement.Size, answer: ?core.placement.Result }; -fn besideAnswer(self: *Workbench) Beside { - const here = self.open_workspace_grouping; +fn besideAnswer(self: *Workbench, here: u64) Beside { const other = for (self.workspaces.values()) |ws| { if (ws.grouping != here and !self.paneClosing(ws.grouping)) break ws.grouping; } else null; - const room = self.paneSize(here) orelse return .{ .other = other, .answer = null }; + const room = self.paneSize(here) orelse return .{ .other = other, .room = null, .answer = null }; var others: [1]core.placement.Size = undefined; const places: []const core.placement.Size = if (other) |g| blk: { others[0] = self.paneSize(g) orelse .{ .w = 0, .h = 0 }; break :blk &others; } else &.{}; - return .{ .other = other, .answer = core.placement.place(room, places) }; + return .{ .other = other, .room = room, .answer = core.placement.place(room, places) }; } /// The grouping a document opened beside the person's goes in (`core.placement.place`): another /// pane that is there, else a new one when the person's has room for two, else the person's own, /// where it becomes a card. The caller makes it the front card, as a plain open does. pub fn besideGrouping(self: *Workbench) u64 { - const asked = self.besideAnswer(); + const asked = self.besideAnswer(self.open_workspace_grouping); // Not drawn yet, so no room to judge: beside as before, in the other pane if there is one. const answer = asked.answer orelse return asked.other orelse self.newGroupingID(); return switch (answer) { @@ -263,11 +262,12 @@ pub fn besideGrouping(self: *Workbench) u64 { }; } -/// The edge of the person's pane an open beside splits it on, or null where it joins a pane or -/// becomes a card. What a demo's `drop:beside` aims at. -pub fn besideEdge(self: *Workbench) ?Panes.Side { - const room = self.paneSize(self.open_workspace_grouping) orelse return null; - return switch (self.besideAnswer().answer orelse return null) { +/// The edge of `grouping`'s pane an open beside it splits on, or null where it joins a pane or +/// becomes a card. What a demo's `drop:beside` aims at, over the pane it is aimed at. +pub fn besideEdge(self: *Workbench, grouping: u64) ?Panes.Side { + const asked = self.besideAnswer(grouping); + const room = asked.room orelse return null; + return switch (asked.answer orelse return null) { .split => sideFor(room.w, room.h), .into, .tab => null, }; diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 98094ca71..05f770b80 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -158,7 +158,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { // The person's pane, when it has the room for an open beside to split it: a tape that drops a // tab on the edge asks for this first, or skips the drop (`core.placement`). if (core.anchor.wanted() and runtime.workbench().open_workspace_grouping == self.grouping and - runtime.workbench().besideEdge() != null) core.anchor.mark(pane_box, "workbench.beside-room", .{}); + runtime.workbench().besideEdge(self.grouping) != null) core.anchor.mark(pane_box, "workbench.beside-room", .{}); if (tabs.len > 0) { self.dropSnapshot(); diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 2813e46c0..d0f45c948 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -285,11 +285,8 @@ pub fn frame(self: *Demo) void { .tape => |bytes| self.playTape(bytes) catch {}, } } - // A drop beside is aimed at where the workbench would split, as the room is now. - self.editor.app.layout.beside = if (self.active()) - if (self.editor.workbench.besideEdge()) |side| std.meta.stringToEnum(SplitTree.Side, @tagName(side)) else null - else - null; + // A drop beside is aimed at where the workbench would split the target place, as its room is now. + self.editor.app.layout.beside = if (self.active()) .{ .ctx = self, .edge = besideEdge } else null; self.snapshot.beginFrame(); self.live.frame(); self.player.frame(); @@ -872,6 +869,19 @@ fn commandTitle(ctx: *anyopaque, id: []const u8) ?[]const u8 { return c.title; } +/// The edge a drop beside aims at over place `name`: where the workbench would split the pane +/// shown there (a pane of its own tree is named for its grouping). A pane the layout's keywords +/// show is in no list, so Main is the person's pane. +pub fn besideEdge(ctx: *anyopaque, name: []const u8) ?SplitTree.Side { + const editor = from(ctx).editor; + const wb = &editor.workbench; + const grouping = Editor.Workspace.groupingOfName(name) orelse editor.workspaces.groupingIn(name) orelse blk: { + const main = editor.regionFor(sdk.keywords.ide.main) orelse return null; + break :blk if (std.mem.eql(u8, main.name, name)) wb.open_workspace_grouping else return null; + }; + return wb.besideEdge(grouping); +} + /// The place a role names, by what it is rather than what the person's layout calls it. fn resolveRole(ctx: *anyopaque, role: []const u8, buf: []u8) ?[]const u8 { const keywords = if (std.mem.eql(u8, role, "documents")) diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig index 1c1bfda8a..0e31bfa65 100644 --- a/src/editor/Workspaces.zig +++ b/src/editor/Workspaces.zig @@ -289,18 +289,29 @@ fn layoutOf(editor: *Editor) Layout { return Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.host.arena()); } +/// The pane shown in place `name`: the person's own when it is there, else the first held. +/// Null where the place holds no pane (a pane the keywords show is in no list: the caller's). +pub fn groupingIn(self: *Workspaces, name: []const u8) ?u64 { + const editor = self.editorOf(); + var l = layoutOf(editor); + var buf: [64]u8 = undefined; + var first: ?u64 = null; + for (self.views.keys()) |g| { + const holder = Layout.ViewDrag.holderOf(&l, viewId(&buf, g)) orelse continue; + if (!std.mem.eql(u8, holder, name)) continue; + if (g == editor.workbench.open_workspace_grouping) return g; + first = first orelse g; + } + return first; +} + fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Side, adaptive: bool) bool { const self: *Workspaces = @ptrCast(@alignCast(ctx)); const editor = self.editorOf(); var l = layoutOf(editor); var abuf: [64]u8 = undefined; const place = Layout.ViewDrag.holderOf(&l, viewId(&abuf, anchor)) orelse return false; - const new = Layout.splitOn(&l, place, switch (side) { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - }) orelse return false; + const new = Layout.splitOn(&l, place, side) orelse return false; var buf: [64]u8 = undefined; const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return false; l.state.assign(l.gpa, new, &.{id}) catch return false; @@ -354,12 +365,7 @@ fn open(ctx: *anyopaque, grouping: u64, side: Workbench.Panes.Side) bool { var l = layoutOf(self.editorOf()); var buf: [64]u8 = undefined; const place = Layout.ViewDrag.holderOf(&l, viewId(&buf, grouping)) orelse return false; - return Layout.splitOn(&l, place, switch (side) { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - }) != null; + return Layout.splitOn(&l, place, side) != null; } /// The view of the workspace whose only tab is document `id`, if one is. diff --git a/tests/integration.zig b/tests/integration.zig index 9ae41d941..d985a3124 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -10898,6 +10898,26 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes // With another pane there, it takes the view; without, the person's own does. const taken = wb.besideGrouping(); if (others > 0) try std.testing.expect(taken != here) else try std.testing.expectEqual(here, taken); + // A demo's `drop:beside` is the answer for the place it is aimed at, not the person's pane's + // for every place: Main's is the pane's edge in its own room, which turns with the room, + // and a place that holds no documents has none. + if (others == 0) { + const Side = fizzy.core.widgets.DockLayout.Side; + const main = (editor.regionFor(fizzy.sdk.keywords.ide.main) orelse return error.TestUnexpectedResult).name; + const sidebar = (editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return error.TestUnexpectedResult).name; + ws.pane_rect_physical = .{ .x = 0, .y = 0, .w = 1200 * s, .h = 800 * s }; + try std.testing.expectEqual(@as(?Side, .right), fizzy.Editor.Demo.besideEdge(&editor.demo, main)); + try std.testing.expectEqual(@as(?Side, null), fizzy.Editor.Demo.besideEdge(&editor.demo, sidebar)); + ws.pane_rect_physical = .{ .x = 0, .y = 0, .w = 820 * s, .h = 1180 * s }; + try std.testing.expectEqual(@as(?Side, .bottom), fizzy.Editor.Demo.besideEdge(&editor.demo, main)); + var pane_buf: [32]u8 = undefined; + const pane = fizzy.Editor.Workspace.name(&pane_buf, here); + try std.testing.expectEqual(@as(?Side, .bottom), fizzy.Editor.Demo.besideEdge(&editor.demo, pane)); + var other_buf: [32]u8 = undefined; + try std.testing.expectEqual(@as(?Side, null), fizzy.Editor.Demo.besideEdge(&editor.demo, fizzy.Editor.Workspace.name(&other_buf, here + 1000))); + try std.testing.expectEqual(@as(?Side, null), wb.besideEdge(here + 1000)); + ws.pane_rect_physical = .{ .x = 0, .y = 0, .w = 390 * s, .h = 844 * s }; + } try std.testing.expectEqual(fizzy.core.widgets.DockLayout.Side.right, workbench.Workbench.sideFor(1200, 800)); try std.testing.expectEqual(fizzy.core.widgets.DockLayout.Side.bottom, workbench.Workbench.sideFor(820, 1180)); } From 7990caf061b5ab7d4039289b3cd1589a1438750e Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:43:36 -0500 Subject: [PATCH 2/4] automation: beside-target-place (codex) Agent-Team: linux Worker: codex --- app/layout/SplitTree.zig | 2 +- app/layout/ViewDrag.zig | 25 +++++++++------- src/editor/Demo.zig | 9 +++++- tests/integration.zig | 65 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index f8c6861d0..c9b7e5005 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -8,7 +8,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); -/// The docking widget's: a pane tree and a place tree name an edge the same way. +/// The docking widget's side: a pane tree and a place tree name an edge the same way. pub const Side = core.widgets.DockLayout.Side; pub fn axisOf(side: Side) dvui.enums.Direction { diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index a01101bff..ca9a078c8 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1322,18 +1322,21 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void { // is the target, so a demo aims at the bubble rather than an offset from the place's middle. // Marked only while a tape asks for anchors, and ahead of the offer queue's limit (which has // nothing to do with the bubble being there). - if (target and !covered and core.anchor.wanted()) for (DropZones.all) |z| { - if (z == .remove and !zones.remove) continue; - const b = zones.bubble(z); - const r: dvui.Rect.Physical = .{ .x = b.c.x - b.r, .y = b.c.y - b.r, .w = 2 * b.r, .h = 2 * b.r }; - switch (z) { - .edge => |side| { - core.anchor.markRect(key, r, true, "drop:{t}", .{side}); - if (l.state.beside) |aim_at| if (aim_at.edge(aim_at.ctx, name) == side) core.anchor.markRect(key, r, true, "drop:beside", .{}); - }, - else => core.anchor.markRect(key, r, true, "drop:{t}", .{z}), + if (target and !covered and core.anchor.wanted()) { + const beside = if (l.state.beside) |aim_at| aim_at.edge(aim_at.ctx, name) else null; + for (DropZones.all) |z| { + if (z == .remove and !zones.remove) continue; + const b = zones.bubble(z); + const r: dvui.Rect.Physical = .{ .x = b.c.x - b.r, .y = b.c.y - b.r, .w = 2 * b.r, .h = 2 * b.r }; + switch (z) { + .edge => |side| { + core.anchor.markRect(key, r, true, "drop:{t}", .{side}); + if (beside == side) core.anchor.markRect(key, r, true, "drop:beside", .{}); + }, + else => core.anchor.markRect(key, r, true, "drop:{t}", .{z}), + } } - }; + } // Queued, not drawn: over everything, once every place has drawn (`drawOverlay`). const now = dvui.currentWindow().frame_time_ns; if (d.pending_frame != now) { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index d0f45c948..c83a31c1e 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -877,7 +877,14 @@ pub fn besideEdge(ctx: *anyopaque, name: []const u8) ?SplitTree.Side { const wb = &editor.workbench; const grouping = Editor.Workspace.groupingOfName(name) orelse editor.workspaces.groupingIn(name) orelse blk: { const main = editor.regionFor(sdk.keywords.ide.main) orelse return null; - break :blk if (std.mem.eql(u8, main.name, name)) wb.open_workspace_grouping else return null; + if (!std.mem.eql(u8, main.name, name)) return null; + var layout = Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.host.arena()); + var buf: [64]u8 = undefined; + const id = std.mem.print(&buf, Editor.Workspaces.id_prefix ++ "{d}", .{wb.open_workspace_grouping}) catch return null; + if (Editor.Layout.ViewDrag.holderOf(&layout, id)) |holder| { + if (!std.mem.eql(u8, holder, name)) return null; + } + break :blk wb.open_workspace_grouping; }; return wb.besideEdge(grouping); } diff --git a/tests/integration.zig b/tests/integration.zig index d985a3124..6975e852f 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -5475,6 +5475,71 @@ test "dragging a view over its own place shows that place's drop zones" { try std.testing.expect(fizzy.core.widgets.DropZones.showing(center.id)); } +test "drop: beside marks the tall target's bottom rather than the wide source's right" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + const gpa = std.testing.allocator; + editor.app.gpa = gpa; + const state = &editor.app.layout; + defer state.regions.deinit(gpa); + defer state.regions_building.deinit(gpa); + defer state.deinitExtents(gpa); + defer state.deinitAssignments(gpa); + defer state.deinitQualified(gpa); + defer state.view_drag.discard(); + const source: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 1200, .h = 800 }; + const target: dvui.Rect.Physical = .{ .x = 1200, .y = 0, .w = 820, .h = 1180 }; + state.registerRegion(gpa, .{ .name = "Source", .keywords = fizzy.sdk.keywords.ide.main, .bounds = source, .size = .{ .w = source.w, .h = source.h } }); + state.registerRegion(gpa, .{ .name = "Target", .keywords = &.{"slot"}, .bounds = target, .size = .{ .w = target.w, .h = target.h } }); + state.publishRegions(); + const Probe = struct { + calls: usize = 0, + fn edge(raw: *anyopaque, name: []const u8) ?fizzy.Editor.Layout.State.SplitTree.Side { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.calls += 1; + const room = if (std.mem.eql(u8, name, "Target")) target else source; + return workbench.Workbench.sideFor(room.w, room.h); + } + }; + var probe: Probe = .{}; + state.beside = .{ .ctx = &probe, .edge = Probe.edge }; + defer state.beside = null; + try editor.app.host.registerSurface(.{ .id = "test.view", .title = "View", .keywords = &.{"slot"}, .draw = ManyPanelFrame.draw }); + try state.assign(gpa, "Source", &.{"test.view"}); + var layout = fizzy.Editor.Layout.init(&editor.app.host, state, gpa, dvui.currentWindow().arena()); + const VD = fizzy.Editor.Layout.ViewDrag; + const cw = dvui.currentWindow(); + const mouse = cw.mouse_pt; + defer cw.mouse_pt = mouse; + cw.mouse_pt = source.center(); + VD.begin(&layout, "Source", source, source); + cw.mouse_pt = target.center(); + fizzy.core.anchor.want(); + VD.drawZones(&layout, "Target", dvui.parentGet().extendId(@src(), 0)); + const beside = dvui.tagGet("drop:beside") orelse return error.TestUnexpectedResult; + const bottom = dvui.tagGet("drop:bottom") orelse return error.TestUnexpectedResult; + const right = dvui.tagGet("drop:right") orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(bottom.rect, beside.rect); + try std.testing.expect(!std.meta.eql(right.rect, beside.rect)); + try std.testing.expectEqual(@as(usize, 1), probe.calls); + + // Main's implicit pane must not borrow the shape of a workspace held elsewhere. + const wb = &editor.workbench; + const grouping = wb.open_workspace_grouping; + var workspace = workbench.Workspace.init(grouping); + workspace.pane_rect_physical = source; + try wb.workspaces.put(gpa, grouping, workspace); + defer wb.workspaces.deinit(gpa); + var id_buf: [64]u8 = undefined; + const id = try std.mem.print(&id_buf, fizzy.Editor.Workspaces.id_prefix ++ "{d}", .{grouping}); + try editor.app.host.registerSurface(.{ .id = id, .title = "Workspace", .keywords = &.{"slot"}, .draw = ManyPanelFrame.draw }); + try state.assign(gpa, "Target", &.{id}); + editor.demo.editor = editor; + editor.app.host.installFizzyApi(.{ .ctx = editor, .vtable = &fizzy.Editor.fizzy_api_vtable }); + try std.testing.expectEqual(@as(?fizzy.Editor.Layout.State.SplitTree.Side, null), fizzy.Editor.Demo.besideEdge(&editor.demo, "Source")); +} + // `core` is not a test root, so its widgets' pure rules are tested here. const DZ = fizzy.core.widgets.DropZones; From c656b43dcff5675509392e6f7067fe2ecda1a497 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:22:20 -0500 Subject: [PATCH 3/4] automation: beside-target-place (codex) Agent-Team: linux Worker: codex --- .github/workflows/ci.yml | 35 +- .github/workflows/hello-numbers.yml | 13 +- CONTRIBUTING.md | 4 +- app/App.zig | 4 +- app/Recents.zig | 4 +- app/Tracks.zig | 63 +- app/automation/overlay.zig | 54 +- app/control/Server.zig | 108 +- app/layout/Picker.zig | 2 +- app/layout/Region.zig | 27 +- app/layout/SplitTree.zig | 28 +- app/layout/State.zig | 2 +- app/layout/ViewDrag.zig | 19 +- app/store/PluginLoader.zig | 44 +- app/store/PluginLoader_web.zig | 4 +- app/store/PluginStore.zig | 4 +- app/update/bounded_join.zig | 51 + app/update/update_notify.zig | 29 +- backend/src/glass/BlurBackdrop.zig | 8 +- backend/src/glass/LiquidField.zig | 12 + build.zig.zon | 6 + build/app.zig | 5 +- build/web.zig | 83 +- core/AGENTS.md | 3 +- core/FileTable.zig | 5 +- core/LocalFs.zig | 17 +- core/dialogs.zig | 8 +- core/form_factor.zig | 26 +- docs/AGENT_WORK.md | 11 + docs/DEPENDENCIES.md | 44 +- docs/HELLO_NUMBERS.md | 23 +- docs/REVIEW.md | 19 +- plugins/markdown/src/md/net_image.zig | 4 +- plugins/text/src/textcore/encoding.zig | 11 +- readme.md | 26 +- scripts/agent-finish | 11 +- scripts/compare-hello.sh | 5 - scripts/compile-shaders.sh | 3 +- scripts/hello-numbers.py | 21 +- scripts/hello-numbers.sh | 4 - scripts/test-agent-work.sh | 12 + scripts/test-hello-numbers.py | 4 +- scripts/test-web-text-input.sh | 13 + sdk/plugin_sdk.zig | 2 +- sdk/replay/BlackBox.zig | 30 +- sdk/replay/Capture.zig | 11 +- sdk/replay/Input.zig | 9 +- sdk/replay/LiveDriver.zig | 26 +- sdk/replay/Player.zig | 69 +- sdk/replay/Snapshot.zig | 69 +- sdk/replay/Stage.zig | 6 +- sdk/replay/overlay.zig | 29 +- src/Entry.zig | 7 +- src/backend/backend_web.zig | 8 +- src/backend/layout_file.zig | 3 + src/editor/Editor.zig | 13 +- src/editor/Keybinds.zig | 2 +- src/editor/PluginReloads.zig | 9 +- src/editor/explorer/settings.zig | 100 +- tests/demos.zig | 14 +- tests/integration.zig | 163 +- web/AGENTS.md | 20 +- web/fizzy-worker.js | 40 +- web/fizzy.js | 1245 +++++++++++ web/index.html | 1224 +---------- .../0001-web-highp-float-in-the-shaders.patch | 100 + ...vents-carry-their-own-modifier-state.patch | 112 + ...the-mouse-and-a-cancelled-touch-ends.patch | 185 ++ ...end-only-the-GL-state-a-draw-changes.patch | 665 ++++++ ...arget-keeps-the-target-that-was-boun.patch | 45 + web/patches/dvui/0006-web-textureBlend.patch | 217 ++ .../0007-web-precise-render-targets.patch | 339 +++ ...erStream-a-flush-s-draws-in-one-call.patch | 98 + ...fragment-programs-in-the-draw-stream.patch | 369 ++++ ...s-correction-replaces-the-typed-word.patch | 266 +++ web/tests/text-input.test.js | 107 + web/web.js | 1813 ----------------- 77 files changed, 4853 insertions(+), 3441 deletions(-) create mode 100644 app/update/bounded_join.zig delete mode 100755 scripts/compare-hello.sh delete mode 100755 scripts/hello-numbers.sh create mode 100644 scripts/test-web-text-input.sh create mode 100644 web/fizzy.js create mode 100644 web/patches/dvui/0001-web-highp-float-in-the-shaders.patch create mode 100644 web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch create mode 100644 web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch create mode 100644 web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch create mode 100644 web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch create mode 100644 web/patches/dvui/0006-web-textureBlend.patch create mode 100644 web/patches/dvui/0007-web-precise-render-targets.patch create mode 100644 web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch create mode 100644 web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch create mode 100644 web/patches/dvui/0010-web-a-keyboard-s-correction-replaces-the-typed-word.patch create mode 100644 web/tests/text-input.test.js delete mode 100644 web/web.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c19f2b3ed..7c68fcf3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,29 @@ name: CI # three cold. Now the merge that changes a dependency saves the new caches, a cache GitHub # evicted after a quiet week comes back on the next merge, and every merge is tested on all # three platforms. Nothing waits on a main run, and a cache hit makes the extra jobs short. +# +# A draft pull request runs only `changes`: its author ran the gates on their own machine, and a +# draft decides nothing, so the matrix would only repeat that. The matrix runs once per push to a +# ready pull request, and `ready_for_review` starts it for the push the draft already holds. +# `changes` reports code=false and sdl=false for a draft, the builds skip, and `ci-ok` passes on +# skipped jobs; `gh pr ready` is therefore what makes the required check mean something, and +# `main`'s ruleset still requires it. Pushes to main and the merge queue are unchanged. +# +# A green `ci-ok` on a draft means "nothing was built", and that is safe only because a draft +# cannot be merged and the merge queue reruns the whole matrix on the `merge_group` ref. Turning +# a ready PR back into a draft is not a trigger, so its last full result stays on it; push or +# mark it ready again for a fresh one. +# +# The Zig compiler is restored from the Actions cache by every job: setup-zig keeps the verified +# tarball under the key `setup-zig-tarball-` (the version is pinned by ZIG_VERSION; +# a job's log shows `Cache hit for: setup-zig-tarball-zig-x86_64-linux-0.17.0`), +# independent of the `use-cache: false` the jobs set for the Zig *package* cache, which has the +# per-job key `zig-----`. on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] merge_group: workflow_dispatch: @@ -59,8 +78,14 @@ jobs: EVENT: ${{ github.event_name }} PUSH_BEFORE: ${{ github.event.before }} QUEUE_BASE: ${{ github.event.merge_group.base_sha }} + DRAFT: ${{ github.event.pull_request.draft }} run: | set -euo pipefail + if [ "$DRAFT" = true ]; then + echo "::notice::draft pull request: nothing was built; the full CI runs at gh pr ready" + printf 'code=false\nsdl=false\n' >> "$GITHUB_OUTPUT" + exit 0 + fi case "$EVENT" in pull_request) base=HEAD^1 ;; merge_group) base=$QUEUE_BASE ;; @@ -112,6 +137,8 @@ jobs: run: sh scripts/test-zb.sh - name: scripts/check-kickoff run: python3 scripts/test-check-kickoff.py + - name: scripts/hello-numbers (harness, no windows) + run: python3 scripts/test-hello-numbers.py - name: scripts/agent-prepare, agent-finish run: bash scripts/test-agent-work.sh - name: superfans (fan-dedupe, fan-route, fan-lint on seeded input) @@ -135,7 +162,8 @@ jobs: - { os: windows-latest, name: Windows } - { os: macos-14, name: macOS } runs-on: ${{ matrix.os }} - timeout-minutes: 30 + # macOS runs 26 to 30 minutes on a healthy day; a slow run should not be a failed one. + timeout-minutes: ${{ matrix.name == 'macOS' && 45 || 30 }} steps: - uses: actions/checkout@v4 # The Zig cache's key, worked out once, here, before anything is unpacked. The fetch below @@ -237,6 +265,11 @@ jobs: - name: Compile web (wasm) build with a custom layout if: matrix.name != 'Windows' shell: bash + # The script builds through scripts/zb, which waits for the load average to fall under the + # performance cores. A runner's load is the previous step's compilers (16 against 3 on + # macOS), so zb idled 10 minutes there. Nothing else shares a CI runner: skip the wait. + env: + FIZZY_BUILD_SKIP: "1" run: bash scripts/ci-retry.sh scripts/check-web-layout.sh # The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer diff --git a/.github/workflows/hello-numbers.yml b/.github/workflows/hello-numbers.yml index eb70b8f1b..8b8eae45e 100644 --- a/.github/workflows/hello-numbers.yml +++ b/.github/workflows/hello-numbers.yml @@ -3,6 +3,13 @@ name: Hello numbers on: push: branches: [main] + pull_request: + paths: + - .github/workflows/hello-numbers.yml + - scripts/hello-numbers.py + - scripts/test-hello-numbers.py + - docs/HELLO_NUMBERS.md + - src/Entry.zig workflow_dispatch: permissions: @@ -47,8 +54,6 @@ jobs: echo "LIBGL_ALWAYS_SOFTWARE=1" >> "$GITHUB_ENV" echo "SDL_VIDEO_DRIVER=x11" >> "$GITHUB_ENV" echo "VK_DRIVER_FILES=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json | head -n 1)" >> "$GITHUB_ENV" - - name: Test the measurement harness without windows - run: python3 scripts/test-hello-numbers.py - name: Measure both release modes run: | set -euo pipefail @@ -58,9 +63,9 @@ jobs: --example-revision "$(git -C ../example-app rev-parse HEAD)" --plugin-revision "$(git -C ../example-plugin rev-parse HEAD)") if [[ "$RUNNER_OS" == Linux ]]; then - xvfb-run -a -s "-screen 0 1280x1024x24" scripts/hello-numbers.sh "${args[@]}" + xvfb-run -a -s "-screen 0 1280x1024x24" python3 scripts/hello-numbers.py "${args[@]}" else - scripts/hello-numbers.sh "${args[@]}" + python3 scripts/hello-numbers.py "${args[@]}" fi - uses: actions/upload-artifact@v4 if: always() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c86a1ea6..f730f7ff8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,10 +42,10 @@ The open PR list is the board of who is working on what. touches the same files or the same plan step, coordinate on it (comment, or stack on top of it) instead of starting a parallel version. 2. **Open a draft PR on your first push** (`gh pr create --draft --body-file `), with the - template filled in as far as you know it. A draft runs CI, so claim early. + template filled in as far as you know it. A draft runs only the light `changes` job (the full matrix starts at `gh pr ready`), so claim early. 3. **Take it to ready yourself; don't hand back a draft.** When the change is done and the gates pass, review your own diff (in Claude Code, the `code-review` skill at `high` on the PR), fix - what holds up, push, and wait for CI to go green. Then `gh pr ready`, and end the session + what holds up, push, then `gh pr ready`, which starts the full CI, and wait for it to go green. End the session with one question: merge it? Say what was reviewed and what was left, so the answer can be yes without opening the PR. 4. **Merging is the maintainer's.** On a yes, merge with `gh pr merge --squash`. Never push to diff --git a/app/App.zig b/app/App.zig index 34201bb52..5565f6a8d 100644 --- a/app/App.zig +++ b/app/App.zig @@ -1013,7 +1013,7 @@ pub fn recordCaseClash(app: *App, id: []const u8, other: []const u8, path: ?[]co const why = app.arena.allocator().print("id '{s}' differs only in case from the plugin '{s}'", .{ id, other }) catch "id differs only in case from another plugin"; std.log.warn("plugin '{s}': {s}; not loaded", .{ id, why }); // The web's loader has no build on disk to read a version from. - const probe = if (comptime builtin.target.cpu.arch == .wasm32) null else if (path) |p| PluginLoader.probeVersionInfo(p) else null; + const probe = if (comptime builtin.target.cpu.arch == .wasm32) null else if (path) |p| PluginLoader.probeVersionInfo(app.gpa, p) else null; app.clearFailedUserPlugin(id); app.recordPluginFailure(id, why, null, if (probe) |info| info.plugin_version else null, if (path) |p| .of(p) else .{}); } @@ -1328,7 +1328,7 @@ pub fn reconcilePluginSettings(app: *App) void { /// (loaded / disabled / failed) rather than vanishing from the UI. pub fn recordLoadFailure(app: *App, id: []const u8, path: []const u8, err: PluginLoader.LoadError) void { const reason = App.pluginLoadFailureReason(err); - const probe = PluginLoader.probeVersionInfo(path); + const probe = PluginLoader.probeVersionInfo(app.gpa, path); const detail: ?[]const u8 = if (probe) |info| App.formatPluginProbeDetail(app.gpa, info) catch null else diff --git a/app/Recents.zig b/app/Recents.zig index 25268309d..e1e67a65a 100644 --- a/app/Recents.zig +++ b/app/Recents.zig @@ -247,7 +247,7 @@ test "a recents.zon from before times reads in order, every time unknown" { var dirs = try TestDirs.init(gpa); defer dirs.deinit(gpa); - const old = try gpa.print(".{{ .last_open_folder = \"\", .folders = .{{ \"{s}\", \"{s}\" }}, .commands = .{{}} }}", .{ dirs.a, dirs.b }); + const old = try gpa.print(".{{ .last_open_folder = \"\", .folders = .{{ \"{f}\", \"{f}\" }}, .commands = .{{}} }}", .{ std.zig.fmtString(dirs.a), std.zig.fmtString(dirs.b) }); defer gpa.free(old); try core.fs.write(dvui.io, dirs.zon, old); @@ -285,7 +285,7 @@ test "a time stays with its folder when a row before it is gone from disk" { var dirs = try TestDirs.init(gpa); defer dirs.deinit(gpa); - const zon = try gpa.print(".{{ .folders = .{{ \"{s}/gone\", \"{s}\" }}, .folders_opened = .{{ 100, 200 }} }}", .{ dirs.a, dirs.b }); + const zon = try gpa.print(".{{ .folders = .{{ \"{f}/gone\", \"{f}\" }}, .folders_opened = .{{ 100, 200 }} }}", .{ std.zig.fmtString(dirs.a), std.zig.fmtString(dirs.b) }); defer gpa.free(zon); try core.fs.write(dvui.io, dirs.zon, zon); diff --git a/app/Tracks.zig b/app/Tracks.zig index c07deca3c..997dfe5f9 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -548,8 +548,7 @@ fn keepLater(self: *Tracks, t: *Track) bool { return true; } -/// Write what `keepLater` left, here: for a read of a kept file (`pickUp`) or the app quitting, -/// neither of which can wait for the checkpoint. +/// Write what `keepLater` left, here: for the app quitting, which cannot wait for the checkpoint. fn flushPending(self: *Tracks) void { if (comptime !can_keep) return; // The checkpoint's thread may hold older copies of the same files. @@ -625,9 +624,18 @@ fn pickUp(self: *Tracks, t: *Track) bool { if (!keepable(path)) return false; const file = self.keptFile(self.gpa, path) orelse return false; defer self.gpa.free(file); - self.flushPending(); - const bytes = std.Io.Dir.cwd().readFileAlloc(dvui.io, file, self.gpa, .unlimited) catch return false; - defer self.gpa.free(bytes); + // A closed document's track still waiting for the checkpoint's thread is read where it is, not + // written first: a reopen never packs or writes on the frame. The last is the newest. + var waiting: ?[]const u8 = null; + for (self.pending.items) |k| { + if (std.mem.eql(u8, k.file, file)) waiting = k.bytes; + } + // One the thread already took is on its way to the file: wait it out, which is rare, so the + // read finds the newest history and not an absent or older file. + if (waiting == null) while (self.writing.load(.acquire) != 0) std.Thread.yield() catch {}; + const read_bytes = if (waiting) |_| null else std.Io.Dir.cwd().readFileAlloc(dvui.io, file, self.gpa, .unlimited) catch return false; + defer if (read_bytes) |b| self.gpa.free(b); + const bytes = waiting orelse read_bytes.?; const prof = core.profile.begin("fizzy", "tracks.pickUp"); defer prof.end(); var kept = track_file.read(self.gpa, bytes) catch return false; @@ -1389,10 +1397,14 @@ const KeptStand = struct { return true; } + // lint-allow(anyerror): the vtable's signature + fn restoreHistory(_: *anyopaque, _: sdk.DocHandle, _: sdk.Plugin.RestoredHistory) anyerror!void {} + const vtable: sdk.Plugin.VTable = v: { var v = Stand.vtable; v.documentPath = documentPath; v.isDirty = isDirty; + v.restoreHistory = restoreHistory; break :v v; }; var plugin: sdk.Plugin = .{ .state = &Stand.state, .vtable = &vtable, .id = "kept", .display_name = "Kept", .step_format = 1 }; @@ -1464,7 +1476,46 @@ test "a closed document's track waits unpacked for the checkpoint's thread" { try testing.expect(size < Stand.bytes.len / 2); } -test "a read of a kept file writes the closed documents' tracks first" { +test "reopening a document reads its closed track from memory and writes nothing" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var t = try KeptStand.tracks(&tmp); + defer t.deinitAll(); + t.closed(1); + try testing.expectEqual(@as(usize, 1), t.pending.items.len); + t.opened(.{ .ptr = &Stand.state, .owner = &KeptStand.plugin, .id = 1 }); + t.tick(); + // The track came back, and the frame neither wrote nor dropped the copy waiting for the thread. + try testing.expectEqual(@as(usize, 1), t.open.get(1).?.steps.items.len); + try testing.expectEqual(@as(usize, 1), t.pending.items.len); + try testing.expectEqual(@as(?u64, null), try KeptStand.keptFileSize(&t, &tmp)); +} + +test "reopening a document while the checkpoint's thread writes its track keeps the history" { + const Writer = struct { + fn run(t: *Tracks, k: KeptFile) void { + dvui.io.sleep(.fromMilliseconds(50), .awake) catch {}; + _ = writeKept(testing.allocator, k, "checkpoint"); + t.doneWriting(); + } + }; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var t = try KeptStand.tracks(&tmp); + defer t.deinitAll(); + t.closed(1); + const taken = t.takeChanged(false); + defer testing.allocator.free(taken); + defer taken[0].deinit(testing.allocator); + const thread = try std.Thread.spawn(.{}, Writer.run, .{ &t, taken[0] }); + defer thread.join(); + // Taken, and not yet renamed into place: a reopen now must not start a fresh track. + t.opened(.{ .ptr = &Stand.state, .owner = &KeptStand.plugin, .id = 1 }); + t.tick(); + try testing.expectEqual(@as(usize, 1), t.open.get(1).?.steps.items.len); +} + +test "the app quitting writes the closed documents' tracks" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var t = try KeptStand.tracks(&tmp); diff --git a/app/automation/overlay.zig b/app/automation/overlay.zig index 3c1c31779..5cdaab483 100644 --- a/app/automation/overlay.zig +++ b/app/automation/overlay.zig @@ -1145,14 +1145,21 @@ fn drawSeeking(player: *Player, win: dvui.Rect) void { done.fill(.all(r.h / 2), .{ .color = .{ .color = theme().color(.highlight, .fill) }, .fade = 1 }); } -test "a caption's card is made of the material tooltips and menus wear" { +test "a caption's card is made of the material tooltips, menus and popovers wear" { const fns = struct { var blur: f32 = 0; + var glass_ready = false; var tooltip: ?f32 = null; var caption: ?f32 = null; + var popover: ?f32 = null; + var tooltip_field: ?bool = null; + var caption_field: ?bool = null; + var popover_field: ?bool = null; var menu: ?core.widgets.BlurBackdrop.Pane = null; var dialog: ?core.widgets.FloatingWindowWidget.Frost = null; + var popover_rect: dvui.Rect = .{}; fn frame() !dvui.App.Result { + core.widgets.LiquidField.readyInTest(glass_ready); core.dialogs.publishStyle(.{ .blur = blur, .opacity = 0.5 }); menu = core.widgets.menuFrost(); dialog = core.dialogs.dialogFrost(); @@ -1161,6 +1168,7 @@ test "a caption's card is made of the material tooltips and menus wear" { defer box.deinit(); core.dialogs.tooltipSurface(box.data()); tooltip = radiusOf(box.data().id); + tooltip_field = fieldOf(box.data().id); dvui.labelNoFmt(@src(), "tooltip", .{}, .{}); } { @@ -1168,33 +1176,65 @@ test "a caption's card is made of the material tooltips and menus wear" { defer box.deinit(); glass(box.data(), .{ .x = 0.5, .y = 0.5 }, .{}); caption = radiusOf(box.data().id); + caption_field = fieldOf(box.data().id); dvui.labelNoFmt(@src(), "caption", .{}, .{}); } + { + var p = core.widgets.Popover.init(@src(), .{ .rect = &popover_rect, .anchor = .{ .x = 20, .y = 20 } }); + defer p.deinit(); + popover = radiusOf(p.win.data().id); + popover_field = fieldOf(p.win.data().id); + dvui.labelNoFmt(@src(), "popover", .{}, .{}); + } return .ok; } fn radiusOf(id: dvui.Id) ?f32 { const f = dvui.dataGetPtr(null, id, "_frost", core.widgets.BlurBackdrop) orelse return null; return f.radius_px; } + /// Whether the pane queued under `id` is the glass program's (a field) rather than the + /// rounded rect's; null when none was queued. + fn fieldOf(id: dvui.Id) ?bool { + const job = dvui.dataGetPtr(null, id, "_frost_job", core.widgets.BlurBackdrop.FrostJob) orelse return null; + if (job.rect.w <= 0) return null; + return job.field != null; + } /// A window of its own each, so no frost is left over from the other's frame. - fn drawnAt(b: f32) !void { + fn drawnAt(b: f32, ready: bool) !void { var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); defer t.deinit(); blur = b; - _ = try dvui.testing.step(frame); + glass_ready = ready; + popover_rect = .{}; + // Frames, for the popover to open out of nothing. + for (0..4) |_| _ = try dvui.testing.step(frame); } }; - // Blur on: both are the dialog frost, at the style's radius. - try fns.drawnAt(20); + // Blur on: all three are the dialog frost, at the style's radius. + try fns.drawnAt(20, false); try std.testing.expectEqual(@as(?f32, 20), fns.tooltip); try std.testing.expectEqual(fns.tooltip, fns.caption); + // A popover wears the same frost (its window forms the radius its own way). + try std.testing.expect(fns.popover != null); // The menus ask for the same frost from the same place. try std.testing.expectEqual(fns.dialog.?.radius, fns.menu.?.radius); try std.testing.expectEqual(fns.dialog.?.mix, fns.menu.?.mix); try std.testing.expectEqual(fns.dialog.?.tint, fns.menu.?.tint); - // Blur off (and no glass program in the testing backend): a plain fill for both, no frost. - try fns.drawnAt(0); + // Blur off and no glass program: a plain fill for all, no frost. + try fns.drawnAt(0, false); try std.testing.expectEqual(@as(?f32, null), fns.tooltip); try std.testing.expectEqual(@as(?f32, null), fns.caption); + try std.testing.expectEqual(@as(?f32, null), fns.popover); try std.testing.expect(fns.menu == null); + // The glass program ready (blur on or off): every one is the rounded rect's frost the dialog + // style asks for. A caption that took its own Liquid Glass path (as it did before) queues + // the program's field here while the rest do not. + for ([_]f32{ 20, 0 }) |b| { + try fns.drawnAt(b, true); + try std.testing.expect(fns.menu != null); + try std.testing.expectEqual(@as(?bool, false), fns.tooltip_field); + try std.testing.expectEqual(@as(?bool, false), fns.caption_field); + try std.testing.expectEqual(@as(?bool, false), fns.popover_field); + } + try std.testing.expect(fns.menu.?.clear); } diff --git a/app/control/Server.zig b/app/control/Server.zig index 5c66c964e..f0bb42c21 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -684,18 +684,25 @@ fn noWake() void {} const Watchdog = struct { const limit_ms = 60_000; + gpa: std.mem.Allocator, io: Io, name: []const u8, done: Io.Event = .unset, - thread: ?std.Thread = null, + thread: std.Thread = undefined, - fn arm(self: *Watchdog) !void { + /// Starts a watch on the test `name` (`@src().fn_name`); the test ends it with `stop`. + fn start(gpa: std.mem.Allocator, io: Io, name: []const u8) !*Watchdog { + const self = try gpa.create(Watchdog); + errdefer gpa.destroy(self); + self.* = .{ .io = io, .name = name, .gpa = gpa }; self.thread = try std.Thread.spawn(.{}, watch, .{self}); + return self; } - fn disarm(self: *Watchdog) void { + fn stop(self: *Watchdog) void { self.done.set(self.io); - if (self.thread) |t| t.join(); + self.thread.join(); + self.gpa.destroy(self); } fn watch(self: *Watchdog) void { @@ -704,7 +711,8 @@ const Watchdog = struct { std.debug.print("\nfizzy-control-server-tests: {s} still running after {d} s: a client or the server is blocked; aborting\n", .{ self.name, limit_ms / 1000 }); std.process.abort(); }, - else => {}, + // Nothing cancels this thread; a cancellation would mean the test is already ending. + error.Canceled => {}, }; } }; @@ -754,16 +762,14 @@ test "a request reaches the UI thread and its answer goes back" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); - defer gpa.free(dir); // In a folder of its own that `start` makes, as the app's is (`control/control.sock`). - const path = try std.fs.path.join(gpa, &.{ dir, "control", "c.sock" }); + const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -808,23 +814,37 @@ test "a request reaches the UI thread and its answer goes back" { try std.testing.expectEqual(@as(std.posix.mode_t, 0o700), folder.permissions.toMode() & 0o777); } +/// A socket path for a test, in a folder of its own that `start` makes and `removeTestSocket` +/// removes. Short: the tmp dir lives under the build's cache, which a deep checkout (a worker's +/// sibling workspace) pushes past what the OS takes (`max_path`). Named by the tmp dir's random +/// name so tests do not meet. fn testSocket(gpa: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { - const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); - defer gpa.free(dir); - return std.fs.path.join(gpa, &.{ dir, "c.sock" }); + if (builtin.target.os.tag == .windows) { + const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); + defer gpa.free(dir); + return std.fs.path.join(gpa, &.{ dir, "control", "c.sock" }); + } + // `/tmp` itself is a link on macOS, which `start` refuses as a folder. + const root = if (builtin.target.os.tag.isDarwin()) "/private/tmp" else "/tmp"; + return std.fmt.allocPrint(gpa, "{s}/fz-{s}/c.sock", .{ root, tmp.sub_path }); +} + +fn removeTestSocket(path: []const u8) void { + if (builtin.target.os.tag == .windows) return; + Io.Dir.cwd().deleteTree(std.testing.io, std.fs.path.dirname(path).?) catch {}; } test "a request still queued at quit is answered: the server is stopping" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -850,13 +870,13 @@ test "what cannot be served is refused before its body, and never reaches the ap if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -915,13 +935,13 @@ test "a server that took over the path keeps it when the old one stops" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); // A restart's handover: the new fizzy listens on the profile's socket while the old runs. var old: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; @@ -951,13 +971,13 @@ test "a stream gets the events it asked for, once each, until the server stops" if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -1052,13 +1072,13 @@ test "a client that stops reading neither holds up the quit nor buffers without if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -1096,13 +1116,13 @@ test "a stopped server ends a stream whose client stopped reading" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -1127,13 +1147,13 @@ test "an event published as the stream is answered is not lost" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -1160,13 +1180,13 @@ test "a stopped server ends a keep-alive connection waiting for its next request if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); @@ -1268,13 +1288,13 @@ test "an upgraded connection carries bytes both ways until the app closes it" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); defer server.stop(); @@ -1315,13 +1335,13 @@ test "a client that leaves an upgraded connection finishes its link" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; - var dog: Watchdog = .{ .io = io, .name = @src().fn_name }; - try dog.arm(); - defer dog.disarm(); + const dog = try Watchdog.start(gpa, io, @src().fn_name); + defer dog.stop(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const path = try testSocket(gpa, &tmp); defer gpa.free(path); + defer removeTestSocket(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); defer server.stop(); diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 57f1c179e..88e8d90b1 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -753,7 +753,7 @@ fn drawLifted(self: *Picker, f: *Layout) void { .release => if (me.button.pointer()) { e.handled = true; ViewDrag.apply(f, ViewDrag.loose_source, me.p); - d.discard(); + d.discard(f.gpa); self.lifted = false; dvui.captureMouse(null, e.num); dvui.dragEnd(); diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 3f17d5c57..bbe5bf638 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -931,7 +931,7 @@ fn cornerButton(self: *Layout, opts: InitOptions, keywords: []const []const u8, ViewDrag.apply(self, opts.name, me.p); dragged = true; } - self.state.view_drag.discard(); + self.state.view_drag.discard(self.gpa); dvui.dataRemove(null, box.data().id, "_chooser_press"); dvui.captureMouse(null, e.num); dvui.dragEnd(); @@ -1453,13 +1453,19 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B const room = alongSize(saved.dir, dims); if (seen == null or seen.? != extent) { dvui.dataSet(null, id, "_seen", extent); - if (room > 0) dvui.dataSet(null, id, "_share", std.math.clamp(extent / room, 0, 1)); + // The first sight of a half that was saved keeps its saved share: its extent is + // along the axis it was left on, which this box may not have. + if (seen == null and saved.share != null) { + setShare(self, saved.created, id, saved.share.?); + } else if (room > 0) { + setShare(self, saved.created, id, std.math.clamp(extent / room, 0, 1)); + } } } else if (chosen) |extent| { // Dragged since the branch set it: the drag is the new share. const set = dvui.dataGet(null, id, "_set", f32) orelse extent; const room = alongSize(drawn.dir, dims); - if (set != extent and room > 0) dvui.dataSet(null, id, "_share", std.math.clamp(extent / room, 0, 1)); + if (set != extent and room > 0) setShare(self, saved.created, id, std.math.clamp(extent / room, 0, 1)); } const share = dvui.dataGet(null, id, "_share", f32) orelse return drawn; @@ -1503,6 +1509,13 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B return drawn; } +/// The share an adaptive split's new half has now: drawn through the turn (`_share`), and saved +/// with the split. +fn setShare(self: *Layout, created: []const u8, id: dvui.Id, share: f32) void { + dvui.dataSet(null, id, "_share", share); + if (self.state.splits.setShare(created, share)) self.extents_changed = true; +} + fn alongSize(dir: dvui.enums.Direction, size: dvui.Size) f32 { return switch (dir) { .horizontal => size.w, @@ -1701,10 +1714,14 @@ fn drawTreeNode( dvui.dataSet(null, id, "_size", default); } const chosen = dvui.dataGet(null, id, "_size", f32) orelse default; - const shown = Split.eased(id, chosen); + // Folds and writes like a leaf (`orientBranch`): the half of an outer turn + // slides shut, and while it is drawn turned its extent is not the person's. + const folding = dvui.dataGet(null, id, "_fold", bool) orelse false; + const turned = dvui.dataGet(null, id, "_turned", bool) orelse false; + const shown = Split.eased(id, if (folding) 0 else chosen); dvui.dataSet(null, id, "_size", chosen); // Forgotten, its nodes are freed with it: nothing more of it to draw this frame. - if (persistExtent(self, .{ .name = b.origin, .forget_when_empty = true }, id, chosen, shown)) return; + if (!turned and persistExtent(self, .{ .name = b.origin, .forget_when_empty = true }, id, chosen, shown)) return; box_opts.min_size_content = switch (parent_dir) { .horizontal => .{ .w = shown }, .vertical => .{ .h = shown }, diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index c9b7e5005..78fa33e27 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -56,6 +56,10 @@ pub const Branch = struct { /// Fizzy made this split, not the person: it runs the way its space's shape asks and turns /// with it (`turn`). `dir` and `side` are how it runs now. adaptive: bool = false, + /// An adaptive split's new half as a share of its box along the axis, 0 to 1. Saved because + /// the half's pixel extent is along the axis it was last chosen on, which a box of another + /// shape (a restart in portrait) no longer has. Null until the person has sized it. + share: ?f32 = null, a: *Node, b: *Node, }; @@ -210,6 +214,7 @@ pub const Forest = struct { parent: []const u8, side: Side, adaptive: bool = false, + share: ?f32 = null, }; /// The split that minted `created` is fizzy's own (`Branch.adaptive`). False when no split did. @@ -219,6 +224,27 @@ pub const Forest = struct { return false; } + /// Record the share of the adaptive split that minted `created`. True when it changed. + pub fn setShare(self: *Forest, created: []const u8, share: f32) bool { + var it = self.roots.valueIterator(); + while (it.next()) |n| if (shareIn(n.*, created, share)) |changed| return changed; + return false; + } + + fn shareIn(node: *Node, created: []const u8, share: f32) ?bool { + switch (node.kind) { + .leaf => return null, + .branch => |*b| { + if (std.mem.eql(u8, b.created, created)) { + if (b.share == share) return false; + b.share = share; + return true; + } + return shareIn(b.a, created, share) orelse shareIn(b.b, created, share); + }, + } + } + fn markIn(node: *Node, created: []const u8) bool { switch (node.kind) { .leaf => return false, @@ -246,7 +272,7 @@ pub const Forest = struct { switch (node.kind) { .leaf => {}, .branch => |b| { - out.append(gpa, .{ .name = b.created, .parent = b.origin, .side = b.side, .adaptive = b.adaptive }) catch {}; + out.append(gpa, .{ .name = b.created, .parent = b.origin, .side = b.side, .adaptive = b.adaptive, .share = b.share }) catch {}; collectFrom(b.a, gpa, out); collectFrom(b.b, gpa, out); }, diff --git a/app/layout/State.zig b/app/layout/State.zig index e1e047fc3..243029465 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -869,7 +869,7 @@ pub fn clearArrangement(self: *State, gpa: std.mem.Allocator) void { self.splits.deinit(gpa); self.splits = .{}; self.slide_open = ""; - self.view_drag.discard(); + self.view_drag.discard(gpa); self.discardSwaps(); self.center_transition.discard(); self.center_prev_id = null; diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index ca9a078c8..73e449dc8 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -58,7 +58,8 @@ texture_rect: dvui.Rect.Physical = .{}, photo_box: dvui.Rect = .{ .x = 0, .y = 0, .w = 1, .h = 1 }, /// Its pixels, premultiplied RGBA rows of `photo_size`, kept where the app shows the picture under /// the OS's glass (`core.dialogs.carry_windows`) — handed over once, as an image (`photo_gen` new -/// with each picture) — and freed with the drag (`discard`). Null elsewhere. +/// with each picture) — and freed with the drag (`discard`), both given the one allocator the layout +/// owns it with. Null elsewhere. photo_pixels: ?[]dvui.Color.PMA = null, photo_size: [2]u32 = .{ 0, 0 }, photo_gen: u32 = 0, @@ -482,11 +483,11 @@ fn takes(d: *const ViewDrag, o: Offer) bool { return false; } -pub fn discard(self: *ViewDrag) void { +pub fn discard(self: *ViewDrag, gpa: std.mem.Allocator) void { // Destroyed with a frame, and only in one: discarded at teardown (quitting mid-drag) there is // no window, and the GPU goes with the process. if (self.texture) |tex| if (dvui.current_window != null) dvui.Texture.destroyLater(tex); - self.forgetPixels(); + self.forgetPixels(gpa); // The drops still showing outlive the drag: they run back together where they were drawn // (`drawOverlay`), whatever the drop has just done to their places. const finishing = self.last_pending; @@ -496,10 +497,10 @@ pub fn discard(self: *ViewDrag) void { self.last_pending_count = finishing_count; } -pub fn takePicture(self: *ViewDrag, pic: *dvui.Picture) void { +pub fn takePicture(self: *ViewDrag, gpa: std.mem.Allocator, pic: *dvui.Picture) void { pic.stop(); self.photo_box = .{ .x = 0, .y = 0, .w = 1, .h = 1 }; - self.forgetPixels(); + self.forgetPixels(gpa); if (dvui.Texture.readTarget(dvui.currentWindow().arena(), pic.texture)) |px| { const w = pic.texture.width; const h = pic.texture.height; @@ -507,7 +508,7 @@ pub fn takePicture(self: *ViewDrag, pic: *dvui.Picture) void { // What the drop shows of it, closer where it is too wide to read shrunk into the drop. self.photo_box = focusBox(dvui.currentWindow().arena(), px, w, h, self.photo_box, 2 * drop_r * dvui.currentWindow().natural_scale * std.math.sqrt1_2); if (core.dialogs.carry_windows and px.len >= @as(usize, w) * h) { - if (std.heap.page_allocator.dupe(dvui.Color.PMA, px[0 .. @as(usize, w) * h])) |kept| { + if (gpa.dupe(dvui.Color.PMA, px[0 .. @as(usize, w) * h])) |kept| { self.photo_pixels = kept; self.photo_size = .{ w, h }; self.photo_gen +%= 1; @@ -521,8 +522,8 @@ pub fn takePicture(self: *ViewDrag, pic: *dvui.Picture) void { self.texture_rect = pic.r; } -fn forgetPixels(self: *ViewDrag) void { - if (self.photo_pixels) |px| std.heap.page_allocator.free(px); +fn forgetPixels(self: *ViewDrag, gpa: std.mem.Allocator) void { + if (self.photo_pixels) |px| gpa.free(px); self.photo_pixels = null; } @@ -678,7 +679,7 @@ pub fn shotWanted(l: *Layout, is_source: bool) Shot { pub fn keepShot(l: *Layout, shot: Shot, pic: *dvui.Picture) void { const d = &l.state.view_drag; if (shot.card) { - d.takePicture(pic); + d.takePicture(l.gpa, pic); if (d.texture) |tex| { // Over what the frame already has under the place, as its draw would have gone: the // capture is clear where the place draws nothing — the window's base shows there — and diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index 09add5938..3361358be 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -179,13 +179,13 @@ fn lockOpenedPaths() void { /// once per plugin (~480ms for three plugins); loading the stable installed file in place pays /// it once, on the launch right after an install. /// -/// Entries (and their duped keys) are intentionally kept for the process lifetime — the set is +/// Entries (and their duped keys) are kept until the app quits (`deinitOpenedPaths`) — the set is /// bounded by the number of distinct plugin paths, and its whole purpose is to outlive /// individual load attempts. -fn markPathOpened(path: []const u8) bool { - // Its own allocator, not a caller's: the set outlives every individual load attempt (and - // the probe helpers that also mark paths have no allocator to hand in). - const gpa = std.heap.page_allocator; +/// +/// `gpa` owns the set's keys for that lifetime, so it is the application's long-lived allocator, +/// never an arena or a per-call one. +fn markPathOpened(gpa: std.mem.Allocator, path: []const u8) bool { lockOpenedPaths(); defer opened_paths_lock.unlock(); if (opened_paths.contains(path)) return true; @@ -197,6 +197,17 @@ fn markPathOpened(path: []const u8) bool { return false; } +/// Frees the opened-path set. At shutdown, with the allocator every caller of `markPathOpened` +/// passed. +pub fn deinitOpenedPaths(gpa: std.mem.Allocator) void { + lockOpenedPaths(); + defer opened_paths_lock.unlock(); + var keys = opened_paths.keyIterator(); + while (keys.next()) |key| gpa.free(key.*); + opened_paths.deinit(gpa); + opened_paths = .empty; +} + /// `{dirname(path)}/.load-tmp` — where fresh-copy load attempts land, beside the plugin itself. fn loadTempDir(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { const dir = std.fs.path.dirname(path) orelse "."; @@ -376,7 +387,7 @@ pub fn open(allocator: std.mem.Allocator, path: []const u8, expected_id: []const // re-uses across launches (so this costs nothing on a normal launch). // * re-open of a path this process already opened → a unique throwaway copy, the only // case where the loader's stale-image behavior is actually in play. - const reopen = markPathOpened(path); + const reopen = markPathOpened(allocator, path); const src_stat: ?std.Io.File.Stat = std.Io.Dir.cwd().statFile(dvui.io, path, .{}) catch null; const load_path: []const u8 = if (reopen) copyToFreshLoadPath(allocator, path) catch return error.DylibOpenFailed @@ -524,10 +535,11 @@ fn allowAbiWarn() bool { /// Best-effort read of a plugin's user-facing display name straight from the dylib, **without /// registering it**. Prefers the embedded `plugin.zig.zon` (`fizzy_plugin_manifest_zon`); falls /// back to the `fizzy_plugin_name` export. Caller owns the returned slice. +/// `allocator` also keeps the opened-path set (`markPathOpened`), so it is the long-lived one. pub fn probeName(allocator: std.mem.Allocator, path: []const u8) ?[]u8 { // Counts as an open for stale-image purposes: after this, a load of the same path must go // through `copyToFreshLoadPath` even if it is the first *load*. - _ = markPathOpened(path); + _ = markPathOpened(allocator, path); var lib = DynLib.open(path) catch return null; defer lib.close(); if (lib.lookup(dylib_api.GetManifestZonFn, dylib_api.symbol_manifest_zon)) |get_zon| { @@ -566,7 +578,7 @@ pub const ProbedManifest = struct { /// dylib or its manifest couldn't be read at all, which callers can distinguish from a /// successfully-read manifest whose fields happen to be empty. pub fn probeManifestInfo(allocator: std.mem.Allocator, path: []const u8) ?ProbedManifest { - _ = markPathOpened(path); // see `probeName` + _ = markPathOpened(allocator, path); // see `probeName` var lib = DynLib.open(path) catch return null; defer lib.close(); const get_zon = lib.lookup(dylib_api.GetManifestZonFn, dylib_api.symbol_manifest_zon) orelse return null; @@ -609,18 +621,18 @@ fn dupeTags(allocator: std.mem.Allocator, tags: []const []const u8) ![][]u8 { /// Best-effort read of version exports from a dylib (for failure diagnostics). /// Prefers semver from the embedded manifest zon when present. -pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo { - _ = markPathOpened(path); // see `probeName` +pub fn probeVersionInfo(gpa: std.mem.Allocator, path: []const u8) ?PluginVersionInfo { + _ = markPathOpened(gpa, path); // see `probeName` var lib = DynLib.open(path) catch return null; defer lib.close(); var plugin_version = readVersionTriplet(lookupVersionFn(&lib, dylib_api.symbol_plugin_version)); if (lib.lookup(dylib_api.GetManifestZonFn, dylib_api.symbol_manifest_zon)) |get_zon| { const zon = std.mem.span(get_zon()); if (zon.len > 0) { - // A short-lived Manifest, in an arena over the page allocator. - var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + // A short-lived Manifest, in an arena over `gpa`. + var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); - if (sdk.Manifest.parse(std.heap.page_allocator, arena.allocator(), zon)) |parsed| { + if (sdk.Manifest.parse(gpa, arena.allocator(), zon)) |parsed| { if (std.SemanticVersion.parse(parsed.version)) |sv| { plugin_version = .{ .major = @intCast(sv.major), .minor = @intCast(sv.minor), .patch = @intCast(sv.patch) }; } else |_| {} @@ -635,8 +647,12 @@ pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo { } test "builtin plugin path joins exe_dir/plugins//." { - const path = try builtinPluginPath(std.testing.allocator, "/app", "pixi"); + const joined = try builtinPluginPath(std.testing.allocator, "/app", "pixi"); + defer std.testing.allocator.free(joined); + // `join` keeps `exe_dir` as given and separates the rest with the native separator. + const path = try std.testing.allocator.dupe(u8, joined); defer std.testing.allocator.free(path); + std.mem.replaceScalar(u8, path, std.fs.path.sep, '/'); switch (builtin.target.os.tag) { .windows => try std.testing.expectEqualStrings("/app/plugins/pixi/pixi.dll", path), .macos => try std.testing.expectEqualStrings("/app/plugins/pixi/pixi.dylib", path), diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index f81f7ffe3..8b263b637 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -1,6 +1,6 @@ //! Runtime plugin loading on the web: a plugin built as a wasm *side module* (see //! `sdk/plugin_sdk.zig`'s `create` for wasm32 and `plans/REVIEW_2026-09.md` §2), fetched and -//! linked into the page by `web/index.html`'s `loadPlugin`, which hands back one function-table +//! linked into the page by `web/fizzy.js`'s `loadPlugin`, which hands back one function-table //! index per entry point. On wasm32 a function pointer *is* a table index, so from there this //! runs the same sequence the desktop loader runs (`PluginLoader.loadAndRegister`): fingerprint, //! versions, id, inject the host's globals, `fizzy_plugin_register`. @@ -62,7 +62,7 @@ pub const PreRegister = struct { arg_c: ?*anyopaque = null, }; -/// Entry points by name → table index, in the order `web/index.html`'s `pluginEntryPoints` +/// Entry points by name → table index, in the order `web/fizzy.js`'s `pluginEntryPoints` /// lists them (the same order as `plugin_sdk.dylib_exports`). pub const WebDynLib = struct { indices: [entry_names.len]u32, diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index afa05f0c4..593e2f959 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -111,7 +111,7 @@ const Job = struct { /// `std.Thread`. The worker writes `err_buf`/`err_len`/`status` back into the `Job`, so the /// `Job` must not be freed while it is alive — and a detached thread gave `freeJob` no way to /// know. Nothing in the native download path sets a network timeout (the web's fetches - /// have one, `web/index.html`'s `fetchPluginBytes`), so a stalled or + /// have one, `web/fizzy.js`'s `fetchPluginBytes`), so a stalled or /// blackholed release host keeps that worker running arbitrarily long: quitting mid-download /// left a live thread writing into freed memory. `Group.cancel` both interrupts the blocked /// syscall (the worker returns `error.Canceled`) *and* awaits the task, so `freeJob` is @@ -424,7 +424,7 @@ fn probeOnDiskInfo() void { } } if (!have_version) { - if (PluginLoader.probeVersionInfo(path)) |info| { + if (PluginLoader.probeVersionInfo(a, path)) |info| { rememberVersion(id, info.plugin_version); } } diff --git a/app/update/bounded_join.zig b/app/update/bounded_join.zig new file mode 100644 index 000000000..eeb91e5df --- /dev/null +++ b/app/update/bounded_join.zig @@ -0,0 +1,51 @@ +//! Waiting for a thread at shutdown for only so long. std-only, so its test needs no window. + +const std = @import("std"); + +/// Waits until `done` is set, up to `bound_ms`, then joins `thread`; past the bound, detaches it +/// instead and returns false. The caller owns what the thread still touches: it must outlive +/// the process or the thread must touch nothing torn down meanwhile. +pub fn joinWithin(io: std.Io, thread: std.Thread, done: *const std.atomic.Value(bool), bound_ms: u32) bool { + var waited_ms: u32 = 0; + while (!done.load(.acquire)) : (waited_ms += poll_ms) { + if (waited_ms >= bound_ms) { + thread.detach(); + return false; + } + io.sleep(.fromMilliseconds(poll_ms), .awake) catch {}; + } + thread.join(); + return true; +} + +const poll_ms = 5; + +test "a stalled thread is detached once the bound passes" { + const Stall = struct { + var release: std.atomic.Value(bool) = .init(false); + var done: std.atomic.Value(bool) = .init(false); + fn run(io: std.Io) void { + while (!release.load(.acquire)) io.sleep(.fromMilliseconds(1), .awake) catch {}; + done.store(true, .release); + } + }; + const io = std.testing.io; + const thread = try std.Thread.spawn(.{}, Stall.run, .{io}); + const start = std.Io.Clock.awake.now(io); + try std.testing.expect(!joinWithin(io, thread, &Stall.done, 50)); + const elapsed = start.durationTo(std.Io.Clock.awake.now(io)); + try std.testing.expect(elapsed.toMilliseconds() < 1000); + Stall.release.store(true, .release); + while (!Stall.done.load(.acquire)) io.sleep(.fromMilliseconds(1), .awake) catch {}; +} + +test "a finished thread is joined" { + const Quick = struct { + var done: std.atomic.Value(bool) = .init(false); + fn run() void { + done.store(true, .release); + } + }; + const thread = try std.Thread.spawn(.{}, Quick.run, .{}); + try std.testing.expect(joinWithin(std.testing.io, thread, &Quick.done, 1000)); +} diff --git a/app/update/update_notify.zig b/app/update/update_notify.zig index cf359d425..ec1fe1656 100644 --- a/app/update/update_notify.zig +++ b/app/update/update_notify.zig @@ -9,6 +9,7 @@ const core = @import("core"); const auto_update = @import("auto_update.zig"); const update_install = @import("update_install.zig"); const move_offer = @import("move_offer.zig"); +const bounded_join = @import("bounded_join.zig"); const restart = @import("../restart.zig"); const Phase = enum(u8) { @@ -94,16 +95,36 @@ fn startCheck() void { return; } - const thread = std.Thread.spawn(.{}, worker, .{io}) catch { + // The worker may outlive `deinit` (see there), so it cannot borrow the app's allocator. + check_thread = std.Thread.spawn(.{}, worker, .{ io, std.heap.page_allocator }) catch { phase.store(@backingInt(Phase.done_no), .release); return; }; - thread.detach(); } -fn worker(io: std.Io) void { +/// The launch check's thread, joined by `deinit`. Written on the GUI thread only. Builds without +/// an updater (the web has no threads) never name `std.Thread`. +var check_thread: if (auto_update.impl) ?std.Thread else void = if (auto_update.impl) null else {}; + +/// Set by the check's thread as its last act. +var check_done: std.atomic.Value(bool) = .init(false); + +/// How long quit waits on a check still in flight: Velopack's request blocks in C and cannot be +/// cancelled, so past this the thread is detached and the process exit ends it. +const quit_wait_ms = 250; + +/// Waits for the launch check, up to `quit_wait_ms`. The check's thread runs on its own allocator, +/// so the one given to `setAllocator` may be torn down after this returns. +pub fn deinit(io: std.Io) void { + if (comptime !auto_update.impl) return; + if (check_thread) |thread| _ = bounded_join.joinWithin(io, thread, &check_done, quit_wait_ms); + check_thread = null; +} + +fn worker(io: std.Io, gpa: std.mem.Allocator) void { + defer check_done.store(true, .release); var ver_buf: [128]u8 = undefined; - const summary = auto_update.checkRemoteVersionSummary(io, std.heap.page_allocator, &ver_buf) catch { + const summary = auto_update.checkRemoteVersionSummary(io, gpa, &ver_buf) catch { phase.store(@backingInt(Phase.done_no), .release); return; }; diff --git a/backend/src/glass/BlurBackdrop.zig b/backend/src/glass/BlurBackdrop.zig index 595ce6e9e..dd0da4114 100644 --- a/backend/src/glass/BlurBackdrop.zig +++ b/backend/src/glass/BlurBackdrop.zig @@ -1201,10 +1201,10 @@ test "marks past the span's room widen its last rect" { try std.testing.expectEqual(@as(u8, 8), span.len); try std.testing.expect(span.hits(.{ .x = 905, .y = 0, .w = 2, .h = 2 })); } - -/// What `frostPane` hands to the replay. Lives in the data store under the owner's id, so the -/// pointer is good until the frame ends. -const FrostJob = struct { +/// What `frostPane` hands to the replay. Lives in the data store under the owner's id (`_frost_job`), +/// so the pointer is good until the frame ends. `pub` for the tests that check which glass a +/// surface asked for: `field` set is the program's, null the rounded rect's. +pub const FrostJob = struct { backdrop: *BlurBackdrop = undefined, corners: dvui.CornerRect = .{}, scale: f32 = 1, diff --git a/backend/src/glass/LiquidField.zig b/backend/src/glass/LiquidField.zig index 145c4fd41..42b0790b4 100644 --- a/backend/src/glass/LiquidField.zig +++ b/backend/src/glass/LiquidField.zig @@ -26,6 +26,7 @@ //! Layers do not join each other: a dialog over a drop is two layers, the dialog's capture taken //! with the drop already on the frame. const std = @import("std"); +const builtin = @import("builtin"); const dvui = @import("dvui"); const programs = @import("programs.zig"); const liquid_glass = @import("liquid_glass.zig"); @@ -371,8 +372,19 @@ fn applyLook(self: *const LiquidField, u: *Uniforms, l: glass_look.InApp) void { if (self.edge != null) u.shapes[max_shapes - 1][2][0] *= l.frost; } +/// Test seam: the testing backend has no glass program, so `ready` is false there and every +/// branch that turns on it goes unexercised. This makes `ready` answer `on` for the current +/// window (the flag lives in its data, so each test window starts unset). Draws nothing. +pub fn readyInTest(on: bool) void { + if (!builtin.is_test) @compileError("LiquidField.readyInTest is for tests"); + dvui.dataSet(null, enabled_id, "_ready_in_test", on); +} + /// Whether `draw` would draw now: programs here, switched on, compiled. pub fn ready() bool { + if (builtin.is_test) { + if (dvui.dataGet(null, enabled_id, "_ready_in_test", bool)) |on| return on; + } if (!enabled()) return false; const h = programs.hooks() orelse return false; return program.ready(h) != null; diff --git a/build.zig.zon b/build.zig.zon index 61e0a3ac4..5132dc046 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,6 +49,12 @@ .url = "git+https://codeberg.org/ziglang/translate-c?ref=zig-0.17.x#02ff0c523fb92a38939b04d0145c4a9602a74d56", .hash = "translate_c-2.0.0-Q_BUWltOBwBYABF84EUL208pIFeUIwbBs29FmVBaafN5", }, + // Applies `web/patches/dvui` to dvui for the web build's `web.js`: docs/DEPENDENCIES.md. + // The pin `backend/build.zig.zon` makes for SDL's; keep them the same. + .patch = .{ + .url = "https://github.com/marler8997/zig-patch/archive/c3ad78b305ba03bcd645c0bac004d1719407c6b8.tar.gz", + .hash = "patch-0.0.0-WCKjsxixAADfwXQv4Vugyxo6PFbDFuuyM7ly3OiqZRYR", + }, .dvui_singleton_app = .{ .path = "libs/dvui-singleton-app", }, diff --git a/build/app.zig b/build/app.zig index 814a182ab..34cf3b592 100644 --- a/build/app.zig +++ b/build/app.zig @@ -641,8 +641,7 @@ pub fn construct( , .{cfg.branding.pixel_art})), }); - // `zig build web` produces `zig-out/web/{web.wasm, web.js, index.html, - // NotoSansKR-Regular.ttf}`, + // `zig build web` produces `zig-out/web/{web.wasm, fizzy.js, web.js, index.html, …}`, // deployable as-is to a static host. const w = web.build( b, @@ -1077,6 +1076,8 @@ fn addTests( // /Applications, and what happens to the copy it leaves. std-only (see // install_location.zig); `macos_bundle.zig` asks the system and acts on it. .{ "fizzy-install-location-tests", "app/update/install_location.zig" }, + // Quit's bounded wait on the launch update check's thread. std-only (see bounded_join.zig). + .{ "fizzy-bounded-join-tests", "app/update/bounded_join.zig" }, // The command palette's recently used commands: order, re-use, the cap. std-only // (see RecentCommands.zig); `Recents` holds it and stores it in recents.zon. .{ "fizzy-recent-commands-tests", "app/RecentCommands.zig" }, diff --git a/build/web.zig b/build/web.zig index 01ac9c1ef..542dc447a 100644 --- a/build/web.zig +++ b/build/web.zig @@ -11,11 +11,11 @@ pub const Web = struct { wasm: *std.Build.Step.Compile, /// The whole site, deployable as it is: `files` and `page`. site: std.Build.LazyPath, - /// Everything but the page: the wasm, `web.js`, the worker, the font, the OAuth page, the + /// Everything but the page: the wasm, `fizzy.js`, `web.js`, the worker, the font, the OAuth page, the /// hosted demo tapes under `demos/` (fizzy's own build) and each plugin's pages under /// `plugins//`. files: std.Build.LazyPath, - /// `index.html`, stamped with the wasm's hash and the storage name. + /// `index.html`, stamped with `fizzy.js`'s hash. page: std.Build.LazyPath, /// Fizzy's own build: a native-built plugin graph re-homed to wasm32 and compiled apart from /// the app, for `check-web`. @@ -49,8 +49,8 @@ pub const Framework = struct { pub fn framework(b: *std.Build, optimize: std.lang.OptimizeMode) Framework { const web_target = target(b); - // The web backend is fizzy's own (`src/backend/WebBackend.zig` + `web/web.js`), started as a - // copy of dvui's: dvui is built in its `custom` mode and our backend linked to it, as dvui's + // The web backend is fizzy's own (`src/backend/WebBackend.zig` + dvui's `web.js`, patched: + // `patchedWebJs`), started as a copy of dvui's: dvui is built in its `custom` mode and our backend linked to it, as dvui's // build does for its own (`linkBackend`). Every option is the one dvui's `web` mode sets, so // dvui's shape — and with it the plugin fingerprint — is exactly what it was. const dvui_web_dep = sdk.dvuiDependency(b, .{ @@ -72,7 +72,7 @@ pub fn framework(b: *std.Build, optimize: std.lang.OptimizeMode) Framework { .optimize = optimize, .single_threaded = true, }); - // What the page calls into (`web/web.js`): dvui's web exports. + // What the page calls into (`web.js`): dvui's web exports. web_backend.export_symbol_names = &.{ "dvui_init", "dvui_deinit", @@ -162,7 +162,7 @@ pub fn build( // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its // own — without this line our trackpad pinch entry point would compile cleanly but - // be missing from `instance.exports`, and the JS bootstrap in `web/index.html` + // be missing from `instance.exports`, and the JS bootstrap in `web/fizzy.js` // would never be able to forward pinch deltas into the canvas widget. The image // trio is the other half of that seam: JS rasterizes a remote URL and writes the // pixels back through these. @@ -183,7 +183,7 @@ pub fn build( "FizzyWebOAuthAlloc", "FizzyWebOAuthResult", "FizzyWebOAuthFailed", - // Plugins loaded at runtime (wasm side modules — `web/index.html`'s `loadPlugin`): + // Plugins loaded at runtime (wasm side modules — `web/fizzy.js`'s `loadPlugin`): // the host's side of the handshake, and what a side module links against — the // stack pointer it shares, dvui's C shims and stb, which it imports from `env`. "FizzyWebPluginAlloc", @@ -352,7 +352,7 @@ pub fn build( web_exe.root_module, ); - // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so + // Cache-buster: stamps a 64-char hash into the index.html / fizzy.js placeholders so // the browser picks up new wasm builds without manual hard-reloads. Re-implements // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step. const cb = b.addExecutable(.{ @@ -362,16 +362,23 @@ pub fn build( .target = b.graph.host, }), }); - const cb_run = b.addRunArtifact(cb); - cb_run.addFileArg(stampStorage(b)); - cb_run.addFileArg(b.path("web/web.js")); - cb_run.addFileArg(b.path("web/fizzy-worker.js")); - cb_run.addFileArg(web_exe.getEmittedBin()); - const index_html_with_hash = cb_run.captureStdOut(.{}); + const web_js = patchedWebJs(b, dvui_web_dep); + // `fizzy.js` names what it loads by their hash, and the page names `fizzy.js` by its own. + const cb_js = b.addRunArtifact(cb); + cb_js.addFileArg(stampStorage(b)); + cb_js.addFileArg(web_js); + cb_js.addFileArg(b.path("web/fizzy-worker.js")); + cb_js.addFileArg(web_exe.getEmittedBin()); + const fizzy_js = cb_js.captureStdOut(.{}); + const cb_page = b.addRunArtifact(cb); + cb_page.addFileArg(b.path("web/index.html")); + cb_page.addFileArg(fizzy_js); + const index_html_with_hash = cb_page.captureStdOut(.{}); const dir = b.addWriteFiles(); _ = dir.addCopyFile(web_exe.getEmittedBin(), web_exe.out_filename); - _ = dir.addCopyFile(b.path("web/web.js"), "web.js"); + _ = dir.addCopyFile(fizzy_js, "fizzy.js"); + _ = dir.addCopyFile(web_js, "web.js"); // Worker mode (`?worker=1`): the editor in a worker, drawing through an OffscreenCanvas. _ = dir.addCopyFile(b.path("web/fizzy-worker.js"), "fizzy-worker.js"); _ = dir.addCopyFile(dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), "NotoSansKR-Regular.ttf"); @@ -474,10 +481,34 @@ pub fn install(b: *std.Build, web: Web) void { ).dependOn(&serve_web_cmd.step); } -/// A plugin package's `"plugin"` module as this web build's own: the same root file and the -/// plugin's own imports (manifest options, its dependencies), with `dvui`/`core`/`fizzy_sdk` -/// swapped for the web build's. A module cannot import two frameworks, so it must be a copy. -/// `web/index.html` with the name the page files what it keeps in the browser under stamped in +/// dvui's `web.js` with `web/patches/dvui/*.patch` applied, in name order, by zig-patch: what +/// fizzy's web backend (`src/backend/WebBackend.zig`) needs of it, each patch a dvui commit +/// written to go upstream. How they are made: docs/DEPENDENCIES.md. +fn patchedWebJs(b: *std.Build, dvui_dep: *std.Build.Dependency) std.Build.LazyPath { + const io = b.graph.io; + const patches_dir = "web/patches/dvui"; + b.dependOnDirectoryContents(b.path(patches_dir)); + var names: std.ArrayList([]const u8) = .empty; + var dir = b.root.openDir(io, patches_dir, .{ .iterate = true }) catch |err| + std.debug.panic("fizzy web: open {s}: {t}", .{ patches_dir, err }); + defer dir.close(io); + var it = dir.iterate(); + while (it.next(io) catch |err| std.debug.panic("fizzy web: list {s}: {t}", .{ patches_dir, err })) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".patch")) continue; + names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM"); + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, l: []const u8, r: []const u8) bool { + return std.mem.lessThan(u8, l, r); + } + }.lessThan); + const patches = b.allocator.alloc(std.Build.LazyPath, names.items.len) catch @panic("OOM"); + for (patches, names.items) |*p, name| p.* = b.path(b.pathJoin(&.{ patches_dir, name })); + const patched = b.dependency("patch", .{ .source = dvui_dep.path(""), .patches = patches }).namedLazyPath("patched"); + return patched.path(b, "src/backends/web.js"); +} + +/// `web/fizzy.js` with the name the app files what it keeps in the browser under stamped in /// (`storageName` there): `-Dweb-storage`, or "fizzy". Every copy of the app on one site shares /// the browser's storage for it, so a test build served beside the real one — fizzyed.it/testapp/ /// beside /app/ — is built with a name of its own and keeps out of the real one's settings. @@ -493,17 +524,17 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath { else => std.debug.panic("-Dweb-storage={s}: only a-z, 0-9, '-' and '_'", .{name}), }; const token = "__FIZZY_WEB_STORAGE__"; - b.dependOnFileContents(b.path("web/index.html")); - const html = std.Io.Dir.cwd().readFileAlloc( + b.dependOnFileContents(b.path("web/fizzy.js")); + const js = std.Io.Dir.cwd().readFileAlloc( b.graph.io, - b.root.joinString(b.allocator, "web/index.html") catch @panic("OOM"), + b.root.joinString(b.allocator, "web/fizzy.js") catch @panic("OOM"), b.allocator, .unlimited, ) catch |err| - std.debug.panic("web/index.html: {t}", .{err}); - if (std.mem.find(u8, html, token) == null) std.debug.panic("web/index.html: no {s} to stamp", .{token}); - const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM"); - return b.addWriteFiles().add("index.html", stamped); + std.debug.panic("web/fizzy.js: {t}", .{err}); + if (std.mem.find(u8, js, token) == null) std.debug.panic("web/fizzy.js: no {s} to stamp", .{token}); + const stamped = std.mem.replaceOwned(u8, b.allocator, js, token, name) catch @panic("OOM"); + return b.addWriteFiles().add("fizzy.js", stamped); } /// Every tape fizzy hosts, built on the host (`scripts/demos.zig`): the bundled demos from their diff --git a/core/AGENTS.md b/core/AGENTS.md index 9af7fafbb..cd464040a 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -38,7 +38,8 @@ paths, fuzzy matching, the LSP client, the profiler. A lifted finger hovers nothing: dvui puts the frame's `.position` event off every window, so a hover look needs no touch check of its own. The native backend gives only fingers on the screen touch buttons (`backend/src/touch_slots.zig`); a trackpad's fingers move the mouse. - The web backend does the same: a stylus touch (an Apple Pencil) is the mouse (`web/web.js`). + The web backend does the same: a stylus touch (an Apple Pencil) is the mouse (dvui's `web.js`, + `web/patches/dvui/0003`). - **A cursor that must win asks with a priority.** `dvui.cursorSet` keeps the first asked in a frame; `dvui.cursorSetPriority` the highest. Splits and a float's corner ask at `.resize`, a carried view at `.drag`, so a hover cursor (a tab's hand) never takes their arrow. The cursor diff --git a/core/FileTable.zig b/core/FileTable.zig index 41a39ce8c..01a58d2c3 100644 --- a/core/FileTable.zig +++ b/core/FileTable.zig @@ -25,8 +25,9 @@ //! path, or whichever mount claims the path's prefix (`gdrive://`). A cloud plugin //! registers a mount through `Host.mount`; nothing here knows what is behind it. The one thing //! a mount changes is *when* an answer arrives: `vfs.Fs` completes asynchronously, so a miss on -//! a cloud directory returns null this frame and the listing appears (with `env.refresh`) when -//! the response lands. The local mount answers inside the same call, exactly as before. +//! a directory returns null this frame and the listing appears (with `env.refresh`) when the +//! response lands. The local mount's listings run on the `Io` pool too: a small folder is waited +//! for briefly and answered in the same call, a large one is pending like a cloud one. const std = @import("std"); const fuzzy = @import("fuzzy.zig"); const vfs = @import("vfs/vfs.zig"); diff --git a/core/LocalFs.zig b/core/LocalFs.zig index 4d459431b..9d1d5e9c8 100644 --- a/core/LocalFs.zig +++ b/core/LocalFs.zig @@ -1,18 +1,21 @@ //! The local disk as a `vfs.Fs`, so `FileTable` has exactly one read path whether a directory //! lives on this machine or on a cloud mount. //! -//! Paths are the OS's own absolute paths — the local mount has no prefix to strip. Every op -//! but a read runs to completion inside its start call (the disk is synchronous here, as it -//! always was) and the result is parked until `pump`, which is the `vfs.Fs` contract. -//! `FileTable` pumps the mount it just asked right away, so a local listing still comes back -//! in the same call that requested it and nothing above notices a difference. +//! Paths are the OS's own absolute paths — the local mount has no prefix to strip. A read and a +//! listing run on the `Io`'s pool, so their start call returns a job before the disk has been +//! touched and the result is delivered by `pump`, which is the `vfs.Fs` contract; a caller must +//! treat a listing as pending (`FileTable.listDir` returns null) until a later `pump` lands it, and +//! rely on `wake` rather than assume the same call completes; `FileTable` only waits a moment +//! (`inline_wait_ns`) so a small folder still answers at once. The other ops run to completion +//! inside their start call and park the result until `pump`. //! //! A read and a listing are the ops worth overlapping: a cold index of a large vault is hundreds //! of thousands of small files, each ~120 µs of waiting when nothing else is in flight, and one //! folder of that many notes is seconds of directory reads that a frame must not wait for. //! `readFile` and `listDir` go through `io.concurrent`, which the host's threaded `Io` runs on its //! pool and a single-threaded `Io` (tests, or a saturated pool) runs inline — one implementation, -//! and the caller that issues a window of them and pumps gets the overlap where it exists. +//! and the caller that issues a window of them and pumps gets the overlap where it exists. Only +//! on the inline path has the result landed by the time the start call returns. const std = @import("std"); const builtin = @import("builtin"); const vfs = @import("vfs/vfs.zig"); @@ -27,7 +30,7 @@ const no_disk = builtin.target.cpu.arch == .wasm32; gpa: std.mem.Allocator, io: std.Io, ready: vfs.http.Completions(Completion), -/// Reads in flight on `io.async`, by job id. Guarded by `lock`; the futures themselves are +/// Reads in flight on `io.concurrent`, by job id. Guarded by `lock`; the futures themselves are /// awaited from `pump` only once their task has set `done`. reads: std.AutoArrayHashMapUnmanaged(u64, *ReadJob) = .empty, /// Listings in flight on `io.concurrent`, by job id; guarded by `lock` like `reads`. diff --git a/core/dialogs.zig b/core/dialogs.zig index 84350b479..5baf5ea77 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -899,8 +899,7 @@ pub fn closeFloatingDialogAnchored() void { dvui.dataSet(null, sub.id, "_close_rect", close_rect); } -// lint-allow(anyerror): this is `dvui.Dialog.DisplayFn`, dvui's own type, which a dialog window must match. -pub fn dialogWindow(id: dvui.Id) anyerror!void { +pub fn dialogWindow(id: dvui.Id) !void { // Shown after dvui has stopped capturing the frame: in one an agent is taking a snapshot of, // the dialog captures itself (`replay.Snapshot.scopeBegin`). const scoped = Snapshot.scopeBegin(); @@ -1944,3 +1943,8 @@ test "a dialog's failure is one of Error, and only an allocation failure keeps i try std.testing.expectEqualStrings("failed", why); try std.testing.expectEqual(@as(Error, error.OutOfMemory), failed("test", error.OutOfMemory)); } + +test "the dialog window handles callback errors without propagating them" { + const display: *const fn (dvui.Id) error{}!void = dialogWindow; + _ = display; +} diff --git a/core/form_factor.zig b/core/form_factor.zig index 33958528a..f21235431 100644 --- a/core/form_factor.zig +++ b/core/form_factor.zig @@ -53,19 +53,19 @@ pub fn effective(detected: FormFactor, mode: LayoutMode, override: ?FormFactor) } test "effective covers every detected form, mode and override" { - const overrides = [_]?FormFactor{ null, .desktop, .tablet, .mobile }; - for (std.enums.values(FormFactor)) |detected| { - for (std.enums.values(LayoutMode)) |mode| { - for (overrides) |override| { - const want: FormFactor = if (override) |o| o else switch (mode) { - .auto => detected, - .mobile => .mobile, - .desktop => .desktop, - }; - try std.testing.expectEqual(want, effective(detected, mode, override)); - } - } - } + const D = FormFactor.desktop; + const T = FormFactor.tablet; + const M = FormFactor.mobile; + // detected, mode, override, expected + const cases = [_]struct { FormFactor, LayoutMode, ?FormFactor, FormFactor }{ + .{ D, .auto, null, D }, .{ T, .auto, null, T }, .{ M, .auto, null, M }, + .{ D, .mobile, null, M }, .{ T, .mobile, null, M }, .{ M, .mobile, null, M }, + .{ D, .desktop, null, D }, .{ T, .desktop, null, D }, .{ M, .desktop, null, D }, + .{ D, .auto, T, T }, .{ M, .auto, D, D }, .{ T, .auto, M, M }, + .{ D, .mobile, D, D }, .{ M, .mobile, T, T }, .{ T, .mobile, M, M }, + .{ D, .desktop, M, M }, .{ M, .desktop, T, T }, .{ T, .desktop, D, D }, + }; + for (cases) |c| try std.testing.expectEqual(c[3], effective(c[0], c[1], c[2])); } test "a mode forces mobile or desktop on any device, and auto keeps a tablet a tablet" { diff --git a/docs/AGENT_WORK.md b/docs/AGENT_WORK.md index 12048502c..50453fea1 100644 --- a/docs/AGENT_WORK.md +++ b/docs/AGENT_WORK.md @@ -146,6 +146,16 @@ Both helpers exit non-zero and print a one-line reason on failure. A failed prep its workspace for inspection; a failed finish can leave a published branch if GitHub refused PR creation. Fix the reported problem and retry finish. Neither helper merges or marks ready. +## Where verification happens + +Three places, in order: the worker's **local gates** (step 3), which decide whether the PR is +worth pushing; **cross-review** by a different worker; and **CI at ready**. A draft PR runs only +the light `changes` job (`ci.yml`), so pushing to a draft costs almost nothing and proves nothing; +`gh pr ready` starts the full matrix, and `ci-ok` is the required check on that run. A reviewer +waits for finished checks, so a draft is reviewed from its diff and the gate output in its body, +and a cross-reviewer may give a verdict on a draft without CI. The merge question waits for the +full CI at ready. + ## Review The stages and markers above are the review. Reviewer and author both answer the four questions @@ -153,6 +163,7 @@ in [`REVIEW.md`](REVIEW.md). A worker working an issue leaves its PR draft and n marking it ready (once the gates pass and CI is green), the merge and the `pm:approve` yes are the maintainer's. `CONTRIBUTING.md`'s "take it to ready yourself" is for a person's own session, not for a worker on this contract. +A draft's CI is only the light `changes` job, so the full CI runs at ready and must be green before the merge question. ## Which worker suits which work diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index de8900192..7836bba99 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -8,6 +8,7 @@ bump it; keep it current whenever a pin or a patch moves. | Dependency | Upstream | Fizzy's changes | Pinned in | Carries | |---|---|---|---|---| | [`foxnne/dvui-dev`](https://github.com/foxnne/dvui-dev) | [`david-vanderson/dvui`](https://github.com/david-vanderson/dvui) | branch `zig-0.17` (on 0.16, `fizzy-dev`) | `sdk/build.zig.zon` | dvui changes fizzy needs | +| dvui's `web.js` | the dvui `sdk/build.zig.zon` pins | `web/patches/dvui` | `build.zig.zon` (`.patch`) | the web backend's JavaScript, patched by zig-patch for the web build (below) | | SDL | [`libsdl-org/SDL`](https://github.com/libsdl-org/SDL) `release-3.4.16` | `backend/patches/sdl` | `backend/build.zig.zon` (`.sdl_source`) | SDL C patches, applied by [zig-patch](https://github.com/marler8997/zig-patch) (below) | | [`fizzyedit/sdl_zig`](https://github.com/fizzyedit/sdl_zig) | [`Games-By-Mason/sdl_zig`](https://github.com/Games-By-Mason/sdl_zig) | branch `fizzy` | `backend/build.zig.zon` (`.sdl`) | the Zig build of SDL, taking the patched tree | | [`fizzyedit/nightwatch`](https://github.com/fizzyedit/nightwatch) | [`neurocyte/nightwatch`](https://github.com/neurocyte/nightwatch) | branch `fizzy` | `build.zig.zon` | a fix to its macOS FSEvents backend | @@ -175,9 +176,10 @@ pinned); `fizzy-sdk-0.2.30-4` → `44010a3`; `fizzy-sdk-0.2.30-5` → `fizzy-sdk-0.2.30-8` → `3d90fdd` (item 14), what `sdk/build.zig.zon` pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends -(`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` -backends are only built by `-Dnative-backend=sdl3`. A dvui change to those backends is not a -change to fizzy: anything fizzy wants from one is merged into its own by hand. +(`backend/`, `src/backend/WebBackend.zig`), so dvui's `sdl3` and `web` backends are only built +by `-Dnative-backend=sdl3`. A dvui change to those backends is not a change to fizzy: anything +fizzy wants from one is merged into its own by hand. The exception is the web backend's +JavaScript: fizzy's web build ships dvui's own `web.js`, with `web/patches/dvui` (below). Local copies of dvui, kept in fizzy until upstream has them (diff these on every dvui bump): @@ -196,6 +198,42 @@ Fizzy adapts to two upstream behaviours rather than patching them: first frame, whatever width it was given. The markdown preview treats a block holding one as not yet measured (`render_ast.Stats.fresh_text_layouts`). +## dvui's `web.js`: `web/patches/dvui` + +The web build ships `src/backends/web.js` of the dvui `sdk/build.zig.zon` pins, patched at build +time: `build/web.zig` (`patchedWebJs`) hands dvui and `web/patches/dvui/*.patch`, in name order, +to zig-patch and takes `web.js` from the tree it writes. The page and `fizzy-worker.js` import it +and drive its `Dvui` class. Each patch is a commit on dvui, `git format-patch` output whose header +says what it does and why, written to go upstream: where dvui's own `web.zig` has a counterpart, +the patch changes it too, and nothing dvui's own web backend uses is taken away +(`wasm_renderGeometry` stays beside `wasm_renderStream`). + +- `0001` **highp float in the shaders.** mediump is a 16-bit float on Apple Silicon and most + mobile GPUs, too coarse for BlurBackdrop's sub-texel taps. +- `0002` **Mouse events carry their own modifier state** (and `web.zig` reads it), so a lost keyup + no longer leaves a modifier held. +- `0003` **A pencil is the mouse, and a cancelled touch ends** (`core/AGENTS.md`). +- `0004` **Send only the GL state a draw changes**, and a framebuffer per render target. +- `0005` **Clearing a target keeps the target that was bound.** +- `0006` **`textureBlend`** (`dvui.Backend.textureBlend`), with `web.zig`'s. +- `0007` **Precise render targets** (`dvui.Backend.support_precise_targets`), with `web.zig`'s. +- `0008` **`wasm_renderStream`**, a flush's draws in one call: what `WebBackend.flushBatch` sends. +- `0009` **Custom fragment programs in the draw stream** (`core.gfx.programs`). +- `0010` **A keyboard's correction replaces the typed word** (`TextInput`): the hidden input holds the + word before the caret, and a replacement or recomposition becomes backspaces and the new text. + Test: `scripts/test-web-text-input.sh` (needs node, or jsc on macOS). + +The patches are the commits of a dvui checkout's branch, from the pinned commit. To change one, +or to carry them to a new dvui pin: rebase the branch (`git rebase `), fix what +conflicts, and export it again over the old files: + +```sh +git -c diff.noprefix=false -c diff.mnemonicPrefix=false format-patch -U8 --zero-commit \ + --no-signature --no-numbered --src-prefix=a/ --dst-prefix=b/ -o /web/patches/dvui ..HEAD +``` + +A patch that dvui merges comes out empty at the rebase and is dropped. + ## SDL: `backend/patches/sdl` Upstream `release-3.4.16`, patched at build time: `backend/build.zig` (`patchedSdl`) hands the diff --git a/docs/HELLO_NUMBERS.md b/docs/HELLO_NUMBERS.md index 1184a5847..911f6ca04 100644 --- a/docs/HELLO_NUMBERS.md +++ b/docs/HELLO_NUMBERS.md @@ -1,9 +1,10 @@ # Minimal app measurements -[`hello-numbers.yml`](../.github/workflows/hello-numbers.yml) runs on every push to main and on manual -dispatch, never on pull requests (two release builds and a package on two OSes is too much load -for each one). `python3 scripts/test-hello-numbers.py` and the `--sizes-only` mode are the checks -a pull request runs itself. Each macOS/Linux artifact contains +[`hello-numbers.yml`](../.github/workflows/hello-numbers.yml) runs on every push to main, on manual +dispatch, and on a pull request that touches the harness (its script, tests, workflow, this doc or +`src/Entry.zig`'s marker); other pull requests skip it, since two release builds and a package on +two OSes is too much load for each one. CI's `python3 scripts/test-hello-numbers.py` step and the +`--sizes-only` mode are the checks every pull request runs itself. Each macOS/Linux artifact contains `hello-numbers.jsonl`: one complete JSON object for each ReleaseFast/ReleaseSmall run. Download both artifacts from the [workflow runs](https://github.com/fizzyedit/fizzy/actions/workflows/hello-numbers.yml). A failed measurement fails the job; a partial artifact may contain only the first mode. @@ -21,7 +22,7 @@ Scratch sources and build outputs are deleted even on failure. On a machine where opening a window is acceptable (not a window-free worker session): ```sh -scripts/hello-numbers.sh --output "$PWD/hello-numbers.jsonl" \ +python3 scripts/hello-numbers.py --output "$PWD/hello-numbers.jsonl" \ --revision '' fizzy \ --example ../example-app --example-revision '' \ --plugin ../example-plugin --plugin-revision '' @@ -37,7 +38,7 @@ runner image, compiler version, all three source revisions and executable SHA-25 | --- | --- | | `binary_bytes` | Installed `minimalapp`, after host `strip` (and ad-hoc signing on macOS). Excludes resources and plugin libraries. Packaging may link a separate executable with Velopack support. | | `installer_bytes` | Unsigned macOS `.pkg` or Linux `.AppImage` from the `package` output; includes packaged resources/libraries. A missing or ambiguous installer fails. | -| `first_frame_ms` | `core.profile`'s `main` to `first frame` phase at the end of the first `AppFrame`. Excludes loader work before Zig main; GPU submission, display presentation and window reveal may follow. | +| `first_frame_ms` | Process spawn to the 10 ms poll that saw the stderr marker `reportFirstFrame` prints at the end of the first `AppFrame`, the clock the peers use. Includes loader work before Zig main; GPU submission, display presentation and window reveal may follow. `first_frame_after_main_ms` is `core.profile`'s `main` to `first frame` phase, the app's own clock. | | `idle_rss_bytes` | Sum of `ps` RSS (KiB x 1024) for the process and descendants, sampled 30 seconds after spawn. Shared pages may be counted more than once. | | `wasm_gzip_bytes` | Installed `web.wasm`, gzip with mtime zero, default compression level 9. Excludes JS, fonts and runtime plugin downloads. | @@ -50,7 +51,7 @@ terminated after the sample; a missing first-frame marker or an early exit fails ## Electron and Tauri comparison (manual only) -`scripts/compare-hello.sh` measures already-built stock hello-world applications on the **same +`python3 scripts/hello-numbers.py compare` measures already-built stock hello-world applications on the **same machine**, outside CI. Keep framework templates at their defaults, use production packaging, and record template/source revision and exact framework version. Do not use a development server or attach developer tools. Supply the executable inside the packaged app and its actual @@ -64,7 +65,9 @@ For Electron, relay an IPC message from the renderer to `console.log` in the mai for Tauri, invoke a Rust command that uses `println!`. A webview console message alone does not reach the harness. Keep this instrumentation in the recorded source revision. Startup is measured externally from spawn to observing stdout (10 ms polling), so it includes loader/IPC -work and is **not directly equivalent** to Fizzy's internal main-to-first-frame clock. +work. Fizzy's `first_frame_ms` is measured the same way (spawn to the poll that saw its stderr +marker), so the two compare; its own main-to-first-frame clock is kept beside it as +`first_frame_after_main_ms`. Configure each application to put all user data under the `HELLO_PROFILE` environment variable before creating its window (Electron: `app.setPath('userData', ...)`; Tauri: configure the @@ -73,10 +76,10 @@ The harness includes descendant RSS so Electron's renderer and GPU helpers count use OS webview services outside its process tree; disclose that limitation with the results. ```sh -scripts/compare-hello.sh --output "$PWD/peers.jsonl" --revision '' \ +python3 scripts/hello-numbers.py --output "$PWD/peers.jsonl" --revision '' \ compare --app electron --framework-version '' \ --binary '/path/to/Hello.app/Contents/MacOS/Hello' --installer '/path/to/Hello.dmg' -scripts/compare-hello.sh --output "$PWD/peers.jsonl" --revision '' \ +python3 scripts/hello-numbers.py --output "$PWD/peers.jsonl" --revision '' \ compare --app tauri --framework-version '' \ --binary '/path/to/hello' --installer '/path/to/hello.AppImage' ``` diff --git a/docs/REVIEW.md b/docs/REVIEW.md index 7a76f4a0e..487aeffee 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -38,14 +38,29 @@ PR uses the same shape, with `VERDICT` replaced by what they changed after answe ## Severity +A finding is **blocker** or **major** only when it breaks the issue's `Done` criteria, a test, +correctness, or a principle of the vision, and it says which. Everything else is minor or nit. + - **blocker**: wrong behaviour, a crash, a leak, build or tests fail, the goal is not met. -- **major**: duplicates existing code; a Zig-standard break with real cost; new logic without a - test; a test that passes without the change; drift from the plan step. +- **major**: breaks a named `Done` criterion, a test or a vision principle, for example + duplicated code (Lean), a `docs/ZIG.md` break with real cost (code quality), new logic without + a test, a test that passes without the change, or drift from the plan step. Name the principle. - **minor**: readability, naming, small lean gains. - **nit**: taste. Only blocker and major hold a PR. Minor and nit are the writer's to take or leave. +## Rounds + +- **Evidence first.** A PR whose `Done` needs evidence (numbers, screenshots, a tape) is not + reviewed until the evidence is in its body. +- **Verify checks the must-fix list.** `VERIFY` checks only the items the `RULING` (or rework + issue) lists. A new finding at verify is a follow-up, unless it is a blocker the fix itself + caused. +- **Two fix rounds at most.** After the second the PM decides once: merge with follow-ups, + split, or close and restart. The stage labels and markers are in + [`AGENT_WORK.md`](AGENT_WORK.md). + ## Lean - A new helper is used more than once, or is clearer than the code it replaces. diff --git a/plugins/markdown/src/md/net_image.zig b/plugins/markdown/src/md/net_image.zig index 51be0be33..85ca255e5 100644 --- a/plugins/markdown/src/md/net_image.zig +++ b/plugins/markdown/src/md/net_image.zig @@ -7,7 +7,7 @@ //! //! Native uses a worker thread + `std.http.Client` and stores encoded bytes (stb decodes //! them later). Web has neither threads nor that HTTP client — the browser loads the URL -//! instead (`new Image`, see `web/index.html`). When CORS allows it, the pixels are copied +//! instead (`new Image`, see `web/fizzy.js`). When CORS allows it, the pixels are copied //! into a dvui texture. When it does not — GitHub Actions badges are the usual case — //! the browser still has the image, so the preview positions an `` over the canvas //! rather than drawing the "open" fallback link. Same ownership shape as fizzy's own @@ -75,7 +75,7 @@ var gpa: ?std.mem.Allocator = null; var next_id: u32 = 1; const wasm = if (is_wasm) struct { - /// Starts a browser load for `url`. Wired in `web/index.html` by adding a `fizzy` + /// Starts a browser load for `url`. Wired in `web/fizzy.js` by adding a `fizzy` /// import namespace at instantiate time — dvui's `web.js` only provides `dvui.*`. extern "fizzy" fn fizzy_web_image_request(id: u32, url_ptr: [*]const u8, url_len: usize) void; } else struct {}; diff --git a/plugins/text/src/textcore/encoding.zig b/plugins/text/src/textcore/encoding.zig index 0d416aae0..3d8b4f51d 100644 --- a/plugins/text/src/textcore/encoding.zig +++ b/plugins/text/src/textcore/encoding.zig @@ -21,7 +21,7 @@ pub fn looksBinary(bytes: []const u8) bool { while (i < sample.len) { const n = std.unicode.utf8ByteSequenceLength(sample[i]) catch return true; if (i + n > sample.len) break; - _ = std.unicode.utf8Decode(sample[i..][0..n]) catch return true; + if (!std.unicode.utf8ValidateSlice(sample[i..][0..n])) return true; i += n; } return false; @@ -61,10 +61,10 @@ pub fn appendLossy(list: *std.ArrayList(u8), gpa: std.mem.Allocator, bytes: []co try list.appendSlice(gpa, replacement); break; } - if (std.unicode.utf8Decode(bytes[i..][0..n])) |_| { + if (std.unicode.utf8ValidateSlice(bytes[i..][0..n])) { try list.appendSlice(gpa, bytes[i .. i + n]); i += n; - } else |_| { + } else { try list.appendSlice(gpa, replacement); i += 1; } @@ -79,4 +79,9 @@ test appendLossy { list.clearRetainingCapacity(); try appendLossy(&list, std.testing.allocator, "a\x00b\xffc"); try std.testing.expectEqualStrings("a\u{FFFD}b\u{FFFD}c", list.items); + list.clearRetainingCapacity(); + // Well-formed multi-byte sequences pass through; a bad continuation byte or a surrogate + // is replaced a byte at a time. + try appendLossy(&list, std.testing.allocator, "\xc3\xa9\xe2\x28\xa1\xed\xa0\x80"); + try std.testing.expectEqualStrings("\u{e9}\u{FFFD}(\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD}", list.items); } diff --git a/readme.md b/readme.md index 9dde73f6c..b61ed7705 100644 --- a/readme.md +++ b/readme.md @@ -44,15 +44,23 @@ Download `hello-numbers--` from a run for the exact JSON lines and sou | Linux x86_64, example-app minimal | ReleaseFast | ReleaseSmall | | --- | --- | --- | -| Stripped executable | 21,789,128 B | 14,892,728 B | -| Installer (`.AppImage`) | 31,582,712 B | 15,084,024 B | -| First frame (`core.profile`) | 491 ms | 87 ms | -| Idle RSS after 30 s | 144,674,816 B | 133,455,872 B | -| Gzipped wasm | 4,145,339 B | 3,348,450 B | - -Source: [run 40195192369](https://github.com/fizzyedit/fizzy/actions/runs/40195192369) at -`c4f0e401` (Xvfb with Mesa software rendering on a GitHub runner; one sample each). The macOS -column is not yet recorded: that run's macOS job failed. +| Stripped executable | 21,168,760 B | 14,607,864 B | +| Installer (`.AppImage`) | 23,091,704 B | 13,548,024 B | +| First frame (`core.profile`) | 731 ms | 79 ms | +| Idle RSS after 30 s | 144,408,576 B | 133,521,408 B | +| Gzipped wasm | 3,952,577 B | 3,243,050 B | + +| macOS arm64, example-app minimal | ReleaseFast | ReleaseSmall | +| --- | --- | --- | +| Stripped executable | 18,929,968 B | 13,134,240 B | +| Installer (`.pkg`) | 13,753,818 B | 11,041,560 B | +| First frame (`core.profile`) | 742 ms | 401 ms | +| Idle RSS after 30 s | 77,217,792 B | 75,186,176 B | +| Gzipped wasm | 3,952,577 B | 3,243,050 B | + +Sources: both from [run 40362195739](https://github.com/fizzyedit/fizzy/actions/runs/40362195739) +at `e572d74a` (ubuntu-24.04 and macos-14 runners). +One sample each; Linux uses Xvfb with Mesa software rendering on a GitHub runner. See the [method and manual Electron/Tauri comparison](docs/HELLO_NUMBERS.md) before comparing samples: runner hardware, OS caches, packaged resources and startup clocks affect the results. diff --git a/scripts/agent-finish b/scripts/agent-finish index 244957631..8b0a10d8d 100755 --- a/scripts/agent-finish +++ b/scripts/agent-finish @@ -67,7 +67,7 @@ reason='could not write the pull request summary' if [[ -f $summary_file ]]; then cat "$summary_file" >"$tmp/summary" elif grep -qxF "$marker" <<<"$pr_body"; then - awk -v m="$marker" '$0 == m { exit } NR == 1 && /^Part of / { next } !started && /^$/ { next } { started = 1; print }' <<<"$pr_body" >"$tmp/summary" + awk -v m="$marker" '$0 == m { exit } !started && (/^Part of / || /^Rework: / || /^$/) { next } { started = 1; print }' <<<"$pr_body" >"$tmp/summary" elif $claim; then printf "$headings\n" '' '' '' 'Draft: work in progress' >"$tmp/summary" else @@ -118,8 +118,15 @@ else done gates_label="$gates_passed of $gates_total passed" fi +# A rework run on an existing PR keeps the PR's own work issue: its running: lock and +# close-merged find the PR by that line. +part_of=$(grep -m1 -E '^Part of fizzyedit/work#[1-9][0-9]*$' <<<"$pr_body" || true) { - printf 'Part of fizzyedit/work#%s\n\n' "$issue" + if [[ -n $part_of && $part_of != "Part of fizzyedit/work#$issue" ]]; then + printf '%s\nRework: work#%s\n\n' "$part_of" "$issue" + else + printf 'Part of fizzyedit/work#%s\n\n' "$issue" + fi sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$tmp/summary" printf '\n\n%s\n\n
Changes (%s)\n\n```text\n' "$marker" "$changes" head -n 100 "$tmp/stat" diff --git a/scripts/compare-hello.sh b/scripts/compare-hello.sh deleted file mode 100755 index 85ec85dfa..000000000 --- a/scripts/compare-hello.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Measure a stock Electron/Tauri hello app; opt-in, never called by CI. -# See docs/HELLO_NUMBERS.md for the first-frame marker and fresh-profile contract. -set -euo pipefail -exec python3 "$(dirname "$0")/hello-numbers.py" "$@" diff --git a/scripts/compile-shaders.sh b/scripts/compile-shaders.sh index 7f890b65f..ee520e451 100755 --- a/scripts/compile-shaders.sh +++ b/scripts/compile-shaders.sh @@ -12,7 +12,8 @@ # # The GLSL is complete source, for the web backend's programs (`core.gfx.programs`): its inputs # are named as the web's vertex shader writes them (`vColor`, `vTextureCoord`), its samplers by -# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names `web/web.js` binds. +# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names dvui's `web.js` binds +# (`web/patches/dvui/0009`). # # Needs `shadercross` and `spirv-cross` on PATH. Exits non-zero if any shader does not compile. set -euo pipefail diff --git a/scripts/hello-numbers.py b/scripts/hello-numbers.py index bab580983..a02cb6555 100755 --- a/scripts/hello-numbers.py +++ b/scripts/hello-numbers.py @@ -103,25 +103,28 @@ def measure(command, log, env, peer=False, idle_seconds=30): env=env, start_new_session=True) try: first_frame_ms = None + after_main_ms = None while time.monotonic() - started < idle_seconds: if process.poll() is not None: raise RuntimeError(f'app exited ({process.returncode}): {log.read_text()}') if first_frame_ms is None: content = log.read_text() - if peer: - if 'HELLO_FIRST_FRAME' in content: - first_frame_ms = (time.monotonic() - started) * 1000 - else: - match = FIRST_FRAME.search(content) + # One clock for every app: spawn to the poll that saw the marker. + match = None if peer else FIRST_FRAME.search(content) + if match or (peer and 'HELLO_FIRST_FRAME' in content): + first_frame_ms = (time.monotonic() - started) * 1000 if match: - first_frame_ms = float(match[1]) + after_main_ms = float(match[1]) time.sleep(min(0.01, max(0, idle_seconds - (time.monotonic() - started)))) sample_at = time.monotonic() - started if first_frame_ms is None: raise RuntimeError(f'first-frame marker missing: {log.read_text()}') rss = rss_bytes(process.pid) - return {'first_frame_ms': first_frame_ms, 'idle_rss_bytes': rss, - 'rss_sample_after_s': sample_at, 'rss_scope': 'process_tree'} + result = {'first_frame_ms': first_frame_ms, 'idle_rss_bytes': rss, + 'rss_sample_after_s': sample_at, 'rss_scope': 'process_tree'} + if after_main_ms is not None: + result['first_frame_after_main_ms'] = after_main_ms + return result finally: # A peer may spawn helpers; tear down the entire private process group. for sig in (signal.SIGTERM, signal.SIGKILL): @@ -189,7 +192,7 @@ def benchmark(args): 'binary_bytes': binary.stat().st_size, 'installer_bytes': installer.stat().st_size, 'installer_format': installer.suffix, 'wasm_gzip_bytes': len(gzip.compress(wasm.read_bytes(), compresslevel=9, mtime=0)), 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), - 'first_frame_source': 'core.profile main -> first frame'}) + 'first_frame_source': 'process spawn -> stderr startup marker (10 ms polling)'}) env = dict(os.environ, FIZZY_PROFILE=str(work / f'profile-{mode}'), FIZZY_QUIET='1', FIZZY_STARTUP_REPORT='1') if not args.sizes_only: diff --git a/scripts/hello-numbers.sh b/scripts/hello-numbers.sh deleted file mode 100755 index 24718eac2..000000000 --- a/scripts/hello-numbers.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Build and measure example-app minimal. See docs/HELLO_NUMBERS.md for the protocol. -set -euo pipefail -exec python3 "$(dirname "$0")/hello-numbers.py" "$@" diff --git a/scripts/test-agent-work.sh b/scripts/test-agent-work.sh index ecfc6d019..e95eff8f2 100755 --- a/scripts/test-agent-work.sh +++ b/scripts/test-agent-work.sh @@ -36,6 +36,7 @@ cat >"$tmp/bin/gh" <<'FAKE' set -e printf '%s\n' "$*" >>"$AGENT_TEST_DIR/calls" if [[ $1 == pr && $2 == list && ${AGENT_TEST_EXISTING:-} == 1 ]]; then echo 12; fi +if [[ $1 == pr && $2 == view && -n ${AGENT_TEST_BODY:-} ]]; then printf '%s\n' "$AGENT_TEST_BODY"; fi if [[ $1 == pr && ( $2 == create || $2 == edit ) ]]; then while [[ $# -gt 0 ]]; do if [[ $1 == --body-file ]]; then cp "$2" "$AGENT_TEST_DIR/body"; break; fi @@ -134,6 +135,17 @@ AGENT_TEST_GIT=1 "$finish" --claim 42 >"$tmp/output" ! grep -q 'allow-empty' "$tmp/calls" || fail 'empty commit despite commits ahead' AGENT_TEST_EXISTING=1 "$finish" 42 >"$tmp/output" grep -q 'pr edit 12 --body-file' "$tmp/calls" || fail 'existing PR refresh' +# A rework run keeps the PR's own work issue; a fresh PR takes the issue being worked. +AGENT_TEST_EXISTING=1 AGENT_TEST_BODY=$'Part of fizzyedit/work#7\n\n## Goal\n\nold' "$finish" 99 >"$tmp/output" +[[ $(head -n 1 "$tmp/body") == 'Part of fizzyedit/work#7' ]] || fail 'rework replaced Part of line' +grep -qx 'Rework: work#99' "$tmp/body" || fail 'rework line missing' +! grep -q 'Part of fizzyedit/work#99' "$tmp/body" || fail 'rework issue became Part of' +AGENT_TEST_EXISTING=1 AGENT_TEST_BODY=$'Part of fizzyedit/work#7\nRework: work#5\n\nsummary\n\n' "$finish" 99 >"$tmp/output" +[[ $(grep -c '^Rework:' "$tmp/body") == 1 ]] || fail 'old rework line carried into summary' +AGENT_TEST_EXISTING=1 AGENT_TEST_BODY=$'Part of fizzyedit/work#42\n\nx' "$finish" 42 >"$tmp/output" +! grep -q '^Rework:' "$tmp/body" || fail 'rework line on a same-issue run' +AGENT_TEST_EXISTING=1 "$finish" 42 >"$tmp/output" +grep -q '^Part of fizzyedit/work#42$' "$tmp/body" || fail 'fresh PR issue line' cat >"$tmp/bin/uname" <<'FAKE' #!/usr/bin/env bash echo Linux diff --git a/scripts/test-hello-numbers.py b/scripts/test-hello-numbers.py index 99635b637..91a484959 100644 --- a/scripts/test-hello-numbers.py +++ b/scripts/test-hello-numbers.py @@ -39,7 +39,9 @@ def test_first_frame_and_rss(self, _rss): command = [sys.executable, '-u', '-c', 'import time; print("info: startup: first frame 12.375 ms after main"); time.sleep(10)'] record = hello.measure(command, Path(temporary) / 'log', os.environ, idle_seconds=2) - self.assertEqual(record['first_frame_ms'], 12.375) + self.assertEqual(record['first_frame_after_main_ms'], 12.375) + # Spawn to observation, the clock the peers use, so it is never below the app's own. + self.assertGreater(record['first_frame_ms'], 0) self.assertGreater(record['idle_rss_bytes'], 0) self.assertGreaterEqual(record['rss_sample_after_s'], 2) output = Path(temporary) / 'numbers.jsonl' diff --git a/scripts/test-web-text-input.sh b/scripts/test-web-text-input.sh new file mode 100644 index 000000000..632417c10 --- /dev/null +++ b/scripts/test-web-text-input.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# The hidden input's tests (web/tests/text-input.test.js) against the web.js the web build +# writes: `zig build web` first. Needs node, or jsc on macOS. +set -eu +cd "$(dirname "$0")/.." +js=zig-out/web/web.js +[ -f "$js" ] || { echo "test-web-text-input: no $js; run zig build web" >&2; exit 1; } +if command -v node >/dev/null; then + exec node web/tests/text-input.test.js "$PWD/$js" +fi +jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc +[ -x "$jsc" ] || { echo "test-web-text-input: needs node (or macOS's jsc)" >&2; exit 1; } +exec "$jsc" -m web/tests/text-input.test.js -- "$PWD/$js" diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index 102399055..287cdc616 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -532,7 +532,7 @@ pub fn create(b: *std.Build, opts: CreateOptions) PluginArtifact { // A wasm *side module* (Emscripten's term): position-independent, no entry, no // threads, loaded into the web host's memory and function table at runtime — the // dylib model with table indices for function pointers. See `plans/REVIEW_2026-09.md` - // §2 and `web/index.html`'s loader. + // §2 and `web/fizzy.js`'s loader. lib.root_module.pic = true; lib.root_module.single_threaded = true; lib.entry = .disabled; diff --git a/sdk/replay/BlackBox.zig b/sdk/replay/BlackBox.zig index eec1711e5..5c3890c7e 100644 --- a/sdk/replay/BlackBox.zig +++ b/sdk/replay/BlackBox.zig @@ -6,9 +6,9 @@ //! person's input and any tape's. Each dvui event becomes a fixed record in `tape.events`' rings: //! pointer motion and buttons where they were, in the window's pixels, wheel ticks, keys down, //! repeated and up with their modifiers, text as it arrived. Focus and position events are left -//! out: dvui makes them from the others, and a replay's dvui will again. A frame costs a copy of its -//! events (ReleaseFast, measured: 0.5 ns a frame with none, 5 ns with four); the rings are 1.3 MB, -//! made the first time it records. +//! out: dvui makes them from the others, and a replay's dvui will again. A frame costs a copy of +//! its events (ReleaseFast, measured: 0.5 ns a frame with none, 5 ns with four); the rings are 1.3 +//! MB, made the first time it records. //! //! Any dvui app can keep one: dvui and `tape` only. Fizzy writes its stretches as captures //! (`fizzy.capture`) and replays them (plans/RECORDER_PLAN.md, step 3). A stream that is not dvui's @@ -68,9 +68,27 @@ pub fn record(self: *BlackBox, gpa: std.mem.Allocator) void { fn toRecord(e: *const dvui.Event) ?events.Event { return switch (e.evt) { .mouse => |me| switch (me.action) { - .motion => .{ .kind = .motion, .button = @backingInt(me.button), .mod = @backingInt(me.mod), .x = me.p.x, .y = me.p.y }, - .press => .{ .kind = .press, .button = @backingInt(me.button), .mod = @backingInt(me.mod), .x = me.p.x, .y = me.p.y }, - .release => .{ .kind = .release, .button = @backingInt(me.button), .mod = @backingInt(me.mod), .x = me.p.x, .y = me.p.y }, + .motion => .{ + .kind = .motion, + .button = @backingInt(me.button), + .mod = @backingInt(me.mod), + .x = me.p.x, + .y = me.p.y, + }, + .press => .{ + .kind = .press, + .button = @backingInt(me.button), + .mod = @backingInt(me.mod), + .x = me.p.x, + .y = me.p.y, + }, + .release => .{ + .kind = .release, + .button = @backingInt(me.button), + .mod = @backingInt(me.mod), + .x = me.p.x, + .y = me.p.y, + }, .wheel_x => |ticks| .{ .kind = .wheel_x, .mod = @backingInt(me.mod), .x = ticks }, .wheel_y => |ticks| .{ .kind = .wheel_y, .mod = @backingInt(me.mod), .x = ticks }, .focus, .position => null, diff --git a/sdk/replay/Capture.zig b/sdk/replay/Capture.zig index 762c015cc..ad1b12f8c 100644 --- a/sdk/replay/Capture.zig +++ b/sdk/replay/Capture.zig @@ -12,8 +12,8 @@ //! and every window at once, laid out as they lie on the desktop round the main one, by `all`. //! //! Asked for like a `Snapshot` (`request`, then `get` once taken): the next frame the app draws -//! is read back at its end (`endFrame`) and kept, as the raw premultiplied pixels it drew, until the -//! next is taken. Nothing is encoded here: the frame pays for one read back and one copy, and +//! is read back at its end (`endFrame`) and kept, as the raw premultiplied pixels it drew, until +//! the next is taken. Nothing is encoded here: the frame pays for one read back and one copy, and //! whoever wants an image file (the agent's bridge, in its own process) scales and encodes it. const Capture = @This(); @@ -148,7 +148,12 @@ const max_side: f32 = 8192; /// Every window, each where it lies round the main window, a later one over an earlier. fn everyWindow(arena: std.mem.Allocator, sources: []const Source) !Pixels { var span: dvui.Rect.Physical = .{ .w = @floatFromInt(sources[0].w), .h = @floatFromInt(sources[0].h) }; - for (sources[1..]) |s| span = span.unionWith(.{ .x = s.at.x, .y = s.at.y, .w = @floatFromInt(s.w), .h = @floatFromInt(s.h) }); + for (sources[1..]) |s| span = span.unionWith(.{ + .x = s.at.x, + .y = s.at.y, + .w = @floatFromInt(s.w), + .h = @floatFromInt(s.h), + }); const w: u32 = @intFromFloat(@min(@round(span.w), max_side)); const h: u32 = @intFromFloat(@min(@round(span.h), max_side)); const out = try arena.alloc(u8, @as(usize, w) * h * 4); diff --git a/sdk/replay/Input.zig b/sdk/replay/Input.zig index 611a8cc64..5720c22d3 100644 --- a/sdk/replay/Input.zig +++ b/sdk/replay/Input.zig @@ -192,7 +192,14 @@ fn pressChord(c: chord.Chord) void { // dvui keeps the last key event's modifiers as the window's, and every pointer event after // carries them: let go of the modifier, as a hand does, or the next click is a Ctrl-click. if (mod != .none) { - const modifier: dvui.enums.Key = if (c.mods.command) .left_command else if (c.mods.ctrl) .left_control else if (c.mods.alt) .left_alt else .left_shift; + const modifier: dvui.enums.Key = if (c.mods.command) + .left_command + else if (c.mods.ctrl) + .left_control + else if (c.mods.alt) + .left_alt + else + .left_shift; addKey(cw, .{ .code = modifier, .mod = .none, .action = .up }); } } diff --git a/sdk/replay/LiveDriver.zig b/sdk/replay/LiveDriver.zig index fbc58c1f2..4ae34ec03 100644 --- a/sdk/replay/LiveDriver.zig +++ b/sdk/replay/LiveDriver.zig @@ -2,12 +2,12 @@ //! landed. The other way to play a tape besides a demo, and a driver of its own rather than a //! mode of the `Player` (`plans/AGENTS_PLAN.md`, "Driving a live tape"): //! -//! | | a demo (`Player`) | a live tape (this) | -//! |-----------------------|-------------------------------------|-----------------------------------| -//! | starts from | a keyframe; the session set aside | the app as it is (`Check.live`) | -//! | pacing | the tape's authored times | each op once the app has settled | -//! | a person steps in | pause, and replay on resume | stop, and say before which op | -//! | going back | seek: snapshots, keyframes | none — undo, through commands | +//! | | a demo (`Player`) | a live tape (this) | +//! | --- | --- | --- | +//! | starts from | a keyframe; the session set aside | the app as it is (`Check.live`) | +//! | pacing | the tape's authored times | each op once the app has settled | +//! | a person steps in | pause, and replay on resume | stop, and say before which op | +//! | going back | seek: snapshots, keyframes | none — undo, through commands | //! //! Consumers: a plugin's tests, a macro, a scripted tutorial, anything driving fizzy the way a //! person does. Plugins reach it through the `automation` service (`Service`). @@ -18,13 +18,13 @@ //! live tape while a demo is loaded, and fizzy's `Demo` a demo while a live tape plays). //! //! **Pacing.** Authored times are ignored: each frame the driver asks the sequencer for the next -//! moment anything happens (`Sequencer.nextAt`) and applies it, and goes on only while the stage -//! is idle — nothing it was asked to load still loading. Except a drag: a glide with a button -//! held plays at its authored pace, a frame at a time, as a hand moves (`drag_step_ms`). The sequencer still yields after -//! everything the app has to draw before the next op can land (a press, a key, the end of a -//! glide), so a click on a menu the last click opened finds it open. A tape's own waits hold as -//! they do in a demo, and one that gives up stops the tape: a live tape that has lost its place -//! is better stopped and reported than carried on blind. +//! moment anything happens (`Sequencer.nextAt`) and applies it, and goes on only while the stage is +//! idle — nothing it was asked to load still loading. Except a drag: a glide with a button held +//! plays at its authored pace, a frame at a time, as a hand moves (`drag_step_ms`). The sequencer +//! still yields after everything the app has to draw before the next op can land (a press, a key, +//! the end of a glide), so a click on a menu the last click opened finds it open. A tape's own +//! waits hold as they do in a demo, and one that gives up stops the tape: a live tape that has lost +//! its place is better stopped and reported than carried on blind. //! //! **Interruption.** A real click, tap, scroll, key or typed text stops it, and the person's //! input goes on to do what they meant. Real pointer motion is held off while it plays, as a diff --git a/sdk/replay/Player.zig b/sdk/replay/Player.zig index 769208328..d7f96c0e7 100644 --- a/sdk/replay/Player.zig +++ b/sdk/replay/Player.zig @@ -42,14 +42,15 @@ //! and the backend's clock is moved on by as much (`frames`' `clock`) so dvui's stays monotonic //! and continuous after. The player's own chrome (the transport bar) runs on the wall (`wallNs`). //! -//! **Closing** always leaves the app as it is (`Stage.keep`), and "Revert to before" (`revert`) is the one way back to the session -//! the demo set aside. `play` after a person changed things carries on from there as a new branch -//! (`branched`) rather than replaying over their work; seeking back is the history. +//! **Closing** always leaves the app as it is (`Stage.keep`), and "Revert to before" (`revert`) is +//! the one way back to the session the demo set aside. `play` after a person changed things carries +//! on from there as a new branch (`branched`) rather than replaying over their work; seeking back +//! is the history. //! //! **Interruption**: while playing, a real click, tap, scroll or key pauses the demo — a pointer //! event goes on to do what the person meant, the first key is only taken as "stop". Real pointer -//! motion is held off (the tape owns the pointer while it plays). Anything real that reaches the app -//! while paused marks the app `diverged` from the tape. +//! motion is held off (the tape owns the pointer while it plays). Anything real that reaches the +//! app while paused marks the app `diverged` from the tape. const Player = @This(); const std = @import("std"); @@ -227,8 +228,8 @@ pub const Transport = struct { /// physical, as far round it as the pointer is on it: what the bar hides into, and a press /// there brings it back. Null when it was not drawn. rest: ?dvui.Rect.Physical = null, - /// Sent into where it rests (its tab): the bar stays there, playing or not, until that brings it - /// back (a press here, the pointer held on it in the overlay). + /// Sent into where it rests (its tab): the bar stays there, playing or not, until that brings + /// it back (a press here, the pointer held on it in the overlay). tucked: bool = false, /// The speed slider, label and line (`Sequencer.speed`), whose notches are `Sequencer.speeds`. speed: dvui.Rect.Physical = .{}, @@ -373,7 +374,8 @@ pub fn revert(self: *Player) void { self.dismiss(.revert); } -/// Stop at once and keep the app as it is, as `close` does: the command's way, with no bar to run back. +/// Stop at once and keep the app as it is, as `close` does: the command's way, with no bar to run +/// back. pub fn stop(self: *Player) void { self.release(.keep); } @@ -717,7 +719,10 @@ fn verify(self: *Player, s: Snapshot) void { if (got == want) return; self.mismatches += 1; const name = if (self.tape()) |t| t.name else "?"; - log.warn("demo '{s}': replaying to {d:.0} ms did not reach what playing did — the tape does not replay exactly", .{ name, s.at }); + log.warn("demo '{s}': replaying to {d:.0} ms did not reach what playing did — the tape does not replay exactly", .{ + name, + s.at, + }); } fn dropSnapshots(self: *Player) void { @@ -733,7 +738,10 @@ fn dropSnapshots(self: *Player) void { pub fn frame(self: *Player) void { // Whether this run is seen, once the seek it asks for (or lands) is known: a run while a seek // catches up draws nothing, but the one `frames` ends it with. - defer self.run_unseen = if (self.set_unseen) |set| set((self.state == .seeking or self.catching_up) and !self.showing) else false; + defer self.run_unseen = if (self.set_unseen) |set| + set((self.state == .seeking or self.catching_up) and !self.showing) + else + false; if (self.owned == null) return; // Widgets name themselves for the tape while one is loaded (`anchor`). anchor.want(); @@ -814,7 +822,12 @@ pub fn frame(self: *Player) void { /// the runs took the app's clock past the wall — and, a seek still in flight, to the moment it /// goes on from — so the next frame carries on from there. Without one a silent run steps the /// clock the least dvui accepts, a microsecond, and timers wait for the wall. -pub fn frames(self: *Player, win: *dvui.Window, frame_fn: *const fn () anyerror!dvui.App.Result, clock: ?*i128) anyerror!dvui.App.Result { +pub fn frames( + self: *Player, + win: *dvui.Window, + frame_fn: *const fn () anyerror!dvui.App.Result, + clock: ?*i128, +) anyerror!dvui.App.Result { // Counted before the run, which may land the seek, and after it, which may have asked for one. const was_seeking = self.state == .seeking; if (was_seeking) self.seek_stats.shown += 1; @@ -869,7 +882,10 @@ fn runAt(self: *const Player, win: *dvui.Window, clock: ?*i128, base_ns: i128, b // Waiting on a glide's target to stop moving (`targetStill`), a run is a frame of motion where // the backend's clock can be moved on: on a microsecond's clock, a place sliding open would // never move between runs, and look still. - const soonest = win.frame_time_ns + if (self.aiming != null and clock != null) msToNs(Sequencer.path_step_ms) else std.time.ns_per_us; + const soonest = win.frame_time_ns + if (self.aiming != null and clock != null) + msToNs(Sequencer.path_step_ms) + else + std.time.ns_per_us; if (clock == null or self.state != .seeking) return soonest; return @max(soonest, base_ns + msToNs(self.nextMoment() - base_ms)); } @@ -1057,7 +1073,10 @@ fn transportTakes(self: *Player, e: *dvui.Event, me: dvui.Event.Mouse) bool { self.seq.setSpeedIndex(tr.speedAt(me.p.x)); } else if (tr.track.contains(me.p)) { tr.scrub = tr.timeAt(me.p.x, self.duration()); - self.scrub_after = if (self.state == .playing or (self.state == .seeking and self.after_seek == .play)) .play else .pause; + self.scrub_after = if (self.state == .playing or (self.state == .seeking and self.after_seek == .play)) + .play + else + .pause; self.scrubbed_to = null; } else if (tr.prev.contains(me.p)) { self.stepChapter(-1); @@ -1204,11 +1223,27 @@ fn failed(ctx: *anyopaque, failure: Sequencer.Failure) void { const at = if (self.tape()) |t| t.ops[failure.op].at else 0; switch (failure.what) { .wait => |until| switch (until) { - .idle => dvui.log.warn("demo '{s}': gave up waiting for the app to settle (op {d}, {d} ms); skipping {d} ops to the next chapter", .{ name, failure.op, at, failure.skipped }), - .shown => |tag| dvui.log.warn("demo '{s}': gave up waiting for '{s}' to be drawn (op {d}, {d} ms); skipping {d} ops to the next chapter", .{ name, tag, failure.op, at, failure.skipped }), - .gone => |tag| dvui.log.warn("demo '{s}': gave up waiting for '{s}' to go (op {d}, {d} ms); skipping {d} ops to the next chapter", .{ name, tag, failure.op, at, failure.skipped }), + .idle => dvui.log.warn( + "demo '{s}': gave up waiting for the app to settle (op {d}, {d} ms); " ++ + "skipping {d} ops to the next chapter", + .{ name, failure.op, at, failure.skipped }, + ), + .shown => |tag| dvui.log.warn( + "demo '{s}': gave up waiting for '{s}' to be drawn (op {d}, {d} ms); " ++ + "skipping {d} ops to the next chapter", + .{ name, tag, failure.op, at, failure.skipped }, + ), + .gone => |tag| dvui.log.warn( + "demo '{s}': gave up waiting for '{s}' to go (op {d}, {d} ms); " ++ + "skipping {d} ops to the next chapter", + .{ name, tag, failure.op, at, failure.skipped }, + ), }, - .target => |t| dvui.log.warn("demo '{s}': '{s}' was never drawn to move to (op {d}, {d} ms); skipping {d} ops to the next chapter", .{ name, t.tag, failure.op, at, failure.skipped }), + .target => |t| dvui.log.warn( + "demo '{s}': '{s}' was never drawn to move to (op {d}, {d} ms); " ++ + "skipping {d} ops to the next chapter", + .{ name, t.tag, failure.op, at, failure.skipped }, + ), } } diff --git a/sdk/replay/Snapshot.zig b/sdk/replay/Snapshot.zig index b5992b422..fb26f25e1 100644 --- a/sdk/replay/Snapshot.zig +++ b/sdk/replay/Snapshot.zig @@ -7,7 +7,10 @@ //! //! ``` //! .{ -//! .{ .role = "tab", .name = "Files", .tag = "fizzy.rail:files", .rect = .{ .x = 8, .y = 52, .w = 40, .h = 40 } }, +//! .{ +//! .role = "tab", .name = "Files", .tag = "fizzy.rail:files", +//! .rect = .{ .x = 8, .y = 52, .w = 40, .h = 40 }, +//! }, //! .{ .role = "button", .name = "Close Tab", .rect = .{ … }, .parent = 3 }, //! } //! ``` @@ -271,7 +274,23 @@ pub const Node = struct { /// The roles a person acts on: a widget with one is a node even with no tag. pub fn acted(role: dvui.AccessKit.Role) bool { return switch (role) { - .button, .default_button, .tab, .check_box, .radio_button, .link, .menu_item, .list_box_option, .tree_item, .text_input, .multiline_text_input, .search_input, .slider, .combo_box, .dialog, .alert_dialog => true, + .button, + .default_button, + .tab, + .check_box, + .radio_button, + .link, + .menu_item, + .list_box_option, + .tree_item, + .text_input, + .multiline_text_input, + .search_input, + .slider, + .combo_box, + .dialog, + .alert_dialog, + => true, else => false, }; } @@ -344,7 +363,12 @@ pub fn build(arena: std.mem.Allocator, widgets: []const CapturedWidget, windows: const owner = index_of.get(p.data.id); const within = if (owner) |i| node_of[i] else null; const subwindow = if (owner) |i| widgets[i].subwindow_id else null; - try nodes.append(arena, .{ .tag = p.name, .rect = round(p.data.rect), .parent = within, .window = windowOf(windows, p.data.id, subwindow, p.data.rect.center()) }); + try nodes.append(arena, .{ + .tag = p.name, + .rect = round(p.data.rect), + .parent = within, + .window = windowOf(windows, p.data.id, subwindow, p.data.rect.center()), + }); } return nodes.items; } @@ -361,7 +385,12 @@ pub fn write(nodes: []const Node, w: *std.Io.Writer) !void { } fn round(r: dvui.Rect.Physical) Node.Rect { - return .{ .x = @intFromFloat(@round(r.x)), .y = @intFromFloat(@round(r.y)), .w = @intFromFloat(@round(r.w)), .h = @intFromFloat(@round(r.h)) }; + return .{ + .x = @intFromFloat(@round(r.x)), + .y = @intFromFloat(@round(r.y)), + .w = @intFromFloat(@round(r.w)), + .h = @intFromFloat(@round(r.h)), + }; } /// The name widget `i` goes by, or null when it has none: its `label` text, the label widget it @@ -413,7 +442,11 @@ fn testFrame() !dvui.App.Result { defer box.deinit(); _ = dvui.button(@src(), "Save", .{}, .{ .tag = "demo.save" }); // An icon-only button: named by its label, not by anything it shows. - var close = dvui.box(@src(), .{}, .{ .role = .button, .label = .{ .text = "Close" }, .min_size_content = .{ .w = 10, .h = 10 } }); + var close = dvui.box(@src(), .{}, .{ + .role = .button, + .label = .{ .text = "Close" }, + .min_size_content = .{ .w = 10, .h = 10 }, + }); close.deinit(); // A mark, named from data, only while someone asked. var area = dvui.box(@src(), .{}, .{ .min_size_content = .{ .w = 20, .h = 20 } }); @@ -445,7 +478,12 @@ test "a snapshot names what a person acts on, and what a tape aims at" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); var diag: std.zon.parse.Diagnostics = undefined; - const nodes = try std.zon.parse.fromSlice([]const Node, .{ .gpa = std.testing.allocator, .arena = arena.allocator(), .source = try arena.allocator().dupeSentinel(u8, text, 0), .diagnostics = &diag }); + const nodes = try std.zon.parse.fromSlice([]const Node, .{ + .gpa = std.testing.allocator, + .arena = arena.allocator(), + .source = try arena.allocator().dupeSentinel(u8, text, 0), + .diagnostics = &diag, + }); var buttons: usize = 0; var save = false; var close = false; @@ -476,7 +514,10 @@ fn testDialog(id: dvui.Id) anyerror!void { const began = scopeBegin(); defer scopeEnd(began); // lint-allow(raw-transient-surface): snapshot test fixture, not a shown surface - var win = dvui.floatingWindow(@src(), .{}, .{ .id_extra = id.asUsize(), .rect = .{ .x = 10, .y = 10, .w = 80, .h = 40 } }); + var win = dvui.floatingWindow(@src(), .{}, .{ + .id_extra = id.asUsize(), + .rect = .{ .x = 10, .y = 10, .w = 80, .h = 40 }, + }); defer win.deinit(); test_dialog_window = win.data().id; _ = dvui.button(@src(), "Go ahead", .{}, .{}); @@ -506,7 +547,12 @@ fn testWindows(_: *anyopaque, arena: std.mem.Allocator) []const Window { const list = arena.dupe(Window, &.{ .{ .tag = "app.window:main", .title = "App", .rect = dvui.windowRectPixels() }, .{ .tag = "app.window:band", .rect = test_band, .area = true }, - .{ .tag = "app.window:dialog", .title = "Ask", .rect = .{ .x = 0, .y = 0, .w = 100, .h = 100 }, .subwindow = test_dialog_window }, + .{ + .tag = "app.window:dialog", + .title = "Ask", + .rect = .{ .x = 0, .y = 0, .w = 100, .h = 100 }, + .subwindow = test_dialog_window, + }, }) catch return &.{}; return list; } @@ -532,7 +578,12 @@ test "a snapshot has the dialogs dvui shows after its capture, and says which wi var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); var diag: std.zon.parse.Diagnostics = undefined; - const nodes = try std.zon.parse.fromSlice([]const Node, .{ .gpa = std.testing.allocator, .arena = arena.allocator(), .source = try arena.allocator().dupeSentinel(u8, text, 0), .diagnostics = &diag }); + const nodes = try std.zon.parse.fromSlice([]const Node, .{ + .gpa = std.testing.allocator, + .arena = arena.allocator(), + .source = try arena.allocator().dupeSentinel(u8, text, 0), + .diagnostics = &diag, + }); // The windows first, each a node. try std.testing.expect(nodes.len > 3); for (nodes[0..3], [_][]const u8{ "app.window:main", "app.window:band", "app.window:dialog" }) |node, tag| { diff --git a/sdk/replay/Stage.zig b/sdk/replay/Stage.zig index 9c423298c..44618b870 100644 --- a/sdk/replay/Stage.zig +++ b/sdk/replay/Stage.zig @@ -49,9 +49,9 @@ pub const VTable = struct { chordFor: ?*const fn (ctx: *anyopaque, id: []const u8) ?chord.Stroke = null, /// What a person would call a command ("Format Document"), for the keystroke display. commandTitle: ?*const fn (ctx: *anyopaque, id: []const u8) ?[]const u8 = null, - /// The live name of the place `role` stands for (`documents`, `explorer`, `panel`), written into - /// `buf`, for a tag like `region:role:documents`: a demo aims at what a place is, not at the - /// name the person's layout gave it. Null when the app has no such place now. Null hook: no + /// The live name of the place `role` stands for (`documents`, `explorer`, `panel`), written + /// into `buf`, for a tag like `region:role:documents`: a demo aims at what a place is, not at + /// the name the person's layout gave it. Null when the app has no such place now. Null hook: no /// roles. resolveRole: ?*const fn (ctx: *anyopaque, role: []const u8, buf: []u8) ?[]const u8 = null, /// Replaying fast (a seek): turn animation off so every replayed frame lands where it is diff --git a/sdk/replay/overlay.zig b/sdk/replay/overlay.zig index 775266f80..138055832 100644 --- a/sdk/replay/overlay.zig +++ b/sdk/replay/overlay.zig @@ -36,7 +36,12 @@ pub fn drawLive(driver: *const LiveDriver, opts: Options) void { // A cursor the app draws itself where the pointer is (a paint tool's pencil) is the pointer // there: two at one place would be one too many, so this one gives way (`anchor.own_cursor`). const own = dvui.currentWindow().tags.containsUsed(anchor.own_cursor) orelse false; - paintPointer(driver.seq.pointer, ripple, if (own) 0 else if (hand.typing) 1 - in else in, driver.input.held.count() > 0); + paintPointer( + driver.seq.pointer, + ripple, + if (own) 0 else if (hand.typing) 1 - in else in, + driver.input.held.count() > 0, + ); } /// A demo's pointer, while it drives, timed in demo time: a seek shows what playing showed. @@ -44,10 +49,16 @@ pub fn drawPlayer(player: *Player, opts: Options) void { if (!player.driving()) return; const fw = layer(@src(), .{ .rect = pointerRect(player.seq.pointer), .name = "DemoPointer" }, .{}); defer fw.deinit(); - const ripple: ?Ripple = if (player.last_press) |press| .{ .pt = press.pt, .age_ms = player.seq.now - press.at } else null; + const ripple: ?Ripple = if (player.last_press) |press| .{ + .pt = press.pt, + .age_ms = player.seq.now - press.at, + } else null; // Out of the way of the words while the tape types, as a desktop's pointer is, and of a // cursor the app draws itself (`drawLive`). - const shown = if (dvui.currentWindow().tags.containsUsed(anchor.own_cursor) orelse false) 0 else player.pointerShown(opts.fade_ms); + const shown = if (dvui.currentWindow().tags.containsUsed(anchor.own_cursor) orelse false) + 0 + else + player.pointerShown(opts.fade_ms); paintPointer(player.seq.pointer, ripple, shown, player.input.held.count() > 0); } @@ -63,7 +74,11 @@ fn pointerRect(p: Sequencer.Point) dvui.Rect { /// A floating widget that takes no input, raised to the top. A floating widget otherwise stays /// just above the window it was made in, under anything opened after it; re-added as a subwindow /// of its own it can be raised, and each call puts the new one above the last. -pub fn layer(src: std.lang.SourceLocation, opts: dvui.Options, init: dvui.FloatingWidget.InitOptions) *dvui.FloatingWidget { +pub fn layer( + src: std.lang.SourceLocation, + opts: dvui.Options, + init: dvui.FloatingWidget.InitOptions, +) *dvui.FloatingWidget { var init_opts = init; init_opts.mouse_events = false; const fw = dvui.widgetAlloc(dvui.FloatingWidget); @@ -118,7 +133,11 @@ pub fn paintPointer(p: Sequencer.Point, ripple: ?Ripple, shown: f32, pressed: bo dvui.Path.fill(&.{built}, .{ .color = .{ .color = dvui.Color.black.opacity(0.28) }, .fade = 2.5 * s }); } else { dvui.Path.fill(&.{built}, .{ .color = .{ .color = .white }, .fade = 1 }); - built.stroke(.{ .thickness = 1.1 * s, .color = .{ .color = dvui.Color.black.opacity(0.85) }, .closed = true }); + built.stroke(.{ + .thickness = 1.1 * s, + .color = .{ .color = dvui.Color.black.opacity(0.85) }, + .closed = true, + }); } } } diff --git a/src/Entry.zig b/src/Entry.zig index 08ecdf247..2df3ebd29 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -438,7 +438,10 @@ fn reportFirstFrame() void { const raw = std.c.getenv("FIZZY_STARTUP_REPORT") orelse return; if (std.mem.eql(u8, std.mem.span(raw), "0")) return; const ph = fizzy.core.profile.phases() orelse return; - std.debug.print("startup: first frame {d:.3} ms after main\n", .{ph.ms(ph.len - 1)}); + var buf: [64]u8 = undefined; + var err = std.Io.File.stderr().writer(dvui.io, &buf); + err.interface.print("startup: first frame {d:.3} ms after main\n", .{ph.ms(ph.len - 1)}) catch return; + err.interface.flush() catch return; } /// Where the window's reveal is (`revealOnceDrawn`): drawing its first frame, then seen. @@ -487,6 +490,8 @@ pub fn AppDeinit(_: *dvui.Window) void { // `editor.deinit` runs each plugin's `deinit` first (pixi's persists its `.fizproject` and // frees its own state + packer while `editor.app.host`/folder are still live). fizzy.editor().deinit() catch unreachable; + // A check still in flight is given a moment, then left to the process exit. + update_notify.deinit(dvui.io); // Tear down the singleton listener after the editor so any callback // currently in flight finishes before we free state it touches. singleton.deinit(); diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index 893bd54bb..c8bb7174b 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -90,7 +90,7 @@ pub fn setWindowStyle(_: *dvui.Window) void {} /// popup already returns to the page that opened it. pub fn raiseWindow() void {} -/// The page's `keydown` check (`web/index.html`): it writes `KeyboardEvent.key` here, then asks +/// The page's `keydown` check (`web/fizzy.js`): it writes `KeyboardEvent.key` here, then asks /// `FizzyWebKeyBound` whether the app binds it — see `Keybinds.webKeyBound`. export fn FizzyWebKeyBuffer() [*]u8 { return &fizzy.Editor.Keybinds.web_key_buf; @@ -108,7 +108,7 @@ const page = if (builtin.target.cpu.arch == .wasm32) struct { fn fizzy_web_toggle_fullscreen() void {} }; -/// The page's Fullscreen API (`web/index.html`), which also takes the keyboard lock so the +/// The page's Fullscreen API (`web/fizzy.js`), which also takes the keyboard lock so the /// shortcuts a browser keeps for itself (⌘W, ⌘T, Ctrl+Tab) reach the app while full screen. /// Runs a frame after the key press or click that asked for it, which is inside the browser's /// user-activation window. @@ -161,13 +161,13 @@ pub fn setupMacOSMenuBar() void {} pub fn teardownMacOSMenuBar() void {} // Browser trackpad pinch arrives as a `wheel` event with `ctrlKey=true` (synthesized by every -// modern browser). The bootstrap JS in `web/index.html` intercepts those events in the +// modern browser). The bootstrap JS in `web/fizzy.js` intercepts those events in the // capture phase, prevents the browser's default page-zoom, and forwards the magnification // delta into the wasm export below. Same accumulator pattern as the macOS native trackpad // monitor — the canvas widget drains via `takeTrackpadPinchRatio` once per frame. var pending_pinch_ratio: f32 = 1.0; -/// Called from `web/index.html` via `app.instance.exports.FizzyWebTrackpadMagnification` +/// Called from `web/fizzy.js` via `app.instance.exports.FizzyWebTrackpadMagnification` /// for every pinch wheel event. `delta` is a small relative magnification (positive = zoom in) /// derived from `-ev.deltaY` scaled to match macOS NSEvent magnification magnitudes. export fn FizzyWebTrackpadMagnification(delta: f32) void { diff --git a/src/backend/layout_file.zig b/src/backend/layout_file.zig index 130a34747..5ac1e9dc9 100644 --- a/src/backend/layout_file.zig +++ b/src/backend/layout_file.zig @@ -44,6 +44,9 @@ pub const SavedRegion = struct { /// That split is fizzy's own, not the person's (`Branch.adaptive`): it follows its space's /// shape and turns with it. Absent is the person's, as every split of an older file is. adaptive: ?bool = null, + /// An adaptive split's new half as a share of its box (`Branch.share`), kept so a restart in + /// a box of another shape comes back to the same proportion. Absent: not sized yet. + share: ?f32 = null, /// What a fizzy before tabs-when-many wrote: whether the place showed one view or several. /// Read only to migrate (`loadRegions`), never written: a place now shows tabs exactly when /// it holds more than one view. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index ac88a0bca..ab67179b2 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -843,7 +843,7 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { dvui.log.info("user plugin '{s}': already loaded as a built-in; skipped", .{plugin_id}); } else { dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id}); - const probe = PluginLoader.probeVersionInfo(path); + const probe = PluginLoader.probeVersionInfo(editor.app.gpa, path); editor.app.recordPluginFailure(plugin_id, "id already registered by a built-in plugin", null, if (probe) |info| info.plugin_version else null, .of(path)); } editor.app.gpa.free(path); @@ -1623,7 +1623,7 @@ fn beginWebPluginLoad(editor: *Editor, id: []const u8, url: []const u8, sha256: // Two requests for one id before the first lands (the page's remembered list and a // `?plugin=` of the same id, say) would register it twice; the second one waits for nothing. // The first one's `arrived` reports for both, so the store's spinner still ends: every - // request ends in exactly one `arrived` (the page's fetches have a deadline, `index.html`'s + // request ends in exactly one `arrived` (the page's fetches have a deadline, `fizzy.js`'s // `fetchPluginBytes`, and its `.catch` reports a failure), and `arrived` settles the store's // row by id, so this early return cannot leave a row on "Updating…". if (web_loads_in_flight.contains(id)) return; @@ -5186,6 +5186,7 @@ pub fn collectSavedRegions(editor: *Editor, gpa: std.mem.Allocator) []fizzy.back gop.value_ptr.parent = l.parent; gop.value_ptr.from = @tagName(l.side); gop.value_ptr.adaptive = if (l.adaptive) true else null; + gop.value_ptr.share = l.share; } } { @@ -5285,7 +5286,10 @@ fn loadRuntimeSplits(state: *Layout.State, gpa: std.mem.Allocator, saved: []cons const parent = r.parent orelse continue; const side = Layout.State.SplitTree.parseSide(r.from orelse continue) orelse continue; const made = state.splits.split(gpa, intern.go, parent, side, r.extent orelse 0, r.name); - if (made != null and (r.adaptive orelse false)) _ = state.splits.markAdaptive(r.name); + if (made != null and (r.adaptive orelse false)) { + _ = state.splits.markAdaptive(r.name); + if (r.share) |share| _ = state.splits.setShare(r.name, share); + } } } @@ -7302,7 +7306,7 @@ pub fn deinit(editor: *Editor) !void { // Owned outright rather than cached by dvui, so it has to be released explicitly. editor.app.layout.center_transition.discard(); editor.app.layout.center_prev_id = null; - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); editor.app.layout.deinitSwaps(editor.app.gpa); editor.app.layout.regions.deinit(editor.app.gpa); editor.preview_docs.deinit(editor.app.gpa); @@ -7342,6 +7346,7 @@ pub fn deinit(editor: *Editor) !void { editor.checkpoint.deinit(editor); // Rebuilds still opening: their threads joined, what they opened dropped unregistered. editor.plugin_reloads.deinit(editor.app.gpa); + if (comptime builtin.target.cpu.arch != .wasm32) PluginLoader.deinitOpenedPaths(editor.app.gpa); if (editor.session) |*kept| kept.deinit(); // Before the plugin `deinit` loop below: `tick` fans out into plugin vtables, and this // joins the thread that feeds it. diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 0677e678f..29db9c310 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -1532,7 +1532,7 @@ pub var web_key_buf: [16]u8 = undefined; /// editor exists. var web_keys_ready = false; -/// Asked by `web/index.html` from inside a `keydown` listener, before the browser acts on the key: +/// Asked by `web/fizzy.js` from inside a `keydown` listener, before the browser acts on the key: /// true means "this key is the app's", and the page cancels the browser's default (⌘S saving the /// page, ⌘P printing it, ⌘O opening a file of the browser's own). It has to be answered here and /// synchronously — by the time dvui sees the event on the next frame the browser has already diff --git a/src/editor/PluginReloads.zig b/src/editor/PluginReloads.zig index 70cd48583..45da551be 100644 --- a/src/editor/PluginReloads.zig +++ b/src/editor/PluginReloads.zig @@ -69,18 +69,19 @@ pub fn start(self: *PluginReloads, editor: *Editor, id: []const u8) void { .started_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds, }; self.jobs.append(gpa, job) catch return freeJob(gpa, job); - job.thread = std.Thread.spawn(.{}, work, .{job}) catch |err| { + job.thread = std.Thread.spawn(.{}, work, .{ job, gpa }) catch |err| { dvui.log.warn("plugin watcher: could not start reloading '{s}': {t}", .{ id, err }); _ = self.jobs.pop(); return freeJob(gpa, job); }; } -fn work(job: *Job) void { +fn work(job: *Job, gpa: std.mem.Allocator) void { var name_buf: [64]u8 = undefined; const timed = fizzy.core.profile.span(std.mem.print(&name_buf, "plugin open {s}", .{job.id}) catch "plugin open"); - // The worker's own allocator: `open` only needs a scratch path from it. - job.opened = PluginLoader.open(std.heap.smp_allocator, job.path, job.id); + // The app's allocator, not a worker's own: `open` also keeps the opened-path set's keys in it + // (`PluginLoader.deinitOpenedPaths` frees them with the app's). + job.opened = PluginLoader.open(gpa, job.path, job.id); timed.end(); job.opened_ns = std.Io.Clock.boot.now(dvui.io).nanoseconds; job.done.store(true, .release); diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 325025ca4..65dd8cb44 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -636,19 +636,8 @@ fn drawHoldMenuDuration() void { } fn drawInputScheme() void { - var dropdown: core.widgets.DropdownWidget = undefined; - dropdown.init(@src(), .{}, .{ - .expand = .horizontal, - .corners = dvui.CornerRect.all(1000), - }); - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - const label_text: []const u8 = switch (fizzy.editor().app.settings.input_scheme) { + const scheme = &fizzy.editor().app.settings.input_scheme; + const label_text: []const u8 = switch (scheme.*) { .auto => switch (dvui.mouseType()) { .unknown => "Auto", .mouse, .trackpad => |hint| dvui.currentWindow().arena().print("Auto ({s})", .{@tagName(hint)}) catch "Auto", @@ -656,62 +645,12 @@ fn drawInputScheme() void { .mouse => "Mouse", .trackpad => "Trackpad", }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); - core.icon.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); - - hbox.deinit(); - - if (dropdown.dropped()) { - inline for (.{ - .{ "Auto", Editor.Settings.InputScheme.auto }, - .{ "Mouse", Editor.Settings.InputScheme.mouse }, - .{ "Trackpad", Editor.Settings.InputScheme.trackpad }, - }) |choice| { - if (dropdown.addChoiceLabel(choice[0])) { - fizzy.editor().app.settings.input_scheme = choice[1]; - fizzy.editor().markSettingsDirty(); - dvui.refresh(null, @src(), null); - } - } - } + drawEnumDropdown(scheme, label_text, &.{ "Auto", "Mouse", "Trackpad" }); } fn drawLayoutMode() void { - var dropdown: core.widgets.DropdownWidget = undefined; - dropdown.init(@src(), .{}, .{ - .expand = .horizontal, - .corners = dvui.CornerRect.all(1000), - }); - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - const label_text: []const u8 = switch (fizzy.editor().app.settings.layout_mode) { - .auto => "Auto", - .mobile => "Mobile", - .desktop => "Desktop", - }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); - core.icon.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); - - hbox.deinit(); - - if (dropdown.dropped()) { - inline for (.{ - .{ "Auto", Editor.Settings.LayoutMode.auto }, - .{ "Mobile", Editor.Settings.LayoutMode.mobile }, - .{ "Desktop", Editor.Settings.LayoutMode.desktop }, - }) |choice| { - if (dropdown.addChoiceLabel(choice[0])) { - fizzy.editor().app.settings.layout_mode = choice[1]; - fizzy.editor().markSettingsDirty(); - dvui.refresh(null, @src(), null); - } - } - } + const mode = &fizzy.editor().app.settings.layout_mode; + drawEnumDropdown(mode, null, &.{ "Auto", "Mobile", "Desktop" }); } // ---- Plugins ---------------------------------------------------------------------------- @@ -720,6 +659,18 @@ fn drawLayoutMode() void { /// the plugin's own card in the Plugins tab (`PluginStore`), not a row here — there is one of /// those per installed plugin, and the tree is fizzy's own settings. fn drawPluginUpdateMode() void { + const mode = &fizzy.editor().app.settings.plugin_update_mode; + drawEnumDropdown(mode, null, &.{ "Prompt", "Silent" }); +} + +/// A dropdown over every value of enum `E`: `choices` are the menu's labels in the enum's +/// declaration order, and the closed button shows the current one unless `label_text` says +/// otherwise. A pick marks the settings dirty. +// anytype: a pointer to any settings enum; `E` is read off its type +fn drawEnumDropdown(value: anytype, label_text: ?[]const u8, comptime choices: []const []const u8) void { + const E = @typeInfo(@TypeOf(value)).pointer.child; + comptime std.debug.assert(choices.len == std.enums.values(E).len); + var dropdown: core.widgets.DropdownWidget = undefined; dropdown.init(@src(), .{}, .{ .expand = .horizontal, @@ -731,23 +682,14 @@ fn drawPluginUpdateMode() void { .expand = .vertical, .gravity_x = 1.0, }); - - const label_text: []const u8 = switch (fizzy.editor().app.settings.plugin_update_mode) { - .prompt => "Prompt", - .silent => "Silent", - }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); + dvui.label(@src(), "{s}", .{label_text orelse choices[@backingInt(value.*)]}, .{ .margin = .all(0), .padding = .all(0) }); core.icon.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); - hbox.deinit(); if (dropdown.dropped()) { - inline for (.{ - .{ "Prompt", Editor.Settings.PluginUpdateMode.prompt }, - .{ "Silent", Editor.Settings.PluginUpdateMode.silent }, - }) |choice| { - if (dropdown.addChoiceLabel(choice[0])) { - fizzy.editor().app.settings.plugin_update_mode = choice[1]; + inline for (choices, std.enums.values(E)) |choice_label, choice| { + if (dropdown.addChoiceLabel(choice_label)) { + value.* = choice; fizzy.editor().markSettingsDirty(); dvui.refresh(null, @src(), null); } diff --git a/tests/demos.zig b/tests/demos.zig index cbf5ad6a2..492694863 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -395,23 +395,27 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la defer gpa.free(panes_zon); try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = panes_zon, .data = ".{ .persons = true }" }); - // The explorer shut, so the person's pane has the room for the tour's drop to split it - // (`core.placement`): with it open the drop is left out of the tour. - app.editor.explorer.close(app.editor); - try dvui.testing.settle(headlessFrame); + // The explorer left open: the person's pane has no room for the tour's drop to split it + // (`core.placement`), so the drop is left out and the tab is brought forward instead. + // (The integration test plays the tour with it shut, for the split.) + try std.testing.expect(!app.editor.explorer.closed); try app.editor.demo.play("tour"); var held = true; var split = false; + var forward = false; frames = 0; while (p.state != .ended and frames < 5_000) : (frames += 1) { _ = try dvui.testing.step(headlessFrame); if (frames == 30) _ = try app.editor.openFilePath(later, wb.currentGroupingID()); if (frames > 5 and p.state == .playing) held = held and wb.panes_held; split = split or wb.workspaces.count() > panes_before.groupings.len; + forward = forward or std.mem.endsWith(u8, activePath(app.editor), "src/main.zig"); } try std.testing.expectEqual(automation.Player.State.ended, p.state); + try std.testing.expectEqual(@as(u32, 0), p.timeouts); try std.testing.expect(held); - try std.testing.expect(split); + try std.testing.expect(!split); + try std.testing.expect(forward); p.unload(); try dvui.testing.settle(headlessFrame); diff --git a/tests/integration.zig b/tests/integration.zig index 6975e852f..5d3ff73e0 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2853,7 +2853,7 @@ test "a hide_when_empty panel stays while its view is dragged onto main" { defer editor.app.layout.deinitQualified(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); defer editor.app.layout.deinitExtents(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); try editor.app.host.registerSurface(.{ .id = "test.main", .title = "Main", .keywords = fizzy.sdk.keywords.ide.main, .draw = EmptyPanelFrame.drawMain }); try editor.app.host.registerSurface(.{ .id = "test.output", .title = "Output", .keywords = fizzy.sdk.keywords.ide.panel, .draw = EmptyPanelFrame.drawMain }); @@ -3836,6 +3836,78 @@ test "an adaptive split turns with the window, keeps its share and writes nothin try std.testing.expect(!editor.app.layout.dirty); } +// The half of an adaptive split that is itself split is an outer branch, not a leaf: its turn +// waits for it to slide shut, and while drawn turned its extent is not the person's to save. +test "an adaptive split whose new half is split again turns and writes nothing" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const ctx = &case.ctx; + const editor = ctx.editor; + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + layout.markAdaptive("Main/r1"); + } + ctx.t.backend.size = .{ .w = 1180, .h = 820 }; + ctx.t.backend.size_pixels = .{ .w = 1180, .h = 820 }; + for (0..40) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + const left = try ColumnCardFrame.sizeOf(editor, "Main"); + try std.testing.expect(left.w < left.h * 1.15); + const saved_extent = editor.app.layout.extent("Main/r1", 0); + editor.app.layout.dirty = false; + + ctx.t.backend.size = .{ .w = 820, .h = 1180 }; + ctx.t.backend.size_pixels = .{ .w = 820, .h = 1180 }; + for (0..60) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + const top = try ColumnCardFrame.sizeOf(editor, "Main"); + const column = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expect(column.h > 40); + try std.testing.expectApproxEqAbs(top.w, column.w, 40); + try std.testing.expect(!editor.app.layout.dirty); + try std.testing.expectEqual(saved_extent, editor.app.layout.extent("Main/r1", 0)); +} + +// A half saved in landscape is a pixel extent along the width. Over a portrait box that number is +// another share, so the share is saved beside it and the turn uses that one. +test "an adaptive split saved beside comes back as the same share stacked" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + + ColumnCardFrame.editor = editor; + defer ColumnCardFrame.editor = null; + + // A restart in portrait: the layout as loaded (`loadRuntimeSplits`) has the landscape half, + // 590 across a 1180 box, and its share of a half. + ctx.t.backend.size = .{ .w = 820, .h = 1180 }; + ctx.t.backend.size_pixels = .{ .w = 820, .h = 1180 }; + try dvui.testing.settle(ColumnCardFrame.frame); + const state = &editor.app.layout; + const intern = struct { + var st: *fizzy.Editor.Layout.State = undefined; + fn go(a: std.mem.Allocator, name: []const u8) []const u8 { + return st.internName(a, name); + } + }; + intern.st = state; + try std.testing.expect(state.splits.split(editor.app.gpa, intern.go, "Main", .right, 590, "Main/r1") != null); + try std.testing.expect(state.splits.markAdaptive("Main/r1")); + try std.testing.expect(state.splits.setShare("Main/r1", 0.5)); + _ = state.setExtent(editor.app.gpa, "Main/r1", 590); + + for (0..60) |_| _ = try dvui.testing.step(ColumnCardFrame.frame); + const top = try ColumnCardFrame.sizeOf(editor, "Main"); + const bottom = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + // Not 590 over the 820 width, which is the share the pixels alone would say. + try std.testing.expectApproxEqAbs(0.5, bottom.h / (top.h + bottom.h), 0.03); +} + /// Main | (Main/r1 over Main/r1/b1) in fizzy's split forest, each holding one view. Main/r1 is two /// things at once: the half Main's split minted, and the half its own split kept. const NestedSplitCase = struct { @@ -4149,10 +4221,10 @@ test "split: the trash is offered out of a place of several, not out of a lone d // Joined back, Center is the shape's one place again, showing one view. try case.place("Center", "Center", .remove); var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); VD.begin(&layout, "Center", .{ .w = 100, .h = 100 }, .{ .w = 100, .h = 100 }); try std.testing.expect(!VD.removable(&layout)); - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); // Holding several, the trash takes just the view carried. try editor.app.layout.assign(editor.app.gpa, "Center", &.{ "test.view", "test.other" }); try dvui.testing.settle(EndlessFrame.frame); @@ -4167,7 +4239,7 @@ test "drag: a strip whose place cannot take the view is not a chooser for it" { const editor = case.ctx.editor; const VD = fizzy.Editor.Layout.ViewDrag; var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); VD.begin(&layout, "Center", .{ .w = 100, .h = 100 }, .{ .w = 100, .h = 100 }); const strip: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 200, .h = 30 }; // A strip of a place the drag mapped no target for (a document pane, say) is passed over... @@ -4529,7 +4601,7 @@ test "dragging a tab off a Multiple place's strip starts the view drag with it" defer editor.app.layout.deinitExtents(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); EndlessFrame.editor = editor; defer EndlessFrame.editor = null; @@ -4559,7 +4631,7 @@ test "a tab carried off a Multiple place's strip and back along it goes in where defer editor.app.layout.deinitExtents(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); EndlessFrame.editor = editor; defer EndlessFrame.editor = null; @@ -4608,7 +4680,7 @@ test "a view let go over another place's chooser goes into its list where along defer editor.app.layout.deinitExtents(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); EndlessFrame.editor = editor; defer EndlessFrame.editor = null; @@ -4964,7 +5036,7 @@ test "a drop runs back together after the release, whatever happened to its plac var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); fizzy.Editor.Layout.ViewDrag.begin(&layout, "Panel", panel_at, panel_at); } - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = main_at.center() }); try dvui.testing.settle(DropFinishFrame.frame); try std.testing.expect(DropZones.showing(DropFinishFrame.key)); @@ -4977,7 +5049,7 @@ test "a drop runs back together after the release, whatever happened to its plac } // Released, and the place it was over is now called something else. - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); editor.app.layout.registerRegion(editor.app.gpa, .{ .name = "Main/b0", .keywords = main_kw, .bounds = main_at }); editor.app.layout.registerRegion(editor.app.gpa, .{ .name = "Panel", .keywords = panel_kw, .bounds = panel_at }); editor.app.layout.publishRegions(); @@ -5020,7 +5092,7 @@ test "a drag aims at the places that were there when it began" { var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); const ViewDrag = fizzy.Editor.Layout.ViewDrag; ViewDrag.begin(&layout, "Panel", panel_at, panel_at); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); try std.testing.expectEqualStrings("Main", ViewDrag.targetAt(&layout, middle, "Panel") orelse return error.TestExpectedEqual); @@ -5240,7 +5312,7 @@ test "drag: a document not open yet goes only to a document's slot, whose drop g defer editor.app.layout.regions_building.deinit(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); const state = &editor.app.layout; const gpa = editor.app.gpa; @@ -5317,7 +5389,7 @@ test "float: a document tab dropped on its own pane's middle floats in a view ho defer editor.app.layout.deinitExtents(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); const state = &editor.app.layout; const gpa = editor.app.gpa; @@ -5351,7 +5423,7 @@ test "float: a document tab dropped on its own pane's middle floats in a view ho DropProbe.last = null; VD.beginLoose(&layout, "text.doc:/project/a.md", .{ .x = 110, .y = 44, .w = 120, .h = 24 }, null); VD.place(&layout, VD.loose_source, "Pane 0", .swap); - state.view_drag.discard(); + state.view_drag.discard(gpa); try std.testing.expect(DropProbe.last == null); try std.testing.expectEqualStrings("text.doc:/project/a.md", DocViews.housed[0..DocViews.housed_len]); try std.testing.expectEqual(@as(usize, 1), state.floats.items.items.len); @@ -5368,7 +5440,7 @@ test "float: a document tab dropped on its own pane's middle floats in a view ho DocViews.lone = "test.workspace.1"; VD.beginLoose(&layout, "text.doc:/project/b.md", .{ .x = 110, .y = 44, .w = 120, .h = 24 }, null); VD.place(&layout, VD.loose_source, "Pane 0", .swap); - state.view_drag.discard(); + state.view_drag.discard(gpa); try std.testing.expectEqual(@as(usize, 2), state.floats.items.items.len); try std.testing.expectEqualStrings("Main", state.floats.items.items[1].home); try std.testing.expectEqualStrings("test.workspace.1", (state.assignment("Float 2") orelse return error.TestExpectedEqual)[0]); @@ -5378,7 +5450,7 @@ test "float: a document tab dropped on its own pane's middle floats in a view ho regions.publish(state, gpa, true); VD.beginLoose(&layout, "text.doc:/project/b.md", .{ .x = 110, .y = 44, .w = 120, .h = 24 }, null); VD.place(&layout, VD.loose_source, "Pane 0", .swap); - state.view_drag.discard(); + state.view_drag.discard(gpa); try std.testing.expectEqual(@as(usize, 2), state.floats.items.items.len); try std.testing.expect(DropProbe.last != null); @@ -5432,7 +5504,7 @@ test "dragging a view over its own place shows that place's drop zones" { defer editor.app.layout.deinitExtents(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); EndlessFrame.editor = editor; defer EndlessFrame.editor = null; @@ -5487,7 +5559,7 @@ test "drop: beside marks the tall target's bottom rather than the wide source's defer state.deinitExtents(gpa); defer state.deinitAssignments(gpa); defer state.deinitQualified(gpa); - defer state.view_drag.discard(); + defer state.view_drag.discard(gpa); const source: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 1200, .h = 800 }; const target: dvui.Rect.Physical = .{ .x = 1200, .y = 0, .w = 820, .h = 1180 }; state.registerRegion(gpa, .{ .name = "Source", .keywords = fizzy.sdk.keywords.ide.main, .bounds = source, .size = .{ .w = source.w, .h = source.h } }); @@ -7599,7 +7671,7 @@ test "float: a drag aims at a float's place over it, at nothing over its header, var layout = fizzy.Editor.Layout.init(&editor.app.host, state, gpa, dvui.currentWindow().arena()); const ViewDrag = fizzy.Editor.Layout.ViewDrag; ViewDrag.begin(&layout, "Panel", panel_at, panel_at); - defer state.view_drag.discard(); + defer state.view_drag.discard(gpa); // Over the float: its place, though Main is under it too. try std.testing.expectEqualStrings("Float 1", ViewDrag.targetAt(&layout, .{ .x = 350, .y = 250 }, "Panel") orelse @@ -7668,7 +7740,7 @@ test "float: a place a float lies over has its drop in the part left clear, wher var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, gpa, dvui.currentWindow().arena()); const ViewDrag = fizzy.Editor.Layout.ViewDrag; ViewDrag.begin(&layout, "Panel", FloatOverMain.panel_at, FloatOverMain.panel_at); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); // Main's middle is under the float: its drop is in Main, clear of the float, all of it. const clear = ViewDrag.zoneBounds(&editor.app.layout, "Main") orelse return error.TestExpectedEqual; @@ -7703,7 +7775,7 @@ test "float: a place a float lies all over has no drop" { var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, gpa, dvui.currentWindow().arena()); const ViewDrag = fizzy.Editor.Layout.ViewDrag; ViewDrag.begin(&layout, "Panel", FloatOverMain.panel_at, FloatOverMain.panel_at); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); try std.testing.expect(ViewDrag.zoneBounds(&editor.app.layout, "Main/r1") == null); try std.testing.expect(ViewDrag.wheelOf(&editor.app.layout, "Main/r1") == null); @@ -7721,7 +7793,7 @@ test "float: dragging a place's view onto its own middle floats it, over a seed defer editor.app.layout.deinitExtents(editor.app.gpa); defer editor.app.layout.deinitAssignments(editor.app.gpa); defer editor.app.layout.deinitQualified(editor.app.gpa); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); EndlessFrame.editor = editor; defer EndlessFrame.editor = null; @@ -7824,7 +7896,7 @@ test "float: the float a view is carried out of is a ghost while the view is aim // Lifted from its corner button, over the float: it is still itself. try pointTo(.{ .x = bounds.x + bounds.w - 16, .y = header.y + header.h + 16 }, ManyPanelFrame.frame, 1); ViewDrag.begin(&layout, "Float 1", bounds, bounds); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); // Mapped with the others, marked as the one the view is carried out of. try std.testing.expectEqual(@as(usize, 1), editor.app.layout.view_drag.occluder_count); try std.testing.expect(editor.app.layout.view_drag.occluders[0].source); @@ -7853,7 +7925,7 @@ test "float: the float a view is carried out of is a ghost while the view is aim // Off it again, and let go over nothing: back as it was. try pointTo(try offFloat(bounds), ManyPanelFrame.frame, ghost_frames); try std.testing.expectEqual(@as(f32, 1), asideOf(editor, 0)); - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); try dvui.testing.settle(ManyPanelFrame.frame); try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); try std.testing.expectEqual(@as(f32, 0), asideOf(editor, 0)); @@ -7884,7 +7956,7 @@ test "float: the drops beneath a ghost sit clear of it, and it firms up as the v const ViewDrag = fizzy.Editor.Layout.ViewDrag; cw.mouse_pt = place.center(); ViewDrag.begin(&layout, "Float 1", place, place); - defer state.view_drag.discard(); + defer state.view_drag.discard(gpa); // Aimed off the float: a ghost, and beside it, what is beneath. const beside: dvui.Point.Physical = .{ .x = 650, .y = 200 }; @@ -7937,7 +8009,7 @@ test "float: a drop squeezed by the ghost stays whole under it, reached through const ViewDrag = fizzy.Editor.Layout.ViewDrag; cw.mouse_pt = place.center(); ViewDrag.begin(&layout, "Float 1", place, place); - defer state.view_drag.discard(); + defer state.view_drag.discard(gpa); const prev_time = cw.frame_time_ns; defer cw.frame_time_ns = prev_time; @@ -8009,7 +8081,7 @@ test "float: a float of two is a ghost too, and comes back without the view that // Landed in Panel, which takes it beside what it shows: the float comes back, holding the // other. ViewDrag.place(&layout, "Float 1", "Panel", .swap); - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); try dvui.testing.settle(ManyPanelFrame.frame); try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); try std.testing.expectEqual(@as(f32, 0), floats.items.items[0].aside.at()); @@ -8032,7 +8104,7 @@ test "float: a ghost for its last view goes when the view lands, without flying try pointTo(try offFloat(bounds), ManyPanelFrame.frame, ghost_frames); // Released on Panel, as `apply` lands it, then the drag ends. ViewDrag.place(&layout, "Float 1", "Panel", .swap); - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); _ = try dvui.testing.step(ManyPanelFrame.frame); // Gone on the next frame: no glass reappearing to fly shut. try std.testing.expectEqual(@as(usize, 0), floats.items.items.len); @@ -8078,7 +8150,7 @@ test "float: coming back without the view that landed elsewhere, it draws its vi ViewDrag.begin(&layout, "Float 1", bounds, bounds); try pointTo(try offFloat(bounds), ManyPanelFrame.frame, ghost_frames); ViewDrag.place(&layout, "Float 1", "Panel", .swap); - editor.app.layout.view_drag.discard(); + editor.app.layout.view_drag.discard(editor.app.gpa); var faded = false; for (0..ghost_frames) |_| { @@ -8118,7 +8190,7 @@ test "float: a drag's own layer is over every float" { // floating widget made in the app's own window would stay under. var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); fizzy.Editor.Layout.ViewDrag.begin(&layout, "Main", main, main); - defer editor.app.layout.view_drag.discard(); + defer editor.app.layout.view_drag.discard(editor.app.gpa); _ = try dvui.testing.step(OverlaidFrame.frame); _ = try dvui.testing.step(OverlaidFrame.frame); const float_id = editor.app.layout.floats.items.items[0].win_id; @@ -8149,7 +8221,7 @@ test "float: a strip a float lies over is no chooser where the float is, and is var layout = fizzy.Editor.Layout.init(&editor.app.host, state, gpa, dvui.currentWindow().arena()); const ViewDrag = fizzy.Editor.Layout.ViewDrag; ViewDrag.begin(&layout, "Panel", FloatOverMain.panel_at, FloatOverMain.panel_at); - defer state.view_drag.discard(); + defer state.view_drag.discard(gpa); // A strip across Main, drawn in the app's own window, running under the float's body — a tab // strip of a pane the float lies over, as a plugin offers one (`Host.Region.offerChooser`). @@ -8543,11 +8615,15 @@ test "workbench: the Files explorer frame does not grow with a 300k-note folder" defer gpa.free(editor.app.folder.?); explorer_scroll = .{ .horizontal = .none }; - const last = try std.fmt.allocPrint(gpa, "{s}/note-{d:0>6}.md", .{ root, n - 1 }); + const last_name = try std.fmt.allocPrint(gpa, "note-{d:0>6}.md", .{n - 1}); + defer gpa.free(last_name); + const last = try std.fs.path.join(gpa, &.{ root, last_name }); defer gpa.free(last); const last_tag = try std.fmt.allocPrint(gpa, "workbench.file:{s}", .{last}); defer gpa.free(last_tag); - const hit_tag = try std.fmt.allocPrint(gpa, "workbench.file:{s}/note-123456.md", .{root}); + const hit = try std.fs.path.join(gpa, &.{ root, "note-123456.md" }); + defer gpa.free(hit); + const hit_tag = try std.fmt.allocPrint(gpa, "workbench.file:{s}", .{hit}); defer gpa.free(hit_tag); // Loading the folder: no frame waits for the disk, and the row says so meanwhile. @@ -9843,6 +9919,15 @@ test "live: a wait that never holds stops the tape and says where" { // Taking `--profile` out of argv is `app/profile.zig`'s own unit tests; these are the single-instance // layer's half: the profile made absolute like any other path, and never forwarded as one. +/// `expected` written with `/`, against a path the OS spelled with its own separator. +fn expectPosixPath(expected: []const u8, actual: []const u8) !void { + const gpa = std.testing.allocator; + const posix = try gpa.dupe(u8, actual); + defer gpa.free(posix); + std.mem.replaceScalar(u8, posix, std.fs.path.sep, '/'); + try std.testing.expectEqualStrings(expected, posix); +} + test "profile: argv names one, made absolute and taken out of what is opened" { const si = @import("app").single_instance; const gpa = std.testing.allocator; @@ -9850,16 +9935,16 @@ test "profile: argv names one, made absolute and taken out of what is opened" { const named = try si.resolveArgs(gpa, "/work", &.{ "fizzy", "--profile", "sandbox/./one", "notes.md", "-v" }, "/from/env"); defer si.freeResolvedArgv(gpa, named.argv); defer gpa.free(named.profile.?); - try std.testing.expectEqualStrings("/work/sandbox/one", named.profile.?); + try expectPosixPath("/work/sandbox/one", named.profile.?); try std.testing.expectEqual(@as(usize, 3), named.argv.len); - try std.testing.expectEqualStrings("/work/notes.md", named.argv[1]); + try expectPosixPath("/work/notes.md", named.argv[1]); try std.testing.expectEqualStrings("-v", named.argv[2]); // The flag absent, the environment names it; neither, there is none. const from_env = try si.resolveArgs(gpa, "/work", &.{"fizzy"}, "/from/env"); defer si.freeResolvedArgv(gpa, from_env.argv); defer gpa.free(from_env.profile.?); - try std.testing.expectEqualStrings("/from/env", from_env.profile.?); + try expectPosixPath("/from/env", from_env.profile.?); const none = try si.resolveArgs(gpa, "/work", &.{ "fizzy", "a.md" }, null); defer si.freeResolvedArgv(gpa, none.argv); @@ -9872,7 +9957,7 @@ test "profile: argv names one, made absolute and taken out of what is opened" { defer gpa.free(quiet.profile.?); try std.testing.expect(quiet.quiet); try std.testing.expectEqual(@as(usize, 2), quiet.argv.len); - try std.testing.expectEqualStrings("/work/a.md", quiet.argv[1]); + try expectPosixPath("/work/a.md", quiet.argv[1]); // Taking the lock with a profile is native startup, not something a test process can do // twice; it is compiled here, so a mistake in it fails this build rather than only the app's. @@ -10012,6 +10097,11 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes defer gpa.destroy(editor); fizzy.setInstances(entry, editor); editor.* = try fizzy.Editor.init(entry); + // A failing expect below returns early, and the watcher threads read the editor until + // `deinit` joins them: without this they outlive the `destroy` above and crash the run + // while it prints the failure. + var quit = false; + errdefer if (!quit) editor.deinit() catch {}; workbench.runtime.setWorkbench(&editor.workbench); try editor.postInit(); try dvui.testing.settle(headlessFrame); @@ -12561,6 +12651,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const harness_window = dvui.current_window; dvui.current_window = null; defer dvui.current_window = harness_window; + quit = true; try editor.deinit(); } diff --git a/web/AGENTS.md b/web/AGENTS.md index 164ceef29..579e85caa 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,17 +1,27 @@ # web/: fizzy in a browser -The page and its JavaScript (`index.html`, `web.js`, workers). The Zig half is +The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Zig half is `src/backend/WebBackend.zig` and `src/web_main.zig`. `zig build web` builds into `zig-out/web`, and `scripts/serve-web.sh` serves it on :8765. `main` deploys to fizzyed.it/testapp; fizzyed.it/app is the live SDK release (`web.yml`). -- **The web backend is fizzy's,** begun from dvui's `web.zig`/`web.js` and linked through dvui's - `custom` backend with dvui's web options passed explicitly (`build/web.zig`). A dvui bump that - touches dvui's own web backend is merged into these by hand. +- **The web backend is fizzy's,** begun from dvui's `web.zig` and linked through dvui's `custom` + backend with dvui's web options passed explicitly (`build/web.zig`). A dvui bump that touches + dvui's own `web.zig` is merged into it by hand. +- **`fizzy.js` is the app on a page,** `index.html` only the page it runs on. + `new Fizzy(opts).run()` runs the editor on `opts.canvas`, through dvui's `Dvui` (`web.js`), with + the wasm's `fizzy` and `env` imports, plus any of the caller's own (`opts.imports`, which keep it + on the page's thread). `Fizzy` never reads the URL or the page's other elements: `index.html` + turns its query (`?storage=`, `?persist=`, `?worker=`, `?form=`, `?plugin=`, ...) into options + and shows `onReady`/`onError` itself, and an app built on fizzy passes its own. Nothing wraps + `WebAssembly.instantiateStreaming`. +- **`web.js` is dvui's,** with `patches/dvui` applied at build time (`docs/DEPENDENCIES.md`). A + change to it is a change to a patch: make it on the dvui branch the patches come from and export + them again, so it can go upstream. Never keep a copy of `web.js` here. - **One upload per frame where possible.** In Firefox (WebGL runs out of process) `bufferData` costs ~15–20 µs, about 100× any other GL call. The backend records a frame as one stream and flushes only at pixel reads, frame end, or a change to a texture a pending draw uses. Keep it that way; call count barely matters, uploads do. -- **Plugins load at runtime as wasm side modules** (`index.html`'s `loadPlugin` → +- **Plugins load at runtime as wasm side modules** (`fizzy.js`'s `loadPlugin` → `app/store/PluginLoader_web.zig`). To test one: build it with `-Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`, copy it to `zig-out/web/plugins//.wasm`, and open `/?plugin=&open=`. `?open=` opens a file diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index 4506106fc..7d9f641a0 100644 --- a/web/fizzy-worker.js +++ b/web/fizzy-worker.js @@ -1,11 +1,11 @@ -// The editor in a worker: dvui's `web.js`, unchanged, driving WebGL on the page's canvas through -// an `OffscreenCanvas`, with the page (`index.html`, worker mode) forwarding input and doing what -// only a page can. +// The editor in a worker: dvui's `web.js` (`patches/dvui`) driving WebGL on the page's canvas +// through an `OffscreenCanvas`, with the page (`index.html`, worker mode) forwarding input and +// doing what only a page can. // // Why: Chrome on Android throttles main-thread frames (`requestAnimationFrame` on the page) to // 60 Hz on a 120 Hz screen unless input is driving them, so every animation — a sidebar // sliding, a dialog forming — ran at half the display's rate. A worker's own frame loop is not -// throttled. Everything here is the page's `index.html` bootstrap, moved: the fizzy imports, the +// throttled. Everything here is the page's bootstrap (`fizzy.js`), moved: the fizzy imports, the // plugin loader. What needs the page is a message to it and back. const utf8 = new TextDecoder(); @@ -23,11 +23,12 @@ const env = { prefs: { dark: false, light: false, reduce: false }, storage: {}, // The page's `storageName`: what this copy of the app files its storage under — the build's - // name, or that and an embed's own (`?storage=`). + // name, or that and an embed's own (`storage`). storageName: "fizzy", // The build's own name, which plugin bytes are kept under whatever the embed's is. buildStorageName: "fizzy", - search: "", + // The page's `startup`: what to load, open and play once the editor is up. + startup: { plugins: [], open: [], show: "", demos: [], tape: "" }, baseURI: "", }; const inputStub = { style: {}, value: "", setAttribute() {}, addEventListener() {}, focus() {}, select() {} }; @@ -89,7 +90,7 @@ function ask(msg, transfer) { }); } -// ---- the fizzy imports (was `index.html`'s `fizzyImports`) ------------------------------------ +// ---- the fizzy imports (as `fizzy.js`'s `fizzyImports`) ----------------------------------------- function mem() { return wasmInstance.exports.memory.buffer; } @@ -274,7 +275,7 @@ const fizzyImports = (target) => ({ }, }); -// ---- plugins at runtime (was `index.html`'s loader; see there for the why) -------------------- +// ---- plugins at runtime (as `fizzy.js`'s loader; see there for the why) ------------------------- const rememberedKey = () => env.storageName + ".web_plugins"; function rememberedPlugins() { try { return JSON.parse(storageGet(rememberedKey()) || "{}"); } catch (_) { return {}; } @@ -348,14 +349,13 @@ function requestPlugin(id, url) { function requestUrlPlugins() { const inst = wasmInstance; if (!inst || !inst.exports.FizzyWebPluginRequest) return; - const params = new URLSearchParams(env.search); - // A `?plugin=` build stands in for a remembered one of the same id. - const devPlugins = params.getAll("plugin"); + // A dev build stands in for a remembered one of the same id. + const devPlugins = env.startup.plugins; for (const [id, url] of Object.entries(rememberedPlugins())) if (!devPlugins.includes(id)) requestPlugin(id, url); for (const id of devPlugins) requestPlugin(id, ""); if (inst.exports.FizzyWebStartupPluginsRequested) inst.exports.FizzyWebStartupPluginsRequested(); render(); - for (const url of params.getAll("open")) { + for (const url of env.startup.open) { fetch(new URL(url, env.baseURI), { cache: "no-store" }).then((r) => r.arrayBuffer()).then((buf) => { const name = utf8encode(url.split("/").pop().split("?")[0]); const namePtr = wasmInstance.exports.FizzyWebPluginAlloc(name.length, 1); @@ -365,8 +365,7 @@ function requestUrlPlugins() { new Uint8Array(mem(), namePtr, name.length).set(name); new Uint8Array(mem(), bytesPtr, bytes.length).set(bytes); wasmInstance.exports.FizzyWebOpenBytes(namePtr, name.length, bytesPtr, bytes.length); - // `&show=`: then open that file from inside the folder just opened. - const show = params.get("show"); + const show = env.startup.show; if (show && wasmInstance.exports.FizzyWebShowInRoot) { const showBytes = utf8encode(show); const showPtr = wasmInstance.exports.FizzyWebPluginAlloc(showBytes.length, 1); @@ -378,13 +377,8 @@ function requestUrlPlugins() { render(); }).catch((err) => console.error("fizzy: could not open", url, err)); } - // `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`, the binary form) fetches a - // tape and plays that. - for (const demo of params.getAll("demo")) playDemo(demo, env.baseURI); - // `?tape=` plays a shared tape: the plugins it needs come from the store, and the page - // shows only the tape. - const tape = params.get("tape"); - if (tape) playTape(tape, env.baseURI); + for (const demo of env.startup.demos) playDemo(demo, env.baseURI); + if (env.startup.tape) playTape(env.startup.tape, env.baseURI); } function playTape(url, base) { @@ -459,7 +453,7 @@ function readDylinkNeeds(module) { return needs; } -// fetchPluginBytes and reportPluginFailed have a matching copy in web/index.html; change both. +// fetchPluginBytes and reportPluginFailed have a matching copy in web/fizzy.js; change both. async function fetchPluginBytes(url) { // Each fetch (the body included) gets a deadline: a request the network swallows would // otherwise leave the store's row on "Updating..." for good, with nothing to say why. @@ -616,7 +610,7 @@ function postBoundKeys() { // ---- start ------------------------------------------------------------------------------- async function start(init) { - Object.assign(env, { size: init.size, prefs: init.prefs, storage: init.storage, storageName: init.storageName, buildStorageName: init.buildStorageName || init.storageName, search: init.search, baseURI: init.baseURI }); + Object.assign(env, { size: init.size, prefs: init.prefs, storage: init.storage, storageName: init.storageName, buildStorageName: init.buildStorageName || init.storageName, startup: init.startup, baseURI: init.baseURI }); adoptCanvas(init.canvas); warmRememberedPlugins(); diff --git a/web/fizzy.js b/web/fizzy.js new file mode 100644 index 000000000..d562dc75a --- /dev/null +++ b/web/fizzy.js @@ -0,0 +1,1245 @@ +// fizzy's web app on a page: the editor's wasm, drawn by dvui's `web.js` (`Dvui`) on a canvas of +// the page's, with the `fizzy` and `env` imports it needs from the page, the plugins it loads at +// runtime, and on Chromium the editor in a worker (`fizzy-worker.js`). `index.html` is fizzy's own +// page, which reads its options from its URL; an app built on fizzy runs it on a page of its own +// with `new Fizzy({ canvas, ... }).run()`. +// +// Stamped by the build (`build/web.zig`): the hash of what it loads, and the storage name +// (`-Dweb-storage`). + +import { Dvui, TextInput } from "./web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256"; + +// The name this build files what it keeps in the browser under: its own files, the plugins it +// remembers and their cached bytes. Every copy of the app served from one site shares the +// browser's storage for it, so a test build beside the real one (fizzyed.it/testapp/ beside +// /app/) is built with a name of its own (`zig build web -Dweb-storage=...`) and never reads or +// overwrites the real one's settings, layout or plugins. Stamped by the build. +const buildStorageName = "__FIZZY_WEB_STORAGE__"; + +/** + * @typedef {object} FizzyOptions + * @property {HTMLCanvasElement} canvas - Where the editor draws and takes its input. + * @property {string} [storage] - A name of this copy's own, as `-Dweb-storage` takes them + * (`[a-z0-9_-]{1,64}`). Its settings, layout, files and installed plugins are filed under it, + * beside the build's, so a copy embedded in a page (fizzyed.it's examples) never reads or + * overwrites the real app's, and two embeds on one page never each other's. + * @property {boolean} [persist] - False: nothing this copy changes outlives the page. Its + * settings, layout, files and installed plugins live in memory only and start from defaults on + * every load, for an embed that should stay as the page set it up (fizzyed.it's homepage). + * Default true: the browser's `localStorage`. + * @property {boolean} [worker] - Run the editor in a worker (true) or on the page's thread (false). + * Default: in a worker on Chromium only. Never in a worker with `imports`. + * @property {"desktop" | "tablet" | "mobile"} [form] - The form factor to take, in place of the + * device's (`core.platform.formFactor()`). Anything else is ignored. + * @property {string[]} [plugins] - Ids of plugins to load from `plugins//.wasm` beside the + * app once it is up, each in place of an installed one of the same id: how a developer tries a + * build. + * @property {string[]} [open] - URLs of files to open once the plugins are asked for, as an upload + * would (a zip becomes the root folder). + * @property {string} [show] - A path inside what `open` opened, to open from it. + * @property {string[]} [demos] - Demos to play: a bundled one by name (`tour`), or a tape's URL + * (`.zon`, or `.tape` for the binary form). + * @property {string} [tape] - The URL of a shared tape to play. The plugins it needs come from the + * store, and the page shows only the tape. + * @property {Record} [imports] - Import namespaces of the app's + * own, beside `dvui`, `fizzy` and `env`. The editor then runs on the page's thread, where they + * can be called. + * @property {(instance: WebAssembly.Instance) => void} [onInstance] - Called with the editor's + * instance once it exists, on the page's thread. + * @property {() => void} [onReady] - Called once the editor is up. + * @property {(message: string) => void} [onError] - Called with what to tell the user when the + * editor cannot start. + */ + +export class Fizzy { + /** @param {FizzyOptions} opts */ + constructor(opts) { + if (!opts || !(opts.canvas instanceof HTMLCanvasElement)) throw new TypeError("fizzy: `canvas` must be a canvas element"); + if (opts.storage !== undefined && !/^[a-z0-9_-]{1,64}$/.test(opts.storage)) { + throw new RangeError(`fizzy: storage name "${opts.storage}" is not [a-z0-9_-]{1,64}`); + } + /** @readonly */ + this.opts = opts; + /** What this copy files its storage under: the build's name, or that and `opts.storage`. */ + this.storageName = opts.storage ? buildStorageName + "~" + opts.storage : buildStorageName; + } + + /** Start the editor. Once per `Fizzy`. */ + run() { + const opts = this.opts; + const storageName = this.storageName; + const persists = opts.persist !== false; + const onReady = opts.onReady ?? (() => {}); + const onError = opts.onError ?? (() => {}); + // What to load, open and play once the editor is up; the worker is sent it as it is. + const startup = { + plugins: opts.plugins ?? [], + open: opts.open ?? [], + show: opts.show ?? "", + demos: opts.demos ?? [], + tape: opts.tape ?? "", + }; + const memoryStore = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => { m.set(k, String(v)); }, + removeItem: (k) => { m.delete(k); }, + key: (i) => [...m.keys()][i] ?? null, + get length() { return m.size; }, + }; + }; + const store = (() => { + if (!persists) return memoryStore(); + // A browser with its site data blocked throws on the bare name. + try { return window.localStorage; } catch (_) { return memoryStore(); } + })(); + // Ask, once a thing worth keeping is kept, for storage the browser will not clear under + // pressure; refused is fine, and a copy that keeps nothing never asks. + const askToPersist = () => { + if (persists && navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); + }; + + function hasWebGl() { + const probe = document.createElement("canvas"); + return !!(probe.getContext("webgl2") || probe.getContext("webgl")); + } + + /** Wait until layout has settled so clientWidth/Height are correct. */ + function waitForLayout() { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + } + + // Markdown remote images (and SVG badges) load through the browser: Zig calls + // `fizzy_web_image_request`, and the page writes RGBA back through `FizzyWebImageAlloc` / + // `FizzyWebImageReady` for dvui to draw like any other texture. + const utf8 = new TextDecoder(); + const utf8encode = (s) => new TextEncoder().encode(s); + let wasmInstance = null; + + // The one function table, created here so it can grow: a plugin loaded at runtime (a + // wasm side module, see `loadPlugin`) brings functions of its own, and its function + // pointers are indices into this table. The main module imports it (`--import-table`). + const functionTable = new WebAssembly.Table({ initial: 4096, element: "anyfunc" }); + + // The `fizzy` import namespace. Memory is always the main module's (a plugin shares it); + // the *callbacks* go to whichever instance made the call — a plugin exports its own + // `FizzyWebRequestReady` and friends, with state of its own behind them — so this is a + // factory over the instance to call back into. + function oauthPopup(inst, url, cleanup) { + const done = () => { if (cleanup) cleanup(); }; + const fail = () => { + done(); + inst.exports.FizzyWebOAuthFailed(); + if (dvuiApp) dvuiApp.requestRender(); + }; + let settled = false; + const onMessage = (ev) => { + if (ev.origin !== location.origin || !ev.data || typeof ev.data.fizzyOAuth !== "string") return; + settled = true; + window.removeEventListener("message", onMessage); + done(); + const bytes = utf8encode(ev.data.fizzyOAuth); + const ptr = inst.exports.FizzyWebOAuthAlloc(bytes.length); + if (!ptr) return fail(); + new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebOAuthResult(ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }; + window.addEventListener("message", onMessage); + const popup = window.open(url, "fizzy-oauth", "popup,width=520,height=680"); + if (!popup) { + window.removeEventListener("message", onMessage); + return fail(); + } + const watch = setInterval(() => { + if (settled) return clearInterval(watch); + if (popup.closed) { + clearInterval(watch); + window.removeEventListener("message", onMessage); + fail(); + } + }, 500); + } + + const fizzyImports = (target) => ({ + fizzy_web_image_request(id, urlPtr, urlLen) { + const inst = target(); + if (!inst) return; + const url = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, urlPtr, urlLen)); + // Pixels or nothing: an image has to be a texture to sit under a menu, be frosted + // behind a flyout and take the theme's corners. A host that does not let the page + // read its pixels is read through the proxy instead, which adds the header. + loadImagePixels(url).catch(() => loadImagePixels(imageProxy + encodeURIComponent(url))).then((imageData) => { + const bytes = new Uint8Array( + imageData.data.buffer, + imageData.data.byteOffset, + imageData.data.byteLength, + ); + const ptr = inst.exports.FizzyWebImageAlloc(bytes.length); + if (!ptr) { + inst.exports.FizzyWebImageFailed(id); + } else { + new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebImageReady(id, ptr, bytes.length, imageData.width, imageData.height); + } + if (dvuiApp) dvuiApp.requestRender(); + }).catch(() => { + inst.exports.FizzyWebImageFailed(id); + if (dvuiApp) dvuiApp.requestRender(); + }); + }, + // A full request for `core.transport.Web`: any method, headers as `Name: value` + // lines, a body, and the status back with the bytes. Cloud drives speak through + // this; `fizzy_web_fetch` below stays the GET-only path the store uses. + fizzy_web_request(id, methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen) { + const inst = target(); + if (!inst) return; + const mem = wasmInstance.exports.memory.buffer; + const method = utf8.decode(new Uint8Array(mem, methodPtr, methodLen)); + const url = utf8.decode(new Uint8Array(mem, urlPtr, urlLen)); + const headers = new Headers(); + for (const line of utf8.decode(new Uint8Array(mem, headersPtr, headersLen)).split("\n")) { + const at = line.indexOf(": "); + if (at > 0) headers.append(line.slice(0, at), line.slice(at + 2)); + } + // Copied: the wasm buffer may move before fetch reads it. + const body = bodyLen > 0 ? new Uint8Array(mem, bodyPtr, bodyLen).slice() : undefined; + const fail = () => { + inst.exports.FizzyWebRequestFailed(id); + if (dvuiApp) dvuiApp.requestRender(); + }; + fetch(url, { method, headers, body, cache: "no-store" }).then((res) => { + return res.arrayBuffer().then((buf) => { + const bytes = new Uint8Array(buf); + const ptr = inst.exports.FizzyWebRequestAlloc(id, bytes.length); + if (!ptr) return fail(); + if (bytes.length > 0) new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebRequestReady(id, res.status, ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }); + }).catch(fail); + }, + // A web OAuth round trip for whichever plugin asks (core/transport/WebOAuth.zig): open + // the provider's URL in a popup and hand back what it redirected to + // oauth-callback.html with. Provider-agnostic on purpose. + // The popup round trip: open `url`, wait for the page it ends on to post its + // location back (`oauth-callback.html`, or a page of the plugin's own that does the + // same), fail if the popup closes first. Bound to the instance that asked, whose + // `FizzyWebOAuth*` exports carry the answer. + fizzy_web_oauth_open(urlPtr, urlLen) { + const inst = target(); + if (!inst) return; + const url = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, urlPtr, urlLen)); + oauthPopup(inst, url); + }, + // The same round trip through a page the plugin *carries* rather than one served + // beside the app: `html` becomes a same-origin blob URL (so its `postMessage` to + // the opener passes the origin check), with `hash` as the fragment the page reads. + // How a plugin loaded at runtime brings its own picker. + fizzy_web_oauth_open_page(htmlPtr, htmlLen, hashPtr, hashLen) { + const inst = target(); + if (!inst) return; + const mem = wasmInstance.exports.memory.buffer; + const html = utf8.decode(new Uint8Array(mem, htmlPtr, htmlLen)); + const hash = utf8.decode(new Uint8Array(mem, hashPtr, hashLen)); + const blobUrl = URL.createObjectURL(new Blob([html], { type: "text/html" })); + oauthPopup(inst, blobUrl + (hash.length ? "#" + hash : ""), () => URL.revokeObjectURL(blobUrl)); + }, + // Load a plugin built for the web (see `loadPlugin` below) and report back through + // `FizzyWebPluginReady` / `FizzyWebPluginFailed` on the host. + fizzy_web_plugin_load(req, idPtr, idLen, urlPtr, urlLen, shaPtr, shaLen) { + const inst = wasmInstance; + if (!inst) return; + const mem = wasmInstance.exports.memory.buffer; + const id = utf8.decode(new Uint8Array(mem, idPtr, idLen)); + const url = utf8.decode(new Uint8Array(mem, urlPtr, urlLen)); + const sha256 = shaLen ? utf8.decode(new Uint8Array(mem, shaPtr, shaLen)) : ""; + loadPlugin(req, url, id, sha256).then(() => { + if (dvuiApp) dvuiApp.requestRender(); + }).catch((err) => { + console.error("fizzy: plugin load failed:", url, err); + reportPluginFailed(inst, req, err); + if (dvuiApp) dvuiApp.requestRender(); + }); + }, + // View › Toggle Full Screen. The request runs a frame after the key press or click + // that asked for it, which is still inside the browser's user-activation window. + fizzy_web_toggle_fullscreen() { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } else if (document.documentElement.requestFullscreen) { + document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); + } + }, + // `Clock.real` for the app's `Io` (src/backend/web_io.zig). + fizzy_web_now_real_ms() { + return Date.now(); + }, + // `core.fs` on the web: the app's own small files (settings.zon, recents.zon, + // keybinds.zon) live in localStorage, keyed by the path the app would have used. + fizzy_web_storage_get(keyPtr, keyLen, bufPtr, bufLen) { + const key = storageName + ".file:" + utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen)); + let value = null; + try { value = store.getItem(key); } catch (_) {} + if (value === null) return 0xffffffff; + const bytes = utf8encode(value); + if (bytes.length <= bufLen) new Uint8Array(wasmInstance.exports.memory.buffer, bufPtr, bytes.length).set(bytes); + return bytes.length; + }, + fizzy_web_storage_set(keyPtr, keyLen, valPtr, valLen) { + const mem = wasmInstance.exports.memory.buffer; + const key = storageName + ".file:" + utf8.decode(new Uint8Array(mem, keyPtr, keyLen)); + const value = utf8.decode(new Uint8Array(mem, valPtr, valLen)); + try { store.setItem(key, value); } catch (err) { console.warn("fizzy: could not store", key, err); } + }, + fizzy_web_storage_remove(keyPtr, keyLen) { + const key = storageName + ".file:" + utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen)); + try { store.removeItem(key); } catch (_) {} + }, + // What the page remembers for an id, so the host can load it again after a disable + // without a round trip through the store. Same copy-into-buffer shape as the storage + // shims: returns the length needed, or `max` when there is nothing. + // Which catalog shard this build installs from, for the download proxy's allowlist. + fizzy_web_plugin_fingerprint(fpPtr, fpLen) { + pluginFingerprint = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, fpPtr, fpLen)); + }, + fizzy_web_plugin_remembered_url(idPtr, idLen, bufPtr, bufLen) { + const mem = wasmInstance.exports.memory.buffer; + const id = utf8.decode(new Uint8Array(mem, idPtr, idLen)); + const url = rememberedPlugins()[id]; + if (typeof url !== "string") return 0xffffffff; + const bytes = utf8encode(url); + if (bytes.length <= bufLen) new Uint8Array(wasmInstance.exports.memory.buffer, bufPtr, bytes.length).set(bytes); + return bytes.length; + }, + fizzy_web_plugin_forget(idPtr, idLen) { + forgetPlugin(utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, idPtr, idLen))); + }, + // Which build to bring back next visit. Written once a module has registered, so a + // build the host refused is never what the page asks for again. + fizzy_web_plugin_remember(idPtr, idLen, urlPtr, urlLen) { + const mem = wasmInstance.exports.memory.buffer; + rememberPlugin( + utf8.decode(new Uint8Array(mem, idPtr, idLen)), + utf8.decode(new Uint8Array(mem, urlPtr, urlLen)), + ); + }, + fizzy_web_oauth_callback_url(bufPtr, bufLen) { + const inst = target(); + if (!inst) return 0; + // Resolved against the document, not the origin: the app is not always at the + // root — fizzyed.it serves it from /app/ — and an origin-rooted guess names a page + // that is not there, which Google rejects as redirect_uri_mismatch before anything + // else happens. `pageUrl` takes this URL's directory for plugin pages too, so both + // follow wherever the app is deployed. + const bytes = utf8encode(new URL("oauth-callback.html", document.baseURI).href); + if (bytes.length <= bufLen) new Uint8Array(wasmInstance.exports.memory.buffer, bufPtr, bytes.length).set(bytes); + return bytes.length; + }, + // Byte fetch for the store catalog / READMEs / icons — no rasterize, just GET. + fizzy_web_fetch(id, urlPtr, urlLen) { + const inst = target(); + if (!inst) return; + const url = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, urlPtr, urlLen)); + fetch(url, { cache: "no-cache" }).then((res) => { + if (!res.ok) throw new Error("http " + res.status); + return res.arrayBuffer(); + }).then((buf) => { + const bytes = new Uint8Array(buf); + const ptr = inst.exports.FizzyWebFetchAlloc(bytes.length); + if (!ptr) { + inst.exports.FizzyWebFetchFailed(id); + } else { + new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebFetchReady(id, ptr, bytes.length); + } + if (dvuiApp) dvuiApp.requestRender(); + }).catch(() => { + inst.exports.FizzyWebFetchFailed(id); + if (dvuiApp) dvuiApp.requestRender(); + }); + }, + }); + + // Plugins the store installed on an earlier visit, by id → URL. The page's memory, since + // the app has no settings file here; forgotten on uninstall. + const rememberedKey = storageName + ".web_plugins"; + function rememberedPlugins() { + try { return JSON.parse(store.getItem(rememberedKey) || "{}"); } catch (_) { return {}; } + } + function rememberPlugin(id, url) { + try { + const all = rememberedPlugins(); + // An update names a new build: the old one's bytes are no longer anybody's. + if (all[id] && all[id] !== url) dropPluginBytes(all[id]); + all[id] = url; + store.setItem(rememberedKey, JSON.stringify(all)); + } catch (_) {} + keepPluginBytes(url); + } + function forgetPlugin(id) { + try { + const all = rememberedPlugins(); + if (all[id]) dropPluginBytes(all[id]); + delete all[id]; + store.setItem(rememberedKey, JSON.stringify(all)); + } catch (_) {} + } + + // ---- installed plugins' bytes, kept between visits -------------------------------------- + // + // Every visit used to download every installed plugin again — `no-store`, and for a release + // asset (no CORS header) a failed direct fetch and then the proxy — before it could compile + // one. The bytes of a build the host has accepted now live in Cache Storage (localStorage + // holds ~5 MB of strings; a plugin is megabytes of binary), keyed by its URL. A store URL + // names one release and never changes under it, so a hit is simply the bytes: no network, + // and an update is a new URL — `rememberPlugin` drops the old entry, `forgetPlugin` the + // uninstalled one. A dev build served beside the app (`opts.plugins`) is rebuilt at the same + // URL, so it is never kept. + // Under the build's name, not the embed's: a store URL names one release, so its bytes + // serve every embed alike, and a plugin installed in one is not downloaded again for the next. + const pluginCacheName = buildStorageName + "-plugins-v1"; + const pluginCacheAvailable = typeof caches !== "undefined"; + // Verified bytes of builds that have linked but not yet registered, by URL: the host + // remembers a build only once it has registered (see the end of `loadPlugin`), and only + // then are they kept. + const unkeptPluginBytes = new Map(); + function keepablePluginUrl(url) { + if (!pluginCacheAvailable || !url) return false; + try { + const u = new URL(url, document.baseURI); + return (u.protocol === "https:" || u.protocol === "http:") && u.origin !== location.origin; + } catch (_) { + return false; + } + } + async function cachedPluginBytes(url) { + if (!keepablePluginUrl(url)) return null; + try { + const hit = await (await caches.open(pluginCacheName)).match(url); + return hit ? await hit.arrayBuffer() : null; + } catch (_) { + return null; + } + } + function keepPluginBytes(url) { + const bytes = unkeptPluginBytes.get(url); + unkeptPluginBytes.delete(url); + if (!bytes || !keepablePluginUrl(url)) return; + caches.open(pluginCacheName) + .then((c) => c.put(url, new Response(bytes, { headers: { "content-type": "application/wasm" } }))) + .catch((err) => console.warn("fizzy: could not keep plugin", url, err)); + askToPersist(); + } + function dropPluginBytes(url) { + if (!keepablePluginUrl(url)) return; + caches.open(pluginCacheName).then((c) => c.delete(url)).catch(() => {}); + } + // Start reading the remembered builds out of the cache now, while the editor itself is + // still downloading and starting — by the time it asks for them they are in hand. + const warmPluginBytes = new Map(); + for (const url of Object.values(rememberedPlugins())) { + if (keepablePluginUrl(url)) warmPluginBytes.set(url, cachedPluginBytes(url)); + } + function requestPlugin(id, url) { + const inst = wasmInstance; + const idBytes = utf8encode(id); + const urlBytes = utf8encode(url || ""); + const idPtr = inst.exports.FizzyWebPluginAlloc(idBytes.length, 1); + const urlPtr = urlBytes.length ? inst.exports.FizzyWebPluginAlloc(urlBytes.length, 1) : 0; + if (!idPtr || (urlBytes.length && !urlPtr)) return; + new Uint8Array(inst.exports.memory.buffer, idPtr, idBytes.length).set(idBytes); + if (urlBytes.length) new Uint8Array(inst.exports.memory.buffer, urlPtr, urlBytes.length).set(urlBytes); + inst.exports.FizzyWebPluginRequest(idPtr, idBytes.length, urlPtr, urlBytes.length); + } + function requestUrlPlugins() { + const inst = wasmInstance; + if (!inst || !inst.exports.FizzyWebPluginRequest) return; + // A dev build stands in for a remembered one of the same id. + const devPlugins = startup.plugins; + for (const [id, url] of Object.entries(rememberedPlugins())) if (!devPlugins.includes(id)) requestPlugin(id, url); + for (const id of devPlugins) requestPlugin(id, ""); + // The store's update pass waits for this: it can only tell a remembered build that runs + // from one that needs replacing once every one of them has been tried. + if (inst.exports.FizzyWebStartupPluginsRequested) inst.exports.FizzyWebStartupPluginsRequested(); + if (dvuiApp) dvuiApp.requestRender(); + // After the plugins, so the one that owns a file may be among them. + for (const url of startup.open) { + fetch(url, { cache: "no-store" }).then((r) => r.arrayBuffer()).then((buf) => { + const inst2 = wasmInstance; + const name = utf8encode(url.split("/").pop().split("?")[0]); + const namePtr = inst2.exports.FizzyWebPluginAlloc(name.length, 1); + const bytes = new Uint8Array(buf); + const bytesPtr = inst2.exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!namePtr || !bytesPtr) return; + new Uint8Array(inst2.exports.memory.buffer, namePtr, name.length).set(name); + new Uint8Array(inst2.exports.memory.buffer, bytesPtr, bytes.length).set(bytes); + inst2.exports.FizzyWebOpenBytes(namePtr, name.length, bytesPtr, bytes.length); + const show = startup.show; + if (show && inst2.exports.FizzyWebShowInRoot) { + const showBytes = utf8encode(show); + const showPtr = inst2.exports.FizzyWebPluginAlloc(showBytes.length, 1); + if (showPtr) { + new Uint8Array(inst2.exports.memory.buffer, showPtr, showBytes.length).set(showBytes); + inst2.exports.FizzyWebShowInRoot(showPtr, showBytes.length); + } + } + if (dvuiApp) dvuiApp.requestRender(); + }).catch((err) => console.error("fizzy: could not open", url, err)); + } + for (const demo of startup.demos) playDemo(demo); + if (startup.tape) playTape(startup.tape); + } + + function playTape(url) { + fetch(url, { cache: "no-store" }) + .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status))) + .then((buf) => { + const exports = wasmInstance.exports; + if (!exports.FizzyWebPlayTape) return; + const bytes = new Uint8Array(buf); + const ptr = exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!ptr) return; + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + exports.FizzyWebPlayTape(ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }) + .catch((err) => console.error("fizzy: could not fetch the tape", url, err)); + } + + function playDemo(demo) { + const send = (bytes, isTape) => { + const exports = wasmInstance.exports; + if (!exports.FizzyWebPlayDemo) return; + const ptr = exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!ptr) return; + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + exports.FizzyWebPlayDemo(ptr, bytes.length, isTape); + if (dvuiApp) dvuiApp.requestRender(); + }; + if (!/\.(zon|tape)(\?|$)/.test(demo)) return send(utf8encode(demo), false); + fetch(demo, { cache: "no-store" }) + .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status))) + .then((buf) => send(new Uint8Array(buf), true)) + .catch((err) => console.error("fizzy: could not fetch demo", demo, err)); + } + + // ---- plugins at runtime: wasm side modules ------------------------------------------- + // + // A plugin built for the web is an Emscripten-style side module: position-independent, + // importing the host's memory, function table and stack pointer, with its own data laid + // out at `__memory_base` and its functions at `__table_base`. Loading one is: fetch, + // read its `dylink.0` needs, carve its data out of the host's heap, grow the table, + // instantiate against the host's exports, apply relocations, then hand the host a table + // index for each of the plugin's entry points — the dylib model, with indices for + // function pointers. Everything after that is the same code the desktop runs. + const pluginEntryPoints = [ + "fizzy_plugin_abi_fingerprint", + "fizzy_plugin_sdk_version", + "fizzy_plugin_min_sdk_version", + "fizzy_plugin_version", + "fizzy_plugin_id", + "fizzy_plugin_manifest_zon", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", + "fizzy_plugin_set_globals", + ]; + const loadedPlugins = new Map(); + + function readDylinkNeeds(module) { + const sections = WebAssembly.Module.customSections(module, "dylink.0"); + if (sections.length === 0) throw new Error("not a side module (no dylink.0 section)"); + const bytes = new Uint8Array(sections[0]); + let pos = 0; + const leb = () => { + let result = 0, shift = 0, b; + do { b = bytes[pos++]; result |= (b & 0x7f) << shift; shift += 7; } while (b & 0x80); + return result >>> 0; + }; + const needs = { memorySize: 0, memoryAlign: 0, tableSize: 0, tableAlign: 0 }; + while (pos < bytes.length) { + const type = bytes[pos++]; + const size = leb(); + const end = pos + size; + if (type === 1) { // WASM_DYLINK_MEM_INFO + needs.memorySize = leb(); + needs.memoryAlign = leb(); + needs.tableSize = leb(); + needs.tableAlign = leb(); + } + pos = end; + } + return needs; + } + + // Where a plugin's bytes come from when its own host will not let a browser have them. + // + // A release asset — the way every plugin publishes — carries no `access-control-allow-origin`, + // so `fetch()` fails on it before a byte moves. That is a browser rule, not a property of + // the file, so it is answered here rather than by making every author publish somewhere + // else: try the URL as given, and on failure ask fizzy's own origin to fetch it. A host + // that does send CORS is read directly and never touches the proxy. + // + // On its own `dl.fizzyed.it` host (a Workers Custom Domain, `web/wrangler.toml`) because the + // zone's other records are DNS-only, so a route on `fizzyed.it/dl/*` would never fire. The + // page checks the sha256 the registry published before it compiles anything, so this is a + // pipe, never something trusted. + const pluginProxy = "https://dl.fizzyed.it/dl/"; + // The same Worker's image route (`dl-worker.js`): an image whose host sends no CORS header + // is fetched through it so its pixels can be read. + const imageProxy = "https://dl.fizzyed.it/img/"; + // Set once at startup by the host (`PluginLoader_web.init`): the ABI fingerprint whose + // catalog shard this build installs from. The proxy checks the URL against that shard. + let pluginFingerprint = ""; + + // fetchPluginBytes and reportPluginFailed have a matching copy in web/fizzy-worker.js; change both. + async function fetchPluginBytes(url) { + // Each fetch (the body included) gets a deadline: a request the network swallows would + // otherwise leave the store's row on "Updating..." for good, with nothing to say why. + const timeoutMs = 30000; + const attempt = async (target) => { + try { + return await fetch(target, { cache: "no-store", signal: AbortSignal.timeout(timeoutMs) }); + } catch (err) { + if (err && err.name === "TimeoutError") throw new Error("timed out after " + timeoutMs / 1000 + " s"); + throw err; + } + }; + // The bytes of a response whose headers arrived: the body is under the same deadline. Worst case before a failure shows is three asks (direct, proxy, proxy again), 90 s. + const body = async (response) => { + try { + return await response.arrayBuffer(); + } catch (err) { + if (err && err.name === "TimeoutError") throw new Error("timed out after " + timeoutMs / 1000 + " s"); + throw err; + } + }; + let directWhy = ""; + try { + const direct = await attempt(url); + if (direct.ok) return await body(direct); + // A real 404 is worth reporting as itself; anything else is worth a second try. + if (direct.status === 404) { console.error("fizzy: plugin not found (404):", url); throw new Error("not found (404)"); } + directWhy = "status " + direct.status; + } catch (err) { + if (String(err).includes("not found (404)")) throw err; + directWhy = String(err && err.message ? err.message : err); + } + if (!pluginFingerprint) throw new Error(directWhy + " (and no catalog shard to ask the proxy)"); + // The proxy is asked twice: one dropped or stalled request should not cost the user the update. + let why = ""; + for (let tries = 0; tries < 2; tries++) { + try { + const viaProxy = await attempt(pluginProxy + pluginFingerprint + "/" + encodeURIComponent(url)); + if (viaProxy.ok) return await body(viaProxy); + why = "proxy said " + viaProxy.status; + // Another ask cannot change a refusal of this URL; only a server-side hiccup is retried. + if (viaProxy.status < 500 && viaProxy.status !== 408 && viaProxy.status !== 429) break; + } catch (err) { + why = String(err && err.message ? err.message : err); + } + } + throw new Error(why); // the cause alone: the store shows it in a narrow row, the URL is in the console + } + + // Tell the host a load failed, and why: the store shows the text beside Retry. The text + // is copied into the host's heap, and the host frees it. + function reportPluginFailed(inst, req, err) { + const msg = utf8encode(String(err && err.message ? err.message : err).slice(0, 300)); + const ptr = msg.length ? inst.exports.FizzyWebPluginAlloc(msg.length, 1) : 0; + if (ptr) new Uint8Array(inst.exports.memory.buffer, ptr, msg.length).set(msg); + inst.exports.FizzyWebPluginFailed(req, ptr, ptr ? msg.length : 0); + } + + // Lowercase hex of the bytes' SHA-256, for comparing against what the registry published. + async function sha256Hex(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); + } + + async function loadPlugin(req, url, id, sha256) { + const host = wasmInstance; + // A build kept from an earlier visit, if there is one (see "installed plugins' bytes"). + const warm = warmPluginBytes.get(url); + warmPluginBytes.delete(url); + let bytes = await (warm || cachedPluginBytes(url)); + // The check the desktop downloader has always done. Before `compile`, so a tampered or + // truncated module is never handed to the engine, let alone registered. A kept build is + // held to it too when the registry's hash is at hand; one that fails it is dropped and + // downloaded again rather than refused. + if (bytes && sha256 && (await sha256Hex(bytes)) !== sha256.toLowerCase()) { + dropPluginBytes(url); + bytes = null; + } + if (!bytes) { + bytes = await fetchPluginBytes(url); + if (sha256) { + const got = await sha256Hex(bytes); + if (got !== sha256.toLowerCase()) { + { console.error("fizzy: sha256 mismatch for " + id + ": expected " + sha256 + ", got " + got); throw new Error("download did not match the published checksum"); } + } + } + if (keepablePluginUrl(url)) unkeptPluginBytes.set(url, bytes); + } + const module = await WebAssembly.compile(bytes); + const needs = readDylinkNeeds(module); + const memoryBase = host.exports.FizzyWebPluginAlloc(needs.memorySize, 1 << needs.memoryAlign); + if (!memoryBase) throw new Error("out of memory for plugin data"); + const tableBase = functionTable.length; + functionTable.grow(needs.tableSize); + // Every function the host exports is available to the plugin (dvui's C shims, stb); + // the linking globals point the plugin at its own slice of memory and table. + const env = {}; + for (const [name, value] of Object.entries(host.exports)) { + if (typeof value === "function") env[name] = value; + } + env.memory = host.exports.memory; + env.__indirect_function_table = functionTable; + env.__stack_pointer = host.exports.__stack_pointer; + env.__memory_base = new WebAssembly.Global({ value: "i32", mutable: false }, memoryBase); + env.__table_base = new WebAssembly.Global({ value: "i32", mutable: false }, tableBase); + // Say which import the host lacks, rather than the linker's "Import #12". + const missing = WebAssembly.Module.imports(module) + .filter((i) => i.module === "env" && i.kind === "function" && !(i.name in env)) + .map((i) => i.name); + if (missing.length) throw new Error("the host does not export: " + missing.join(", ")); + const holder = { inst: null }; + const instance = await WebAssembly.instantiate(module, { + env, + fizzy: fizzyImports(() => holder.inst), + dvui: dvuiApp ? dvuiApp.imports : {}, + }); + holder.inst = instance; + if (instance.exports.__wasm_apply_data_relocs) instance.exports.__wasm_apply_data_relocs(); + if (instance.exports.__wasm_call_ctors) instance.exports.__wasm_call_ctors(); + // The entry points, as table indices the host can call through. + const base = functionTable.length; + functionTable.grow(pluginEntryPoints.length); + pluginEntryPoints.forEach((name, i) => { + const fn = instance.exports[name]; + if (!fn) throw new Error("plugin is missing " + name); + functionTable.set(base + i, fn); + }); + const list = host.exports.FizzyWebPluginAlloc(4 * pluginEntryPoints.length, 4); + const indices = new Uint32Array(host.exports.memory.buffer, list, pluginEntryPoints.length); + for (let i = 0; i < pluginEntryPoints.length; i++) indices[i] = base + i; + loadedPlugins.set(url, instance); + host.exports.FizzyWebPluginReady(req, list, pluginEntryPoints.length); + // Deliberately not remembered here. Linking a module says nothing about whether this + // host will run it — the fingerprint, SDK version and declared id are checked after + // `FizzyWebPluginReady`, in `PluginLoader_web.loadAndRegister` — and a build remembered + // at this point would be re-fetched and re-refused on every later visit. The host calls + // `fizzy_web_plugin_remember` once the plugin has actually registered. + } + + /** Rasterize `url` the way an `` would — PNG/JPEG and CORS-friendly SVG. */ + function loadImagePixels(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + // An SVG with no size of its own (a badge, often) still needs a box to raster into; + // GitHub's are about 200×20. + const nw = img.naturalWidth || 200; + const nh = img.naturalHeight || 20; + // Screenshots in READMEs run to 8+ megapixels and are shown at a fraction of that: + // scale down to the cap rather than refuse, so the pixels handed over stay bounded. + const fit = Math.min(1, Math.sqrt((4 * 1024 * 1024) / (nw * nh))); + const w = Math.max(1, Math.floor(nw * fit)); + const h = Math.max(1, Math.floor(nh * fit)); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, w, h); + try { + resolve(ctx.getImageData(0, 0, w, h)); + } catch (err) { + reject(err); + } + }; + img.onerror = () => reject(new Error("image load failed")); + img.src = url; + }); + } + + // Trackpad pinch arrives as a `wheel` event with `ctrlKey=true` and `deltaMode=0` + // (every modern browser synthesizes this). Two things to do here in the capture phase, + // before dvui's bubble-phase listener on the canvas: + // 1. preventDefault + stopImmediatePropagation — keeps the browser from page-zooming + // and keeps dvui's wheel handler from folding the tiny pinch delta into its + // `scroll_lowest` minimum (an all-time min that never resets and otherwise + // poisons trackpad scroll sensitivity for the rest of the session). + // 2. Forward the magnification into the wasm trackpad accumulator + // (`FizzyWebTrackpadMagnification`). The canvas widget drains it each frame and + // applies the same scale-around-point math used by macOS native pinch. + // `dvuiApp` starts null and is filled in once the app loads — events that arrive + // before then are still safely suppressed. + // Scale factor (100): browser pinch deltaY is roughly ±1..±20 per event; dividing by + // 100 produces ratios around 1.01..1.2 per event, matching the magnitude AppKit + // reports for native trackpad gestures. Negate deltaY because positive deltaY means + // "pinch fingers together = zoom out". + let dvuiApp = null; + const dvuiCanvas = opts.canvas; + + // Shortcuts the app binds are the app's, not the browser's: ⌘S must not also save the + // page. Capture phase, so this runs before dvui's own listener (which still receives the + // key — preventDefault stops the browser, not the event). The app answers synchronously + // from its keymap (`Keybinds.webKeyBound`), which also keeps typing, copy/cut/paste and + // unbound chords with the browser. + window.addEventListener("keydown", (ev) => { + const ex = wasmInstance && wasmInstance.exports; + if (!ex || !ex.FizzyWebKeyBound) return; + const bytes = utf8encode(ev.key); + if (bytes.length === 0 || bytes.length > 16) return; + new Uint8Array(ex.memory.buffer, ex.FizzyWebKeyBuffer(), bytes.length).set(bytes); + const mods = (ev.metaKey << 3) | (ev.altKey << 2) | (ev.ctrlKey << 1) | (ev.shiftKey << 0); + if (ex.FizzyWebKeyBound(bytes.length, mods)) ev.preventDefault(); + }, { capture: true }); + + // Some shortcuts never reach a page at all (⌘W, ⌘T, ⌘N, Ctrl+Tab). The Keyboard Lock API + // hands them over, but only in full screen and only in Chromium; elsewhere this is a no-op + // and those chords stay the browser's. + document.addEventListener("fullscreenchange", () => { + const kb = navigator.keyboard; + if (!kb || !kb.lock) return; + if (document.fullscreenElement) kb.lock().catch(() => {}); + else kb.unlock(); + }); + dvuiCanvas.addEventListener("wheel", (ev) => { + if (ev.ctrlKey && ev.deltaMode === 0) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + if (dvuiApp && dvuiApp.instance.exports.FizzyWebTrackpadMagnification) { + dvuiApp.instance.exports.FizzyWebTrackpadMagnification(-ev.deltaY / 100); + // dvui normally calls `requestRender` from its own wheel listener — since we + // swallowed the event in the capture phase, drive the frame loop ourselves so + // the canvas widget actually drains the accumulator we just wrote. + dvuiApp.requestRender(); + } else if (pinchSink) { + pinchSink(-ev.deltaY / 100); + } + } + }, { capture: true, passive: false }); + + // ---- form factor ---------------------------------------------------------------------- + // + // The device as `core.platform.formFactor()` sees it, for both the worker and the page's + // own loop: a finger with no hover as the primary pointer, and the screen in CSS px (not + // the viewport or canvas, which the soft keyboard and sheets shrink). `opts.form` overrides + // the rule. The extra fields are for the console readout. + const formNames = ["desktop", "tablet", "mobile"]; // the order of `FormFactor` in core/form_factor.zig + const formOverride = formNames.indexOf(opts.form ?? ""); // -1: none + function formMeasure() { + const mq = (q) => window.matchMedia(q).matches; + return { + coarse: mq("(pointer: coarse)") && mq("(hover: none)"), w: screen.width, h: screen.height, + override: formOverride, overrideName: formOverride >= 0 ? opts.form : "", + pointerCoarse: mq("(pointer: coarse)"), hoverHover: mq("(hover: hover)"), + }; + } + // The main-thread loop's twin of the worker's `applyForm`. + let appliedForm = ""; + function applyFormMain() { + const f = formMeasure(); + const exp = dvuiApp && dvuiApp.instance.exports.FizzyWebSetFormFactor; + if (!exp) return; + const key = `${f.coarse}|${f.w}|${f.h}|${f.override}`; + if (key === appliedForm) return; + appliedForm = key; + const chosen = exp(f.coarse, f.w, f.h, f.override); + console.log( + `form-factor: coarse=${f.pointerCoarse} hover=${f.hoverHover} w=${f.w} h=${f.h} ` + + `override=${f.overrideName || "none"} -> ${formNames[chosen]}`, + ); + dvuiApp.requestRender(); + } + + // ---- worker mode: the editor runs in `fizzy-worker.js` -------------------------------- + // + // Chrome on Android throttles a page's own frames to 60 Hz on a 120 Hz screen unless input + // is driving them; a worker's frame loop is not. So the editor, dvui's `web.js` and all, + // runs in a worker drawing to this canvas through an `OffscreenCanvas`, and the page is + // what only a page can be: where input lands (sent on as dvui would have queued it), and + // where the worker's cursor, on-screen keyboard, links, downloads, file pickers, + // clipboard, full screen and OAuth popups happen. + function startWorker(wasmUrl, wasmModule) { + const canvas = dvuiCanvas; + const worker = new Worker(new URL("fizzy-worker.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url), { type: "module" }); + const post = (msg, transfer) => worker.postMessage(msg, transfer || []); + const reply = (request, value, transfer) => post({ type: "reply", request, value }, transfer); + + const media = (q) => window.matchMedia(q).matches; + // `form` is the device, in CSS px, for `core.platform.formFactor()`: a finger with no + // hover as the primary pointer, and the screen (not the viewport or canvas, which the soft keyboard and sheets shrink). + const size = () => ({ + w: canvas.clientWidth, h: canvas.clientHeight, dpr: window.devicePixelRatio, + form: formMeasure(), + }); + const prefs = () => ({ + dark: media("(prefers-color-scheme: dark)"), + light: media("(prefers-color-scheme: light)"), + reduce: media("(prefers-reduced-motion: reduce)"), + }); + const storage = {}; + try { + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key && key.startsWith(storageName + ".")) storage[key] = store.getItem(key); + } + } catch (_) {} + + // dvui's hidden input: where text arrives, and what raises the on-screen keyboard. + const hidden = document.createElement("input"); + hidden.setAttribute("autocapitalize", "none"); + Object.assign(hidden.style, { position: "absolute", left: 0, top: 0, padding: 0, border: 0, margin: 0, opacity: 0, zIndex: -1 }); + document.body.prepend(hidden); + let textInputRect = []; + // Focus never scrolls the page; and a copy embedded in another page (fizzyed.it's + // examples, in an iframe) takes none until it is clicked, tapped or tabbed into. + // Focusing anything inside an iframe scrolls the page holding it to show it, so an + // embed below the fold that grabbed focus as it started pulled the visitor down to it. + let engaged = window.parent === window; + const engage = () => { engaged = true; }; + canvas.addEventListener("pointerdown", engage, { capture: true }); + window.addEventListener("focus", engage); + function focusQuietly(el) { + if (engaged) el.focus({ preventScroll: true }); + } + function oskCheck() { + if (textInputRect.length === 0) { + focusQuietly(canvas); + return; + } + const rect = canvas.getBoundingClientRect(); + const left = window.scrollX + rect.left + textInputRect[0]; + const top = window.scrollY + rect.top + textInputRect[1]; + hidden.style.left = left + "px"; + hidden.style.top = top + "px"; + hidden.style.width = Math.max(0, Math.min(textInputRect[2], canvas.clientWidth - left)) + "px"; + hidden.style.height = Math.max(0, Math.min(textInputRect[2], canvas.clientHeight - top)) + "px"; + focusQuietly(hidden); + } + + let boundKeys = new Set(); + const offscreen = canvas.transferControlToOffscreen(); + // The init waits for the early compile (`wasmModule`), which by now has usually finished. + Promise.resolve(wasmModule).then((module) => post({ + type: "init", + canvas: offscreen, + wasmUrl: new URL(wasmUrl, document.baseURI).href, + wasmModule: module || null, + webJsUrl: new URL("web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url).href, + size: size(), + prefs: prefs(), + storage, + storageName, + buildStorageName, + startup, + baseURI: document.baseURI, + }, [offscreen])); + + new ResizeObserver(() => post({ type: "size", size: size() })).observe(canvas); + window.addEventListener("resize", () => post({ type: "size", size: size() })); + window.addEventListener("orientationchange", () => post({ type: "size", size: size() })); + for (const q of ["(pointer: coarse)", "(hover: none)"]) { + window.matchMedia(q).addEventListener("change", () => post({ type: "size", size: size() })); + } + for (const q of ["(prefers-color-scheme: dark)", "(prefers-reduced-motion: reduce)"]) { + window.matchMedia(q).addEventListener("change", () => post({ type: "prefs", prefs: prefs() })); + } + + worker.onerror = (err) => { + console.error(err); + onError(`Failed to load the editor (${err.message || "worker error"}).`); + }; + worker.onmessage = (e) => { + const m = e.data; + switch (m.type) { + case "ready": + onReady(); + break; + case "error": + console.error(m.message); + onError(`Failed to load the editor (${m.message}). Try a hard refresh, or use a recent Chrome, Firefox, or Safari.`); + break; + case "alert": + alert(m.msg); + break; + case "cursor": + canvas.style.cursor = m.name; + break; + case "textInput": + if ((textInputRect.length === 0) !== (m.rect.length === 0)) textInput.clear(); + textInputRect = m.rect; + oskCheck(); + break; + case "openUrl": + if (m.newWin) window.open(m.url); + else window.location.href = m.url; + break; + case "download": { + const url = URL.createObjectURL(new Blob([m.data], { type: "application/octet-stream" })); + const a = document.createElement("a"); + a.href = url; + a.download = m.name; + a.click(); + a.remove(); + URL.revokeObjectURL(url); + break; + } + case "filePicker": { + const input = document.createElement("input"); + input.type = "file"; + input.accept = m.accept; + if (m.multiple) input.multiple = true; + input.oncancel = () => reply(m.request, []); + input.onchange = () => { + const files = Array.from(input.files || []); + Promise.all(files.map((f) => f.arrayBuffer())).then((datas) => { + const out = files.map((f, i) => ({ name: f.name, size: f.size, data: datas[i] })); + reply(m.request, out, datas); + }); + }; + input.click(); + break; + } + case "clipboard": + if (navigator.clipboard) navigator.clipboard.writeText(m.text); + else { + hidden.value = m.text; + hidden.focus({ preventScroll: true }); + hidden.select(); + document.execCommand("copy"); + hidden.value = ""; + } + break; + case "fullscreen": + if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); + else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); + break; + case "oauth": { + let blobUrl = null; + let url = m.url; + if (m.html !== undefined) { + blobUrl = URL.createObjectURL(new Blob([m.html], { type: "text/html" })); + url = blobUrl + (m.hash ? "#" + m.hash : ""); + } + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + window.removeEventListener("message", onMessage); + if (blobUrl) URL.revokeObjectURL(blobUrl); + reply(m.request, value); + }; + const onMessage = (ev) => { + if (ev.origin !== location.origin || !ev.data || typeof ev.data.fizzyOAuth !== "string") return; + finish({ text: ev.data.fizzyOAuth }); + }; + window.addEventListener("message", onMessage); + const popup = window.open(url, "fizzy-oauth", "popup,width=520,height=680"); + if (!popup) { + finish(null); + break; + } + const watch = setInterval(() => { + if (settled) return clearInterval(watch); + if (popup.closed) { + clearInterval(watch); + finish(null); + } + }, 500); + break; + } + case "rasterize": + loadImagePixels(m.url).then((img) => { + reply(m.request, { data: img.data, width: img.width, height: img.height }, [img.data.buffer]); + }).catch(() => reply(m.request, null)); + break; + case "storageSet": + try { store.setItem(m.key, m.value); } catch (err) { console.warn("fizzy: could not store", m.key, err); } + break; + case "storageRemove": + try { store.removeItem(m.key); } catch (_) {} + break; + case "persistStorage": + askToPersist(); + break; + case "boundKeys": + boundKeys = new Set(m.keys); + break; + } + }; + + // Input, as dvui's `web.js` would have queued it (its event codes, its wheel heuristics). + const mods = (ev) => (ev.metaKey << 3) | (ev.altKey << 2) | (ev.ctrlKey << 1) | (ev.shiftKey << 0); + const event = (a0, a1, a2, a3, a4) => post({ type: "event", a: [a0, a1, a2, a3, a4] }); + canvas.addEventListener("contextmenu", (ev) => ev.preventDefault()); + canvas.addEventListener("mousemove", (ev) => { + const rect = canvas.getBoundingClientRect(); + post({ type: "mouse", x: (ev.clientX - rect.left) / (rect.right - rect.left), y: (ev.clientY - rect.top) / (rect.bottom - rect.top), mods: mods(ev) }); + }); + canvas.addEventListener("mousedown", (ev) => event(2, ev.button, mods(ev), 0, 0)); + canvas.addEventListener("mouseup", (ev) => event(3, ev.button, mods(ev), 0, 0)); + + const scrollLowest = [99999, 99999]; + let scrollLowestBatch = [99999, 99999]; + let scrollLastMs = Date.now(); + canvas.addEventListener("wheel", (ev) => { + ev.preventDefault(); + if (Date.now() - scrollLastMs > 1000) scrollLowestBatch = [99999, 99999]; + scrollLastMs = Date.now(); + const touchpadAdj = 0.025; + const axis = (i, delta, shiftSizes) => { + if (delta === 0) return; + scrollLowest[i] = Math.min(Math.abs(delta), scrollLowest[i]); + scrollLowestBatch[i] = Math.min(Math.abs(delta), scrollLowestBatch[i]); + let ticks = -delta; + let trackpad = 0; + const low = scrollLowestBatch[i]; + if (ev.deltaMode !== 0) { + ticks /= low; + } else if (low >= 100 || low === 16 || shiftSizes.includes(low) || low === 4.000244140625) { + ticks /= low; + if (low === 4.000244140625) ticks *= touchpadAdj; + } else { + trackpad = 1; + ticks = ticks / scrollLowest[i] * touchpadAdj; + } + event(4, i, trackpad, ticks, 0); + }; + axis(0, ev.deltaX, [9, 40]); + axis(1, ev.deltaY, []); + }, { passive: false }); + + // What the keyboard does to the word before the caret reaches the worker as typing. + const textInput = new TextInput(hidden, { + text: (text) => post({ type: "text", text }), + backspace: () => { + post({ type: "key", kind: 5, key: "Backspace", repeat: 0, mods: 0 }); + post({ type: "key", kind: 6, key: "Backspace", repeat: 0, mods: 0 }); + }, + }); + canvas.addEventListener("pointerdown", () => textInput.clear()); + const keydown = (ev) => { + textInput.keydown(ev); + if (ev.key === "Tab") { + if (ev.ctrlKey) return; + ev.preventDefault(); + } + if (boundKeys.has(ev.key + "|" + mods(ev))) ev.preventDefault(); + post({ type: "key", kind: 5, key: ev.key, repeat: ev.repeat ? 1 : 0, mods: mods(ev) }); + }; + const keyup = (ev) => post({ type: "key", kind: 6, key: ev.key, repeat: 0, mods: mods(ev) }); + canvas.addEventListener("keydown", keydown); + hidden.addEventListener("keydown", keydown); + canvas.addEventListener("keyup", keyup); + hidden.addEventListener("keyup", keyup); + + const touches = []; + const touchIndex = (id) => { + let idx = touches.findIndex((t) => t[0] === id); + if (idx < 0) { + idx = touches.length; + touches.push([id, idx]); + } + return idx; + }; + const touchEvents = (kind, ev, done) => { + ev.preventDefault(); + const rect = canvas.getBoundingClientRect(); + for (const touch of ev.changedTouches) { + const x = (touch.clientX - rect.left) / (rect.right - rect.left); + const y = (touch.clientY - rect.top) / (rect.bottom - rect.top); + const tidx = touchIndex(touch.identifier); + event(kind, touches[tidx][1], 0, x, y); + if (done) touches.splice(tidx, 1); + } + }; + canvas.addEventListener("touchstart", (ev) => touchEvents(8, ev, false), { passive: false }); + canvas.addEventListener("touchend", (ev) => { + touchEvents(9, ev, true); + oskCheck(); + }, { passive: false }); + canvas.addEventListener("touchmove", (ev) => touchEvents(10, ev, false), { passive: false }); + + pinchSink = (amount) => post({ type: "pinch", amount }); + } + + // Where a trackpad pinch goes in worker mode (the capture listener above). + var pinchSink = null; + + // The worker is for Chromium, whose page frames are held to 60 Hz on a phone unless input + // drives them; a worker's are not. Safari drives a worker's frames at about 60 Hz where its + // page's run at the display's 120, so everywhere else the page is the faster place. + // `navigator.userAgentData` is Chromium's alone; `opts.worker` overrides. Never with imports + // of the caller's own: they are the page's functions, which a worker cannot call. + const workerMode = (opts.worker ?? "userAgentData" in navigator) && + !opts.imports && typeof OffscreenCanvas !== "undefined" && "transferControlToOffscreen" in HTMLCanvasElement.prototype; + + if (!hasWebGl()) { + onError("This browser does not support WebGL, which fizzy needs to run."); + } else if (workerMode) { + const wasmUrl = new URL("web.wasm?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url).href; + // Fetched and compiled now, seen or not. A page opened in a background tab gets no + // animation frames, so `waitForLayout` holds the worker until the tab is shown; the + // editor's download used to wait with it, tens of seconds on a slow link. The worker is + // handed the compiled module and fetches nothing itself, so a page shown at once never + // downloads it twice. Null on failure: the worker then fetches it as before. + const wasmModule = WebAssembly.compileStreaming(fetch(wasmUrl)).catch((err) => { + console.warn("fizzy: compiling the editor early failed; the worker fetches it", err); + return null; + }); + waitForLayout().then(() => startWorker(wasmUrl, wasmModule)).catch((err) => { + console.error(err); + onError(`Failed to load the editor (${err instanceof Error ? err.message : String(err)}).`); + }); + } else { + const wasmUrl = new URL("web.wasm?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url).href; + waitForLayout() + .then(() => { + const app = new Dvui(); + return WebAssembly.instantiateStreaming(fetch(wasmUrl), { + ...opts.imports, + dvui: app.imports, + fizzy: fizzyImports(() => wasmInstance), + env: { __indirect_function_table: functionTable }, + }).then((result) => { + wasmInstance = result.instance; + if (opts.onInstance) opts.onInstance(result.instance); + app.setInstance(result.instance); + app.setCanvas(dvuiCanvas); + app.run(); + return app; + }); + }) + .then((app) => { + // Late-bind the app reference so the capture-phase wheel listener above can + // forward pinch deltas into the trackpad accumulator and kick a render. + dvuiApp = app; + // The main-thread loop never sent the device to the editor, so Firefox and Safari + // (which take this path) were always `.desktop`: send it now and on change. + applyFormMain(); + for (const ev of ["resize", "orientationchange"]) window.addEventListener(ev, applyFormMain); + for (const q of ["(pointer: coarse)", "(hover: none)"]) { + window.matchMedia(q).addEventListener("change", applyFormMain); + } + window.dispatchEvent(new Event("resize")); + app.requestRender(); + onReady(); + // After the first frame: the editor exists once `dvui_init` has run. + setTimeout(requestUrlPlugins, 0); + }) + .catch((err) => { + console.error(err); + const detail = err instanceof Error ? err.message : String(err); + onError(`Failed to load the editor (${detail}). Try a hard refresh, or use a recent Chrome, Firefox, or Safari.`); + }); + } + } +} diff --git a/web/index.html b/web/index.html index bedd20366..2c850ded2 100644 --- a/web/index.html +++ b/web/index.html @@ -110,1206 +110,44 @@ diff --git a/web/patches/dvui/0001-web-highp-float-in-the-shaders.patch b/web/patches/dvui/0001-web-highp-float-in-the-shaders.patch new file mode 100644 index 000000000..6a6a0dff2 --- /dev/null +++ b/web/patches/dvui/0001-web-highp-float-in-the-shaders.patch @@ -0,0 +1,100 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:09:37 -0400 +Subject: [PATCH] web: highp float in the shaders + +On GPUs where mediump is a real 16-bit float (Apple Silicon under ANGLE/Metal, most +mobile), a texture coordinate across a 2048px texture has about 2 texels of resolution, +and BlurBackdrop's kernels sample at sub-texel offsets, so blurs band and shimmer there. +Vertex shaders always have highp; WebGL1 fragment shaders use it where the device has it. +--- + src/backends/web.js | 13 ++++++++++--- + 1 file changed, 10 insertions(+), 3 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 92fbe96..27d22e9 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -41,18 +41,21 @@ async function dvui_open_file_picker(accept, multiple) { + return; + } + res(file_input.files); + }; + file_input.click(); + }); + } + ++// highp, not mediump: on GPUs where mediump is a real 16-bit float (Apple Silicon under ++// ANGLE/Metal, most mobile) a texture coordinate at 2048px wide has ~2 texels of resolution, ++// and dvui's blur kernels are sub-texel offsets. Vertex shaders always have highp. + const vertexShaderSource_webgl = ` +- precision mediump float; ++ precision highp float; + + attribute vec4 aVertexPosition; + attribute vec4 aVertexColor; + attribute vec2 aTextureCoord; + + uniform mat4 uMatrix; + + varying vec4 vColor; +@@ -62,17 +65,17 @@ const vertexShaderSource_webgl = ` + gl_Position = uMatrix * aVertexPosition; + vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1 + vTextureCoord = aTextureCoord; + } + `; + + const vertexShaderSource_webgl2 = `# version 300 es + +- precision mediump float; ++ precision highp float; + + in vec4 aVertexPosition; + in vec4 aVertexColor; + in vec2 aTextureCoord; + + uniform mat4 uMatrix; + + out vec4 vColor; +@@ -81,17 +84,21 @@ const vertexShaderSource_webgl2 = `# version 300 es + void main() { + gl_Position = uMatrix * aVertexPosition; + vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1 + vTextureCoord = aTextureCoord; + } + `; + + const fragmentShaderSource_webgl = ` ++#ifdef GL_FRAGMENT_PRECISION_HIGH ++ precision highp float; ++#else + precision mediump float; ++#endif + + varying vec4 vColor; + varying vec2 vTextureCoord; + + uniform sampler2D uSampler; + uniform bool useTex; + + void main() { +@@ -101,17 +108,17 @@ const fragmentShaderSource_webgl = ` + else { + gl_FragColor = vColor; + } + } + `; + + const fragmentShaderSource_webgl2 = `# version 300 es + +- precision mediump float; ++ precision highp float; + + in vec4 vColor; + in vec2 vTextureCoord; + + uniform sampler2D uSampler; + uniform bool useTex; + + out vec4 fragColor; diff --git a/web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch b/web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch new file mode 100644 index 000000000..2e4f3bad3 --- /dev/null +++ b/web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch @@ -0,0 +1,112 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:10:14 -0400 +Subject: [PATCH] web: mouse events carry their own modifier state + +A mouse event's modifiers came from the last key event, so a modifier whose keyup was +lost (cmd-tab away, a popup taking focus) stayed held for every click until the next +key event. web.js passes each mouse event's own metaKey/altKey/ctrlKey/shiftKey, and +web.zig sets the window's modifiers from them before adding the event. +--- + src/backends/web.js | 14 +++++++++++--- + src/backends/web.zig | 17 ++++++++++++++--- + 2 files changed, 25 insertions(+), 6 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 27d22e9..3285a64 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -174,16 +174,21 @@ export class Dvui { + }; + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); + newTextureId = 1; + ++ // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. ++ modCode(ev) { ++ return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); ++ } ++ + /** @returns {[WebGLTexture, number, number, number] | null} */ + textureEntry(id) { + if (id === 0) return null; + return this.textures.get(id) ?? null; + } + using_fb = false; + /** @type {WebGLFramebuffer | null} */ + frame_buffer = null; +@@ -1376,27 +1381,30 @@ export class Dvui { + } + this.gl.canvas.addEventListener("mousemove", (ev) => { + if (this.stopped) return; + let rect = this.gl.canvas.getBoundingClientRect(); + let x = (ev.clientX - rect.left) / (rect.right - rect.left) * + this.gl.drawingBufferWidth; + let y = (ev.clientY - rect.top) / (rect.bottom - rect.top) * + this.gl.drawingBufferHeight; +- this.instance.exports.add_event(1, 0, 0, x, y); ++ // The event's own modifier state, not the last key event's: a modifier whose keyup ++ // was lost (cmd-tab away, a popup taking focus) would otherwise stick to every ++ // click until the next key event. ++ this.instance.exports.add_event(1, this.modCode(ev), 0, x, y); + this.requestRender(); + }); + this.gl.canvas.addEventListener("mousedown", (ev) => { + if (this.stopped) return; +- this.instance.exports.add_event(2, ev.button, 0, 0, 0); ++ this.instance.exports.add_event(2, ev.button, this.modCode(ev), 0, 0); + this.requestRender(); + }); + this.gl.canvas.addEventListener("mouseup", (ev) => { + if (this.stopped) return; +- this.instance.exports.add_event(3, ev.button, 0, 0, 0); ++ this.instance.exports.add_event(3, ev.button, this.modCode(ev), 0, 0); + this.requestRender(); + }); + this.gl.canvas.addEventListener("wheel", (ev) => { + if (this.stopped) return; + ev.preventDefault(); + + // If we haven't gotten a wheel event in a second, reset our first + // because the user might have switched between mouse and touchpad. +diff --git a/src/backends/web.zig b/src/backends/web.zig +index d860ae7..b019009 100644 +--- a/src/backends/web.zig ++++ b/src/backends/web.zig +@@ -293,19 +293,30 @@ fn add_event_raw(w: *dvui.Window, which: u8, int1: u32, int2: u32, float1: f32, + // .int2 = int2, + // .float1 = float1, + // .float2 = float2, + //}) catch |err| { + // const msg = std.fmt.allocPrint(gpa, "{any}", .{err}) catch "allocPrint OOM"; + // wasm.wasm_panic(msg.ptr, msg.len); + //}; + switch (which) { +- 1 => _ = try w.addEventMouseMotion(.{ .pt = .{ .x = float1, .y = float2 } }), +- 2 => _ = try w.addEventMouseButton(buttonFromJS(int1), .press), +- 3 => _ = try w.addEventMouseButton(buttonFromJS(int1), .release), ++ // Mouse events carry the browser's own modifier state (see web.js): the modifiers ++ // dvui remembers from the last key event go stale when a keyup is lost. ++ 1 => { ++ w.modifiers = web_mod_code_to_dvui(@intCast(int1)); ++ _ = try w.addEventMouseMotion(.{ .pt = .{ .x = float1, .y = float2 } }); ++ }, ++ 2 => { ++ w.modifiers = web_mod_code_to_dvui(@intCast(int2)); ++ _ = try w.addEventMouseButton(buttonFromJS(int1), .press); ++ }, ++ 3 => { ++ w.modifiers = web_mod_code_to_dvui(@intCast(int2)); ++ _ = try w.addEventMouseButton(buttonFromJS(int1), .release); ++ }, + 4 => _ = try w.addEventMouseWheel(float1 * dvui.scroll_speed, if (int1 > 0) .vertical else .horizontal, if (int2 == 0) .mouse else .trackpad), + 5 => { + const str = @as([*]u8, @ptrFromInt(int1))[0..int2]; + _ = try w.addEventKey(.{ + .action = if (float1 > 0) .repeat else .down, + .code = web_key_code_to_dvui(str), + .mod = web_mod_code_to_dvui(@trunc(float2)), + }); diff --git a/web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch b/web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch new file mode 100644 index 000000000..6be33fe66 --- /dev/null +++ b/web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch @@ -0,0 +1,185 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:10:31 -0400 +Subject: [PATCH] web: a pencil is the mouse, and a cancelled touch ends + +An Apple Pencil on iPad Safari arrives as a touch whose touchType is "stylus". As a +finger it panned and left nothing hovered; it now goes in as the mouse (motion, then +the left button), so dvui counts it as .mouse, as native does with SDL's pen touches. + +A touch the system took (a gesture, an alert) fires touchcancel and no touchend, so +its touch, or the pencil's button, stayed held. touchcancel now ends it the same way. +--- + src/backends/web.js | 61 +++++++++++++++++++++++++++++++++++++++------ + 1 file changed, 54 insertions(+), 7 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 3285a64..368fbfb 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -149,16 +149,29 @@ export function dvui(canvas, wasmRef) { + dvui.run(); + return dvui; + }); + } + + const utf8decoder = new TextDecoder(); + const utf8encoder = new TextEncoder(); + ++/** ++ * What a pencil's touch does to the mouse; the values are `add_event`'s mouse codes. ++ * @enum {number} ++ */ ++const StylusAction = Object.freeze({ ++ /** motion only */ ++ move: 1, ++ /** motion, then the left button pressed */ ++ press: 2, ++ /** motion, then the left button released */ ++ release: 3, ++}); ++ + export class Dvui { + /** @type {WebGL2RenderingContext | WebGLRenderingContext} */ + gl; + /** @type {WebGLBuffer} */ + indexBuffer; + /** @type {WebGLBuffer} */ + vertexBuffer; + /** @type {WebGLProgram} */ +@@ -271,16 +284,39 @@ export class Dvui { + this.hidden_input.style.top = top + "px"; + this.hidden_input.style.width = width + "px"; + this.hidden_input.style.height = height + "px"; + this.hidden_input.focus(); + //par.textContent = hidden_input.style.left + " " + hidden_input.style.top + " " + hidden_input.style.width + " " + hidden_input.style.height; + } + } + ++ /** ++ * An Apple Pencil on iPad Safari arrives as a TouchEvent whose touch has `touchType` ++ * "stylus" (a finger's is "direct"). It is a pen, not a finger, so it goes in as the mouse: ++ * motion to where it is, then the left button pressed or released. dvui then counts it as ++ * `.mouse` (`dvui.pointerKind()`), so what it last touched stays hovered after a lift and a ++ * canvas treats it as a pointer, not a pan; native excludes SDL's pen touches the same way. ++ * The touch handlers' `preventDefault` stops the browser's own mouse events following it. ++ * ++ * @param {TouchEvent} ev ++ * @param {Touch} touch ++ * @param {StylusAction} action ++ */ ++ stylusEvent(ev, touch, action) { ++ const rect = this.gl.canvas.getBoundingClientRect(); ++ const x = (touch.clientX - rect.left) / (rect.right - rect.left) * ++ this.gl.drawingBufferWidth; ++ const y = (touch.clientY - rect.top) / (rect.bottom - rect.top) * ++ this.gl.drawingBufferHeight; ++ const mods = this.modCode(ev); ++ this.instance.exports.add_event(1, mods, 0, x, y); ++ if (action !== StylusAction.move) this.instance.exports.add_event(action, 0, mods, 0, 0); ++ } ++ + touchIndex(pointerId) { + let idx = this.touches.findIndex((e) => e[0] === pointerId); + if (idx < 0) { + idx = this.touches.length; + this.touches.push([pointerId, idx]); + } + + return idx; +@@ -1578,37 +1614,45 @@ export class Dvui { + ev.target.value = ""; + }); + this.gl.canvas.addEventListener("touchstart", (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; ++ if (touch.touchType === "stylus") { ++ this.stylusEvent(ev, touch, StylusAction.press); ++ continue; ++ } + let x = (touch.clientX - rect.left) / + (rect.right - rect.left); + let y = (touch.clientY - rect.top) / + (rect.bottom - rect.top); + let tidx = this.touchIndex(touch.identifier); + this.instance.exports.add_event( + 8, + this.touches[tidx][1], + 0, + x, + y, + ); + } + this.requestRender(); + }); +- this.gl.canvas.addEventListener("touchend", (ev) => { ++ const touchEnd = (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; ++ if (touch.touchType === "stylus") { ++ this.stylusEvent(ev, touch, StylusAction.release); ++ continue; ++ } + let x = (touch.clientX - rect.left) / + (rect.right - rect.left); + let y = (touch.clientY - rect.top) / + (rect.bottom - rect.top); + let tidx = this.touchIndex(touch.identifier); + this.instance.exports.add_event( + 9, + this.touches[tidx][1], +@@ -1618,39 +1662,42 @@ export class Dvui { + ); + this.touches.splice(tidx, 1); + } + // This oskCheck is for some platforms (iphone) where showing + // the keyboard has to be done inside an event handler. + // https://stackoverflow.com/a/6837575 + this.oskCheck(); + this.requestRender(); +- }); ++ }; ++ this.gl.canvas.addEventListener("touchend", touchEnd); ++ // A cancelled touch (the system took it: a gesture, an alert) ends like a lifted one, ++ // or its button, or the pencil's mouse button, would stay held. ++ this.gl.canvas.addEventListener("touchcancel", touchEnd); + this.gl.canvas.addEventListener("touchmove", (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; ++ if (touch.touchType === "stylus") { ++ this.stylusEvent(ev, touch, StylusAction.move); ++ continue; ++ } + let x = (touch.clientX - rect.left) / + (rect.right - rect.left); + let y = (touch.clientY - rect.top) / + (rect.bottom - rect.top); + let tidx = this.touchIndex(touch.identifier); + this.instance.exports.add_event( + 10, + this.touches[tidx][1], + 0, + x, + y, + ); + } + this.requestRender(); + }); +- //canvas.addEventListener("touchcancel", (ev) => { +- // console.log(ev); +- // this.requestRender(); +- //}); +- + // start the first update + this.requestRender(); + } + } diff --git a/web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch b/web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch new file mode 100644 index 000000000..25da83cee --- /dev/null +++ b/web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch @@ -0,0 +1,665 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:12:28 -0400 +Subject: [PATCH] web: send only the GL state a draw changes + +Every GL call is a trip to the GPU process in some browsers (Firefox), and each draw +sent the whole state: program, buffers, layout, texture, matrix, scissor, some 25 +calls where 3 or 4 change. The program, buffers and sampler are now bound once, and +cached setters (bindTex, bindFb, setScissor, setViewport, setUseTex, setMatrix, +setLayout) send the rest only when it differs. + +Each render target gets a framebuffer of its own, attached once, instead of the one +shared framebuffer re-attached on every switch, which made the driver check it again +each time. The canvas is resized only when its size changed: setting width or height, +even to the same value, makes a new drawing buffer. +--- + src/backends/web.js | 385 +++++++++++++++++--------------------------- + 1 file changed, 144 insertions(+), 241 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 368fbfb..309c5fa 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -186,28 +186,100 @@ export class Dvui { + useTex: WebGLUniformLocation | null; + }; + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); + newTextureId = 1; ++ /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the ++ /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — ++ /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. ++ /// `undefined` is unknown, and sends it. ++ gls = {}; ++ /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): ++ /// re-attaching a texture to the one shared framebuffer on every switch made the driver ++ /// check the framebuffer again each time. ++ targetFramebuffers = new Map(); ++ /// The matrix uniform's storage, filled in place. ++ matrix = new Float32Array(16); + + // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. + modCode(ev) { + return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); + } + ++ // The cached setters (`gls`): each sends its state only when it differs from what is set. ++ bindTex(texture) { ++ if (this.gls.tex === texture) return; ++ this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.gls.tex = texture; ++ } ++ bindFb(framebuffer) { ++ if (this.gls.fb === framebuffer) return; ++ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, framebuffer); ++ this.gls.fb = framebuffer; ++ } ++ setScissor(x, y, w, h) { ++ const g = this.gls; ++ if (g.sx === x && g.sy === y && g.sw === w && g.sh === h) return; ++ this.gl.scissor(x, y, w, h); ++ g.sx = x; g.sy = y; g.sw = w; g.sh = h; ++ } ++ setViewport(w, h) { ++ if (this.gls.vw === w && this.gls.vh === h) return; ++ this.gl.viewport(0, 0, w, h); ++ this.gls.vw = w; this.gls.vh = h; ++ } ++ setUseTex(on) { ++ if (this.gls.useTex === on) return; ++ this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); ++ this.gls.useTex = on; ++ } ++ /// The projection for the current render target: its size, and flipped for the screen. ++ setMatrix() { ++ const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; ++ if (this.gls.mw === w && this.gls.mh === h && this.gls.mfb === fb) return; ++ this.fillMatrix(w, h, fb); ++ this.gl.uniformMatrix4fv(this.programInfo.uniformLocations.matrix, false, this.matrix); ++ this.gls.mw = w; this.gls.mh = h; this.gls.mfb = fb; ++ } ++ fillMatrix(w, h, fb) { ++ const m = this.matrix; ++ m.fill(0); ++ m[0] = 2.0 / w; ++ m[5] = (fb ? 2.0 : -2.0) / h; ++ m[10] = 1.0; ++ m[12] = -1.0; ++ m[13] = fb ? -1.0 : 1.0; ++ m[15] = 1.0; ++ } ++ /// The vertex layout, which only changes if the vertex type does. ++ setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv) { ++ const g = this.gls; ++ if (g.ls === sizeof_vertex && g.lp === offset_pos && g.lc === offset_col && g.lu === offset_uv) return; ++ const a = this.programInfo.attribLocations; ++ this.gl.vertexAttribPointer(a.vertexPosition, 2, this.gl.FLOAT, false, sizeof_vertex, offset_pos); ++ this.gl.enableVertexAttribArray(a.vertexPosition); ++ this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); ++ this.gl.enableVertexAttribArray(a.vertexColor); ++ this.gl.vertexAttribPointer(a.textureCoord, 2, this.gl.FLOAT, false, sizeof_vertex, offset_uv); ++ this.gl.enableVertexAttribArray(a.textureCoord); ++ g.ls = sizeof_vertex; g.lp = offset_pos; g.lc = offset_col; g.lu = offset_uv; ++ } ++ + /** @returns {[WebGLTexture, number, number, number] | null} */ + textureEntry(id) { + if (id === 0) return null; + return this.textures.get(id) ?? null; + } + using_fb = false; ++ /** Texture id bound as the render target, 0 for the screen. */ ++ renderTargetId = 0; + /** @type {WebGLFramebuffer | null} */ + frame_buffer = null; + /** @type {[number, number]} */ + renderTargetSize = [0, 0]; + + renderRequested = false; + renderTimeoutId = 0; + +@@ -487,17 +559,17 @@ export class Dvui { + const pixelData = this.bytesFromPointer(pixels, width * height * 4); + + const texture = this.gl.createTexture(); + const id = this.newTextureId; + //console.log("creating texture " + id); + this.newTextureId += 1; + this.textures.set(id, [texture, width, height, interp]); + +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + + this.gl.texImage2D( + this.gl.TEXTURE_2D, + 0, + this.gl.RGBA, + width, + height, + 0, +@@ -541,59 +613,59 @@ export class Dvui { + this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); + } + if (wrap_v === 1) { + this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT); + } else { + this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); + } + +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); ++ this.bindTex(null); + + return id; + }, + // Replace every pixel of a texture made by wasm_textureCreate. Returns 0 for an + // unknown texture. No mipmaps to regenerate: textures sample NEAREST/LINEAR. + wasm_textureUpdate: (id, pixels) => { + const entry = this.textureEntry(id); + if (entry === null) { + console.warn( + `wasm_textureUpdate: missing texture id ${id}`, + ); + return 0; + } + const [texture, width, height, interp] = entry; +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + this.gl.texSubImage2D( + this.gl.TEXTURE_2D, 0, 0, 0, width, height, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, + this.bytesFromPointer(pixels, width * height * 4), + ); + // mipmaps currently break BlurBackdrop + //if (this.webgl2 && (interp == 1)) { + //this.gl.generateMipmap(this.gl.TEXTURE_2D); + //} +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); ++ this.bindTex(null); + return 1; + }, + // Replace the x,y,w,h rect of a texture made by wasm_textureCreate. `pixels` is the + // whole texture's buffer (see dvui.Backend.textureUpdateSubRect); only the rect is + // uploaded. Returns 0 for an unknown texture. + wasm_textureUpdateSubRect: (id, pixels, x, y, w, h) => { + const entry = this.textureEntry(id); + if (entry === null) { + console.warn( + `wasm_textureUpdateSubRect: missing texture id ${id}`, + ); + return 0; + } + const [texture, width, height, interp] = entry; + if (w === 0 || h === 0) return 1; + const full = this.bytesFromPointer(pixels, width * height * 4); +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + if (this.webgl2) { + // Read the rect in place out of the full buffer. + this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, width); + this.gl.pixelStorei(this.gl.UNPACK_SKIP_PIXELS, x); + this.gl.pixelStorei(this.gl.UNPACK_SKIP_ROWS, y); + this.gl.texSubImage2D( + this.gl.TEXTURE_2D, 0, x, y, w, h, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, full, +@@ -613,27 +685,27 @@ export class Dvui { + const start = ((y + row) * width + x) * 4; + rect.set(full.subarray(start, start + w * 4), row * w * 4); + } + this.gl.texSubImage2D( + this.gl.TEXTURE_2D, 0, x, y, w, h, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, rect, + ); + } +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); ++ this.bindTex(null); + return 1; + }, + wasm_textureCreateTarget: (width, height, interp, wrap_u, wrap_v) => { + const texture = this.gl.createTexture(); + const id = this.newTextureId; + //console.log("creating texture " + id); + this.newTextureId += 1; + this.textures.set(id, [texture, width, height, interp]); + +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + + this.gl.texImage2D( + this.gl.TEXTURE_2D, + 0, + this.gl.RGBA, + width, + height, + 0, +@@ -677,17 +749,17 @@ export class Dvui { + this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); + } + if (wrap_v === 1) { + this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT); + } else { + this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); + } + +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); ++ this.bindTex(null); + + this.imports.wasm_textureClearTarget(id); + + return id; + }, + wasm_textureClearTarget: (textureId) => { + this.imports.wasm_renderTarget(textureId); + this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent +@@ -698,112 +770,78 @@ export class Dvui { + //console.log("textureRead " + textureId); + const entry = this.textureEntry(textureId); + if (entry === null) { + console.warn( + `wasm_textureRead: missing texture id ${textureId}`, + ); + return; + } +- const texture = entry[0]; +- +- this.gl.bindFramebuffer( +- this.gl.FRAMEBUFFER, +- this.frame_buffer, +- ); ++ const prev = this.gls.fb; ++ this.bindFb(this.frame_buffer); + this.gl.framebufferTexture2D( + this.gl.FRAMEBUFFER, + this.gl.COLOR_ATTACHMENT0, + this.gl.TEXTURE_2D, +- texture, ++ entry[0], + 0, + ); + + var dest = this.bytesFromPointer(pixels_out, width * height * 4); + this.gl.readPixels( + 0, + 0, + width, + height, + this.gl.RGBA, + this.gl.UNSIGNED_BYTE, + dest, + 0, + ); + +- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); ++ this.bindFb(prev); + }, + wasm_renderTarget: (id) => { + //console.log("renderTarget " + id); +- if (id === 0) { ++ const rt = id === 0 ? null : this.textureEntry(id); ++ if (id !== 0 && rt === null) console.warn(`wasm_renderTarget: missing texture id ${id}`); ++ if (rt === null) { ++ this.renderTargetId = 0; + this.using_fb = false; +- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); +- this.renderTargetSize = [ +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ]; +- this.gl.viewport( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); +- this.gl.scissor( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); ++ this.bindFb(null); ++ this.renderTargetSize = [this.gl.drawingBufferWidth, this.gl.drawingBufferHeight]; + } else { ++ this.renderTargetId = id; + this.using_fb = true; +- this.gl.bindFramebuffer( +- this.gl.FRAMEBUFFER, +- this.frame_buffer, +- ); +- +- const rt = this.textureEntry(id); +- if (rt === null) { +- console.warn( +- `wasm_renderTarget: missing texture id ${id}`, +- ); +- this.using_fb = false; +- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); +- this.renderTargetSize = [ +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ]; ++ let fb = this.targetFramebuffers.get(id); ++ if (fb === undefined) { ++ fb = this.gl.createFramebuffer(); ++ this.bindFb(fb); ++ this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, rt[0], 0); ++ this.targetFramebuffers.set(id, fb); + } else { +- this.gl.framebufferTexture2D( +- this.gl.FRAMEBUFFER, +- this.gl.COLOR_ATTACHMENT0, +- this.gl.TEXTURE_2D, +- rt[0], +- 0, +- ); +- this.renderTargetSize = [rt[1], rt[2]]; ++ this.bindFb(fb); + } +- this.gl.viewport( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); +- this.gl.scissor( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); ++ this.renderTargetSize = [rt[1], rt[2]]; + } ++ this.setViewport(this.renderTargetSize[0], this.renderTargetSize[1]); ++ this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); + }, + wasm_textureDestroy: (id) => { + //console.log("deleting texture " + id); + const entry = this.textureEntry(id); + if (entry === null) return; + this.textures.delete(id); ++ const fb = this.targetFramebuffers.get(id); ++ if (fb !== undefined) { ++ if (this.gls.fb === fb) this.bindFb(null); ++ this.gl.deleteFramebuffer(fb); ++ this.targetFramebuffers.delete(id); ++ } ++ if (this.gls.tex === entry[0]) this.bindTex(null); + this.gl.deleteTexture(entry[0]); + }, + wasm_renderGeometry: ( + textureId, + index_ptr, + index_len, + vertex_ptr, + vertex_len, +@@ -812,175 +850,41 @@ export class Dvui { + offset_col, + offset_uv, + clip, + x, + y, + w, + h, + ) => { +- //console.log("drawClippedTriangles " + textureId + " sizeof " + sizeof_vertex + " pos " + offset_pos + " col " + offset_col + " uv " + offset_uv); +- +- //let old_scissor; + if (clip === 1) { +- // just calling getParameter here is quite slow (5-10 ms per frame according to chrome) +- //old_scissor = gl.getParameter(gl.SCISSOR_BOX); +- this.gl.scissor(x, y, w, h); +- } +- +- this.gl.bindBuffer( +- this.gl.ELEMENT_ARRAY_BUFFER, +- this.indexBuffer, +- ); +- const indices = new Uint16Array( +- this.instance.exports.memory.buffer, +- index_ptr, +- index_len / 2, +- ); +- this.gl.bufferData( +- this.gl.ELEMENT_ARRAY_BUFFER, +- indices, +- this.gl.DYNAMIC_DRAW, +- ); +- +- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); +- const vertexes = this.bytesFromPointer(vertex_ptr, vertex_len) +- this.gl.bufferData( +- this.gl.ARRAY_BUFFER, +- vertexes, +- this.gl.DYNAMIC_DRAW, +- ); +- +- let matrix = new Float32Array(16); +- matrix[0] = 2.0 / this.renderTargetSize[0]; +- matrix[1] = 0.0; +- matrix[2] = 0.0; +- matrix[3] = 0.0; +- matrix[4] = 0.0; +- if (this.using_fb) { +- matrix[5] = 2.0 / this.renderTargetSize[1]; +- } else { +- matrix[5] = -2.0 / this.renderTargetSize[1]; +- } +- matrix[6] = 0.0; +- matrix[7] = 0.0; +- matrix[8] = 0.0; +- matrix[9] = 0.0; +- matrix[10] = 1.0; +- matrix[11] = 0.0; +- matrix[12] = -1.0; +- if (this.using_fb) { +- matrix[13] = -1.0; ++ this.setScissor(x, y, w, h); + } else { +- matrix[13] = 1.0; ++ this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); + } +- matrix[14] = 0.0; +- matrix[15] = 1.0; +- +- // vertex +- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); +- this.gl.vertexAttribPointer( +- this.programInfo.attribLocations.vertexPosition, +- 2, // num components +- this.gl.FLOAT, +- false, // don't normalize +- sizeof_vertex, // stride +- offset_pos, // offset +- ); +- this.gl.enableVertexAttribArray( +- this.programInfo.attribLocations.vertexPosition, +- ); +- +- // color +- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); +- this.gl.vertexAttribPointer( +- this.programInfo.attribLocations.vertexColor, +- 4, // num components +- this.gl.UNSIGNED_BYTE, +- false, // don't normalize +- sizeof_vertex, // stride +- offset_col, // offset +- ); +- this.gl.enableVertexAttribArray( +- this.programInfo.attribLocations.vertexColor, +- ); +- +- // texture +- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); +- this.gl.vertexAttribPointer( +- this.programInfo.attribLocations.textureCoord, +- 2, // num components +- this.gl.FLOAT, +- false, // don't normalize +- sizeof_vertex, // stride +- offset_uv, // offset +- ); +- this.gl.enableVertexAttribArray( +- this.programInfo.attribLocations.textureCoord, +- ); +- +- // Tell WebGL to use our program when drawing +- this.gl.useProgram(this.shaderProgram); +- +- // Set the shader uniforms +- this.gl.uniformMatrix4fv( +- this.programInfo.uniformLocations.matrix, +- false, +- matrix, +- ); + +- if (textureId != 0) { +- const tex = this.textureEntry(textureId); +- if (tex !== null) { +- this.gl.activeTexture(this.gl.TEXTURE0); +- this.gl.bindTexture(this.gl.TEXTURE_2D, tex[0]); +- this.gl.uniform1i( +- this.programInfo.uniformLocations.useTex, +- 1, +- ); +- } else { +- console.warn( +- `wasm_renderGeometry: missing texture id ${textureId}`, +- ); +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); +- this.gl.uniform1i( +- this.programInfo.uniformLocations.useTex, +- 0, +- ); +- } ++ const indices = new Uint16Array(this.instance.exports.memory.buffer, index_ptr, index_len / 2); ++ this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.DYNAMIC_DRAW); ++ this.gl.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); ++ this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); ++ this.setMatrix(); ++ ++ const tex = textureId != 0 ? this.textureEntry(textureId) : null; ++ if (textureId != 0 && tex === null) console.warn(`wasm_renderGeometry: missing texture id ${textureId}`); ++ if (tex !== null) { ++ this.bindTex(tex[0]); ++ this.setUseTex(1); + } else { +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); +- this.gl.uniform1i( +- this.programInfo.uniformLocations.useTex, +- 0, +- ); ++ // Not left bound: a target texture still bound while drawing into it is a ++ // feedback loop to some drivers, sampled or not. ++ this.bindTex(null); ++ this.setUseTex(0); + } + +- this.gl.uniform1i( +- this.programInfo.uniformLocations.uSampler, +- 0, +- ); +- +- //console.log("drawElements " + textureId); +- this.gl.drawElements( +- this.gl.TRIANGLES, +- indices.length, +- this.gl.UNSIGNED_SHORT, +- 0, +- ); +- +- if (clip === 1) { +- //gl.scissor(old_scissor[0], old_scissor[1], old_scissor[2], old_scissor[3]); +- this.gl.scissor( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); +- } ++ this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, + wasm_cursor: (name_ptr, name_len) => { + const cursor_name = this.stringFromPointer(name_ptr, name_len); + this.gl.canvas.style.cursor = cursor_name; + }, + wasm_text_input: (x, y, w, h) => { + if (w > 0 && h > 0) { + this.textInputRect = [x, y, w, h]; +@@ -1253,20 +1157,29 @@ export class Dvui { + "useTex", + ), + }, + }; + + this.indexBuffer = this.gl.createBuffer(); + this.vertexBuffer = this.gl.createBuffer(); + ++ // Bound for good: there is one program, one pair of buffers and one sampler, so a draw ++ // sends only its data and whatever else changed (`gls`). ++ this.gl.useProgram(this.shaderProgram); ++ this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); ++ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); ++ this.gl.activeTexture(this.gl.TEXTURE0); ++ this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); ++ ++ this.gls = {}; + this.gl.enable(this.gl.BLEND); + this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); + this.gl.enable(this.gl.SCISSOR_TEST); +- this.gl.scissor( ++ this.setScissor( + 0, + 0, + this.gl.canvas.clientWidth, + this.gl.canvas.clientHeight, + ); + } + + init() { +@@ -1334,34 +1247,24 @@ export class Dvui { + + this.renderRequested = false; + + // if the canvas changed size, adjust the backing buffer + const w = this.gl.canvas.clientWidth; + const h = this.gl.canvas.clientHeight; + const scale = window.devicePixelRatio; + //console.log("wxh " + w + "x" + h + " scale " + scale); +- this.gl.canvas.width = Math.round(w * scale); +- this.gl.canvas.height = Math.round(h * scale); +- this.renderTargetSize = [ +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ]; +- this.gl.viewport( +- 0, +- 0, +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ); +- this.gl.scissor( +- 0, +- 0, +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ); ++ // Only when it changed: setting either one, even to what it already is, makes a new ++ // drawing buffer. ++ const cw = Math.round(w * scale); ++ const ch = Math.round(h * scale); ++ if (this.gl.canvas.width !== cw) this.gl.canvas.width = cw; ++ if (this.gl.canvas.height !== ch) this.gl.canvas.height = ch; ++ // Every frame starts on the screen. ++ this.imports.wasm_renderTarget(0); + + this.gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + let millis_to_wait = this.instance.exports.dvui_update(); + + // This oskCheck is for desktop to get text events. Touch devices will + // show/hide the keyboard (but not all, see touchend handler). diff --git a/web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch b/web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch new file mode 100644 index 000000000..9160ca005 --- /dev/null +++ b/web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch @@ -0,0 +1,45 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:12:35 -0400 +Subject: [PATCH] web: clearing a target keeps the target that was bound + +wasm_textureClearTarget bound the target, cleared it, and bound the screen. A target +is often created, and so cleared, while another one is being rendered into (a Picture +drawn inside a frame target), and the rest of that drawing then went to the screen. +It now binds back whatever target was bound before. +--- + src/backends/web.js | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 309c5fa..200e34f 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -755,21 +755,26 @@ export class Dvui { + } + + this.bindTex(null); + + this.imports.wasm_textureClearTarget(id); + + return id; + }, ++ // Restores whatever target was bound before: a target is often created and ++ // cleared while another one is being rendered into (a Picture inside a frame ++ // target), and landing on the screen there sends the rest of that drawing to the ++ // wrong place. + wasm_textureClearTarget: (textureId) => { ++ const prev = this.renderTargetId; + this.imports.wasm_renderTarget(textureId); + this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent + this.gl.clear(this.gl.COLOR_BUFFER_BIT); +- this.imports.wasm_renderTarget(0); ++ this.imports.wasm_renderTarget(prev); + }, + wasm_textureRead: (textureId, pixels_out, width, height) => { + //console.log("textureRead " + textureId); + const entry = this.textureEntry(textureId); + if (entry === null) { + console.warn( + `wasm_textureRead: missing texture id ${textureId}`, + ); diff --git a/web/patches/dvui/0006-web-textureBlend.patch b/web/patches/dvui/0006-web-textureBlend.patch new file mode 100644 index 000000000..e4a7a2e02 --- /dev/null +++ b/web/patches/dvui/0006-web-textureBlend.patch @@ -0,0 +1,217 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:13:15 -0400 +Subject: [PATCH] web: textureBlend + +The web backend implements Backend.textureBlend: wasm_textureBlend records a +texture's blend (over, add, copy), and each draw of the texture sets it, since WebGL's +blend state is global. web.zig flushes its batch first, so draws already batched keep +the blend they were made with. +--- + src/backends/web.js | 27 +++++++++++++++++++++++++-- + src/backends/web.zig | 10 ++++++++++ + 2 files changed, 35 insertions(+), 2 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 200e34f..dbae62d 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -185,16 +185,19 @@ export class Dvui { + uSampler: WebGLUniformLocation | null; + useTex: WebGLUniformLocation | null; + }; + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); ++ /// texture id -> blend mode set by wasm_textureBlend: 0 source-over (the default), 1 add, ++ /// 2 copy. WebGL blend state is global, so it is applied per draw when the texture binds. ++ textureBlends = new Map(); + newTextureId = 1; + /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the + /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — + /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. + /// `undefined` is unknown, and sends it. + gls = {}; + /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): + /// re-attaching a texture to the one shared framebuffer on every switch made the driver +@@ -225,16 +228,28 @@ export class Dvui { + this.gl.scissor(x, y, w, h); + g.sx = x; g.sy = y; g.sw = w; g.sh = h; + } + setViewport(w, h) { + if (this.gls.vw === w && this.gls.vh === h) return; + this.gl.viewport(0, 0, w, h); + this.gls.vw = w; this.gls.vh = h; + } ++ /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`). ++ setBlend(mode) { ++ if (this.gls.blend === mode) return; ++ if (mode === 2) { ++ this.gl.disable(this.gl.BLEND); ++ } else { ++ if (this.gls.blend === 2 || this.gls.blend === undefined) this.gl.enable(this.gl.BLEND); ++ if (mode === 1) this.gl.blendFunc(this.gl.ONE, this.gl.ONE); ++ else this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); ++ } ++ this.gls.blend = mode; ++ } + setUseTex(on) { + if (this.gls.useTex === on) return; + this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); + this.gls.useTex = on; + } + /// The projection for the current render target: its size, and flipped for the screen. + setMatrix() { + const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; +@@ -755,16 +770,22 @@ export class Dvui { + } + + this.bindTex(null); + + this.imports.wasm_textureClearTarget(id); + + return id; + }, ++ // See dvui.Backend.textureBlend. Returns 0 for an unknown texture. ++ wasm_textureBlend: (id, mode) => { ++ if (this.textureEntry(id) === null) return 0; ++ this.textureBlends.set(id, mode); ++ return 1; ++ }, + // Restores whatever target was bound before: a target is often created and + // cleared while another one is being rendered into (a Picture inside a frame + // target), and landing on the screen there sends the rest of that drawing to the + // wrong place. + wasm_textureClearTarget: (textureId) => { + const prev = this.renderTargetId; + this.imports.wasm_renderTarget(textureId); + this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent +@@ -830,16 +851,17 @@ export class Dvui { + this.setViewport(this.renderTargetSize[0], this.renderTargetSize[1]); + this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); + }, + wasm_textureDestroy: (id) => { + //console.log("deleting texture " + id); + const entry = this.textureEntry(id); + if (entry === null) return; + this.textures.delete(id); ++ this.textureBlends.delete(id); + const fb = this.targetFramebuffers.get(id); + if (fb !== undefined) { + if (this.gls.fb === fb) this.bindFb(null); + this.gl.deleteFramebuffer(fb); + this.targetFramebuffers.delete(id); + } + if (this.gls.tex === entry[0]) this.bindTex(null); + this.gl.deleteTexture(entry[0]); +@@ -872,21 +894,23 @@ export class Dvui { + this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); + this.setMatrix(); + + const tex = textureId != 0 ? this.textureEntry(textureId) : null; + if (textureId != 0 && tex === null) console.warn(`wasm_renderGeometry: missing texture id ${textureId}`); + if (tex !== null) { + this.bindTex(tex[0]); + this.setUseTex(1); ++ this.setBlend(this.textureBlends.get(textureId) ?? 0); + } else { + // Not left bound: a target texture still bound while drawing into it is a + // feedback loop to some drivers, sampled or not. + this.bindTex(null); + this.setUseTex(0); ++ this.setBlend(0); + } + + this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, + wasm_cursor: (name_ptr, name_len) => { + const cursor_name = this.stringFromPointer(name_ptr, name_len); + this.gl.canvas.style.cursor = cursor_name; + }, +@@ -1171,18 +1195,17 @@ export class Dvui { + // sends only its data and whatever else changed (`gls`). + this.gl.useProgram(this.shaderProgram); + this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); + + this.gls = {}; +- this.gl.enable(this.gl.BLEND); +- this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); ++ this.setBlend(0); + this.gl.enable(this.gl.SCISSOR_TEST); + this.setScissor( + 0, + 0, + this.gl.canvas.clientWidth, + this.gl.canvas.clientHeight, + ); + } +diff --git a/src/backends/web.zig b/src/backends/web.zig +index b019009..d05caa4 100644 +--- a/src/backends/web.zig ++++ b/src/backends/web.zig +@@ -57,16 +57,17 @@ pub const wasm = if (!builtin.is_test) struct { + pub extern "dvui" fn wasm_canvas_height() f32; + + pub extern "dvui" fn wasm_frame_buffer() u8; + pub extern "dvui" fn wasm_textureCreate(pixels: [*]const u8, width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureCreateTarget(width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureUpdate(texture: u32, pixels: [*]const u8) u8; + pub extern "dvui" fn wasm_textureUpdateSubRect(texture: u32, pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) u8; + pub extern "dvui" fn wasm_textureClearTarget(u32) void; ++ pub extern "dvui" fn wasm_textureBlend(texture: u32, mode: u8) u8; + pub extern "dvui" fn wasm_textureRead(texture: u32, pixels_out: [*]u8, width: u32, height: u32) void; + pub extern "dvui" fn wasm_renderTarget(u32) void; + pub extern "dvui" fn wasm_textureDestroy(u32) void; + pub extern "dvui" fn wasm_renderGeometry(texture: u32, index_ptr: [*]const u8, index_len: usize, vertex_ptr: [*]const u8, vertex_len: usize, sizeof_vertex: u8, offset_pos: u8, offset_col: u8, offset_uv: u8, clip: u8, x: i32, y: i32, w: i32, h: i32) void; + + pub extern "dvui" fn wasm_cursor(name: [*]const u8, name_len: usize) void; + pub extern "dvui" fn wasm_text_input(x: f32, y: f32, w: f32, h: f32) void; + pub extern "dvui" fn wasm_open_url(ptr: [*]const u8, len: usize, new_window: bool) void; +@@ -122,16 +123,19 @@ pub const wasm = if (!builtin.is_test) struct { + } + pub fn wasm_textureUpdate(_: u32, _: [*]const u8) u8 { + return 0; + } + pub fn wasm_textureUpdateSubRect(_: u32, _: [*]const u8, _: u32, _: u32, _: u32, _: u32) u8 { + return 0; + } + pub fn wasm_textureClearTarget(_: u32) void {} ++ pub fn wasm_textureBlend(_: u32, _: u8) u8 { ++ return 0; ++ } + pub fn wasm_textureRead(_: u32, _: [*]u8, _: u32, _: u32) void {} + pub fn wasm_renderTarget(_: u32) void {} + pub fn wasm_textureDestroy(_: u32) void {} + pub fn wasm_renderGeometry(_: u32, _: [*]const u8, _: usize, _: [*]const u8, _: usize, _: u8, _: u8, _: u8, _: u8, _: u8, _: i32, _: i32, _: i32, _: i32) void {} + + pub fn wasm_cursor(_: [*]const u8, _: usize) void {} + pub fn wasm_text_input(_: f32, _: f32, _: f32, _: f32) void {} + pub fn wasm_open_url(_: [*]const u8, _: usize, _: bool) void {} +@@ -751,16 +755,22 @@ pub fn textureCreateTarget(_: *WebBackend, options: dvui.Texture.CreateOptions) + .height = options.height, + .format = options.format, + .interpolation = options.interpolation, + .wrap_u = options.wrap_u, + .wrap_v = options.wrap_v, + }; + } + ++/// See `dvui.Backend.textureBlend`. Draws already batched keep the blend they were made with. ++pub fn textureBlend(_: *WebBackend, texture: dvui.Texture, blend: dvui.Backend.TextureBlend) !void { ++ flushBatch(); ++ if (wasm.wasm_textureBlend(@intCast(@intFromPtr(texture.ptr)), @intFromEnum(blend)) == 0) return dvui.Backend.TextureError.TextureUpdate; ++} ++ + pub fn textureClearTarget(_: *WebBackend, tex: dvui.TextureTarget) void { + flushBatch(); + wasm.wasm_textureClearTarget(@intCast(@intFromPtr(tex.ptr))); + } + + pub fn textureFromTarget(_: *WebBackend, texture: dvui.TextureTarget) !dvui.Texture { + flushBatch(); + return .cast(texture); diff --git a/web/patches/dvui/0007-web-precise-render-targets.patch b/web/patches/dvui/0007-web-precise-render-targets.patch new file mode 100644 index 000000000..7a4ab6f69 --- /dev/null +++ b/web/patches/dvui/0007-web-precise-render-targets.patch @@ -0,0 +1,339 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:14:21 -0400 +Subject: [PATCH] web: precise render targets + +The web backend implements Backend.support_precise_targets: a target created with +precision = .high is RGBA16F, which WebGL2 samples with linear filtering as standard +and renders into with EXT_color_buffer_float (or the half-float one). web.js checks +for the extension at context creation, and the first such framebuffer for +completeness, falling back to the 8-bit target where either fails. BlurBackdrop's +pyramid is the user: at 8 bits, its downsampled levels band. +--- + src/backends/web.js | 165 ++++++++++++++++++++++++++----------------- + src/backends/web.zig | 14 +++- + 2 files changed, 113 insertions(+), 66 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index dbae62d..7cd1983 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -188,16 +188,19 @@ export class Dvui { + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); + /// texture id -> blend mode set by wasm_textureBlend: 0 source-over (the default), 1 add, + /// 2 copy. WebGL blend state is global, so it is applied per draw when the texture binds. + textureBlends = new Map(); ++ /// Whether half-float render targets are available (WebGL2 + EXT_color_buffer_float). ++ preciseTargets = false; ++ preciseChecked = false; + newTextureId = 1; + /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the + /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — + /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. + /// `undefined` is unknown, and sends it. + gls = {}; + /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): + /// re-attaching a texture to the one shared framebuffer on every switch made the driver +@@ -277,16 +280,100 @@ export class Dvui { + this.gl.enableVertexAttribArray(a.vertexPosition); + this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); + this.gl.enableVertexAttribArray(a.vertexColor); + this.gl.vertexAttribPointer(a.textureCoord, 2, this.gl.FLOAT, false, sizeof_vertex, offset_uv); + this.gl.enableVertexAttribArray(a.textureCoord); + g.ls = sizeof_vertex; g.lp = offset_pos; g.lc = offset_col; g.lu = offset_uv; + } + ++ createTarget(width, height, interp, wrap_u, wrap_v, precise) { ++ const texture = this.gl.createTexture(); ++ const id = this.newTextureId; ++ //console.log("creating texture " + id); ++ this.newTextureId += 1; ++ this.textures.set(id, [texture, width, height, interp]); ++ ++ this.bindTex(texture); ++ ++ this.gl.texImage2D( ++ this.gl.TEXTURE_2D, ++ 0, ++ precise ? this.gl.RGBA16F : this.gl.RGBA, ++ width, ++ height, ++ 0, ++ this.gl.RGBA, ++ precise ? this.gl.HALF_FLOAT : this.gl.UNSIGNED_BYTE, ++ null, ++ ); ++ ++ if (interp == 0) { ++ this.gl.texParameteri( ++ this.gl.TEXTURE_2D, ++ this.gl.TEXTURE_MIN_FILTER, ++ this.gl.NEAREST, ++ ); ++ this.gl.texParameteri( ++ this.gl.TEXTURE_2D, ++ this.gl.TEXTURE_MAG_FILTER, ++ this.gl.NEAREST, ++ ); ++ } else { ++ // mipmaps currently break BlurBackdrop ++ //if (this.webgl2) { ++ //this.gl.generateMipmap(this.gl.TEXTURE_2D); ++ //} ++ ++ this.gl.texParameteri( ++ this.gl.TEXTURE_2D, ++ this.gl.TEXTURE_MIN_FILTER, ++ this.gl.LINEAR, ++ //this.gl.LINEAR_MIPMAP_LINEAR, ++ ); ++ this.gl.texParameteri( ++ this.gl.TEXTURE_2D, ++ this.gl.TEXTURE_MAG_FILTER, ++ this.gl.LINEAR, ++ ); ++ } ++ if (wrap_u === 1) { ++ this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT); ++ } else { ++ this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); ++ } ++ if (wrap_v === 1) { ++ this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT); ++ } else { ++ this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); ++ } ++ ++ this.bindTex(null); ++ ++ if (precise && !this.preciseChecked) { ++ // The extension says half-float attachments are renderable; make sure this context ++ // agrees before trusting every later target to it. Checked once. ++ this.preciseChecked = true; ++ const prev = this.renderTargetId; ++ this.imports.wasm_renderTarget(id); ++ const status = this.gl.checkFramebufferStatus(this.gl.FRAMEBUFFER); ++ this.imports.wasm_renderTarget(prev); ++ if (status !== this.gl.FRAMEBUFFER_COMPLETE) { ++ console.warn("dvui: half-float render target incomplete (status " + status + "); falling back to 8-bit targets"); ++ this.preciseTargets = false; ++ this.imports.wasm_textureDestroy(id); ++ return this.createTarget(width, height, interp, wrap_u, wrap_v, false); ++ } ++ } ++ ++ this.imports.wasm_textureClearTarget(id); ++ ++ return id; ++ } ++ + /** @returns {[WebGLTexture, number, number, number] | null} */ + textureEntry(id) { + if (id === 0) return null; + return this.textures.get(id) ?? null; + } + using_fb = false; + /** Texture id bound as the render target, 0 for the screen. */ + renderTargetId = 0; +@@ -704,81 +791,22 @@ export class Dvui { + this.gl.TEXTURE_2D, 0, x, y, w, h, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, rect, + ); + } + this.bindTex(null); + return 1; + }, + wasm_textureCreateTarget: (width, height, interp, wrap_u, wrap_v) => { +- const texture = this.gl.createTexture(); +- const id = this.newTextureId; +- //console.log("creating texture " + id); +- this.newTextureId += 1; +- this.textures.set(id, [texture, width, height, interp]); +- +- this.bindTex(texture); +- +- this.gl.texImage2D( +- this.gl.TEXTURE_2D, +- 0, +- this.gl.RGBA, +- width, +- height, +- 0, +- this.gl.RGBA, +- this.gl.UNSIGNED_BYTE, +- null, +- ); +- +- if (interp == 0) { +- this.gl.texParameteri( +- this.gl.TEXTURE_2D, +- this.gl.TEXTURE_MIN_FILTER, +- this.gl.NEAREST, +- ); +- this.gl.texParameteri( +- this.gl.TEXTURE_2D, +- this.gl.TEXTURE_MAG_FILTER, +- this.gl.NEAREST, +- ); +- } else { +- // mipmaps currently break BlurBackdrop +- //if (this.webgl2) { +- //this.gl.generateMipmap(this.gl.TEXTURE_2D); +- //} +- +- this.gl.texParameteri( +- this.gl.TEXTURE_2D, +- this.gl.TEXTURE_MIN_FILTER, +- this.gl.LINEAR, +- //this.gl.LINEAR_MIPMAP_LINEAR, +- ); +- this.gl.texParameteri( +- this.gl.TEXTURE_2D, +- this.gl.TEXTURE_MAG_FILTER, +- this.gl.LINEAR, +- ); +- } +- if (wrap_u === 1) { +- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT); +- } else { +- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); +- } +- if (wrap_v === 1) { +- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT); +- } else { +- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); +- } +- +- this.bindTex(null); +- +- this.imports.wasm_textureClearTarget(id); +- +- return id; ++ return this.createTarget(width, height, interp, wrap_u, wrap_v, false); ++ }, ++ // A 16-bit-float-per-channel target where the context has them; the 8-bit one ++ // otherwise. See dvui.Backend.support_precise_targets. ++ wasm_textureCreatePreciseTarget: (width, height, interp, wrap_u, wrap_v) => { ++ return this.createTarget(width, height, interp, wrap_u, wrap_v, this.preciseTargets); + }, + // See dvui.Backend.textureBlend. Returns 0 for an unknown texture. + wasm_textureBlend: (id, mode) => { + if (this.textureEntry(id) === null) return 0; + this.textureBlends.set(id, mode); + return 1; + }, + // Restores whatever target was bound before: a target is often created and +@@ -1091,16 +1119,23 @@ export class Dvui { + this.gl = canvas.getContext("webgl", { alpha: true, antialias: false }); + } + + if (this.gl === null) { + alert("Unable to initialize WebGL."); + return; + } + ++ // Half-float render targets for dvui's blur pyramid (Texture.CreateOptions.precision): ++ // WebGL2 samples RGBA16F with linear filtering as standard; rendering into it needs the ++ // extension. Without it the precise target is the plain 8-bit one. ++ this.preciseTargets = this.webgl2 && ++ (this.gl.getExtension("EXT_color_buffer_float") !== null || ++ this.gl.getExtension("EXT_color_buffer_half_float") !== null); ++ + if (!this.webgl2) { + const ext = this.gl.getExtension("OES_element_index_uint"); + if (ext === null) { + alert("WebGL doesn't support OES_element_index_uint."); + return; + } + } + this.frame_buffer = this.gl.createFramebuffer(); +diff --git a/src/backends/web.zig b/src/backends/web.zig +index d05caa4..9a7c9ab 100644 +--- a/src/backends/web.zig ++++ b/src/backends/web.zig +@@ -54,16 +54,17 @@ pub const wasm = if (!builtin.is_test) struct { + pub extern "dvui" fn wasm_pixel_width() f32; + pub extern "dvui" fn wasm_pixel_height() f32; + pub extern "dvui" fn wasm_canvas_width() f32; + pub extern "dvui" fn wasm_canvas_height() f32; + + pub extern "dvui" fn wasm_frame_buffer() u8; + pub extern "dvui" fn wasm_textureCreate(pixels: [*]const u8, width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureCreateTarget(width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; ++ pub extern "dvui" fn wasm_textureCreatePreciseTarget(width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureUpdate(texture: u32, pixels: [*]const u8) u8; + pub extern "dvui" fn wasm_textureUpdateSubRect(texture: u32, pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) u8; + pub extern "dvui" fn wasm_textureClearTarget(u32) void; + pub extern "dvui" fn wasm_textureBlend(texture: u32, mode: u8) u8; + pub extern "dvui" fn wasm_textureRead(texture: u32, pixels_out: [*]u8, width: u32, height: u32) void; + pub extern "dvui" fn wasm_renderTarget(u32) void; + pub extern "dvui" fn wasm_textureDestroy(u32) void; + pub extern "dvui" fn wasm_renderGeometry(texture: u32, index_ptr: [*]const u8, index_len: usize, vertex_ptr: [*]const u8, vertex_len: usize, sizeof_vertex: u8, offset_pos: u8, offset_col: u8, offset_uv: u8, clip: u8, x: i32, y: i32, w: i32, h: i32) void; +@@ -116,16 +117,19 @@ pub const wasm = if (!builtin.is_test) struct { + return undefined; + } + pub fn wasm_textureCreate(_: [*]const u8, _: u32, _: u32, _: u8, _: u8, _: u8) u32 { + return undefined; + } + pub fn wasm_textureCreateTarget(_: u32, _: u32, _: u8, _: u8, _: u8) u32 { + return undefined; + } ++ pub fn wasm_textureCreatePreciseTarget(_: u32, _: u32, _: u8, _: u8, _: u8) u32 { ++ return undefined; ++ } + pub fn wasm_textureUpdate(_: u32, _: [*]const u8) u8 { + return 0; + } + pub fn wasm_textureUpdateSubRect(_: u32, _: [*]const u8, _: u32, _: u32, _: u32, _: u32) u8 { + return 0; + } + pub fn wasm_textureClearTarget(_: u32) void {} + pub fn wasm_textureBlend(_: u32, _: u8) u8 { +@@ -723,16 +727,21 @@ pub fn textureUpdate(_: *WebBackend, texture: dvui.Texture, pixels: [*]const u8) + /// See `dvui.Backend.textureUpdateSubRect`. `pixels` is the full texture's buffer; only the + /// rect is uploaded (WebGL2 reads it in place through the unpack row length and skips; WebGL1 + /// copies the rect's rows out first). + pub fn textureUpdateSubRect(_: *WebBackend, texture: dvui.Texture, pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) !void { + flushBatch(); + if (wasm.wasm_textureUpdateSubRect(@intCast(@intFromPtr(texture.ptr)), pixels, x, y, w, h) == 0) return dvui.Backend.TextureError.TextureUpdate; + } + ++/// See `dvui.Backend.support_precise_targets`. `precision = .high` is RGBA16F on WebGL2 with ++/// `EXT_color_buffer_float` (or the half-float one); web.js checks at context creation and ++/// verifies the first such framebuffer is complete, falling back to the 8-bit target otherwise. ++pub const support_precise_targets = true; ++ + pub fn textureCreateTarget(_: *WebBackend, options: dvui.Texture.CreateOptions) !dvui.TextureTarget { + if (options.format != .rgba_32) { + log.err("textureCreateTarget currently only supports pixel format .rgba_32", .{}); + return dvui.Backend.TextureError.TextureCreate; + } + + const wasm_interp: u8 = switch (options.interpolation) { + .nearest => 0, +@@ -743,17 +752,20 @@ pub fn textureCreateTarget(_: *WebBackend, options: dvui.Texture.CreateOptions) + .clamp => 0, + .repeat => 1, + }; + const wasm_wrap_v: u8 = switch (options.wrap_v) { + .clamp => 0, + .repeat => 1, + }; + +- const id = wasm.wasm_textureCreateTarget(options.width, options.height, wasm_interp, wasm_wrap_u, wasm_wrap_v); ++ const id = if (options.precision == .high) ++ wasm.wasm_textureCreatePreciseTarget(options.width, options.height, wasm_interp, wasm_wrap_u, wasm_wrap_v) ++ else ++ wasm.wasm_textureCreateTarget(options.width, options.height, wasm_interp, wasm_wrap_u, wasm_wrap_v); + return dvui.TextureTarget{ + .ptr = @ptrFromInt(id), + .width = options.width, + .height = options.height, + .format = options.format, + .interpolation = options.interpolation, + .wrap_u = options.wrap_u, + .wrap_v = options.wrap_v, diff --git a/web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch b/web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch new file mode 100644 index 000000000..01bc57032 --- /dev/null +++ b/web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch @@ -0,0 +1,98 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:14:48 -0400 +Subject: [PATCH] web: wasm_renderStream, a flush's draws in one call + +wasm_renderGeometry uploads the geometry and sets the state of one draw per call. A +backend that batches its draws can instead hand web.js the whole flush: the geometry, +uploaded once, and a list of steps, each a draw (texture, clip, blend and its range of +the indices), a switch of render target or a clear of one. A blur pass's dozens of +small draws then cost one call from wasm and one upload. + +wasm_renderGeometry stays, and web.zig still draws through it. +--- + src/backends/web.js | 61 +++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 61 insertions(+) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 7cd1983..afc813b 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -933,16 +933,77 @@ export class Dvui { + // feedback loop to some drivers, sampled or not. + this.bindTex(null); + this.setUseTex(0); + this.setBlend(0); + } + + this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, ++ // A flush's worth of draws, for a backend that batches them: the stream's geometry, ++ // uploaded once, and its steps in order — each `cmd_count` entry eight i32s: texture, ++ // kind, x, y, w, h, and the range of the stream's indices it draws. Kind's bit 0 says ++ // the draw is clipped to x, y, w, h and the bits above it are the draw's blend (0 ++ // source-over, 1 add, 2 copy); -1 is a switch of render target to `texture`, -2 a ++ // clear of it. ++ wasm_renderStream: ( ++ index_ptr, ++ index_len, ++ vertex_ptr, ++ vertex_len, ++ sizeof_vertex, ++ offset_pos, ++ offset_col, ++ offset_uv, ++ cmd_ptr, ++ cmd_count, ++ ) => { ++ // A stream of only switches and clears has no geometry (and an empty slice's ++ // pointer is not one to make a view at). ++ if (index_len > 0) { ++ const indices = new Uint16Array(this.instance.exports.memory.buffer, index_ptr, index_len / 2); ++ this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.DYNAMIC_DRAW); ++ this.gl.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); ++ this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); ++ } ++ ++ const cmds = new Int32Array(this.instance.exports.memory.buffer, cmd_ptr, cmd_count * 8); ++ for (let i = 0; i < cmd_count; i += 1) { ++ const c = i * 8; ++ const textureId = cmds[c]; ++ const kind = cmds[c + 1]; ++ if (kind === -1) { ++ // A switch of render target (0 the screen). ++ this.imports.wasm_renderTarget(textureId); ++ continue; ++ } ++ if (kind === -2) { ++ // A clear of a target, wherever drawing is going. ++ this.imports.wasm_textureClearTarget(textureId); ++ continue; ++ } ++ this.setMatrix(); ++ if ((kind & 1) === 1) { ++ this.setScissor(cmds[c + 2], cmds[c + 3], cmds[c + 4], cmds[c + 5]); ++ } else { ++ this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); ++ } ++ const tex = textureId != 0 ? this.textureEntry(textureId) : null; ++ if (textureId != 0 && tex === null) console.warn(`wasm_renderStream: missing texture id ${textureId}`); ++ if (tex !== null) { ++ this.bindTex(tex[0]); ++ this.setUseTex(1); ++ } else { ++ this.setUseTex(0); ++ } ++ // The draw's blend as it was when drawn (bits above the clip's). ++ this.setBlend(kind >> 1); ++ this.gl.drawElements(this.gl.TRIANGLES, cmds[c + 7], this.gl.UNSIGNED_SHORT, cmds[c + 6] * 2); ++ } ++ }, + wasm_cursor: (name_ptr, name_len) => { + const cursor_name = this.stringFromPointer(name_ptr, name_len); + this.gl.canvas.style.cursor = cursor_name; + }, + wasm_text_input: (x, y, w, h) => { + if (w > 0 && h > 0) { + this.textInputRect = [x, y, w, h]; + } else { diff --git a/web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch b/web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch new file mode 100644 index 000000000..648d90226 --- /dev/null +++ b/web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch @@ -0,0 +1,369 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:15:44 -0400 +Subject: [PATCH] web: custom fragment programs in the draw stream + +A backend can compile a fragment shader of its own (wasm_programCreate) and draw parts +of a stream through it: a -3 step switches to it, with its vec4 uniforms (uData) from +the stream's uniform buffer and up to two more textures at units 1 and 2 (uTex1, +uTex2). It runs on dvui's own vertex shader and vertex layout, so dvui's geometry +feeds it unchanged. A source is written against a short prelude that builds it as +GLSL ES 3.00 or 1.00 (VARYING, TEX, FRAG_COLOR, MAX_VEC4); one starting #version is +taken as it is, on WebGL2 only. + +Programs link while frames go on where the context has KHR_parallel_shader_compile; +wasm_programStatus says when one is ready. wasm_programMaxVec4 says how many uniform +vec4s a program may use, asked of the context once. A punch blend (dst * (1 - src.a)) +joins the texture blends for a program to cut out what it covers. +--- + src/backends/web.js | 181 ++++++++++++++++++++++++++++++++++++++++++-- + 1 file changed, 175 insertions(+), 6 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index afc813b..cecf29a 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -203,16 +203,27 @@ export class Dvui { + /// `undefined` is unknown, and sends it. + gls = {}; + /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): + /// re-attaching a texture to the one shared framebuffer on every switch made the driver + /// check the framebuffer again each time. + targetFramebuffers = new Map(); + /// The matrix uniform's storage, filled in place. + matrix = new Float32Array(16); ++ /// Custom programs (`wasm_programCreate`), by id: `{ prog, loc, status, mw, mh, mfb }`. ++ /// Id 0 is dvui's own (`stock`). The program draws go through is `cur`; each keeps its own ++ /// matrix as last set, since a uniform belongs to its program. ++ programs = new Map(); ++ stock = null; ++ cur = null; ++ newProgramId = 1; ++ /// KHR_parallel_shader_compile, where there is one: a program compiles while frames go on. ++ parallelCompile = null; ++ /// The most uniform vec4s a program may have, asked of the context once. ++ programMaxVec4 = undefined; + + // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. + modCode(ev) { + return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); + } + + // The cached setters (`gls`): each sends its state only when it differs from what is set. + bindTex(texture) { +@@ -231,51 +242,136 @@ export class Dvui { + this.gl.scissor(x, y, w, h); + g.sx = x; g.sy = y; g.sw = w; g.sh = h; + } + setViewport(w, h) { + if (this.gls.vw === w && this.gls.vh === h) return; + this.gl.viewport(0, 0, w, h); + this.gls.vw = w; this.gls.vh = h; + } +- /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`). ++ /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`), 3 punch — `dst · (1 − src.a)`, a ++ /// program's. + setBlend(mode) { + if (this.gls.blend === mode) return; + if (mode === 2) { + this.gl.disable(this.gl.BLEND); + } else { + if (this.gls.blend === 2 || this.gls.blend === undefined) this.gl.enable(this.gl.BLEND); + if (mode === 1) this.gl.blendFunc(this.gl.ONE, this.gl.ONE); ++ else if (mode === 3) this.gl.blendFunc(this.gl.ZERO, this.gl.ONE_MINUS_SRC_ALPHA); + else this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); + } + this.gls.blend = mode; + } + setUseTex(on) { + if (this.gls.useTex === on) return; + this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); + this.gls.useTex = on; + } ++ /// Draw through program `id` (0 dvui's own): its uniforms `data`, and `tex1`, `tex2` (texture ++ /// ids, 0 none) at units 1 and 2. Leaving a program unbinds what it read there: a texture ++ /// still bound to a unit while it is the target being drawn into is a feedback loop to some ++ /// drivers. ++ useProgram(id, data, tex1, tex2) { ++ const p = id === 0 ? this.stock : this.programs.get(id); ++ if (p === undefined || p === null || p.status !== 2) return false; ++ const gl = this.gl; ++ if (this.cur !== p) { ++ if (this.cur !== this.stock && this.cur !== null) { ++ for (const unit of [1, 2]) { ++ gl.activeTexture(gl.TEXTURE0 + unit); ++ gl.bindTexture(gl.TEXTURE_2D, null); ++ } ++ gl.activeTexture(gl.TEXTURE0); ++ } ++ gl.useProgram(p.prog); ++ this.cur = p; ++ } ++ if (p === this.stock) return true; ++ if (data !== null && data.length > 0 && p.loc.data !== null) gl.uniform4fv(p.loc.data, data); ++ const units = [tex1, tex2]; ++ for (let u = 0; u < 2; u += 1) { ++ if (units[u] === 0) continue; ++ const entry = this.textureEntry(units[u]); ++ gl.activeTexture(gl.TEXTURE1 + u); ++ gl.bindTexture(gl.TEXTURE_2D, entry === null ? null : entry[0]); ++ } ++ gl.activeTexture(gl.TEXTURE0); ++ return true; ++ } + /// The projection for the current render target: its size, and flipped for the screen. + setMatrix() { + const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; ++ const p = this.cur; ++ if (p !== this.stock && p !== null) { ++ if (p.mw === w && p.mh === h && p.mfb === fb) return; ++ this.fillMatrix(w, h, fb); ++ this.gl.uniformMatrix4fv(p.loc.matrix, false, this.matrix); ++ p.mw = w; p.mh = h; p.mfb = fb; ++ return; ++ } + if (this.gls.mw === w && this.gls.mh === h && this.gls.mfb === fb) return; + this.fillMatrix(w, h, fb); + this.gl.uniformMatrix4fv(this.programInfo.uniformLocations.matrix, false, this.matrix); + this.gls.mw = w; this.gls.mh = h; this.gls.mfb = fb; + } + fillMatrix(w, h, fb) { + const m = this.matrix; + m.fill(0); + m[0] = 2.0 / w; + m[5] = (fb ? 2.0 : -2.0) / h; + m[10] = 1.0; + m[12] = -1.0; + m[13] = fb ? -1.0 : 1.0; + m[15] = 1.0; + } ++ queryProgramMaxVec4() { ++ if (!this.webgl2) { ++ // Pixel positions need a full float: no program where the fragment ++ // shader has no highp. ++ const hp = this.gl.getShaderPrecisionFormat(this.gl.FRAGMENT_SHADER, this.gl.HIGH_FLOAT); ++ if (hp === null || hp.precision < 23) return 0; ++ } ++ // What dvui's own uniforms leave of the guaranteed fragment budget. ++ return Math.max(0, this.gl.getParameter(this.gl.MAX_FRAGMENT_UNIFORM_VECTORS) - 8); ++ } ++ /// The prelude a program's source is written against: one source, built as GLSL ES 3.00 or ++ /// 1.00. ++ programPrelude(uniform_vec4s) { ++ if (this.webgl2) { ++ return "#version 300 es\nprecision highp float;\n#define VARYING in\n#define TEX texture\n" + ++ "out vec4 dvui_frag_color;\n#define FRAG_COLOR dvui_frag_color\n#define MAX_VEC4 " + uniform_vec4s + "\n"; ++ } ++ return "precision highp float;\n#define VARYING varying\n#define TEX texture2D\n" + ++ "#define FRAG_COLOR gl_FragColor\n#define MAX_VEC4 " + uniform_vec4s + "\n"; ++ } ++ /// A program's link has finished (or there is no way to ask, and it is done): look up what it ++ /// needs, or log why it failed. ++ programLinked(p) { ++ const gl = this.gl; ++ if (!gl.getProgramParameter(p.prog, gl.LINK_STATUS)) { ++ console.error("dvui: program failed: " + gl.getProgramInfoLog(p.prog) + "\n" + ++ (gl.getShaderInfoLog(p.fs) || "")); ++ p.status = 0; ++ return; ++ } ++ p.loc = { ++ matrix: gl.getUniformLocation(p.prog, "uMatrix"), ++ data: gl.getUniformLocation(p.prog, "uData"), ++ }; ++ const prev = this.cur; ++ gl.useProgram(p.prog); ++ const units = ["uSampler", "uTex1", "uTex2"]; ++ for (let u = 0; u < units.length; u += 1) { ++ const l = gl.getUniformLocation(p.prog, units[u]); ++ if (l !== null) gl.uniform1i(l, u); ++ } ++ gl.useProgram(prev === null ? this.stock.prog : prev.prog); ++ p.status = 2; ++ } + /// The vertex layout, which only changes if the vertex type does. + setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv) { + const g = this.gls; + if (g.ls === sizeof_vertex && g.lp === offset_pos && g.lc === offset_col && g.lu === offset_uv) return; + const a = this.programInfo.attribLocations; + this.gl.vertexAttribPointer(a.vertexPosition, 2, this.gl.FLOAT, false, sizeof_vertex, offset_pos); + this.gl.enableVertexAttribArray(a.vertexPosition); + this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); +@@ -585,16 +681,70 @@ export class Dvui { + this.hidden_input.style.padding = 0; + this.hidden_input.style.border = 0; + this.hidden_input.style.margin = 0; + this.hidden_input.style.opacity = 0; + this.hidden_input.style.zIndex = -1; + document.body.prepend(this.hidden_input); + + this.imports = { ++ // Custom programs: a fragment shader drawn with dvui's own vertex shader and ++ // vertex layout, and up to `wasm_programMaxVec4` vec4s of uniforms (`uData`). ++ // Asked once: `getParameter` is a synchronous trip to the GPU process in Firefox, ++ // some 3.5 ms a call on a phone — asked every frame, it was the frame's worst cost. ++ wasm_programMaxVec4: () => { ++ if (this.programMaxVec4 !== undefined) return this.programMaxVec4; ++ this.programMaxVec4 = this.queryProgramMaxVec4(); ++ return this.programMaxVec4; ++ }, ++ wasm_programCreate: (ptr, len, textures, uniform_vec4s) => { ++ const gl = this.gl; ++ const body = this.stringFromPointer(ptr, len); ++ // A complete source (one starting `#version`, GLSL ES 3.00) is compiled as it is, ++ // on WebGL2 only. On WebGL1 it fails, and whoever drew with it falls back. ++ const complete = body.startsWith("#version"); ++ if (complete && !this.webgl2) { ++ const id = this.newProgramId; ++ this.newProgramId += 1; ++ this.programs.set(id, { prog: null, fs: null, loc: null, status: 0 }); ++ return id; ++ } ++ const src = complete ? body : this.programPrelude(uniform_vec4s) + body; ++ const vs = gl.createShader(gl.VERTEX_SHADER); ++ gl.shaderSource(vs, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl); ++ gl.compileShader(vs); ++ const fs = gl.createShader(gl.FRAGMENT_SHADER); ++ gl.shaderSource(fs, src); ++ gl.compileShader(fs); ++ const prog = gl.createProgram(); ++ gl.attachShader(prog, vs); ++ gl.attachShader(prog, fs); ++ // The stock program's attribute slots, so the vertex layout set once ++ // (`setLayout`) feeds every program. ++ const a = this.programInfo.attribLocations; ++ gl.bindAttribLocation(prog, a.vertexPosition, "aVertexPosition"); ++ gl.bindAttribLocation(prog, a.vertexColor, "aVertexColor"); ++ gl.bindAttribLocation(prog, a.textureCoord, "aTextureCoord"); ++ gl.linkProgram(prog); ++ const id = this.newProgramId; ++ this.newProgramId += 1; ++ const p = { prog, fs, loc: null, status: 1, mw: undefined, mh: undefined, mfb: undefined }; ++ this.programs.set(id, p); ++ if (this.parallelCompile === null) this.programLinked(p); ++ return id; ++ }, ++ // 0 failed, 1 compiling, 2 ready to draw with. ++ wasm_programStatus: (id) => { ++ const p = this.programs.get(id); ++ if (p === undefined) return 0; ++ if (p.status === 1 && this.gl.getProgramParameter(p.prog, this.parallelCompile.COMPLETION_STATUS_KHR)) { ++ this.programLinked(p); ++ } ++ return p.status; ++ }, + wasm_about_webgl2: () => { + if (this.webgl2) { + return 1; + } else { + return 0; + } + }, + wasm_panic: (ptr, len) => { +@@ -937,30 +1087,35 @@ export class Dvui { + } + + this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, + // A flush's worth of draws, for a backend that batches them: the stream's geometry, + // uploaded once, and its steps in order — each `cmd_count` entry eight i32s: texture, + // kind, x, y, w, h, and the range of the stream's indices it draws. Kind's bit 0 says + // the draw is clipped to x, y, w, h and the bits above it are the draw's blend (0 +- // source-over, 1 add, 2 copy); -1 is a switch of render target to `texture`, -2 a +- // clear of it. ++ // source-over, 1 add, 2 copy, 3 punch); -1 is a switch of render target to `texture`, ++ // -2 a clear of it, -3 a switch of program to `texture` (0 dvui's own), x and y the ++ // offset and count of its vec4s in the stream's uniforms, w and h the textures it ++ // reads at units 1 and 2. Each stream starts and ends in dvui's own program. + wasm_renderStream: ( + index_ptr, + index_len, + vertex_ptr, + vertex_len, + sizeof_vertex, + offset_pos, + offset_col, + offset_uv, + cmd_ptr, + cmd_count, ++ uniform_ptr, ++ uniform_floats, + ) => { ++ const uniforms = uniform_floats > 0 ? new Float32Array(this.instance.exports.memory.buffer, uniform_ptr, uniform_floats) : null; + // A stream of only switches and clears has no geometry (and an empty slice's + // pointer is not one to make a view at). + if (index_len > 0) { + const indices = new Uint16Array(this.instance.exports.memory.buffer, index_ptr, index_len / 2); + this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.DYNAMIC_DRAW); + this.gl.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); + this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); + } +@@ -975,34 +1130,44 @@ export class Dvui { + this.imports.wasm_renderTarget(textureId); + continue; + } + if (kind === -2) { + // A clear of a target, wherever drawing is going. + this.imports.wasm_textureClearTarget(textureId); + continue; + } ++ if (kind === -3) { ++ // A switch of program, with its uniforms and textures. ++ const at = cmds[c + 2] * 4, n = cmds[c + 3] * 4; ++ const data = textureId !== 0 && uniforms !== null ? uniforms.subarray(at, at + n) : null; ++ if (!this.useProgram(textureId, data, cmds[c + 4], cmds[c + 5])) this.useProgram(0, null, 0, 0); ++ continue; ++ } + this.setMatrix(); + if ((kind & 1) === 1) { + this.setScissor(cmds[c + 2], cmds[c + 3], cmds[c + 4], cmds[c + 5]); + } else { + this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); + } + const tex = textureId != 0 ? this.textureEntry(textureId) : null; + if (textureId != 0 && tex === null) console.warn(`wasm_renderStream: missing texture id ${textureId}`); ++ const stock = this.cur === this.stock; + if (tex !== null) { + this.bindTex(tex[0]); +- this.setUseTex(1); +- } else { ++ if (stock) this.setUseTex(1); ++ } else if (stock) { + this.setUseTex(0); + } +- // The draw's blend as it was when drawn (bits above the clip's). ++ // The draw's blend as it was when drawn (bits above the clip's): its ++ // texture's, or the program's. + this.setBlend(kind >> 1); + this.gl.drawElements(this.gl.TRIANGLES, cmds[c + 7], this.gl.UNSIGNED_SHORT, cmds[c + 6] * 2); + } ++ if (this.cur !== this.stock) this.useProgram(0, null, 0, 0); + }, + wasm_cursor: (name_ptr, name_len) => { + const cursor_name = this.stringFromPointer(name_ptr, name_len); + this.gl.canvas.style.cursor = cursor_name; + }, + wasm_text_input: (x, y, w, h) => { + if (w > 0 && h > 0) { + this.textInputRect = [x, y, w, h]; +@@ -1290,16 +1455,20 @@ export class Dvui { + // Bound for good: there is one program, one pair of buffers and one sampler, so a draw + // sends only its data and whatever else changed (`gls`). + this.gl.useProgram(this.shaderProgram); + this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); + ++ this.stock = { prog: this.shaderProgram, loc: null, status: 2 }; ++ this.cur = this.stock; ++ this.parallelCompile = this.gl.getExtension("KHR_parallel_shader_compile"); ++ + this.gls = {}; + this.setBlend(0); + this.gl.enable(this.gl.SCISSOR_TEST); + this.setScissor( + 0, + 0, + this.gl.canvas.clientWidth, + this.gl.canvas.clientHeight, diff --git a/web/patches/dvui/0010-web-a-keyboard-s-correction-replaces-the-typed-word.patch b/web/patches/dvui/0010-web-a-keyboard-s-correction-replaces-the-typed-word.patch new file mode 100644 index 000000000..4bc3e1fda --- /dev/null +++ b/web/patches/dvui/0010-web-a-keyboard-s-correction-replaces-the-typed-word.patch @@ -0,0 +1,266 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Fizzy macos worker +Date: Mon, 26 Oct 2026 12:00:00 -0400 +Subject: [PATCH] web: a keyboard's correction replaces the typed word + +A phone keyboard corrects the word before the caret, but the hidden input stayed empty and +every beforeinput/compositionend was sent as new text, so "im" became "imI'm". TextInput keeps +the word typed since the last space in the hidden input, caret at its end, and turns an +insertReplacementText (its target range, or the selection the keyboard made, when a range is +empty as it is for an input) or a composition that rewrote the word into backspaces and the +new text. A pointer, a navigation key, a delete it cannot follow, a blur or another field +clears the word. +--- + src/backends/web.js | 159 ++++++++++++++++ + 1 file changed, 128 insertions(+), 31 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index cecf29a..cc084e5 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -162,16 +162,138 @@ const StylusAction = Object.freeze({ + /** motion only */ + move: 1, + /** motion, then the left button pressed */ + press: 2, + /** motion, then the left button released */ + release: 3, + }); + ++/** ++ * Keeps the hidden input showing the word before the caret, and turns what a phone keyboard ++ * does to that word into the edits dvui's text widgets understand. ++ * ++ * The widgets draw their own text, so the input is empty and a keyboard has nothing to ++ * correct: autocorrect or a suggestion then arrives as plain new text ("im" becomes "imI'm"). ++ * The input holds the word typed since the last space instead, the caret at its end, so a ++ * keyboard replaces it, and a replacement is sent as that many backspaces and then the new ++ * text. The word is only what this class has seen typed; a caret moved by a pointer or a key, ++ * or a delete it cannot follow, clears it. ++ */ ++export class TextInput { ++ /** The word before the caret, as the hidden input shows it. */ ++ word = ""; ++ /** @type {boolean} */ ++ composing = false; ++ /** A Backspace keydown not yet matched by its `deleteContentBackward`. */ ++ backspaceKey = false; ++ ++ /** ++ * @param {HTMLInputElement} hidden ++ * @param {{text: (text: string) => void, backspace: () => void}} sink Where the edits go. ++ */ ++ constructor(hidden, sink) { ++ this.hidden = hidden; ++ this.sink = sink; ++ hidden.addEventListener("beforeinput", (ev) => this.beforeInput(ev)); ++ hidden.addEventListener("compositionstart", () => { this.composing = true; }); ++ hidden.addEventListener("compositionend", (ev) => this.compositionEnd(ev)); ++ hidden.addEventListener("blur", () => this.clear()); ++ } ++ ++ /** Forget the word: the caret moved, or the field changed. */ ++ clear() { ++ this.word = ""; ++ if (!this.composing && this.hidden.value !== "") this.hidden.value = ""; ++ } ++ ++ /** Called for every keydown on the canvas or the hidden input. */ ++ keydown(ev) { ++ if (this.composing || ["Shift", "Control", "Alt", "Meta", "CapsLock", "Unidentified", "Process", "Dead"].includes(ev.key)) return; ++ if (ev.key === "Backspace" && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { ++ this.backspaceKey = true; ++ this.set(Array.from(this.word).slice(0, -1).join("")); ++ } else if (ev.key.length > 1 || ev.ctrlKey || ev.metaKey) { ++ this.clear(); ++ } ++ } ++ ++ set(word) { ++ this.word = word; ++ if (!this.composing && this.hidden.value !== word) this.hidden.value = word; ++ } ++ ++ /** The word after `text` is typed at the caret. */ ++ typed(text) { ++ this.set((this.word + text).split(/\s/).pop()); ++ } ++ ++ /** Replace [start, end) of the word with `text`: the deleted tail, then the text. */ ++ replace(start, end, text) { ++ // The DOM counts UTF-16 code units, so slice the string by them; the keyboard erases code points. ++ start = Math.max(0, Math.min(start, this.word.length)); ++ end = Math.max(start, Math.min(end, this.word.length)); ++ // The caret is at the end of the word, so what follows `end` goes too and comes back. ++ const after = this.word.slice(end); ++ const kept = this.word.slice(0, start); ++ const deleted = Array.from(this.word.slice(start)).length; ++ for (let i = 0; i < deleted; i++) this.sink.backspace(); ++ this.sink.text(text + after); ++ this.set(kept); ++ this.typed(text + after); ++ } ++ ++ beforeInput(ev) { ++ ev.preventDefault(); ++ if (ev.isComposing) return; ++ const type = ev.inputType; ++ if (type === "deleteContentBackward") { ++ // Soft keyboards usually send the Backspace key as well, which has popped the word. ++ if (this.backspaceKey) this.backspaceKey = false; ++ else this.set(Array.from(this.word).slice(0, -1).join("")); ++ return; ++ } ++ if (type && type.startsWith("delete")) { ++ this.clear(); ++ return; ++ } ++ let text = ev.data; ++ if (text == null && type === "insertReplacementText" && ev.dataTransfer) text = ev.dataTransfer.getData("text/plain"); ++ if (!text) return; ++ if (type === "insertReplacementText") { ++ // For an input the target ranges are empty; the keyboard selects what it replaces. ++ const ranges = ev.getTargetRanges ? ev.getTargetRanges() : []; ++ const start = ranges.length > 0 ? ranges[0].startOffset : this.hidden.selectionStart; ++ const end = ranges.length > 0 ? ranges[0].endOffset : this.hidden.selectionEnd; ++ if (start != null && end != null && start < end) { ++ this.replace(start, end, text); ++ return; ++ } ++ } ++ this.sink.text(text); ++ this.typed(text); ++ } ++ ++ compositionEnd(ev) { ++ this.composing = false; ++ // The input now holds the word as the keyboard composed it. Whatever it shares with the ++ // word the widgets have is untouched; the rest of ours is deleted and theirs typed. ++ const had = Array.from(this.word); ++ let now = Array.from(this.hidden.value); ++ if (now.length === 0 && had.length === 0 && ev.data) now = Array.from(ev.data); ++ let same = 0; ++ while (same < had.length && same < now.length && had[same] === now[same]) same++; ++ for (let i = same; i < had.length; i++) this.sink.backspace(); ++ const text = now.slice(same).join(""); ++ if (text) this.sink.text(text); ++ this.word = ""; ++ this.typed(now.join("")); ++ } ++} ++ + export class Dvui { + /** @type {WebGL2RenderingContext | WebGLRenderingContext} */ + gl; + /** @type {WebGLBuffer} */ + indexBuffer; + /** @type {WebGLBuffer} */ + vertexBuffer; + /** @type {WebGLProgram} */ +@@ -1164,21 +1286,24 @@ export class Dvui { + } + if (this.cur !== this.stock) this.useProgram(0, null, 0, 0); + }, + wasm_cursor: (name_ptr, name_len) => { + const cursor_name = this.stringFromPointer(name_ptr, name_len); + this.gl.canvas.style.cursor = cursor_name; + }, + wasm_text_input: (x, y, w, h) => { ++ const was = this.textInputRect; + if (w > 0 && h > 0) { + this.textInputRect = [x, y, w, h]; + } else { + this.textInputRect = []; + } ++ // Another field: the word typed in the last is not this one's. ++ if (was.length !== this.textInputRect.length) this.textInput?.clear(); + }, + wasm_open_url: (ptr, len, new_win) => { + const url = this.stringFromPointer(ptr, len); + + if (new_win) { + window.open(url); + } else { + window.location.href = url; +@@ -1725,16 +1850,17 @@ export class Dvui { + 0, + ); + } + this.requestRender(); + }); + + let keydown = (ev) => { + if (this.stopped) return; ++ this.textInput.keydown(ev); + if (ev.key == "Tab") { + + // In most browsers we don't even see a control-tab, the + // browser uses it to switch tabs. But Vivaldi sends it to us + // first, so bail (before preventDefault) so that Vivaldi + // switches tabs and we don't do anything. + if (ev.ctrlKey) return; + +@@ -1771,49 +1897,32 @@ export class Dvui { + (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + + (ev.shiftKey << 0), + ); + this.requestRender(); + }; + this.gl.canvas.addEventListener("keyup", keyup.bind(this)); + this.hidden_input.addEventListener("keyup", keyup.bind(this)); + +- this.hidden_input.addEventListener("beforeinput", (ev) => { +- if (this.stopped) return; +- ev.preventDefault(); +- if (ev.data && !ev.isComposing) { +- const str = utf8encoder.encode(ev.data); +- const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); +- this.instance.exports.add_event( +- 7, +- ptr, +- str.length, +- 0, +- 0, +- ); +- this.requestRender(); +- } +- }); +- this.hidden_input.addEventListener("compositionend", (ev) => { ++ const addString = (kind, str) => { + if (this.stopped) return; +- if (ev.data) { +- const str = utf8encoder.encode(ev.data); +- const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); +- this.instance.exports.add_event( +- 7, +- ptr, +- str.length, +- 0, +- 0, +- ); +- this.requestRender(); +- } +- // Reset value to empty after composition maybe put text there +- ev.target.value = ""; ++ const bytes = utf8encoder.encode(str); ++ const ptr = this.allocBuffer(this.instance.exports.arena_u8, bytes); ++ this.instance.exports.add_event(kind, ptr, bytes.length, 0, 0); ++ this.requestRender(); ++ }; ++ this.textInput = new TextInput(this.hidden_input, { ++ text: (text) => addString(7, text), ++ backspace: () => { ++ addString(5, "Backspace"); ++ addString(6, "Backspace"); ++ }, + }); ++ this.gl.canvas.addEventListener("pointerdown", () => this.textInput.clear()); ++ + this.gl.canvas.addEventListener("touchstart", (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; + if (touch.touchType === "stylus") { + this.stylusEvent(ev, touch, StylusAction.press); diff --git a/web/tests/text-input.test.js b/web/tests/text-input.test.js new file mode 100644 index 000000000..441da2a59 --- /dev/null +++ b/web/tests/text-input.test.js @@ -0,0 +1,107 @@ +// The hidden input's word and what a keyboard's correction does to the text. Run by +// scripts/test-web-text-input.sh against the web.js the build writes (dvui's, patched): +// node text-input.test.js (or jsc -m, as the script does) +// A fake input stands in for the page's; the fake sink applies the edits like a text field. +const path = globalThis.process ? process.argv[2] : globalThis.arguments[0]; +const print = globalThis.print ?? console.log; +const exit = (code) => (globalThis.process ? process.exit(code) : quit()); +// web.js builds its codecs as it loads; jsc has none, and these tests never decode. +globalThis.TextDecoder ??= class { decode() { return ""; } }; +globalThis.TextEncoder ??= class { encode() { return new Uint8Array(); } }; +const { TextInput } = await import(path); + +let failed = 0; +function check(name, got, want) { + if (got === want) return; + failed++; + print(`FAIL ${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`); +} + +function fixture() { + const listeners = {}; + const hidden = { + value: "", selectionStart: 0, selectionEnd: 0, + addEventListener(type, fn) { listeners[type] = fn; }, + }; + const field = { text: "" }; + const input = new TextInput(hidden, { + text: (t) => { field.text += t; }, + backspace: () => { field.text = Array.from(field.text).slice(0, -1).join(""); }, + }); + const fire = (type, ev) => listeners[type](ev); + const type = (data) => fire("beforeinput", { preventDefault() {}, inputType: "insertText", data, isComposing: false }); + const select = (a, b) => { hidden.selectionStart = a; hidden.selectionEnd = b; }; + return { hidden, field, input, fire, type, select }; +} + +const replacement = (data, ranges = []) => ({ + preventDefault() {}, inputType: "insertReplacementText", data, isComposing: false, getTargetRanges: () => ranges, +}); + +{ + const { hidden, field, type, fire, select } = fixture(); + for (const c of "teh") type(c); + check("the word is mirrored", hidden.value, "teh"); + select(0, 3); + fire("beforeinput", replacement("the", [{ startOffset: 0, endOffset: 3 }])); + check("target range replaced", field.text, "the"); + check("mirror follows the replacement", hidden.value, "the"); +} +{ + const { field, type, fire, select } = fixture(); + for (const c of "teh") type(c); + select(0, 3); + fire("beforeinput", replacement("the")); + check("selection stands in for empty target ranges", field.text, "the"); +} +{ + const { field, type, fire, select } = fixture(); + for (const c of "a teh") type(c); + select(0, 3); + fire("beforeinput", replacement("the", [{ startOffset: 0, endOffset: 3 }])); + check("only the word after the space is replaced", field.text, "a the"); +} +{ + // The DOM's offsets are UTF-16 code units: the emoji spans [0, 2), "teh" is [2, 5). + const { field, type, fire, select } = fixture(); + for (const c of ["\u{1F600}", "t", "e", "h"]) type(c); + select(2, 5); + fire("beforeinput", replacement("the", [{ startOffset: 2, endOffset: 5 }])); + check("offsets count UTF-16 units past an astral character", field.text, "\u{1F600}the"); +} +{ + const { hidden, field, type, fire } = fixture(); + for (const c of "im") type(c); + fire("compositionstart", {}); + hidden.value = "I'm"; + fire("compositionend", { data: "I'm" }); + check("a composition over the typed word", field.text, "I'm"); +} +{ + const { hidden, field, fire } = fixture(); + fire("compositionstart", {}); + hidden.value = "hello"; + fire("compositionend", { data: "hello" }); + check("a composition on a new word", field.text, "hello"); +} +{ + const { field, type, input } = fixture(); + for (const c of "teh") type(c); + input.clear(); + type(" "); + check("a cleared word is not replaced", field.text, "teh "); +} +{ + const { hidden, field, type, input } = fixture(); + for (const c of "tex") type(c); + input.keydown({ key: "Backspace" }); + check("backspace pops the mirror", hidden.value, "te"); + input.keydown({ key: "ArrowLeft" }); + check("a navigation key clears it", hidden.value, ""); + check("and leaves the field alone", field.text, "tex"); +} +if (failed > 0) { + print(`text-input: ${failed} failed`); + exit(1); +} +print("text-input: ok"); diff --git a/web/web.js b/web/web.js deleted file mode 100644 index 31b169a43..000000000 --- a/web/web.js +++ /dev/null @@ -1,1813 +0,0 @@ -/// - -/**@typedef {BigInt} Id */ - -/** - * @param {string} url - * @returns {Promise} - */ -async function dvui_fetch(url) { - let x = await fetch(url); - let blob = await x.blob(); - //console.log("dvui_fetch: " + blob.size); - return new Uint8Array(await blob.arrayBuffer()); -} - -/** - * @param {string} accept Maps to the accept attribute of the file input element - * @param {boolean} multiple - * @returns {Promise} - */ -async function dvui_open_file_picker(accept, multiple) { - return new Promise((res, rej) => { - const file_input = document.createElement("input"); - file_input.setAttribute("type", "file"); - file_input.setAttribute("accept", accept); - if (multiple) { - file_input.toggleAttribute("multiple", true); - } - file_input.oncancel = () => { - console.trace("File picking cancelled"); - rej("canceled"); - }; - file_input.onchange = () => { - if (file_input.files.length === 0) { - console.error("File picker picked no files"); - file_input.oncancel(); - } - if (!multiple && file_input.files.length > 1) { - console.error("Picked multiple files in single file picker"); - rej("Too many files"); - return; - } - res(file_input.files); - }; - file_input.click(); - }); -} - -// highp, not mediump: on GPUs where mediump is a real 16-bit float (Apple Silicon under -// ANGLE/Metal, most mobile) a texture coordinate at 2048px wide has ~2 texels of resolution, -// and dvui's blur kernels are sub-texel offsets. Vertex shaders always have highp. -const vertexShaderSource_webgl = ` - precision highp float; - - attribute vec4 aVertexPosition; - attribute vec4 aVertexColor; - attribute vec2 aTextureCoord; - - uniform mat4 uMatrix; - - varying vec4 vColor; - varying vec2 vTextureCoord; - - void main() { - gl_Position = uMatrix * aVertexPosition; - vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1 - vTextureCoord = aTextureCoord; - } -`; - -const vertexShaderSource_webgl2 = `# version 300 es - - precision highp float; - - in vec4 aVertexPosition; - in vec4 aVertexColor; - in vec2 aTextureCoord; - - uniform mat4 uMatrix; - - out vec4 vColor; - out vec2 vTextureCoord; - - void main() { - gl_Position = uMatrix * aVertexPosition; - vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1 - vTextureCoord = aTextureCoord; - } -`; - -const fragmentShaderSource_webgl = ` -#ifdef GL_FRAGMENT_PRECISION_HIGH - precision highp float; -#else - precision mediump float; -#endif - - varying vec4 vColor; - varying vec2 vTextureCoord; - - uniform sampler2D uSampler; - uniform bool useTex; - - void main() { - if (useTex) { - gl_FragColor = texture2D(uSampler, vTextureCoord) * vColor; - } - else { - gl_FragColor = vColor; - } - } -`; - -const fragmentShaderSource_webgl2 = `# version 300 es - - precision highp float; - - in vec4 vColor; - in vec2 vTextureCoord; - - uniform sampler2D uSampler; - uniform bool useTex; - - out vec4 fragColor; - - void main() { - if (useTex) { - fragColor = texture(uSampler, vTextureCoord) * vColor; - } - else { - fragColor = vColor; - } - } -`; - -/** - * @param {string | HTMLCanvasElement} canvas - A canvas element or string id of one - * @param {DVUI.WasmArg} wasmRef - The url to the wasm file, to be used in `fetch` - * @returns {Promise} - */ -export function dvui(canvas, wasmRef) { - const dvui = new Dvui(); - const wasmPromise = typeof wasmRef === "string" - ? WebAssembly.instantiateStreaming(fetch(wasmRef), { dvui: dvui.imports }) - : Promise.resolve(wasmRef); - return wasmPromise.then((result) => { - dvui.setInstance(result.instance); - dvui.setCanvas(canvas); - dvui.run(); - return dvui; - }); -} - -const utf8decoder = new TextDecoder(); -const utf8encoder = new TextEncoder(); - -export class Dvui { - /** @type {WebGL2RenderingContext | WebGLRenderingContext} */ - gl; - /** @type {WebGLBuffer} */ - indexBuffer; - /** @type {WebGLBuffer} */ - vertexBuffer; - /** @type {WebGLProgram} */ - shaderProgram; - /** @type {{ attribLocations: { vertexPosition: number; - vertexColor: number; - textureCoord: number; - }; - uniformLocations: { - matrix: WebGLUniformLocation | null; - uSampler: WebGLUniformLocation | null; - useTex: WebGLUniformLocation | null; - }; - }} */ - programInfo; - /** @type {Map} */ - textures = new Map(); - /// texture id -> blend mode set by wasm_textureBlend: 0 source-over (the default), 1 add, - /// 2 copy. WebGL blend state is global, so it is applied per draw when the texture binds. - textureBlends = new Map(); - /// Whether half-float render targets are available (WebGL2 + EXT_color_buffer_float). - preciseTargets = false; - preciseChecked = false; - newTextureId = 1; - /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the - /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — - /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. - /// `undefined` is unknown, and sends it. - gls = {}; - /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): - /// re-attaching a texture to the one shared framebuffer on every switch made the driver - /// check the framebuffer again each time. - targetFramebuffers = new Map(); - /// The matrix uniform's storage, filled in place. - matrix = new Float32Array(16); - /// Custom programs (`wasm_programCreate`), by id: `{ prog, loc, status, mw, mh, mfb }`. - /// Id 0 is dvui's own (`stock`). The program draws go through is `cur`; each keeps its own - /// matrix as last set, since a uniform belongs to its program. - programs = new Map(); - stock = null; - cur = null; - newProgramId = 1; - /// KHR_parallel_shader_compile, where there is one: a program compiles while frames go on. - parallelCompile = null; - /// The most uniform vec4s a program may have, asked of the context once. - programMaxVec4 = undefined; - - /** @returns {[WebGLTexture, number, number] | null} */ - // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. - modCode(ev) { - return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); - } - - // The cached setters (`gls`): each sends its state only when it differs from what is set. - bindTex(texture) { - if (this.gls.tex === texture) return; - this.gl.bindTexture(this.gl.TEXTURE_2D, texture); - this.gls.tex = texture; - } - bindFb(framebuffer) { - if (this.gls.fb === framebuffer) return; - this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, framebuffer); - this.gls.fb = framebuffer; - } - setScissor(x, y, w, h) { - const g = this.gls; - if (g.sx === x && g.sy === y && g.sw === w && g.sh === h) return; - this.gl.scissor(x, y, w, h); - g.sx = x; g.sy = y; g.sw = w; g.sh = h; - } - setViewport(w, h) { - if (this.gls.vw === w && this.gls.vh === h) return; - this.gl.viewport(0, 0, w, h); - this.gls.vw = w; this.gls.vh = h; - } - /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`), 3 punch — `dst · (1 − src.a)`, a - /// program's (`core.gfx.programs.Blend`). - setBlend(mode) { - if (this.gls.blend === mode) return; - if (mode === 2) { - this.gl.disable(this.gl.BLEND); - } else { - if (this.gls.blend === 2 || this.gls.blend === undefined) this.gl.enable(this.gl.BLEND); - if (mode === 1) this.gl.blendFunc(this.gl.ONE, this.gl.ONE); - else if (mode === 3) this.gl.blendFunc(this.gl.ZERO, this.gl.ONE_MINUS_SRC_ALPHA); - else this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); - } - this.gls.blend = mode; - } - setUseTex(on) { - if (this.gls.useTex === on) return; - this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); - this.gls.useTex = on; - } - /// Draw through program `id` (0 dvui's own): its uniforms `data`, and `tex1`, `tex2` (texture - /// ids, 0 none) at units 1 and 2. Leaving a program unbinds what it read there: a texture - /// still bound to a unit while it is the target being drawn into is a feedback loop to some - /// drivers, and the next frost capture draws into exactly those. - useProgram(id, data, tex1, tex2) { - const p = id === 0 ? this.stock : this.programs.get(id); - if (p === undefined || p === null || p.status !== 2) return false; - const gl = this.gl; - if (this.cur !== p) { - if (this.cur !== this.stock && this.cur !== null) { - for (const unit of [1, 2]) { - gl.activeTexture(gl.TEXTURE0 + unit); - gl.bindTexture(gl.TEXTURE_2D, null); - } - gl.activeTexture(gl.TEXTURE0); - } - gl.useProgram(p.prog); - this.cur = p; - } - if (p === this.stock) return true; - if (data !== null && data.length > 0 && p.loc.data !== null) gl.uniform4fv(p.loc.data, data); - const units = [tex1, tex2]; - for (let u = 0; u < 2; u += 1) { - if (units[u] === 0) continue; - const entry = this.textureEntry(units[u]); - gl.activeTexture(gl.TEXTURE1 + u); - gl.bindTexture(gl.TEXTURE_2D, entry === null ? null : entry[0]); - } - gl.activeTexture(gl.TEXTURE0); - return true; - } - /// The projection for the current render target: its size, and flipped for the screen. - setMatrix() { - const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; - const p = this.cur; - if (p !== this.stock && p !== null) { - if (p.mw === w && p.mh === h && p.mfb === fb) return; - this.fillMatrix(w, h, fb); - this.gl.uniformMatrix4fv(p.loc.matrix, false, this.matrix); - p.mw = w; p.mh = h; p.mfb = fb; - return; - } - if (this.gls.mw === w && this.gls.mh === h && this.gls.mfb === fb) return; - this.fillMatrix(w, h, fb); - this.gl.uniformMatrix4fv(this.programInfo.uniformLocations.matrix, false, this.matrix); - this.gls.mw = w; this.gls.mh = h; this.gls.mfb = fb; - } - fillMatrix(w, h, fb) { - const m = this.matrix; - m.fill(0); - m[0] = 2.0 / w; - m[5] = (fb ? 2.0 : -2.0) / h; - m[10] = 1.0; - m[12] = -1.0; - m[13] = fb ? -1.0 : 1.0; - m[15] = 1.0; - } - queryProgramMaxVec4() { - if (!this.webgl2) { - // Pixel positions need a full float: no program where the fragment - // shader has no highp. - const hp = this.gl.getShaderPrecisionFormat(this.gl.FRAGMENT_SHADER, this.gl.HIGH_FLOAT); - if (hp === null || hp.precision < 23) return 0; - } - // What dvui's own uniforms leave of the guaranteed fragment budget. - return Math.max(0, this.gl.getParameter(this.gl.MAX_FRAGMENT_UNIFORM_VECTORS) - 8); - } - /// The prelude a program's source is written against (`core.gfx.programs`): one source, - /// built as GLSL ES 3.00 or 1.00. - programPrelude(uniform_vec4s) { - if (this.webgl2) { - return "#version 300 es\nprecision highp float;\n#define VARYING in\n#define TEX texture\n" + - "out vec4 fizzy_frag_color;\n#define FRAG_COLOR fizzy_frag_color\n#define MAX_VEC4 " + uniform_vec4s + "\n"; - } - return "precision highp float;\n#define VARYING varying\n#define TEX texture2D\n" + - "#define FRAG_COLOR gl_FragColor\n#define MAX_VEC4 " + uniform_vec4s + "\n"; - } - /// A program's link has finished (or there is no way to ask, and it is done): look up what it - /// needs, or log why it failed. - programLinked(p) { - const gl = this.gl; - if (!gl.getProgramParameter(p.prog, gl.LINK_STATUS)) { - console.error("fizzy: program failed: " + gl.getProgramInfoLog(p.prog) + "\n" + - (gl.getShaderInfoLog(p.fs) || "")); - p.status = 0; - return; - } - p.loc = { - matrix: gl.getUniformLocation(p.prog, "uMatrix"), - data: gl.getUniformLocation(p.prog, "uData"), - }; - const prev = this.cur; - gl.useProgram(p.prog); - const units = ["uSampler", "uTex1", "uTex2"]; - for (let u = 0; u < units.length; u += 1) { - const l = gl.getUniformLocation(p.prog, units[u]); - if (l !== null) gl.uniform1i(l, u); - } - gl.useProgram(prev === null ? this.stock.prog : prev.prog); - p.status = 2; - } - /// The vertex layout, which only changes if the vertex type does. - setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv) { - const g = this.gls; - if (g.ls === sizeof_vertex && g.lp === offset_pos && g.lc === offset_col && g.lu === offset_uv) return; - const a = this.programInfo.attribLocations; - this.gl.vertexAttribPointer(a.vertexPosition, 2, this.gl.FLOAT, false, sizeof_vertex, offset_pos); - this.gl.enableVertexAttribArray(a.vertexPosition); - this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); - this.gl.enableVertexAttribArray(a.vertexColor); - this.gl.vertexAttribPointer(a.textureCoord, 2, this.gl.FLOAT, false, sizeof_vertex, offset_uv); - this.gl.enableVertexAttribArray(a.textureCoord); - g.ls = sizeof_vertex; g.lp = offset_pos; g.lc = offset_col; g.lu = offset_uv; - } - - createTarget(width, height, interp, wrap_u, wrap_v, precise) { - const texture = this.gl.createTexture(); - const id = this.newTextureId; - //console.log("creating texture " + id); - this.newTextureId += 1; - this.textures.set(id, [texture, width, height]); - - this.bindTex(texture); - - this.gl.texImage2D( - this.gl.TEXTURE_2D, - 0, - precise ? this.gl.RGBA16F : this.gl.RGBA, - width, - height, - 0, - this.gl.RGBA, - precise ? this.gl.HALF_FLOAT : this.gl.UNSIGNED_BYTE, - null, - ); - - if (interp == 0) { - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MIN_FILTER, - this.gl.NEAREST, - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MAG_FILTER, - this.gl.NEAREST, - ); - } else { - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MIN_FILTER, - this.gl.LINEAR, - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MAG_FILTER, - this.gl.LINEAR, - ); - } - if (wrap_u === 1) { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT); - } else { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); - } - if (wrap_v === 1) { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT); - } else { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); - } - - this.bindTex(null); - - if (precise && !this.preciseChecked) { - // The extension says half-float attachments are renderable; make sure this context - // agrees before trusting every later target to it. Checked once. - this.preciseChecked = true; - const prev = this.renderTargetId; - this.imports.wasm_renderTarget(id); - const status = this.gl.checkFramebufferStatus(this.gl.FRAMEBUFFER); - this.imports.wasm_renderTarget(prev); - if (status !== this.gl.FRAMEBUFFER_COMPLETE) { - console.warn("dvui: half-float render target incomplete (status " + status + "); falling back to 8-bit targets"); - this.preciseTargets = false; - this.textures.delete(id); - this.gl.deleteTexture(texture); - return this.createTarget(width, height, interp, wrap_u, wrap_v, false); - } - } - - this.imports.wasm_textureClearTarget(id); - - return id; - } - - textureEntry(id) { - if (id === 0) return null; - return this.textures.get(id) ?? null; - } - using_fb = false; - /** Texture id bound as the render target, 0 for the screen. */ - renderTargetId = 0; - /** @type {WebGLFramebuffer | null} */ - frame_buffer = null; - /** @type {[number, number]} */ - renderTargetSize = [0, 0]; - - renderRequested = false; - renderTimeoutId = 0; - - /** @type {WebAssembly.ModuleImports} */ - imports; - - /** @type {WebAssembly.Instance} */ - instance; - stopped = false; - console_string = ""; - /** @type {HTMLInputElement} */ - hidden_input; - /** - * list of tuple (touch identifier, initial index) - * @type {[number, number][]} */ - touches = []; - /** The lowest deltaX/Y seen, used to determine the delta for touchpads - * - * The first number is x and second is y - * @type {[number, number]} */ - scroll_lowest = [99999, 99999]; - /** The lowest deltaX/Y seen in this batch (resets if none in 1s). Used to - * determine if we think a touchpad is being used and also as the delta for - * mouse wheels. - * - * The first number is x and second is y - * @type {[number, number]} */ - scroll_lowest_batch = [99999, 99999]; - scroll_last_ms = Date.now(); - /** - * x y w h of on screen keyboard editing position, or empty if none - * - * @type {[number, number, number, number] | []} */ - textInputRect = []; - - // Used for file uploads. Only valid for one frame - filesCacheModified = false; - /** @type {Map} */ - filesCache = new Map(); - - //let par = document.createElement("p"); - //document.body.prepend(par); - - get webgl2() { - return this.gl instanceof WebGL2RenderingContext; - } - - // This does 2 things: - // * on desktop it's needed for us to get text events (not just char down/up) - // * on touch it's needed to show the on screen keyboard - oskCheck() { - if (this.textInputRect.length == 0) { - this.gl.canvas.focus(); - } else { - const rect = this.gl.canvas.getBoundingClientRect(); - const left = window.scrollX + rect.left + this.textInputRect[0]; - const top = window.scrollY + rect.top + this.textInputRect[1]; - // limit the width and height to prevent overflow - const width = Math.max( - 0, - Math.min( - this.textInputRect[2], - this.gl.canvas.clientWidth - left, - ), - ); - const height = Math.max( - 0, - Math.min( - this.textInputRect[2], - this.gl.canvas.clientHeight - top, - ), - ); - this.hidden_input.style.left = left + "px"; - this.hidden_input.style.top = top + "px"; - this.hidden_input.style.width = width + "px"; - this.hidden_input.style.height = height + "px"; - this.hidden_input.focus(); - //par.textContent = hidden_input.style.left + " " + hidden_input.style.top + " " + hidden_input.style.width + " " + hidden_input.style.height; - } - } - - /** - * An Apple Pencil on iPad Safari arrives as a TouchEvent whose touch has `touchType` - * "stylus" (a finger's is "direct"). It is a pen, not a finger, so it goes in as the mouse: - * motion to where it is, then the left button pressed or released. dvui then counts it as - * `.mouse` (`dvui.pointerKind()`), so what it last touched stays hovered after a lift and a - * canvas treats it as a pointer, not a pan; native excludes SDL's pen touches the same way. - * The touch handlers' `preventDefault` stops the browser's own mouse events following it. - * - * @param {TouchEvent} ev - * @param {Touch} touch - * @param {1 | 2 | 3} which 1 motion only, 2 press, 3 release (`add_event`'s mouse codes) - */ - stylusEvent(ev, touch, which) { - const rect = this.gl.canvas.getBoundingClientRect(); - const x = (touch.clientX - rect.left) / (rect.right - rect.left) * - this.gl.drawingBufferWidth; - const y = (touch.clientY - rect.top) / (rect.bottom - rect.top) * - this.gl.drawingBufferHeight; - const mods = this.modCode(ev); - this.instance.exports.add_event(1, mods, 0, x, y); - if (which !== 1) this.instance.exports.add_event(which, 0, mods, 0, 0); - } - - touchIndex(pointerId) { - let idx = this.touches.findIndex((e) => e[0] === pointerId); - if (idx < 0) { - idx = this.touches.length; - this.touches.push([pointerId, idx]); - } - - return idx; - } - - /** - * @param {DVUI.AllocatorFunction} allocFn - * @param {number} len - * @returns {[pointer: number, slice: Uint8Array]} - */ - genericAlloc(allocFn, len) { - const pointer = allocFn(len); - const slice = new Uint8Array( - this.instance.exports.memory.buffer, - pointer, - len - ); - - return [pointer, slice]; - } - - /** - * @param {DVUI.AllocatorFunction} allocFn - * @param {ArrayLike} bytes - * @returns {number} pointer - */ - allocBuffer(allocFn, bytes) { - const [pointer, slice] = this.genericAlloc(allocFn, bytes.length); - slice.set(bytes); - return pointer; - } - - /** - * @param {DVUI.AllocatorFunction} allocFn - * @param {ArrayLike} bytes - * @param {number} sentinel - * @returns {number} pointer - */ - allocBufferZ(allocFn, bytes, sentinel = 0) { - const [pointer, slice] = this.genericAlloc(allocFn, bytes.length + 1); - slice.set(bytes); - slice[bytes.length] = sentinel; - return pointer; - } - - /** - * @param {DVUI.AllocatorFunction} allocFn - * @param {string} string - * @returns - */ - allocString(allocFn, string) { - const buffer = utf8encoder.encode(string); - return this.allocBuffer(allocFn, buffer); - }; - - /** - * @param {DVUI.AllocatorFunction} allocFn - * @param {string} string - * @param {number} sentinel - * @returns {number} pointer - */ - allocStringZ(allocFn, string, sentinel) { - const buffer = utf8encoder.encode(string); - return this.allocBufferZ(allocFn, buffer, sentinel); - }; - - /** - * @param {number} ptr - * @param {number} length - * @returns {string} - */ - stringFromPointer(ptr, length) { - return utf8decoder.decode(this.bytesFromPointer(ptr, length)); - } - - /** - * @param {number} ptr - * @param {number} length - * @returns {Uint8Array} - */ - bytesFromPointer(ptr, length) { - return new Uint8Array(this.instance.exports.memory.buffer, ptr, length) - } - - constructor() { - this.hidden_input = document.createElement("input"); - this.hidden_input.setAttribute("autocapitalize", "none"); - this.hidden_input.style.position = "absolute"; - this.hidden_input.style.left = 0; - this.hidden_input.style.top = 0; - // remove extra size so input doesn't cause overflow - this.hidden_input.style.padding = 0; - this.hidden_input.style.border = 0; - this.hidden_input.style.margin = 0; - this.hidden_input.style.opacity = 0; - this.hidden_input.style.zIndex = -1; - document.body.prepend(this.hidden_input); - - this.imports = { - // Custom programs (`WebBackend.program_api`, `core.gfx.programs`). - // Asked once: `getParameter` is a synchronous trip to the GPU process in Firefox, - // some 3.5 ms a call on a phone — asked every frame, it was the frame's worst cost. - wasm_programMaxVec4: () => { - if (this.programMaxVec4 !== undefined) return this.programMaxVec4; - this.programMaxVec4 = this.queryProgramMaxVec4(); - return this.programMaxVec4; - }, - wasm_programCreate: (ptr, len, textures, uniform_vec4s) => { - const gl = this.gl; - const body = this.stringFromPointer(ptr, len); - // A complete source — one generated from a program's HLSL, GLSL ES 3.00 - // (`scripts/compile-shaders.sh`) — is compiled as it is, on WebGL2 only. On WebGL1 - // it fails, and whoever drew with it falls back (the glass to its meshes). - const complete = body.startsWith("#version"); - if (complete && !this.webgl2) { - const id = this.newProgramId; - this.newProgramId += 1; - this.programs.set(id, { prog: null, fs: null, loc: null, status: 0 }); - return id; - } - const src = complete ? body : this.programPrelude(uniform_vec4s) + body; - const vs = gl.createShader(gl.VERTEX_SHADER); - gl.shaderSource(vs, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl); - gl.compileShader(vs); - const fs = gl.createShader(gl.FRAGMENT_SHADER); - gl.shaderSource(fs, src); - gl.compileShader(fs); - const prog = gl.createProgram(); - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - // The stock program's attribute slots, so the vertex layout set once - // (`setLayout`) feeds every program. - const a = this.programInfo.attribLocations; - gl.bindAttribLocation(prog, a.vertexPosition, "aVertexPosition"); - gl.bindAttribLocation(prog, a.vertexColor, "aVertexColor"); - gl.bindAttribLocation(prog, a.textureCoord, "aTextureCoord"); - gl.linkProgram(prog); - const id = this.newProgramId; - this.newProgramId += 1; - const p = { prog, fs, loc: null, status: 1, mw: undefined, mh: undefined, mfb: undefined }; - this.programs.set(id, p); - if (this.parallelCompile === null) this.programLinked(p); - return id; - }, - wasm_programStatus: (id) => { - const p = this.programs.get(id); - if (p === undefined) return 0; - if (p.status === 1 && this.gl.getProgramParameter(p.prog, this.parallelCompile.COMPLETION_STATUS_KHR)) { - this.programLinked(p); - } - return p.status; - }, - wasm_about_webgl2: () => { - if (this.webgl2) { - return 1; - } else { - return 0; - } - }, - wasm_panic: (ptr, len) => { - this.stop(); - const msg = this.stringFromPointer(ptr, len); - console.error("PANIC:", msg); - alert(msg); - }, - wasm_console_drain: (ptr, len) => { - this.console_string += this.stringFromPointer(ptr, len); - }, - wasm_console_flush: (level) => { - switch (level) { - case 9: - console.error(this.console_string); - break; - case 7: - console.warn(this.console_string); - break; - case 5: - console.info(this.console_string); - break; - case 3: - console.debug(this.console_string); - break; - default: - console.log(this.console_string); - break; - } - this.console_string = ""; - }, - wasm_now: () => { - return performance.now(); - }, - wasm_sleep: (ms) => { - const end = Date.now() + ms; - while (Date.now() < end) { - // block because the point is to limit the framerate - } - }, - wasm_refresh: () => { - this.requestRender(); - }, - wasm_pixel_width: () => { - return this.gl.drawingBufferWidth; - }, - wasm_pixel_height: () => { - return this.gl.drawingBufferHeight; - }, - wasm_frame_buffer: () => { - if (this.using_fb) { - return 1; - } else { - return 0; - } - }, - wasm_canvas_width: () => { - return this.gl.canvas.clientWidth; - }, - wasm_canvas_height: () => { - return this.gl.canvas.clientHeight; - }, - wasm_textureCreate: (pixels, width, height, interp, wrap_u, wrap_v) => { - const pixelData = this.bytesFromPointer(pixels, width * height * 4); - - const texture = this.gl.createTexture(); - const id = this.newTextureId; - //console.log("creating texture " + id); - this.newTextureId += 1; - this.textures.set(id, [texture, width, height]); - - this.bindTex(texture); - - this.gl.texImage2D( - this.gl.TEXTURE_2D, - 0, - this.gl.RGBA, - width, - height, - 0, - this.gl.RGBA, - this.gl.UNSIGNED_BYTE, - pixelData, - ); - - if (this.webgl2) { - this.gl.generateMipmap(this.gl.TEXTURE_2D); - } - - if (interp == 0) { - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MIN_FILTER, - this.gl.NEAREST, - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MAG_FILTER, - this.gl.NEAREST, - ); - } else { - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MIN_FILTER, - this.gl.LINEAR, - ); - this.gl.texParameteri( - this.gl.TEXTURE_2D, - this.gl.TEXTURE_MAG_FILTER, - this.gl.LINEAR, - ); - } - if (wrap_u === 1) { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT); - } else { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); - } - if (wrap_v === 1) { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT); - } else { - this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); - } - - this.bindTex(null); - - return id; - }, - // Replace every pixel of a texture made by wasm_textureCreate. Returns 0 for an - // unknown texture. No mipmaps to regenerate: textures sample NEAREST/LINEAR. - wasm_textureUpdate: (id, pixels) => { - const entry = this.textures.get(id); - if (!entry) return 0; - const [texture, width, height] = entry; - this.bindTex(texture); - this.gl.texSubImage2D( - this.gl.TEXTURE_2D, 0, 0, 0, width, height, - this.gl.RGBA, this.gl.UNSIGNED_BYTE, - this.bytesFromPointer(pixels, width * height * 4), - ); - this.bindTex(null); - return 1; - }, - // Replace the x,y,w,h rect of a texture made by wasm_textureCreate. `pixels` is the - // whole texture's buffer (see dvui.Backend.textureUpdateSubRect); only the rect is - // uploaded. Returns 0 for an unknown texture. - wasm_textureUpdateSubRect: (id, pixels, x, y, w, h) => { - const entry = this.textures.get(id); - if (!entry) return 0; - const [texture, width, height] = entry; - if (w === 0 || h === 0) return 1; - const full = this.bytesFromPointer(pixels, width * height * 4); - this.bindTex(texture); - if (this.webgl2) { - // Read the rect in place out of the full buffer. - this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, width); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_PIXELS, x); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_ROWS, y); - this.gl.texSubImage2D( - this.gl.TEXTURE_2D, 0, x, y, w, h, - this.gl.RGBA, this.gl.UNSIGNED_BYTE, full, - ); - this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, 0); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_PIXELS, 0); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_ROWS, 0); - } else { - // WebGL1 has no unpack row length: copy the rect's rows out tightly. - const rect = new Uint8Array(w * h * 4); - for (let row = 0; row < h; row += 1) { - const start = ((y + row) * width + x) * 4; - rect.set(full.subarray(start, start + w * 4), row * w * 4); - } - this.gl.texSubImage2D( - this.gl.TEXTURE_2D, 0, x, y, w, h, - this.gl.RGBA, this.gl.UNSIGNED_BYTE, rect, - ); - } - this.bindTex(null); - return 1; - }, - wasm_textureCreateTarget: (width, height, interp, wrap_u, wrap_v) => { - return this.createTarget(width, height, interp, wrap_u, wrap_v, false); - }, - // A 16-bit-float-per-channel target where the context has them; the 8-bit one - // otherwise. See dvui.Backend.support_precise_targets. - wasm_textureCreatePreciseTarget: (width, height, interp, wrap_u, wrap_v) => { - return this.createTarget(width, height, interp, wrap_u, wrap_v, this.preciseTargets); - }, - // See dvui.Backend.textureBlend. Returns 0 for an unknown texture. - wasm_textureBlend: (id, mode) => { - if (this.textureEntry(id) === null) return 0; - this.textureBlends.set(id, mode); - return 1; - }, - // Restores whatever target was bound before: a target is often created and - // cleared while another one is being rendered into (a Picture inside a frame - // target), and landing on the screen there sends the rest of that drawing to the - // wrong place. - wasm_textureClearTarget: (textureId) => { - const prev = this.renderTargetId; - this.imports.wasm_renderTarget(textureId); - this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent - this.gl.clear(this.gl.COLOR_BUFFER_BIT); - this.imports.wasm_renderTarget(prev); - }, - wasm_textureRead: (textureId, pixels_out, width, height) => { - //console.log("textureRead " + textureId); - const entry = this.textureEntry(textureId); - if (entry === null) { - console.warn( - `wasm_textureRead: missing texture id ${textureId}`, - ); - return; - } - const prev = this.gls.fb; - this.bindFb(this.frame_buffer); - this.gl.framebufferTexture2D( - this.gl.FRAMEBUFFER, - this.gl.COLOR_ATTACHMENT0, - this.gl.TEXTURE_2D, - entry[0], - 0, - ); - - var dest = this.bytesFromPointer(pixels_out, width * height * 4); - this.gl.readPixels( - 0, - 0, - width, - height, - this.gl.RGBA, - this.gl.UNSIGNED_BYTE, - dest, - 0, - ); - - this.bindFb(prev); - }, - wasm_renderTarget: (id) => { - //console.log("renderTarget " + id); - const rt = id === 0 ? null : this.textureEntry(id); - if (id !== 0 && rt === null) console.warn(`wasm_renderTarget: missing texture id ${id}`); - if (rt === null) { - this.renderTargetId = 0; - this.using_fb = false; - this.bindFb(null); - this.renderTargetSize = [this.gl.drawingBufferWidth, this.gl.drawingBufferHeight]; - } else { - this.renderTargetId = id; - this.using_fb = true; - let fb = this.targetFramebuffers.get(id); - if (fb === undefined) { - fb = this.gl.createFramebuffer(); - this.bindFb(fb); - this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, rt[0], 0); - this.targetFramebuffers.set(id, fb); - } else { - this.bindFb(fb); - } - this.renderTargetSize = [rt[1], rt[2]]; - } - this.setViewport(this.renderTargetSize[0], this.renderTargetSize[1]); - this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); - }, - wasm_textureDestroy: (id) => { - //console.log("deleting texture " + id); - const entry = this.textureEntry(id); - if (entry === null) return; - this.textures.delete(id); - this.textureBlends.delete(id); - const fb = this.targetFramebuffers.get(id); - if (fb !== undefined) { - if (this.gls.fb === fb) this.bindFb(null); - this.gl.deleteFramebuffer(fb); - this.targetFramebuffers.delete(id); - } - if (this.gls.tex === entry[0]) this.bindTex(null); - this.gl.deleteTexture(entry[0]); - }, - // A flush's worth of draws (`WebBackend.flushBatch`): the stream's geometry, uploaded - // once, and its steps in order — each `cmd_count` entry eight i32s: texture, clip, - // x, y, w, h, and the range of the stream's indices it draws. Clip's bit 0 says the - // draw is clipped and the bits above it are the texture's blend (0 source-over, - // 1 add, 2 copy); -1 is a switch of render target to `texture`, -2 a clear of it. - wasm_renderStream: ( - index_ptr, - index_len, - vertex_ptr, - vertex_len, - sizeof_vertex, - offset_pos, - offset_col, - offset_uv, - cmd_ptr, - cmd_count, - uniform_ptr, - uniform_floats, - ) => { - const uniforms = uniform_floats > 0 ? new Float32Array(this.instance.exports.memory.buffer, uniform_ptr, uniform_floats) : null; - // A stream of only switches and clears has no geometry (and an empty slice's - // pointer is not one to make a view at). - if (index_len > 0) { - const indices = new Uint16Array(this.instance.exports.memory.buffer, index_ptr, index_len / 2); - this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.DYNAMIC_DRAW); - this.gl.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); - this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); - } - - const cmds = new Int32Array(this.instance.exports.memory.buffer, cmd_ptr, cmd_count * 8); - for (let i = 0; i < cmd_count; i += 1) { - const c = i * 8; - const textureId = cmds[c]; - const kind = cmds[c + 1]; - if (kind === -1) { - // A switch of render target (0 the screen). - this.imports.wasm_renderTarget(textureId); - continue; - } - if (kind === -2) { - // A clear of a target, wherever drawing is going. - this.imports.wasm_textureClearTarget(textureId); - continue; - } - if (kind === -3) { - // A switch of program, with its uniforms and textures. - const at = cmds[c + 2] * 4, n = cmds[c + 3] * 4; - const data = textureId !== 0 && uniforms !== null ? uniforms.subarray(at, at + n) : null; - if (!this.useProgram(textureId, data, cmds[c + 4], cmds[c + 5])) this.useProgram(0, null, 0, 0); - continue; - } - this.setMatrix(); - if ((kind & 1) === 1) { - this.setScissor(cmds[c + 2], cmds[c + 3], cmds[c + 4], cmds[c + 5]); - } else { - this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); - } - const tex = textureId != 0 ? this.textureEntry(textureId) : null; - if (textureId != 0 && tex === null) console.warn(`wasm_renderStream: missing texture id ${textureId}`); - const stock = this.cur === this.stock; - if (tex !== null) { - this.bindTex(tex[0]); - if (stock) this.setUseTex(1); - } else if (stock) { - this.setUseTex(0); - } - // The draw's blend as it was when drawn (bits above the clip's): its - // texture's, or the program's. - this.setBlend(kind >> 1); - this.gl.drawElements(this.gl.TRIANGLES, cmds[c + 7], this.gl.UNSIGNED_SHORT, cmds[c + 6] * 2); - } - // Each stream starts in dvui's own program (`WebBackend.flushBatch`). - if (this.cur !== this.stock) this.useProgram(0, null, 0, 0); - }, - wasm_cursor: (name_ptr, name_len) => { - const cursor_name = this.stringFromPointer(name_ptr, name_len); - this.gl.canvas.style.cursor = cursor_name; - }, - wasm_text_input: (x, y, w, h) => { - if (w > 0 && h > 0) { - this.textInputRect = [x, y, w, h]; - } else { - this.textInputRect = []; - } - }, - wasm_open_url: (ptr, len, new_win) => { - const url = this.stringFromPointer(ptr, len); - - if (new_win) { - window.open(url); - } else { - window.location.href = url; - } - }, - wasm_preferred_color_scheme: () => { - if ( - window.matchMedia("(prefers-color-scheme: dark)").matches - ) { - return 1; - } - if ( - window.matchMedia("(prefers-color-scheme: light)").matches - ) { - return 2; - } - return 0; - }, - wasm_prefers_reduced_motion: () => { - if ( - window.matchMedia("(prefers-reduced-motion: no-preference)").matches - ) { - return 0; - } - if ( - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - return 1; - } - return 0; - }, - wasm_download_data: ( - name_ptr, - name_len, - data_ptr, - data_len, - ) => { - const name = this.stringFromPointer(name_ptr, name_len); - const data = this.bytesFromPointer(data_ptr, data_len); - const blob = new Blob([data], { type: "application/octet-stream" }); - const fileURL = URL.createObjectURL(blob); - const dl = document.createElement("a"); - dl.href = fileURL; - dl.download = name; - dl.click(); - dl.remove(); - URL.revokeObjectURL(fileURL); - }, - wasm_open_file_picker: (id, accept_ptr, accept_len, multiple) => { - const accept = this.stringFromPointer(accept_ptr, accept_len); - // console.log("Open picker", accept_ptr, accept_len, accept, multiple); - dvui_open_file_picker(accept, multiple).then((filelist) => { - let files = []; - let data = []; - for (let i = 0; i < filelist.length; i++) { - const file = filelist.item(i); - files.push(file); - data.push(file.arrayBuffer()); - } - Promise.all(data).then((data) => { - this.filesCacheModified = true; - this.filesCache.set(id, { files, data }); - this.requestRender(); - }); - }).catch(() => { - console.debug( - "Filepicker canceled: This is currently not detectable from within dvui", - ); - this.requestRender(); - }); - }, - wasm_get_file_size: (id, file_index) => { - const cached = this.filesCache.get(id); - if (!cached || cached.files.length <= file_index) return; - const size = cached.files[file_index].size; - return size; - }, - wasm_get_file_name: (id, file_index) => { - const cached = this.filesCache.get(id); - if (!cached || cached.files.length <= file_index) return; - - return this.allocStringZ(this.instance.exports.arena_u8, cached.files[file_index].name); - }, - wasm_read_file_data: (id, file_index, data_ptr) => { - const cached = this.filesCache.get(id); - if (!cached || cached.files.length <= file_index) return; - var dest = new Uint8Array( - this.instance.exports.memory.buffer, - data_ptr, - ); - dest.set(new Uint8Array(cached.data[file_index])); - }, - wasm_get_number_of_files_available: (id) => { - const cached = this.filesCache.get(id); - if (!cached) return 0; - return cached.files.length; - }, - wasm_clipboardTextSet: (ptr, len) => { - if (len == 0) { - return; - } - - const msg = this.stringFromPointer(ptr, len) - if (navigator.clipboard) { - navigator.clipboard.writeText(msg); - } else { - this.hidden_input.value = msg; - this.hidden_input.focus(); - this.hidden_input.select(); - document.execCommand("copy"); - this.hidden_input.value = ""; - } - }, - wasm_add_noto_font: () => { - dvui_fetch("NotoSansKR-Regular.ttf").then((bytes) => { - //console.log("bytes len " + bytes.length); - const ptr = this.allocBuffer(this.instance.exports.gpa_u8, bytes) - this.instance.exports.new_font( - ptr, - bytes.length, - ); - }); - }, - }; - } - - setInstance(instance) { - this.instance = instance; - } - - setCanvas(canvasSelectorOrCanvasElement) { - /** @type {HTMLCanvasElement | null} */ - const canvas = - canvasSelectorOrCanvasElement instanceof HTMLCanvasElement - ? canvasSelectorOrCanvasElement - : document.querySelector(canvasSelectorOrCanvasElement); - - if (!canvas) { - alert("Could not find canvas element."); - return; - } - - if (!canvas.style.width || !canvas.style.height) { - // Needed so that the canvas element can scale and report its size correctly. - // Absolute and relative length are both valid. Setting both to 100% is - // probably what you want. - console.error( - "Canvas element does not have defined width and height inline styles", - ); - } - - // We do our own edge fades with triangles. If we leave antialias on - // then you can get a faint brightness on the edge of a filled square. - // Right on the edge you get the blended combination of both triangles - // that share that edge. - // It's not easy to notice, but to reproduce: - // * turn antialias on - // * use the builtin Adwaita Dark theme - // * put a small filled (.background = true) box in a larger filled box - // * screenshot it - // * the edge of the small box will be brighter than the fill color - this.gl = canvas.getContext("webgl2", { alpha: true, antialias: false }); - if (this.gl === null) { - this.gl = canvas.getContext("webgl", { alpha: true, antialias: false }); - } - - if (this.gl === null) { - alert("Unable to initialize WebGL."); - return; - } - - // Half-float render targets for dvui's blur pyramid (Texture.CreateOptions.precision): - // WebGL2 samples RGBA16F with linear filtering as standard; rendering into it needs the - // extension. Without it the precise target is the plain 8-bit one. - this.preciseTargets = this.webgl2 && - (this.gl.getExtension("EXT_color_buffer_float") !== null || - this.gl.getExtension("EXT_color_buffer_half_float") !== null); - console.log("dvui: precise (half-float) render targets " + (this.preciseTargets ? "available" : "unavailable (" + (this.webgl2 ? "no EXT_color_buffer_(half_)float" : "WebGL1") + ")")); - - if (!this.webgl2) { - const ext = this.gl.getExtension("OES_element_index_uint"); - if (ext === null) { - alert("WebGL doesn't support OES_element_index_uint."); - return; - } - } - this.frame_buffer = this.gl.createFramebuffer(); - - const vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER); - if (this.webgl2) { - this.gl.shaderSource(vertexShader, vertexShaderSource_webgl2); - } else { - this.gl.shaderSource(vertexShader, vertexShaderSource_webgl); - } - this.gl.compileShader(vertexShader); - if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS)) { - alert( - `Error compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader) - }`, - ); - this.gl.deleteShader(vertexShader); - return null; - } - - const fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER); - if (this.webgl2) { - this.gl.shaderSource(fragmentShader, fragmentShaderSource_webgl2); - } else { - this.gl.shaderSource(fragmentShader, fragmentShaderSource_webgl); - } - this.gl.compileShader(fragmentShader); - if ( - !this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS) - ) { - alert( - `Error compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader) - }`, - ); - this.gl.deleteShader(fragmentShader); - return null; - } - - this.shaderProgram = this.gl.createProgram(); - this.gl.attachShader(this.shaderProgram, vertexShader); - this.gl.attachShader(this.shaderProgram, fragmentShader); - this.gl.linkProgram(this.shaderProgram); - - if ( - !this.gl.getProgramParameter( - this.shaderProgram, - this.gl.LINK_STATUS, - ) - ) { - alert( - `Error initializing shader program: ${this.gl.getProgramInfoLog(this.shaderProgram) - }`, - ); - return null; - } - - this.programInfo = { - attribLocations: { - vertexPosition: this.gl.getAttribLocation( - this.shaderProgram, - "aVertexPosition", - ), - vertexColor: this.gl.getAttribLocation( - this.shaderProgram, - "aVertexColor", - ), - textureCoord: this.gl.getAttribLocation( - this.shaderProgram, - "aTextureCoord", - ), - }, - uniformLocations: { - matrix: this.gl.getUniformLocation( - this.shaderProgram, - "uMatrix", - ), - uSampler: this.gl.getUniformLocation( - this.shaderProgram, - "uSampler", - ), - useTex: this.gl.getUniformLocation( - this.shaderProgram, - "useTex", - ), - }, - }; - - this.indexBuffer = this.gl.createBuffer(); - this.vertexBuffer = this.gl.createBuffer(); - - // Bound for good: there is one program, one pair of buffers and one sampler, so a draw - // sends only its data and whatever else changed (`gls`). - this.gl.useProgram(this.shaderProgram); - this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); - this.gl.activeTexture(this.gl.TEXTURE0); - this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); - - this.stock = { prog: this.shaderProgram, loc: null, status: 2 }; - this.cur = this.stock; - this.parallelCompile = this.gl.getExtension("KHR_parallel_shader_compile"); - - this.gls = {}; - this.setBlend(0); - this.gl.enable(this.gl.SCISSOR_TEST); - this.setScissor( - 0, - 0, - this.gl.canvas.clientWidth, - this.gl.canvas.clientHeight, - ); - } - - init() { - let dvui_init_return = 0; - let str = utf8encoder.encode(navigator.platform); - if (str.length > 0) { - const ptr = this.allocBuffer(this.instance.exports.gpa_u8, str); - dvui_init_return = this.instance.exports.dvui_init( - ptr, - str.length, - ); - this.instance.exports.gpa_free(ptr, str.length); - } else { - dvui_init_return = this.instance.exports.dvui_init( - 0, - 0, - ); - } - - if (dvui_init_return != 0) { - throw new Error("ERROR: dvui_init returned " + dvui_init_return); - } - } - - requestRender() { - if (this.stopped) return; - - if (this.renderTimeoutId > 0) { - // we got called before the timeout happened - clearTimeout(this.renderTimeoutId); - this.renderTimeoutId = 0; - } - - if (!this.renderRequested) { - // multiple events could call requestRender multiple times, and - // we only want a single requestAnimationFrame to happen before - // each call to dvui_update - this.renderRequested = true; - requestAnimationFrame(this.render.bind(this)); - } - } - - // For debugging. Turns off dvui rendering (zig code stops running). - stop() { - if (this.renderTimeoutId > 0) { - clearTimeout(this.renderTimeoutId); - this.renderTimeoutId = 0; - } - - this.renderRequested = false; - this.stopped = true; - } - - // For debugging. Turn back on dvui rendering (zig code starts running). - restart() { - if (!this.stopped) { - console.log("dvui.restart() called when not stopped"); - } - this.stopped = false; - this.requestRender(); - } - - render() { - if (this.stopped) return; - - this.renderRequested = false; - - // if the canvas changed size, adjust the backing buffer - const w = this.gl.canvas.clientWidth; - const h = this.gl.canvas.clientHeight; - const scale = window.devicePixelRatio; - //console.log("wxh " + w + "x" + h + " scale " + scale); - // Only when it changed: setting either one, even to what it already is, makes a new - // drawing buffer. - const cw = Math.round(w * scale); - const ch = Math.round(h * scale); - if (this.gl.canvas.width !== cw) this.gl.canvas.width = cw; - if (this.gl.canvas.height !== ch) this.gl.canvas.height = ch; - // Every frame starts on the screen; the backend's stream counts on it. - this.imports.wasm_renderTarget(0); - - this.gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque - this.gl.clear(this.gl.COLOR_BUFFER_BIT); - - let millis_to_wait = this.instance.exports.dvui_update(); - - // This oskCheck is for desktop to get text events. Touch devices will - // show/hide the keyboard (but not all, see touchend handler). - this.oskCheck(); - - if (!this.filesCacheModified) { - // Only clear if we didn't add anything this frame. Async could add items after they were requested - // in the frame, so keep if for two frames - this.filesCache.clear(); - } - this.filesCacheModified = false; - - if (millis_to_wait < 0) { - this.stop(); - } else if (millis_to_wait == 0) { - this.requestRender(); - } else if (millis_to_wait > 0) { - this.renderTimeoutId = setTimeout( - function() { - this.renderTimeoutId = 0; - this.requestRender(); - }.bind(this), - millis_to_wait, - ); - } - } - - run() { - if (!this.instance) { - throw new Error( - "Missing wasm instance, did you forget to call `setInstance`?", - ); - } - if (!this.gl) { - throw new Error( - "Missing rendering context, did you forget to call `setCanvas`?", - ); - } - this.init(); - - // event listeners - this.gl.canvas.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - }); - window.addEventListener("resize", (ev) => { - this.requestRender(); - }); - if (this.gl.canvas instanceof HTMLCanvasElement) { - const resizeObserver = new ResizeObserver(() => { - this.requestRender(); - }); - resizeObserver.observe(this.gl.canvas); - } - this.gl.canvas.addEventListener("mousemove", (ev) => { - if (this.stopped) return; - let rect = this.gl.canvas.getBoundingClientRect(); - let x = (ev.clientX - rect.left) / (rect.right - rect.left) * - this.gl.drawingBufferWidth; - let y = (ev.clientY - rect.top) / (rect.bottom - rect.top) * - this.gl.drawingBufferHeight; - // The event's own modifier state, not the last key event's: a modifier whose keyup - // was lost (cmd-tab away, a popup taking focus) would otherwise stick to every - // click until the next key event. - this.instance.exports.add_event(1, this.modCode(ev), 0, x, y); - this.requestRender(); - }); - this.gl.canvas.addEventListener("mousedown", (ev) => { - if (this.stopped) return; - this.instance.exports.add_event(2, ev.button, this.modCode(ev), 0, 0); - this.requestRender(); - }); - this.gl.canvas.addEventListener("mouseup", (ev) => { - if (this.stopped) return; - this.instance.exports.add_event(3, ev.button, this.modCode(ev), 0, 0); - this.requestRender(); - }); - this.gl.canvas.addEventListener("wheel", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - - // If we haven't gotten a wheel event in a second, reset our first - // because the user might have switched between mouse and touchpad. - if ((Date.now() - this.scroll_last_ms) > 1000) { - this.scroll_lowest_batch = [99999, 99999]; - } - this.scroll_last_ms = Date.now(); - - const touchpad_adj = 0.025; - - if (ev.deltaX != 0) { - this.scroll_lowest[0] = Math.min( - Math.abs(ev.deltaX), - this.scroll_lowest[0], - ); - this.scroll_lowest_batch[0] = Math.min( - Math.abs(ev.deltaX), - this.scroll_lowest_batch[0], - ); - var ticks = -ev.deltaX; - var trackpad = 0; - if (ev.deltaMode !== 0) { - // only mouse wheels produce non-pixel deltas, so this is definitive without - // needing the magnitude heuristic. - ticks /= this.scroll_lowest_batch[0]; - } else if ((this.scroll_lowest_batch[0] >= 100) || // most wheels - (this.scroll_lowest_batch[0] === 16) || // mac firefox - (this.scroll_lowest_batch[0] === 9) || // mac firefox holding shift - (this.scroll_lowest_batch[0] === 40) || // mac safari/chrome holding shift - (this.scroll_lowest_batch[0] === 4.000244140625)) { // mac safari/chrome - // assume this is a mouse wheel - ticks /= this.scroll_lowest_batch[0]; - if (this.scroll_lowest_batch[0] === 4.000244140625) { - ticks *= touchpad_adj; // mac safari/chrome scale wheel like touchpad - } - //console.log("wheelX -deltaX " + -ev.deltaX + " ticks " + ticks); - } else { - // assume touchpad - trackpad = 1; - ticks = ticks / this.scroll_lowest[0] * touchpad_adj; - //console.log("touchpadX -deltaX " + -ev.deltaX + " ticks " + ticks); - } - this.instance.exports.add_event( - 4, - 0, - trackpad, - ticks, - 0, - ); - } - if (ev.deltaY != 0) { - //console.log("deltaMode: " + ev.deltaMode + " deltaY: " + ev.deltaY); - this.scroll_lowest[1] = Math.min( - Math.abs(ev.deltaY), - this.scroll_lowest[1], - ); - this.scroll_lowest_batch[1] = Math.min( - Math.abs(ev.deltaY), - this.scroll_lowest_batch[1], - ); - var ticks = -ev.deltaY; - var trackpad = 0; - if (ev.deltaMode !== 0) { - // only mouse wheels produce non-pixel deltas - ticks /= this.scroll_lowest_batch[1]; - } else if ((this.scroll_lowest_batch[1] >= 100) || // most wheels - (this.scroll_lowest_batch[1] === 16) || // mac firefox - (this.scroll_lowest_batch[1] === 4.000244140625)) { // mac safari/chrome - // assume this is a mouse wheel - ticks /= this.scroll_lowest_batch[1]; - if (this.scroll_lowest_batch[1] === 4.000244140625) { - ticks *= touchpad_adj; // mac safari/chrome scale wheel like touchpad - } - //console.log("wheelY -deltaY " + -ev.deltaY + " ticks " + ticks); - } else { - // assume touchpad - trackpad = 1; - ticks = ticks / this.scroll_lowest[1] * touchpad_adj; - //console.log("touchpadY -deltaY " + -ev.deltaY + " ticks " + ticks); - } - this.instance.exports.add_event( - 4, - 1, - trackpad, - ticks, - 0, - ); - } - this.requestRender(); - }); - - let keydown = (ev) => { - if (this.stopped) return; - if (ev.key == "Tab") { - - // In most browsers we don't even see a control-tab, the - // browser uses it to switch tabs. But Vivaldi sends it to us - // first, so bail (before preventDefault) so that Vivaldi - // switches tabs and we don't do anything. - if (ev.ctrlKey) return; - - // stop tab from tabbing away from the canvas - ev.preventDefault(); - } - - let str = utf8encoder.encode(ev.key); - if (str.length > 0) { - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str) - this.instance.exports.add_event( - 5, - ptr, - str.length, - ev.repeat, - (ev.metaKey << 3) + (ev.altKey << 2) + - (ev.ctrlKey << 1) + (ev.shiftKey << 0), - ); - this.requestRender(); - } - }; - this.gl.canvas.addEventListener("keydown", keydown.bind(this)); - this.hidden_input.addEventListener("keydown", keydown.bind(this)); - - let keyup = (ev) => { - if (this.stopped) return; - const str = utf8encoder.encode(ev.key); - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); - this.instance.exports.add_event( - 6, - ptr, - str.length, - 0, - (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + - (ev.shiftKey << 0), - ); - this.requestRender(); - }; - this.gl.canvas.addEventListener("keyup", keyup.bind(this)); - this.hidden_input.addEventListener("keyup", keyup.bind(this)); - - this.hidden_input.addEventListener("beforeinput", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - if (ev.data && !ev.isComposing) { - const str = utf8encoder.encode(ev.data); - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); - this.instance.exports.add_event( - 7, - ptr, - str.length, - 0, - 0, - ); - this.requestRender(); - } - }); - this.hidden_input.addEventListener("compositionend", (ev) => { - if (this.stopped) return; - if (ev.data) { - const str = utf8encoder.encode(ev.data); - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); - this.instance.exports.add_event( - 7, - ptr, - str.length, - 0, - 0, - ); - this.requestRender(); - } - // Reset value to empty after composition maybe put text there - ev.target.value = ""; - }); - this.gl.canvas.addEventListener("touchstart", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - let rect = this.gl.canvas.getBoundingClientRect(); - for (let i = 0; i < ev.changedTouches.length; i++) { - let touch = ev.changedTouches[i]; - if (touch.touchType === "stylus") { - this.stylusEvent(ev, touch, 2); - continue; - } - let x = (touch.clientX - rect.left) / - (rect.right - rect.left); - let y = (touch.clientY - rect.top) / - (rect.bottom - rect.top); - let tidx = this.touchIndex(touch.identifier); - this.instance.exports.add_event( - 8, - this.touches[tidx][1], - 0, - x, - y, - ); - } - this.requestRender(); - }); - const touchEnd = (ev) => { - if (this.stopped) return; - ev.preventDefault(); - let rect = this.gl.canvas.getBoundingClientRect(); - for (let i = 0; i < ev.changedTouches.length; i++) { - let touch = ev.changedTouches[i]; - if (touch.touchType === "stylus") { - this.stylusEvent(ev, touch, 3); - continue; - } - let x = (touch.clientX - rect.left) / - (rect.right - rect.left); - let y = (touch.clientY - rect.top) / - (rect.bottom - rect.top); - let tidx = this.touchIndex(touch.identifier); - this.instance.exports.add_event( - 9, - this.touches[tidx][1], - 0, - x, - y, - ); - this.touches.splice(tidx, 1); - } - // This oskCheck is for some platforms (iphone) where showing - // the keyboard has to be done inside an event handler. - // https://stackoverflow.com/a/6837575 - this.oskCheck(); - this.requestRender(); - }; - this.gl.canvas.addEventListener("touchend", touchEnd); - // A cancelled touch (the system took it: a gesture, an alert) ends like a lifted one, - // or its button, or the pencil's mouse button, would stay held. - this.gl.canvas.addEventListener("touchcancel", touchEnd); - this.gl.canvas.addEventListener("touchmove", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - let rect = this.gl.canvas.getBoundingClientRect(); - for (let i = 0; i < ev.changedTouches.length; i++) { - let touch = ev.changedTouches[i]; - if (touch.touchType === "stylus") { - this.stylusEvent(ev, touch, 1); - continue; - } - let x = (touch.clientX - rect.left) / - (rect.right - rect.left); - let y = (touch.clientY - rect.top) / - (rect.bottom - rect.top); - let tidx = this.touchIndex(touch.identifier); - this.instance.exports.add_event( - 10, - this.touches[tidx][1], - 0, - x, - y, - ); - } - this.requestRender(); - }); - // start the first update - this.requestRender(); - } -} From a1441dca4554d8445c67faf9c0c2e746b9514d41 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:57:53 -0500 Subject: [PATCH 4/4] automation: beside-target-place (codex) Agent-Team: linux Worker: codex --- build.zig | 14 ++++---------- build/plugins.zig | 4 ++-- build/sdk.zig | 7 ------- scripts/check-examples.sh | 20 ++++++++++++++++++++ scripts/test-web-text-input.sh | 0 src/editor/Editor.zig | 6 ++---- tests/fixtures/app-only/build.zig | 18 ++++++++++++++++++ tests/fixtures/app-only/build.zig.zon | 14 ++++++++++++++ tests/fixtures/app-only/plugin.zig | 1 + 9 files changed, 61 insertions(+), 23 deletions(-) mode change 100644 => 100755 scripts/test-web-text-input.sh create mode 100644 tests/fixtures/app-only/build.zig create mode 100644 tests/fixtures/app-only/build.zig.zon create mode 100644 tests/fixtures/app-only/plugin.zig diff --git a/build.zig b/build.zig index 60b6ac9fc..27fa52d9c 100644 --- a/build.zig +++ b/build.zig @@ -230,7 +230,9 @@ pub fn framework(fizzy_dep: *std.Build.Dependency, platform: Platform) Framework /// ``` /// /// `linked` is everything the app links, in registration order (the order of the sidebar's -/// views): fizzy's own (`fizzy.plugins`) and the app's. For an app's, `name` is the plugin's id +/// views): fizzy's own (`fizzy.plugins`) and the app's, and nothing else is added. Until +/// fizzyedit/plans#16 makes it optional the list must include `fizzy.plugins.workbench`; one +/// without it, the empty list too, fails the build. For an app's, `name` is the plugin's id /// (its root's `plugin_id`) and `module` its static module, which the plugin package exports and /// which fizzy wires to `dvui`, `core`, `fizzy_sdk` and `icons`. pub fn buildApp(fizzy_dep: *std.Build.Dependency, linked: []const BundledPlugin) !void { @@ -274,7 +276,7 @@ pub const App = struct { pub fn addApp(fizzy_dep: *std.Build.Dependency, linked: []const BundledPlugin) !App { const d = deferred orelse @panic("fizzy.addApp: pass `.@\"defer-app\" = true` to b.dependency(\"fizzy\", …) first"); var opts = d.base; - opts.app_plugins = try withFizzys(fizzy_dep.builder, linked); + opts.app_plugins = linked; const built = try @import("build/app.zig").construct( fizzy_dep.builder, d.target, @@ -294,14 +296,6 @@ pub fn addApp(fizzy_dep: *std.Build.Dependency, linked: []const BundledPlugin) ! }; } -/// Until fizzyedit/example-app lists fizzy's plugins itself (its CI and fizzy's build each other's -/// main): a list naming none of fizzy's gets all of them ahead of its own, as before. Goes in -/// fizzyedit/plans#13 step 2. -fn withFizzys(b: *std.Build, linked: []const BundledPlugin) ![]const BundledPlugin { - for (linked) |p| if (p.isFizzys()) return linked; - return std.mem.concat(b.allocator, BundledPlugin, &.{ &plugins.all, linked }); -} - /// `zig build package` (and its alias `desktop`) and `zig build packageall` for an app built with /// `buildApp`, as steps of the app's own build `b`: a dependency's steps are not the command /// line's. `package` packages the app for the target the app gave fizzy, into the app's diff --git a/build/plugins.zig b/build/plugins.zig index 2d6c6d219..1a2e5cdb0 100644 --- a/build/plugins.zig +++ b/build/plugins.zig @@ -75,11 +75,11 @@ pub const Linked = struct { /// Make the modules for `plugins`: fizzy's own built for this executable, an application's /// re-homed onto it. workbench must be listed, because the editor still holds its state -/// (fizzyedit/plans#13). +/// (fizzyedit/plans#16). pub fn link(b: *std.Build, w: Wiring, plugins: []const BundledPlugin) Linked { if (!linksFizzys(plugins, "workbench")) std.debug.panic( "fizzy: the plugin list must include `fizzy.plugins.workbench`; the editor " ++ - "still holds its state (fizzyedit/plans#13)", + "still holds its state (fizzyedit/plans#16)", .{}, ); diff --git a/build/sdk.zig b/build/sdk.zig index 40e333e70..e40bdee49 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -177,13 +177,6 @@ pub const BundledPlugin = struct { module: ?*std.Build.Module = null, }; - /// One of fizzy's own: no module for any executable. - pub fn isFizzys(self: BundledPlugin) bool { - if (self.module != null) return false; - for (self.targets orelse &.{}) |t| if (t.module != null) return false; - return true; - } - /// The entry as `platform`'s executables link it, or null if they do not. pub fn forPlatform(self: BundledPlugin, platform: Platform) ?BundledPlugin { const targets = self.targets orelse return self; diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh index 7295127e6..9e7d1ce17 100755 --- a/scripts/check-examples.sh +++ b/scripts/check-examples.sh @@ -7,6 +7,9 @@ # dependency does. A path dependency (or `zig build --fork`) would see the whole checkout and # miss a file left out of `.paths`. # +# tests/fixtures/app-only is an app listing only a plugin of its own, which must fail to build +# naming `fizzy.plugins.workbench` (the list is the list, and workbench is required for now). +# # example-app gets the whole repo (its shapes, and its dvui app on either backend), and for its # replay app the plugin SDK; example-plugin gets the plugin SDK. The SDK is packed as a release # would be (`scripts/pack-sdk.sh`). @@ -118,3 +121,20 @@ for b in $builds; do done echo "== example-plugin and example-app, against this tree" printf '%s\n' $builds | xargs -P "${EXAMPLES_JOBS:-3}" -n 1 bash -c 'build_one "$1"' _ + +# An app's list is exact: one naming none of fizzy's, workbench included, fails while build.zig +# configures, which compiles nothing. The build must fail and say which plugin is missing. +echo "== app-only fixture, expected to fail naming fizzy.plugins.workbench" +mkdir -p "$work/app-only" +pack "$root/tests/fixtures/app-only" | tar -x -C "$work/app-only" +(cd "$work/app-only" && zig fetch --save=fizzy "$work/fizzy.tar.gz" >/dev/null) +if (cd "$work/app-only" && zig build --prefix "$work/out/app-only") > "$work/log-app-only.txt" 2>&1; then + echo "FAIL app-only: built, but an app without fizzy.plugins.workbench must not" + exit 1 +fi +if ! grep -q 'fizzy\.plugins\.workbench' "$work/log-app-only.txt"; then + echo "FAIL app-only: failed without naming fizzy.plugins.workbench" + sed 's/^/ /' "$work/log-app-only.txt" + exit 1 +fi +echo "ok app-only (failed as expected)" diff --git a/scripts/test-web-text-input.sh b/scripts/test-web-text-input.sh old mode 100644 new mode 100755 diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index ab67179b2..2c99793ad 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -494,8 +494,6 @@ pub fn init( } } - // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`). - { // Setup themes var fizzy_dark = dvui.themeGet(); fizzy_dark.embedded_fonts = embedded_fonts; @@ -937,8 +935,8 @@ fn unloadPluginLibs(editor: *Editor) void { // ---- runtime plugin lifecycle (store: install / enable / disable / update) --------- // -// Only dylib-loaded *user* plugins are managed here. Bundled built-ins (pixi/workbench/ -// code) ship in the app and are never unloaded, even though they also appear in +// Only dylib-loaded *user* plugins are managed here. Bundled built-ins (workbench, +// text, …) ship in the app and are never unloaded, even though they also appear in // `loaded_plugin_libs` when loaded from their bundled dylibs. /// True when `id` names one of the bundled built-ins (ships in the app, must never be diff --git a/tests/fixtures/app-only/build.zig b/tests/fixtures/app-only/build.zig new file mode 100644 index 000000000..bf4eca20a --- /dev/null +++ b/tests/fixtures/app-only/build.zig @@ -0,0 +1,18 @@ +//! An app that lists only a plugin of its own. `buildApp` links exactly the list it is given, and +//! until fizzyedit/plans#16 makes workbench optional the build must fail naming +//! `fizzy.plugins.workbench`; `scripts/check-examples.sh` expects that. When plans#16 lands this +//! flips to expecting success, which is where "names none of fizzy's links none" becomes true. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) !void { + const fizzy_dep = b.dependency("fizzy", .{ + .target = b.standardTargetOptions(.{}), + .optimize = b.standardOptimizeOption(.{}), + .@"defer-app" = true, + }); + try fizzy.buildApp(fizzy_dep, &.{.{ + .name = "solo", + .module = b.createModule(.{ .root_source_file = b.path("plugin.zig") }), + }}); +} diff --git a/tests/fixtures/app-only/build.zig.zon b/tests/fixtures/app-only/build.zig.zon new file mode 100644 index 000000000..d9c3e6b83 --- /dev/null +++ b/tests/fixtures/app-only/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .app_only, + .version = "0.0.0", + .fingerprint = 0xe5f716f041fcf114, + .minimum_zig_version = "0.17.0", + .dependencies = .{ + // A placeholder: `scripts/check-examples.sh` swaps in the packed tree. + .fizzy = .{ + .url = "https://github.com/fizzyedit/fizzy/archive/refs/heads/main.tar.gz", + .hash = "N-V-__8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + }, + .paths = .{ "build.zig", "build.zig.zon", "plugin.zig" }, +} diff --git a/tests/fixtures/app-only/plugin.zig b/tests/fixtures/app-only/plugin.zig new file mode 100644 index 000000000..86cb59e1d --- /dev/null +++ b/tests/fixtures/app-only/plugin.zig @@ -0,0 +1 @@ +pub const plugin_id = "solo";