From 375b279cf3f87bbefdb4214ae219bf61fcaaf4d0 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:51:47 -0500 Subject: [PATCH 1/3] linux: work-448 (codex) Agent-Team: linux Worker: codex --- app/root.zig | 1 + app/update/bounded_join.zig | 51 ------------------------------------ app/update/update_notify.zig | 43 +++++++++++++++++++++++++----- build/app.zig | 2 -- 4 files changed, 37 insertions(+), 60 deletions(-) delete mode 100644 app/update/bounded_join.zig diff --git a/app/root.zig b/app/root.zig index 509f6bd3d..5899cb9de 100644 --- a/app/root.zig +++ b/app/root.zig @@ -218,6 +218,7 @@ pub const store = struct { // whose tests no std-only test root of their own reaches. `Layout`'s own `test` block brings in // the split trees, seeds, regions, drop plans and view drag beside it. test { + _ = update.update_notify; _ = settings.Settings; _ = view.ViewHost; _ = layout.Layout; diff --git a/app/update/bounded_join.zig b/app/update/bounded_join.zig deleted file mode 100644 index eeb91e5df..000000000 --- a/app/update/bounded_join.zig +++ /dev/null @@ -1,51 +0,0 @@ -//! 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 ec1fe1656..bde84f6c8 100644 --- a/app/update/update_notify.zig +++ b/app/update/update_notify.zig @@ -9,7 +9,6 @@ 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) { @@ -96,7 +95,7 @@ fn startCheck() void { } // 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 { + check_thread = std.Thread.spawn(.{}, worker, .{ io, std.heap.page_allocator, auto_update.checkRemoteVersionSummary }) catch { phase.store(@backingInt(Phase.done_no), .release); return; }; @@ -107,7 +106,7 @@ fn startCheck() void { 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); +var check_done: std.Io.Event = .unset; /// 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. @@ -117,14 +116,25 @@ const quit_wait_ms = 250; /// 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); + if (check_thread) |thread| { + if (check_done.waitTimeout(io, .{ .duration = .{ .raw = .fromMilliseconds(quit_wait_ms), .clock = .awake } })) |_| { + thread.join(); + } else |_| { + // Timeout or cancellation must both let shutdown proceed. + thread.detach(); + } + } check_thread = null; } -fn worker(io: std.Io, gpa: std.mem.Allocator) void { - defer check_done.store(true, .release); +fn worker( + io: std.Io, + gpa: std.mem.Allocator, + check: *const fn (std.Io, std.mem.Allocator, []u8) error{OutOfMemory}!auto_update.CheckSummary, +) void { + defer check_done.set(io); var ver_buf: [128]u8 = undefined; - const summary = auto_update.checkRemoteVersionSummary(io, gpa, &ver_buf) catch { + const summary = check(io, gpa, &ver_buf) catch { phase.store(@backingInt(Phase.done_no), .release); return; }; @@ -419,3 +429,22 @@ fn displayProgressToast(id: dvui.Id) !void { animator.data().min_size = .{}; } } + +test "launch worker signals completion on success and error" { + const Check = struct { + fn success(_: std.Io, _: std.mem.Allocator, _: []u8) error{OutOfMemory}!auto_update.CheckSummary { + return .no_update; + } + fn failure(_: std.Io, _: std.mem.Allocator, _: []u8) error{OutOfMemory}!auto_update.CheckSummary { + return error.OutOfMemory; + } + }; + defer check_done = .unset; + defer phase.store(@backingInt(Phase.pending), .release); + for ([_]@TypeOf(&Check.success){ &Check.success, &Check.failure }) |check| { + check_done = .unset; + worker(std.testing.io, std.testing.allocator, check); + try std.testing.expect(check_done.isSet()); + try std.testing.expectEqual(AppUpdateState.none, appUpdateState()); + } +} diff --git a/build/app.zig b/build/app.zig index 34cf3b592..82552bb8a 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1076,8 +1076,6 @@ 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" }, From eab929a5ba0746d9929c41b436084f27a89d778e Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:49:05 -0500 Subject: [PATCH 2/3] linux: work-448 (codex) Agent-Team: linux Worker: codex --- AGENTS.md | 8 + app/AGENTS.md | 4 +- app/Tracks.zig | 4 +- app/control/Server.zig | 61 +++- app/layout/SplitTree.zig | 4 +- app/layout/State.zig | 17 +- app/layout/ViewDrag.zig | 25 +- app/update/update_notify.zig | 9 +- build.zig | 14 +- build/app.zig | 29 ++ build/plugins.zig | 4 +- build/sdk.zig | 7 - core/FileTable.zig | 104 +++++- core/widgets/DropZones.zig | 2 +- core/widgets/ScrollArea.zig | 53 ++- core/widgets/TreeWidget.zig | 74 ++-- docs/AUTOMATION.md | 1 - docs/DEPENDENCIES.md | 3 +- plugins/workbench/src/Workbench.zig | 22 +- plugins/workbench/src/Workspace.zig | 23 +- scripts/check-examples.sh | 137 ++++++-- scripts/fixtures/build-api/app/build.zig | 50 +++ scripts/fixtures/build-api/app/build.zig.zon | 16 + scripts/fixtures/build-api/plugin/build.zig | 24 ++ .../fixtures/build-api/plugin/build.zig.zon | 14 + scripts/fixtures/build-api/plugin/lib.c | 4 + scripts/fixtures/build-api/plugin/lib.zig | 3 + scripts/fixtures/build-api/plugin/plugin.zig | 76 ++++ .../build-api/plugin/plugin.zig.zon.tmpl | 7 + scripts/test-zb.sh | 13 +- scripts/zb | 34 +- sdk/tape/Script.zig | 15 +- sdk/tape/Tape.zig | 14 - sdk/tape/binary.zig | 15 +- src/editor/Chrome.zig | 28 ++ src/editor/Demo.zig | 229 +++++++----- src/editor/Editor.zig | 27 +- src/editor/Keybinds.zig | 2 +- src/editor/Workspaces.zig | 30 +- src/editor/layout.zig | 102 ++++-- src/editor/layout_mobile.zig | 27 +- src/fizzy.zig | 2 + tests/demos.zig | 7 + tests/fixtures/app-only/build.zig | 18 + tests/fixtures/app-only/build.zig.zon | 14 + tests/fixtures/app-only/plugin.zig | 1 + tests/integration.zig | 328 +++++++++++++++++- tests/tapes/soak.zon | 2 - web/fizzy.js | 2 +- ...s-correction-replaces-the-typed-word.patch | 70 ++-- web/tests/text-input.test.js | 67 +++- 51 files changed, 1444 insertions(+), 402 deletions(-) create mode 100644 scripts/fixtures/build-api/app/build.zig create mode 100644 scripts/fixtures/build-api/app/build.zig.zon create mode 100644 scripts/fixtures/build-api/plugin/build.zig create mode 100644 scripts/fixtures/build-api/plugin/build.zig.zon create mode 100644 scripts/fixtures/build-api/plugin/lib.c create mode 100644 scripts/fixtures/build-api/plugin/lib.zig create mode 100644 scripts/fixtures/build-api/plugin/plugin.zig create mode 100644 scripts/fixtures/build-api/plugin/plugin.zig.zon.tmpl create mode 100644 src/editor/Chrome.zig 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/AGENTS.md b/AGENTS.md index e55708e6f..078d320a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,14 @@ both against each pull request's own tree (`scripts/check-examples.sh`, which yo locally): a change here that breaks either fails here. A change that needs them changed too lands its PR in that repo alongside. +An app that wants fizzy's own shape with a different frame sets the **`Chrome`** build options +(`src/editor/Chrome.zig`): `-Dchrome-sidebar-edge=right` puts the rail and Sidebar on the right +(the width stays the Sidebar's `extent`), `-Dchrome-panel=false` leaves out the Panel and fizzy's +Output view, `-Dchrome-menu-bar=false` and `-Dchrome-infobar=false` leave out those bars +(macOS keeps its native menu). The defaults are fizzy's own frame. An app-supplied layout +(`-Dapp-layout=`) draws its own frame. The phone shape reads only `sidebar_edge` (landscape +sheets) and `panel`. + ## Plugin store: built, not forward-looking The plugin registry/install flow (author repo → release CI → `fizzyedit/plugins` registry → diff --git a/app/AGENTS.md b/app/AGENTS.md index d80947e77..fc425ce73 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -47,5 +47,5 @@ each directory is. ReleaseFast). Check against `control.Server.max_path`, the real size, before `init`. - **On Windows, a socket test must not read to EOF when it need not.** Its AF_UNIX sometimes never tells a reader that the other side closed, so a test client reading "until the end" hangs about - one run in twenty. Read `Content-Length` bytes, as HTTP clients do (`control/Server.zig`'s - `TestClient`). It sent a day after server bugs that were not there. + one run in twenty. Read `Content-Length` bytes, as HTTP clients do, or stop after the expected + complete SSE message for an event stream (`control/Server.zig`'s `TestClient`). diff --git a/app/Tracks.zig b/app/Tracks.zig index 997dfe5f9..7338fa745 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -1177,7 +1177,7 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m const files = try arena.alloc(tape.Tape.File, 1); files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; const keyframes = try arena.alloc(tape.Tape.Keyframe, 1); - keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}) }; const ops = try arena.alloc(tape.Tape.Op, to - first + 2); ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; @@ -1288,7 +1288,7 @@ pub fn exportLine(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m const files = try arena.alloc(tape.Tape.File, 1); files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; const keyframes = try arena.alloc(tape.Tape.Keyframe, 1); - keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}) }; const ops = try arena.alloc(tape.Tape.Op, recs.len + 2); ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; diff --git a/app/control/Server.zig b/app/control/Server.zig index f0bb42c21..e8f4178e4 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -721,6 +721,8 @@ const TestClient = struct { io: Io, path: []const u8, request: []const u8, + /// A complete final SSE message: streams have no Content-Length or reliable EOF. + event_end: ?[]const u8 = null, reply: [8192]u8 = undefined, reply_len: usize = 0, @@ -735,9 +737,8 @@ const TestClient = struct { w.interface.flush() catch return; var rbuf: [1024]u8 = undefined; var r = stream.reader(self.io, &rbuf); - // As an HTTP client does: the head, then `Content-Length` bytes when it says, else to the - // end (a stream). Never to the end when it need not: whether Windows' AF_UNIX tells a - // reader the other side closed is not something to test here. + // Read the HTTP body by its length, an SSE stream through its expected final message, else to the end. + // Windows AF_UNIX does not reliably notify readers when the peer closes. while (self.reply_len < self.reply.len) { const n = r.interface.readSliceShort(self.reply[self.reply_len..][0..1]) catch return; if (n == 0) return; @@ -752,6 +753,13 @@ const TestClient = struct { const want = @min(len, self.reply.len - self.reply_len); r.interface.readSliceAll(self.reply[self.reply_len..][0..want]) catch return; self.reply_len += want; + } else if (self.event_end) |end| { + while (self.reply_len < self.reply.len) { + const n = r.interface.readSliceShort(self.reply[self.reply_len..][0..1]) catch return; + if (n == 0) return; + self.reply_len += 1; + if (std.mem.endsWith(u8, self.reply[0..self.reply_len], end)) return; + } } else { self.reply_len += r.interface.readSliceShort(self.reply[self.reply_len..]) catch 0; } @@ -967,7 +975,7 @@ test "a server that took over the path keeps it when the old one stops" { try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nnew")); } -test "a stream gets the events it asked for, once each, until the server stops" { +test "a stream gets only the events it asked for, once each" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; @@ -982,7 +990,12 @@ test "a stream gets the events it asked for, once each, until the server stops" var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); errdefer server.stop(); - var client: TestClient = .{ .io = io, .path = path, .request = "GET /events?topics=fizzy.document.*,chat.turn_ended HTTP/1.1\r\nHost: x\r\n\r\n" }; + var client: TestClient = .{ + .io = io, + .path = path, + .request = "GET /events?topics=fizzy.document.*,chat.turn_ended HTTP/1.1\r\nHost: x\r\n\r\n", + .event_end = "event: chat.turn_ended\ndata: {}\n\n", + }; const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); var taken: std.ArrayList(*Request) = .empty; @@ -999,10 +1012,9 @@ test "a stream gets the events it asked for, once each, until the server stops" server.publish("fizzy.document.saved", "{\"path\":\"/a\"}"); server.publish("fizzy.folder.opened", "{\"path\":\"/p\"}"); server.publish("chat.turn_ended", "{}"); - // Let the stream write before the stop ends it. - io.sleep(.fromMilliseconds(50), .awake) catch {}; - server.stop(); + // Wait for the final expected message before stopping can cancel the writer. thread.join(); + server.stop(); const reply = client.reply[0..client.reply_len]; try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); @@ -1143,7 +1155,7 @@ test "a stopped server ends a stream whose client stopped reading" { server.stop(); } -test "an event published as the stream is answered is not lost" { +test "an event published as the stream is answered arrives while the stream stays open" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; @@ -1157,7 +1169,12 @@ test "an event published as the stream is answered is not lost" { var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; try server.start(); - var client: TestClient = .{ .io = io, .path = path, .request = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n" }; + var client: TestClient = .{ + .io = io, + .path = path, + .request = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n", + .event_end = "event: first\ndata: 1\n\n", + }; const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); var taken: std.ArrayList(*Request) = .empty; defer taken.deinit(gpa); @@ -1170,9 +1187,9 @@ test "an event published as the stream is answered is not lost" { // though its connection has not written the headers yet. taken.items[0].answerStream(&server); server.publish("first", "1"); - io.sleep(.fromMilliseconds(50), .awake) catch {}; - server.stop(); + // The client must finish on the message, while the server still holds the stream open. thread.join(); + server.stop(); try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nevent: first\ndata: 1\n\n")); } @@ -1363,3 +1380,23 @@ test "a client that leaves an upgraded connection finishes its link" { test { _ = Link; } + +test "a thousand listeners stop without a client waking accept" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + 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); + + // Reuse the path, as repeated launches in one profile do. + for (0..1000) |_| { + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + server.stop(); + } +} diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index bcb8b86f6..78fa33e27 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 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 { return switch (side) { diff --git a/app/layout/State.zig b/app/layout/State.zig index 46aa109cf..243029465 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 ce83ef85d..73e449dc8 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1323,18 +1323,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) |aimed_side| if (std.mem.eql(u8, @tagName(aimed_side), @tagName(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/app/update/update_notify.zig b/app/update/update_notify.zig index bde84f6c8..cfb9f5f77 100644 --- a/app/update/update_notify.zig +++ b/app/update/update_notify.zig @@ -95,7 +95,7 @@ fn startCheck() void { } // 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, auto_update.checkRemoteVersionSummary }) catch { + check_thread = std.Thread.spawn(.{}, worker, .{ std.heap.page_allocator, auto_update.checkRemoteVersionSummary }) catch { phase.store(@backingInt(Phase.done_no), .release); return; }; @@ -128,10 +128,13 @@ pub fn deinit(io: std.Io) void { } fn worker( - io: std.Io, gpa: std.mem.Allocator, check: *const fn (std.Io, std.mem.Allocator, []u8) error{OutOfMemory}!auto_update.CheckSummary, ) void { + // A detached check must also outlive teardown of the application's Io. + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); defer check_done.set(io); var ver_buf: [128]u8 = undefined; const summary = check(io, gpa, &ver_buf) catch { @@ -443,7 +446,7 @@ test "launch worker signals completion on success and error" { defer phase.store(@backingInt(Phase.pending), .release); for ([_]@TypeOf(&Check.success){ &Check.success, &Check.failure }) |check| { check_done = .unset; - worker(std.testing.io, std.testing.allocator, check); + worker(std.testing.allocator, check); try std.testing.expect(check_done.isSet()); try std.testing.expectEqual(AppUpdateState.none, appUpdateState()); } 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/app.zig b/build/app.zig index 82552bb8a..9aa0eb32d 100644 --- a/build/app.zig +++ b/build/app.zig @@ -30,6 +30,9 @@ pub const WebPluginDir = struct { }; }; +/// The type of `Chrome.sidebar_edge`, for the build option. +const ChromeEdge = enum { left, right }; + pub const Options = struct { /// Every plugin the application links, fizzy's own among them, each in the executables its /// `targets` names — see `build.zig`'s `buildApp`. @@ -98,6 +101,10 @@ pub const Config = struct { static_text: bool, static_image: bool, dynamic_plugins: bool, + chrome_sidebar_edge: ChromeEdge, + chrome_panel: bool, + chrome_menu_bar: bool, + chrome_infobar: bool, workbench_opts: *std.Build.Step.Options, /// Null on the configure pass that fetches Velopack (see `readConfig`). msvcup_before_compile: ?*std.Build.Step.Run, @@ -503,6 +510,20 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options "App-owned layout file (pub fn layout(?*anyopaque, *Layout))", ); build_opts.addOption(bool, "has_app_layout", app_layout_path != null); + // The frame of fizzy's own shape an app keeps (`src/editor/Chrome.zig`); the defaults are + // fizzy's own. + const chrome_sidebar_edge = b.option( + ChromeEdge, + "chrome-sidebar-edge", + "Which end of the window the icon rail and Sidebar sit at: left (default) or right", + ) orelse .left; + const chrome_panel = b.option(bool, "chrome-panel", "Draw the bottom Panel and its Output view (default: true)") orelse true; + const chrome_menu_bar = b.option(bool, "chrome-menu-bar", "Draw the in-window menu bar (default: true)") orelse true; + const chrome_infobar = b.option(bool, "chrome-infobar", "Draw the infobar (default: true)") orelse true; + build_opts.addOption(ChromeEdge, "chrome_sidebar_edge", chrome_sidebar_edge); + build_opts.addOption(bool, "chrome_panel", chrome_panel); + build_opts.addOption(bool, "chrome_menu_bar", chrome_menu_bar); + build_opts.addOption(bool, "chrome_infobar", chrome_infobar); const static_workbench = b.option( bool, "static-workbench", @@ -597,6 +618,10 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .static_text = static_text, .static_image = static_image, .dynamic_plugins = dynamic_plugins, + .chrome_sidebar_edge = chrome_sidebar_edge, + .chrome_panel = chrome_panel, + .chrome_menu_bar = chrome_menu_bar, + .chrome_infobar = chrome_infobar, .workbench_opts = workbench_opts, .msvcup_before_compile = msvcup_before_compile, .accesskit = accesskit, @@ -787,6 +812,10 @@ fn native( pack_opts.addOption(bool, "static_image", static_image); pack_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); pack_opts.addOption(bool, "has_app_layout", app_layout_path != null); + pack_opts.addOption(ChromeEdge, "chrome_sidebar_edge", cfg.chrome_sidebar_edge); + pack_opts.addOption(bool, "chrome_panel", cfg.chrome_panel); + pack_opts.addOption(bool, "chrome_menu_bar", cfg.chrome_menu_bar); + pack_opts.addOption(bool, "chrome_infobar", cfg.chrome_infobar); break :package_blk try fizzy_exe.addFizzyExecutableForTarget( b, vz, 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/core/FileTable.zig b/core/FileTable.zig index 01a58d2c3..0f85c418d 100644 --- a/core/FileTable.zig +++ b/core/FileTable.zig @@ -1283,12 +1283,10 @@ fn freeListing(self: *FileTable, listing: *Listing) void { self.gpa.destroy(listing); } -pub const nameOrder = vfs.nameOrder; - fn entryLessThan(_: void, lhs: Entry, rhs: Entry) bool { if (lhs.kind == .directory and rhs.kind != .directory) return true; if (lhs.kind != .directory and rhs.kind == .directory) return false; - return nameOrder(lhs.name, rhs.name) == .lt; + return vfs.nameOrder(lhs.name, rhs.name) == .lt; } /// Binary search of the file half of a listing (`entries[dir_count..]`, sorted by `nameOrder` — @@ -1300,7 +1298,7 @@ fn listingHasFile(listing: *const Listing, name: []const u8) bool { var hi: usize = files_only.len; while (lo < hi) { const mid = lo + (hi - lo) / 2; - switch (nameOrder(files_only[mid].name, name)) { + switch (vfs.nameOrder(files_only[mid].name, name)) { .lt => lo = mid + 1, .gt => hi = mid, .eq => return true, @@ -1472,6 +1470,96 @@ test "a query matching nothing stops at its budget instead of scanning the whole try t.expect(table.ranking()); } +/// An index already built for `fx.root`: `needles` paths matching `needle`, followed by `n` +/// non-matching paths to keep ranking busy beyond one frame's share. +fn fillIndex(fx: *Fixture, table: *FileTable, n: usize, needles: usize) !void { + const gpa = table.gpa; + table.index_stale = false; + try table.index_roots.append(gpa, try gpa.dupe(u8, fx.root)); + try table.index.ensureTotalCapacity(gpa, n + needles); + for (0..needles) |i| { + table.index.appendAssumeCapacity(try std.fmt.allocPrint(gpa, "{s}/needle{d}.txt", .{ fx.root, i })); + } + for (0..n) |i| { + table.index.appendAssumeCapacity(try std.fmt.allocPrint(gpa, "{s}/dir{d}/file{d}.txt", .{ fx.root, i % 997, i })); + } +} + +/// `search` called until its ranking is done, with no disk walk in play. +fn finishRanking(table: *FileTable, root: []const u8, query: []const u8) []const Entry { + var hits = table.search(root, query); + while (table.ranking()) hits = table.search(root, query); + return hits; +} + +test "a ranking carries on over paths appended to the index mid-rank" { + const t = std.testing; + // The arena batches the large index allocations; its backing allocator checks for leaks. + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + var fx = try Fixture.init(arena.allocator()); + defer fx.deinit(); + const table = fx.wire(); + + // More matches than `4 * max_results`, so the ranking has shrunk to its best more than once. + try fillIndex(&fx, table, 400_000, 1500); + _ = table.search(fx.root, "needle"); + try t.expect(table.ranking()); + const reached = table.rank_next; + try t.expect(reached > 0); + + // A walk that only appends: the epoch stays, the generation moves. + const late = try std.fmt.allocPrint(table.gpa, "{s}/late/needle.txt", .{fx.root}); + try table.index.append(table.gpa, late); + table.index_generation += 1; + + _ = table.search(fx.root, "needle"); + try t.expect(table.rank_next >= reached); + const hits = finishRanking(table, fx.root, "needle"); + + try t.expectEqual(max_results, hits.len); + try t.expectEqual(@as(usize, table.index.items.len), table.rank_next); + // The appended path is scored, and its short name beats the numbered ones, as it would in a + // ranking that never paused. + var found = false; + for (hits) |h| { + if (std.mem.endsWith(u8, h.dir.?, "late")) found = true; + } + try t.expect(found); + for (hits) |h| try t.expect(std.mem.startsWith(u8, h.name, "needle")); +} + +test "a ranking restarts when the query changes or the index is thrown away mid-rank" { + const t = std.testing; + // The arena batches the large index allocations; its backing allocator checks for leaks. + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + var fx = try Fixture.init(arena.allocator()); + defer fx.deinit(); + const table = fx.wire(); + + try fillIndex(&fx, table, 400_000, 1500); + _ = table.search(fx.root, "qqqqqqqq"); + try t.expect(table.ranking()); + + // Another query must not inherit candidates scored for the first. + var hits = finishRanking(table, fx.root, "needle1499"); + try t.expect(hits.len > 0); + try t.expectEqualStrings("needle1499.txt", hits[0].name); + try t.expectEqual(@as(usize, table.index.items.len), table.rank_next); + + // A half-ranked query, then the index replaced under it (`index_epoch`): the old position and + // candidates index the old strings, so the ranking starts over on the new ones. + _ = table.search(fx.root, "qqqqqqqq"); + try t.expect(table.ranking()); + table.freeIndex(); + try fillIndex(&fx, table, 10, 3); + hits = finishRanking(table, fx.root, "qqqqqqqq"); + try t.expectEqual(@as(usize, 0), hits.len); + hits = finishRanking(table, fx.root, "needle"); + try t.expectEqual(@as(usize, 3), hits.len); +} + test "a folder bigger than one frame's share is indexed whole" { const t = std.testing; var fx = try Fixture.init(t.allocator); @@ -1888,10 +1976,10 @@ test "unmount tells the host first, while the filesystem still exists" { test "nameOrder is case-insensitive but total" { const t = std.testing; - try t.expectEqual(std.math.Order.lt, nameOrder("apple", "Banana")); - try t.expectEqual(std.math.Order.lt, nameOrder("README", "readme")); - try t.expectEqual(std.math.Order.eq, nameOrder("same", "same")); - try t.expectEqual(std.math.Order.lt, nameOrder("doc", "docs")); + try t.expectEqual(std.math.Order.lt, vfs.nameOrder("apple", "Banana")); + try t.expectEqual(std.math.Order.lt, vfs.nameOrder("README", "readme")); + try t.expectEqual(std.math.Order.eq, vfs.nameOrder("same", "same")); + try t.expectEqual(std.math.Order.lt, vfs.nameOrder("doc", "docs")); } test "a listing sorts directories first, then names" { 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/core/widgets/ScrollArea.zig b/core/widgets/ScrollArea.zig index 6bd078b5a..bc0e91e4c 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -56,7 +56,7 @@ pub const tuning: Fling.Tuning = .{ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.InitOpts, opts: dvui.Options) ScrollArea { const area = dvui.scrollArea(src, init_opts, opts); const c = &area.scroll.?; - const self: ScrollArea = .{ + var self: ScrollArea = .{ .area = area, .si = area.si, .touch = dvui.dataGetPtrDefault(null, c.data().id, "_touch", Touch, .{}), @@ -77,6 +77,14 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: dvui.ScrollAreaWidget.In self.touch.x.cancel(); self.touch.y.cancel(); } + + // The coast moves the content before it is laid out, so a frame draws where the clock is at + // that frame. Moved after the children, it would draw where the clock was one gap ago, and + // an uneven gap would show as an uneven step. + if (!self.touch.dragging) { + self.coast(); + if (c.init_opts.frame_viewport == null) c.frame_viewport = self.si.viewport.topLeft(); + } return self; } @@ -127,8 +135,6 @@ pub fn deinit(self: *ScrollArea) void { _ = t.y.releaseWindowed(tuning, window_s); } - if (!t.dragging) self.coast(); - // A nested child's propagated motion sets this (parent) area's velocity through dvui's // `processMotionScroll`; clear it so dvui's coast never runs beside ours. self.si.velocity = .{}; @@ -212,6 +218,7 @@ var t_use_dvui: bool = false; /// Where a child of the area is drawn this frame (physical y, as the renderer would place it), /// and whether the renderer snaps there. var t_drawn_y: f32 = 0; +var t_raw_y: f32 = 0; var t_snapping: bool = false; fn testFrame() !dvui.App.Result { @@ -224,13 +231,14 @@ fn testFrame() !dvui.App.Result { t_viewport_y = scroll.si.viewport.y; } else { var scroll = ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .hide }, .{ .expand = .both }); - // After the area's `deinit`, which is where the coast moves it: the frame's final place. + // Read the final viewport after the area's event handling and edge correction. const si = scroll.si; defer t_viewport_y = si.viewport.y; defer scroll.deinit(); const child = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 20000 } }); t_snapping = dvui.snapToPixels(); const y = child.rectScale().r.y; + t_raw_y = y; t_drawn_y = if (t_snapping) @round(y) else y; } return .ok; @@ -273,19 +281,20 @@ fn flick() !f32 { return t_viewport_y - at_release; } -/// How far the viewport has coasted from the release at each of 15 steps of 33 ms, the clock +/// How far the content has been drawn to move from the release at each of 15 steps of 33 ms, the clock /// cut into frames by `gaps_ms` (summing to 33), so different cuts meet at equal timestamps. fn coastTrace(gaps_ms: []const u64, out: *[15]f32) !void { var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); defer t.deinit(); t_use_dvui = false; - const at_release = try swipe(); - // A frame's step spans the gap before it, so end on the last gap and the frames after - // it span `gaps_ms` rotated, summing to 33 each step. + _ = try swipe(); + const at_release = t_raw_y; + // A frame draws where the clock is at its start, so end on the last gap and the frames + // after it span `gaps_ms` rotated, summing to 33 each step. try frameAfter(gaps_ms[gaps_ms.len - 1]); for (out) |*slot| { for (gaps_ms) |g| try frameAfter(g); - slot.* = t_viewport_y - at_release; + slot.* = at_release - t_raw_y; } } @@ -297,6 +306,32 @@ test "the coast is a function of time, not of how the frames cut it" { for (steady, jittery) |a, b| try std.testing.expectApproxEqAbs(a, b, 0.01); } +test "coast displacement follows each frame gap" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + t_use_dvui = false; + _ = try swipe(); + var previous_time = dvui.currentWindow().frame_time_ns; + try frameAfter(25); + var previous_y = t_raw_y; + var previous_speed: f32 = std.math.inf(f32); + var follows_gaps = true; + // frameAfter draws at the current timestamp, then begins the next frame with this gap. + // Alternating long and short gaps exposes a displacement applied one frame late. + for ([_]u64{ 8, 25, 8, 25, 8, 25, 8, 25 }) |next_gap| { + const time = dvui.currentWindow().frame_time_ns; + const gap_ms: f32 = @as(f32, @floatFromInt(time - previous_time)) / std.time.ns_per_ms; + try frameAfter(next_gap); + const displacement = previous_y - t_raw_y; + const speed = displacement / gap_ms; + follows_gaps = follows_gaps and displacement > 0 and speed < previous_speed; + previous_time = time; + previous_y = t_raw_y; + previous_speed = speed; + } + try std.testing.expect(follows_gaps); +} + test "a slow coast draws at a new place every frame, then snaps at rest" { var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); defer t.deinit(); diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index f1e858700..79ad4b5b9 100644 --- a/core/widgets/TreeWidget.zig +++ b/core/widgets/TreeWidget.zig @@ -390,8 +390,8 @@ pub fn branch(self: *TreeWidget, src: std.lang.SourceLocation, init_opts: Branch pub const Branch = struct { pub const InitOptions = struct { /// Whether the caller wants the branch open, passed every frame. The caller owns this - /// state; the branch only eases toward it (`Branch.expanded` is what is drawn) and reports - /// a click or key press in `Branch.requested`. + /// state; the branch animates toward it and reports a click or key press in + /// `Branch.requested`. expanded: bool = false, // if null, uses widget id @@ -427,8 +427,7 @@ pub const Branch = struct { /// One of the rows carried back over the tree (`TreeWidget.carriedOver`): the rows drawn /// inside it are carried with it. carried_source: bool = false, - /// What is drawn: open while opening and while closing, until the close animation ends. - /// Follows `init_options.expanded` and is never to be copied back into the caller's state. + /// The caller's open state for this frame. expanded: bool = false, /// The open state the user asked for this frame (a click, Left/Right, hover-to-open), null /// when they asked for nothing. Valid after `expander()`; the caller applies it to its own state. @@ -436,6 +435,7 @@ pub const Branch = struct { /// Left/Right pressed on this row this frame; `expander()` turns it into `requested`. key_toggle: bool = false, can_expand: bool = false, + draw_children: bool = false, anim: ?*dvui.AnimateWidget = null, /// Populated while `expander_vbox` is active: clip children to `anim`'s content (animated height). expander_clip_prev: ?dvui.Rect.Physical = null, @@ -474,7 +474,7 @@ pub const Branch = struct { self.init_options = init_opts; self.options = defaults.override(opts); self.wd = WidgetData.init(src, .{}, wrapOuter(self.options).override(.{ .rect = .{} })); - self.expanded = if (dvui.dataGet(null, self.wd.id, "_expanded", bool)) |e| e else init_opts.expanded; + self.expanded = init_opts.expanded; } // can call this after init before install @@ -567,11 +567,7 @@ pub const Branch = struct { if (rs.r.contains(dp)) { if (!self.expanded) { // Auto-expand after timer so hover over closed folder opens it. - // Set only `init_options.expanded` so `self.expanded` stays false until - // `expander()` runs: that keeps `init_options.expanded != self.expanded` and - // replays the same open animation as a click. if (dvui.timerDone(self.data().id)) { - self.init_options.expanded = true; self.requested = true; } else { _ = dvui.timer(self.data().id, 500_000); @@ -778,38 +774,32 @@ pub const Branch = struct { ); const toggled = clicked or self.key_toggle; - const want = self.init_options.expanded != toggled; - if (toggled) self.requested = want; + const want = self.init_options.expanded; + if (toggled) self.requested = !want; + if (self.requested != null) dvui.refresh(null, @src(), self.data().id); const anim = self.anim.?; - // A close in flight, through the frame it ends on (when `end()` reports it). - const closing_anim = if (dvui.animationGet(anim.data().id, "_end")) |a| (if (a.end_val == 0) a else null) else null; - if (want and (!self.expanded or closing_anim != null)) { - if (closing_anim) |a| { - // Reopen from where the close had got to. - dvui.animation(anim.data().id, "_end", .{ - .start_val = a.value(), - .end_val = 1.0, - .end_time = anim.init_opts.duration, - .easing = anim.init_opts.easing orelse dvui.easing.linear, - }); - anim.val = a.value(); - } else { - anim.val = 0.0; - anim.start(); - } - self.expanded = true; - } else if (!want and self.expanded and closing_anim == null) { - anim.init_opts.easing = motion.settle; - anim.init_opts.duration = motion.duration(@divTrunc(self.init_options.animation_duration, 2)); - anim.startEnd(); - } - - if (!want and anim.end()) { - self.expanded = false; - } - - if (self.expanded) { + const target: f32 = if (want) 1 else 0; + const progress = dvui.dataGet(null, self.data().id, "branch_progress", f32) orelse target; + const active = dvui.animationGet(anim.data().id, "_end"); + if ((active != null and active.?.end_val != target) or + (active == null and progress != target)) + { + dvui.animation(anim.data().id, "_end", .{ + .start_val = if (active) |a| a.value() else progress, + .end_val = target, + .end_time = motion.duration(if (want) self.init_options.animation_duration else @divTrunc(self.init_options.animation_duration, 2)), + .easing = if (want) self.init_options.animation_easing else motion.settle, + }); + } + const animate_progress = anim.init_opts.kind != .none and anim.init_opts.duration != 0; + const current = dvui.animationGet(anim.data().id, "_end"); + const value = if (animate_progress and current != null) current.?.value() else target; + anim.val = value; + dvui.dataSet(null, self.data().id, "branch_progress", value); + self.draw_children = want or value > 0; + + if (self.draw_children) { // Always expand the inner box to fill the animation const expander_opts = dvui.Options{ .expand = .both }; @@ -823,7 +813,7 @@ pub const Branch = struct { self.can_expand = true; - return self.expanded; + return self.draw_children; } pub fn targetRectScale(self: *Branch) ?dvui.RectScale { @@ -910,7 +900,7 @@ pub const Branch = struct { if (self.carried_source) self.tree.carried_depth -= 1; if (self.can_expand) { - if (self.expanded) { + if (self.draw_children) { if (self.expander_clip_prev) |p| { dvui.clipSet(p); self.expander_clip_prev = null; @@ -920,8 +910,6 @@ pub const Branch = struct { if (self.anim) |a| { a.deinit(); } - - dvui.dataSet(null, self.data().id, "_expanded", self.expanded); } else { self.hbox.deinit(); self.button.deinit(); diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index ed0f78a94..472454fb8 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -350,7 +350,6 @@ one. .root = "demo://hello", .files = .{ .{ .path = "hello.md", .text = "# Hello\n" } }, .open = .{"hello.md"}, // the last one is active - .layout = .keep, // fizzy leaves the window as it is whatever this says .settings = .{ .{ .owner = "markdown", .key = "default_md_view", .value = ".split" } }, } }, .chapters = .{ .{ .at = 0, .title = "Hello" } }, diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 7836bba99..c2826e8d9 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -220,7 +220,8 @@ the patch changes it too, and nothing dvui's own web backend uses is taken away - `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. + sentence fragment before the caret (up to 60 code points). Replacements, native input and + composition updates become backspaces and 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, 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..a4eef97db 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -50,6 +50,9 @@ tab_slot_count: usize = 0, /// Where in this pane's assignment a tab carried in the app's view drag would go in, while it is /// over the strip — last frame's reading, the gap this frame opens for it (`offerStrip`). strip_insert: ?usize = null, +/// The tab (`tab_key`) last seen on the strip's visible part, so the strip scrolls to the selected +/// tab when the selection moves and not again while the person scrolls it by hand. +strip_shown: ?usize = null, /// The gap drawn this frame: where it starts along the strip and how wide it is, physical. /// `insertIndexAt` reads the tabs as if it were not there, so the gap does not chase itself. gap_x: f32 = 0, @@ -158,7 +161,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(); @@ -475,6 +478,24 @@ fn drawTabs(self: *Workspace, region: sdk.Host.Region, tabs: []const *sdk.Surfac } } + // A selection that moved off the strip's visible part (a new pane's tabs, a phone's narrow + // strip) is brought on. A tab the strip has not yet grown to reach is asked again until it + // shows, and once shown is left to the person's own scrolling. + if (selected_index == i and self.strip_shown != tab_key) { + const shown = scroll_area.data().borderRectScale().r; + var tab = slot.data().borderRectScale().r; + // A tab still opening out from nothing has no width to be seen by: the next frame. + if (tab.w >= 1) { + // One wider than the strip is shown by its leading edge, the part a name starts in. + tab.w = @min(tab.w, shown.w); + if (tab.x >= shown.x - 1 and tab.x + tab.w <= shown.x + shown.w + 1) { + self.strip_shown = tab_key; + } else { + dvui.scrollTo(.{ .screen_rect = tab }); + } + } + } + if (is_selected) { core.draw.drawTabActiveIndicator( slot.data().borderRectScale(), diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh index 7295127e6..f866248e8 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`). @@ -17,6 +20,8 @@ # # uses checkouts of your own (example-app/ and # # example-plugin/ in that directory), as they are on # # disk, copied so they are left untouched +# EXAMPLES_BUILDS="" scripts/check-examples.sh +# # builds none of their apps: the build API fixture alone # # CI: the examples job of .github/workflows/ci.yml. set -euo pipefail @@ -45,29 +50,32 @@ pack "$root" | gzip > "$work/fizzy.tar.gz" bash scripts/pack-sdk.sh "$work/sdk" >/dev/null sdk_tarball="$(ls "$work"/sdk/fizzy-sdk-v*.tar.gz)" -for repo in example-app example-plugin; do - if [[ -n "${EXAMPLES_DIR:-}" ]]; then - mkdir -p "$work/$repo" - pack "$EXAMPLES_DIR/$repo" | tar -x -C "$work/$repo" - else - git clone --quiet --depth 1 "https://github.com/fizzyedit/$repo" "$work/$repo" - fi -done - -(cd "$work/example-plugin" && zig fetch --save=fizzy "$sdk_tarball" >/dev/null) -# example-app bundles example-plugin (`hello`), pinned by URL: the copy here, on this tree's SDK. -pack "$work/example-plugin" | gzip > "$work/example-plugin.tar.gz" -( - cd "$work/example-app" - zig fetch --save=hello "$work/example-plugin.tar.gz" >/dev/null - zig fetch --save=fizzy "$work/fizzy.tar.gz" >/dev/null - # Its replay app depends on the plugin SDK alone: this tree's, packed as a release would be. - zig fetch --save=fizzy_sdk "$sdk_tarball" >/dev/null - # So do the plugins it carries, each pinning a released SDK of its own. - for plugin in plugins/*/; do - (cd "$plugin" && zig fetch --save=fizzy "$sdk_tarball" >/dev/null) +builds="${EXAMPLES_BUILDS-plugin minimal studio endless studio-zon replay dvui dvui-sdl3}" +if [[ -n $builds ]]; then + for repo in example-app example-plugin; do + if [[ -n "${EXAMPLES_DIR:-}" ]]; then + mkdir -p "$work/$repo" + pack "$EXAMPLES_DIR/$repo" | tar -x -C "$work/$repo" + else + git clone --quiet --depth 1 "https://github.com/fizzyedit/$repo" "$work/$repo" + fi done -) + + (cd "$work/example-plugin" && zig fetch --save=fizzy "$sdk_tarball" >/dev/null) + # example-app bundles example-plugin (`hello`), pinned by URL: the copy here, on this tree's SDK. + pack "$work/example-plugin" | gzip > "$work/example-plugin.tar.gz" + ( + cd "$work/example-app" + zig fetch --save=hello "$work/example-plugin.tar.gz" >/dev/null + zig fetch --save=fizzy "$work/fizzy.tar.gz" >/dev/null + # Its replay app depends on the plugin SDK alone: this tree's, packed as a release would be. + zig fetch --save=fizzy_sdk "$sdk_tarball" >/dev/null + # So do the plugins it carries, each pinning a released SDK of its own. + for plugin in plugins/*/; do + (cd "$plugin" && zig fetch --save=fizzy "$sdk_tarball" >/dev/null) + done + ) +fi # The plugin build installs the plugin: into a profile of its own, not this machine's fizzy. export FIZZY_PROFILE="$work/profile" @@ -104,8 +112,6 @@ build_one() { } export -f build_args build_one -builds="plugin minimal studio endless studio-zon replay dvui dvui-sdl3" - # Each build is one compile of an app that mostly keeps one core busy, so they run side by side, # EXAMPLES_JOBS at once (3: CI's runner has 4 cores and 16 GB). First each configuration is # configured alone (`-l` lists its steps), which fetches the lazy dependencies it asks for, so @@ -116,5 +122,84 @@ for b in $builds; do for a in "${args[@]:1}"; do [[ $a == -* ]] && opts+=("$a"); done (cd "$work/${args[0]}" && zig build -l ${opts[@]+"${opts[@]}"} > /dev/null) 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"' _ +if [[ -n $builds ]]; then + 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"' _ +fi + +# 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)" + +# The build API an app and a plugin package call (`fizzy.framework`, `staticModule`, `addApp`, +# `addWebSteps`, `addPackageSteps`; design/build-api.md), on a fixture app of its own: two plugin +# packages, one for both kinds of executable and one `.native` only, which fails to compile if it +# reaches the wasm. +fixtures="scripts/fixtures/build-api" +for id in fixture_both fixture_native; do + mkdir -p "$work/$id" + cp "$fixtures"/plugin/{build.zig,build.zig.zon,plugin.zig,lib.zig,lib.c} "$work/$id/" + sed "s/@ID@/$id/g" "$fixtures/plugin/plugin.zig.zon.tmpl" > "$work/$id/plugin.zig.zon" + (cd "$work/$id" && zig fetch --save=fizzy "$sdk_tarball" >/dev/null) +done +mkdir -p "$work/fixture-app" +cp "$fixtures"/app/{build.zig,build.zig.zon} "$work/fixture-app/" +(cd "$work/fixture-app" && zig fetch --save=fizzy "$work/fizzy.tar.gz" >/dev/null \ + && zig build -l > /dev/null) + +echo "== build API fixture app" +fixture_fail=0 +# step