diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5be910324..2bb1797ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,8 +114,8 @@ jobs: echo "code=$code" >> "$GITHUB_OUTPUT" echo "sdl=$sdl" >> "$GITHUB_OUTPUT" - # Every tracked .zig and .zon file as `zig fmt` writes it. Needs Zig and nothing else: no - # dependencies, no cache, a few seconds. + # Formatting, lint and script regression checks. Needs Zig, Python and shell tools, + # but no app dependencies or build cache. format: name: Format needs: changes @@ -132,6 +132,8 @@ jobs: - name: lint # Not `zig build lint`: the root build fetches the app's URL dependencies. run: zig run scripts/lint.zig + - name: ci-ok result policy + run: python3 scripts/test-ci-ok.py - name: scripts/zb (build lock) run: sh scripts/test-zb.sh - name: scripts/check-kickoff diff --git a/AGENTS.md b/AGENTS.md index da935bd50..e55708e6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,6 @@ Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register( - **`archive`** — opens a `.zip` as a mounted folder (the web's "open a vault" path, too). - **`devtools`** — the dashboard: where the app's time goes, from `core.profile`, for a person and an agent alike. - **`guides`** — every demo the app can play, on the rail: fizzy's tour and each plugin's own, by chapter, through the `demos` service. -- **`chat`** — a conversation and its composer over the `chat.provider` service; owns no conversation (a small in-memory demo provider when none is offered). Not in fizzy's own bundle: an app lists it. - `shared` — build helpers used across plugins' `static/integration.zig` (not a plugin itself). **Pixi (pixel-art editor) and Drive (Google Drive as a mount) live outside this repo** as third-party-style plugins ([`fizzyedit/pixi`](https://github.com/fizzyedit/pixi), [`fizzyedit/drive`](https://github.com/fizzyedit/drive)) — they ship and update purely through the plugin store (`docs/PLUGINS.md` §6), with no special treatment in Fizzy itself. **Trust `ls plugins/` and `jj log` over any doc's plugin list.** @@ -104,14 +103,6 @@ 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 fc425ce73..d80947e77 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, or stop after the expected - complete SSE message for an event stream (`control/Server.zig`'s `TestClient`). + 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. diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig index 1b1e4beff..def842c89 100644 --- a/app/HistoryFrames.zig +++ b/app/HistoryFrames.zig @@ -39,8 +39,9 @@ unread: ?Unread = null, /// while the history moves frame after frame (a held undo, a scrub), so each step is a copy into /// it rather than a new pane-sized target; let go of the first frame no picture is asked for. whole: ?dvui.TextureTarget = null, -/// The size of the pane's part a picture is taken from as the pane stands (`take`, each frame). -/// Kept pictures retain their original size even when the pane resizes. +/// The size of the pane's part a picture is taken from as the pane stands (`take`, each frame): a +/// step's picture from another size (the window resized since) is of another view of it, and a +/// visit takes it again. pane_source: [2]u32 = .{ 0, 0 }, const Unread = struct { @@ -112,8 +113,8 @@ pub const max_w: u32 = 384; /// document drew) are seen the frame after, which draws them. pub fn moved(self: *HistoryFrames, gpa: std.mem.Allocator, top: u64, below: ?u64, change: Change) void { if (change == .restarted) self.forget(gpa); - // A visit keeps the original pixels and change box, even after the pane resizes. - if (change == .visited and self.frames.contains(top)) return; + // Only visited, and it has its picture of the pane as it stands: kept as it was taken. + if (change == .visited) if (self.frames.get(top)) |pic| if (std.mem.eql(u32, &pic.source, &self.pane_source)) return; self.due = .{ .top = top, .below = below, .made = change != .visited }; // Keep the frame in a texture to read from, on a backend that would draw it straight to the // window (the web). @@ -190,11 +191,12 @@ fn land(self: *HistoryFrames, gpa: std.mem.Allocator) void { } else |_| {} } else |_| {} const top = u.step.top; - // A picture may have landed since this visit was requested: keep that original. - if (!u.step.made and self.frames.contains(top)) { + // A visit's picture of a step that has one of this view by now (made since it was asked for) + // is not the step's: it keeps its own. + if (!u.step.made) if (self.frames.get(top)) |old| if (std.mem.eql(u32, &old.source, &pic.source)) { gpa.free(pic.prints); return; - } + }; // Round what the step changed; one that changed nothing visible, round the document's content, // or the pane's middle. const round = pic.change orelse content orelse rules.Frac{}; @@ -378,9 +380,7 @@ test "scrubbing across the steps leaves each step's picture as it was taken" { } // Back to the start and forward again, then to and fro: undo, redo, a scrub's jumps. const stops = [_]u64{ 4, 3, 2, 1, 0, 1, 2, 3, 4, 0, 4, 2 }; - for (stops, 0..) |top, visit| { - // Opening the strip or resizing the pane must not retake a kept picture. - hf.pane_source = if (visit % 2 == 0) .{ 800, 500 } else .{ 800, 430 }; + for (stops) |top| { hf.moved(gpa, top, if (top == 0) null else top - 1, changeOf(&.{}, top)); try std.testing.expectEqual(@as(?Step, null), hf.due); } diff --git a/app/Tracks.zig b/app/Tracks.zig index 7338fa745..997dfe5f9 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}) }; + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; 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}) }; + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; 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 82c748aec..f0bb42c21 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -721,8 +721,6 @@ 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, @@ -737,13 +735,11 @@ const TestClient = struct { w.interface.flush() catch return; var rbuf: [1024]u8 = undefined; var r = stream.reader(self.io, &rbuf); - self.readReply(&r.interface); - } - - fn readReply(self: *TestClient, reader: *Io.Reader) void { - // Read only a framed response: EOF is not reliable on Windows AF_UNIX. + // 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. while (self.reply_len < self.reply.len) { - const n = reader.readSliceShort(self.reply[self.reply_len..][0..1]) catch return; + 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], "\r\n\r\n")) break; @@ -754,37 +750,14 @@ const TestClient = struct { const end = std.mem.findPos(u8, head, i, "\r\n") orelse return; const len = std.fmt.parseInt(usize, head[i + key.len .. end], 10) catch return; const want = @min(len, self.reply.len - self.reply_len); - reader.readSliceAll(self.reply[self.reply_len..][0..want]) catch return; + 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 = reader.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; } } }; -test "the HTTP test client stops at the response boundary without waiting for EOF" { - const cases = [_]struct { response: []const u8, event_end: ?[]const u8 = null }{ - .{ .response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi" }, - .{ .response = "HTTP/1.1 204 No Content\r\n\r\n" }, - .{ .response = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\nevent: end\ndata: {}\n\n", .event_end = "event: end\ndata: {}\n\n" }, - }; - for (cases) |case| { - // Bytes after the response stand in for an open connection: none may be consumed. - const bytes = try std.mem.concat(std.testing.allocator, u8, &.{ case.response, "still open" }); - defer std.testing.allocator.free(bytes); - var reader: Io.Reader = .fixed(bytes); - var client: TestClient = .{ .io = std.testing.io, .path = "", .request = "", .event_end = case.event_end }; - client.readReply(&reader); - try std.testing.expectEqualStrings(case.response, client.reply[0..client.reply_len]); - try std.testing.expectEqualStrings("still open", reader.buffered()); - } -} - 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; @@ -994,7 +967,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 only the events it asked for, once each" { +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; @@ -1009,12 +982,7 @@ test "a stream gets only the events it asked for, once each" { 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", - .event_end = "event: chat.turn_ended\ndata: {}\n\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" }; const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); var taken: std.ArrayList(*Request) = .empty; @@ -1031,9 +999,10 @@ test "a stream gets only the events it asked for, once each" { server.publish("fizzy.document.saved", "{\"path\":\"/a\"}"); server.publish("fizzy.folder.opened", "{\"path\":\"/p\"}"); server.publish("chat.turn_ended", "{}"); - // Wait for the final expected message before stopping can cancel the writer. - thread.join(); + // Let the stream write before the stop ends it. + io.sleep(.fromMilliseconds(50), .awake) catch {}; server.stop(); + thread.join(); const reply = client.reply[0..client.reply_len]; try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); @@ -1174,7 +1143,7 @@ test "a stopped server ends a stream whose client stopped reading" { server.stop(); } -test "an event published as the stream is answered arrives while the stream stays open" { +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; @@ -1188,12 +1157,7 @@ test "an event published as the stream is answered arrives while the stream stay 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", - .event_end = "event: first\ndata: 1\n\n", - }; + var client: TestClient = .{ .io = io, .path = path, .request = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n" }; const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); var taken: std.ArrayList(*Request) = .empty; defer taken.deinit(gpa); @@ -1206,9 +1170,9 @@ test "an event published as the stream is answered arrives while the stream stay // though its connection has not written the headers yet. taken.items[0].answerStream(&server); server.publish("first", "1"); - // The client must finish on the message, while the server still holds the stream open. - thread.join(); + io.sleep(.fromMilliseconds(50), .awake) catch {}; server.stop(); + thread.join(); try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nevent: first\ndata: 1\n\n")); } @@ -1399,23 +1363,3 @@ 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/history_transport.zig b/app/history_transport.zig index c0c0463a1..77d875665 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -184,9 +184,9 @@ pub fn stopAt(x: f32, x0: f32, x1: f32, n: usize) usize { } /// Where stop `i` of `n` sits on the line from `x0` to `x1`: evenly spaced, stop 0 at `x0` and -/// stop `n` at `x1`, so the knob reaches both ends. With no history, the one stop is at `x0`. +/// stop `n` at `x1`, so the knob reaches both ends. With no history, the one stop is in the middle. pub fn stopX(i: usize, x0: f32, x1: f32, n: usize) f32 { - if (n == 0) return x0; + if (n == 0) return (x0 + x1) / 2; return x0 + (x1 - x0) * @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n)); } @@ -344,7 +344,7 @@ test "stop 0 is at the left end, stop n at the right, and stopAt inverts stopX" try testing.expectApproxEqAbs(@as(f32, 220), stopX(n, 20, 220, n), 1e-3); for (0..n + 1) |i| try testing.expectEqual(i, stopAt(stopX(i, 20, 220, n), 20, 220, n)); } - try testing.expectEqual(@as(f32, 20), stopX(0, 20, 220, 0)); + try testing.expectEqual(@as(f32, 120), stopX(0, 20, 220, 0)); } test "a bar told to go goes from where it is, and one left goes soon" { diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index 91ce807ef..30b2475af 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -209,11 +209,6 @@ const ghost_blur: f32 = 0.55; /// Where `Float.aside` goes for a ghost closing — its last view landed elsewhere: past the ghost, /// to nothing. const gone: f32 = 2; -/// The leading traffic light of a titled macOS window, points: its diameter, and how far its left -/// edge sits in from the window's. A tape presses the same spot in the frame, since the OS draws -/// the button outside it. -const traffic_light_diameter: f32 = 12; -const traffic_light_inset: f32 = 7; /// How a float looks `v` of the way aside (`Float.aside`): itself at 0, its ghost at 1, nothing /// at `gone` — how much of it shows, and how far out of focus. @@ -684,8 +679,8 @@ fn drawOne(l: *Layout, i: usize) bool { // click there never reaches it. A tape aims at the same spot in the frame (the leading // traffic light), and a press there closes the float as the OS button does. if (header_close == null and osButtons(first) and core.anchor.wanted()) { - const side = traffic_light_diameter * scale; - const spot: dvui.Rect.Physical = .{ .x = header.x + traffic_light_inset * scale, .y = header.y + (header.h - side) / 2, .w = side, .h = side }; + const side = 12 * scale; + const spot: dvui.Rect.Physical = .{ .x = header.x + 7 * scale, .y = header.y + (header.h - side) / 2, .w = side, .h = side }; core.anchor.markRect(win_id, spot, true, "float-close:{s}", .{first.name}); for (dvui.events()) |*e| { if (e.evt != .mouse or e.handled) continue; diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 01c54b483..bbe5bf638 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1437,9 +1437,7 @@ fn initTree( /// A turn is the new half sliding shut along the old axis (`_fold`, which its place reads as a /// target of zero), then easing open along the new one. Its share of the box is kept through the /// turn (`_share`, taken from the extent when that was last chosen, not from a box that has since -/// changed shape), and follows a drag while turned. That drag is shown and nothing more: turning -/// back takes the share from the saved one again (`_seen` is unread while turned, so dvui drops it -/// and the first frame back reads the saved share anew). +/// changed shape), and follows a drag while turned. fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.Branch { if (!saved.adaptive) return saved; const dims = dvui.dataGet(null, id, "_dims", dvui.Size) orelse return saved; @@ -1464,10 +1462,10 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B } } } else if (chosen) |extent| { - // Dragged since the branch set it: the drag is the share drawn, and is not saved. + // 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; diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index 78fa33e27..bcb8b86f6 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -6,10 +6,8 @@ //! picker fills them. Closing an empty leaf collapses the branch. const std = @import("std"); const dvui = @import("dvui"); -const core = @import("core"); -/// 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 const Side = enum { left, right, top, bottom }; pub fn axisOf(side: Side) dvui.enums.Direction { return switch (side) { diff --git a/app/layout/State.zig b/app/layout/State.zig index 243029465..46aa109cf 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -38,14 +38,6 @@ 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`). @@ -177,11 +169,10 @@ tree_cleared: bool = false, slide_open: []const u8 = "", /// A place's view being dragged to another place. Empty `name` when idle. view_drag: ViewDrag = .{}, -/// 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, +/// 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, /// 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 73e449dc8..ce83ef85d 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1323,21 +1323,18 @@ 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()) { - 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}), - } + 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}), } - } + }; // 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/bounded_join.zig b/app/update/bounded_join.zig index cebac162c..eeb91e5df 100644 --- a/app/update/bounded_join.zig +++ b/app/update/bounded_join.zig @@ -6,9 +6,9 @@ const std = @import("std"); /// 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 { - const start = std.Io.Clock.awake.now(io); - while (!done.load(.acquire)) { - if (start.durationTo(std.Io.Clock.awake.now(io)).toMilliseconds() >= bound_ms) { + var waited_ms: u32 = 0; + while (!done.load(.acquire)) : (waited_ms += poll_ms) { + if (waited_ms >= bound_ms) { thread.detach(); return false; } @@ -22,28 +22,21 @@ const poll_ms = 5; test "a stalled thread is detached once the bound passes" { const Stall = struct { - // The test thread releases the check; the worker publishes entry and completion. - var entered: std.atomic.Value(bool) = .init(false); var release: std.atomic.Value(bool) = .init(false); var done: std.atomic.Value(bool) = .init(false); fn run(io: std.Io) void { - entered.store(true, .release); 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}); - defer { - Stall.release.store(true, .release); - while (!Stall.done.load(.acquire)) io.sleep(.fromMilliseconds(1), .awake) catch {}; - } - while (!Stall.entered.load(.acquire)) io.sleep(.fromMilliseconds(1), .awake) catch {}; 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); - try std.testing.expect(!Stall.done.load(.acquire)); + Stall.release.store(true, .release); + while (!Stall.done.load(.acquire)) io.sleep(.fromMilliseconds(1), .awake) catch {}; } test "a finished thread is joined" { diff --git a/app/update/update_notify.zig b/app/update/update_notify.zig index dec9a5a49..ec1fe1656 100644 --- a/app/update/update_notify.zig +++ b/app/update/update_notify.zig @@ -96,7 +96,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, .{}) catch { + check_thread = std.Thread.spawn(.{}, worker, .{ io, std.heap.page_allocator }) catch { phase.store(@backingInt(Phase.done_no), .release); return; }; @@ -121,13 +121,8 @@ pub fn deinit(io: std.Io) void { check_thread = null; } -fn worker() void { +fn worker(io: std.Io, gpa: std.mem.Allocator) void { defer check_done.store(true, .release); - // A detached check must also outlive teardown of the application's Io. - const gpa = std.heap.page_allocator; - var threaded: std.Io.Threaded = .init(gpa, .{}); - defer threaded.deinit(); - const io = threaded.io(); var ver_buf: [128]u8 = undefined; const summary = auto_update.checkRemoteVersionSummary(io, gpa, &ver_buf) catch { phase.store(@backingInt(Phase.done_no), .release); diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index 51b148979..9df175e70 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -216,9 +216,6 @@ pub const Viewport = struct { /// pointer is read against it, and it is kept above every window by its level, not ordered over /// the main one. passive: bool = false, - /// Windows: whether a carry window is topmost, as last applied - /// (`win32_titlebar.viewportCarryTopmost`): while the app is active, not after it is left. - carry_topmost: bool = true, /// A held pointer over it is read as over what is beneath it — the main window — this frame /// (`viewportSeeThrough`): a float's window gone to its ghost while a view is carried out of it. see_through: bool = false, @@ -1527,7 +1524,7 @@ pub fn viewportOpenMenu(self: *SDLBackend, at: viewport_map.Rect, radius: f32, r .macos => { _ = fizzy_macos_viewport_menu(cocoaWindow(vp.window), cocoaWindow(self.window), platform.window.ns_visual_effect_material, radius, @intFromBool(dialog)); // Gone while the app is left (a quiet run is never active: its menus stay). - if (platform.window_layout.hidesWhenAppLeft(if (dialog) .dialog else .menu, quiet)) fizzy_macos_viewport_menu_hides(cocoaWindow(vp.window)); + if (!dialog and !quiet) fizzy_macos_viewport_menu_hides(cocoaWindow(vp.window)); vp.ride = switch (ride) { .none => null, .main => self.window, @@ -1780,19 +1777,12 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // Dressed by `viewportOpenMenu`, and no hit test: nothing in it moves or resizes it. if (menu) return vp; if (role == .overlay) { - if (comptime builtin.target.os.tag == .macos) { - fizzy_macos_viewport_overlay(cocoaWindow(window), cocoaWindow(self.window)); - // Its bubbles and zones stood over other apps' windows while fizzy was in the background. - if (platform.window_layout.hidesWhenAppLeft(.overlay, quiet)) fizzy_macos_viewport_menu_hides(cocoaWindow(window)); - } + if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_overlay(cocoaWindow(window), cocoaWindow(self.window)); return vp; } if (carry) { switch (comptime builtin.target.os.tag) { - .macos => { - fizzy_macos_viewport_carry(cocoaWindow(window), cocoaWindow(self.window), platform.window.ns_visual_effect_material); - if (platform.window_layout.hidesWhenAppLeft(.carry, quiet)) fizzy_macos_viewport_menu_hides(cocoaWindow(window)); - }, + .macos => fizzy_macos_viewport_carry(cocoaWindow(window), cocoaWindow(self.window), platform.window.ns_visual_effect_material), .windows => if (c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null)) |hwnd| { platform.win32_titlebar.viewportCarryChrome(hwnd); }, @@ -2838,16 +2828,6 @@ pub fn renderPresent(self: *SDLBackend) void { // window is (`mainHeld`). Presented into while a sibling's way ran, every frame waited on its // drawable, and the moving window's way ran at 55 frames a second, the main window's at 120. const main_moving = platform.window.windowMovingItself(self.window); - // A carry window stands over every window only while the app is the active one. - if (comptime builtin.target.os.tag == .windows) { - const active = appActive(); - for (&self.viewport_slots) |*slot| { - const vp = if (slot.*) |*v| v else continue; - if (!vp.passive or vp.carry_topmost == active) continue; - vp.carry_topmost = active; - if (c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(vp.window), c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null)) |hwnd| platform.win32_titlebar.viewportCarryTopmost(hwnd, active); - } - } var moving_vp: ?*Viewport = null; for (&self.viewport_slots) |*slot| if (slot.*) |*v| if (v.shown and platform.window.windowMovingItself(v.window)) { moving_vp = v; @@ -3587,8 +3567,6 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("event FOCUS_GAINED\n", .{}); } - // A frame to put a carry window back over the others (`renderPresent`). - if (builtin.target.os.tag == .windows) self.refresh(); if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { dvui.AccessKit.c.accesskit_unix_adapter_update_window_focus_state(win.accesskit.adapter, true); } else if (dvui.accesskit_enabled and builtin.target.os.tag == .macos) { diff --git a/backend/src/platform/win32_titlebar.zig b/backend/src/platform/win32_titlebar.zig index ca1847dc4..dca4597ca 100644 --- a/backend/src/platform/win32_titlebar.zig +++ b/backend/src/platform/win32_titlebar.zig @@ -576,21 +576,6 @@ pub fn viewportCarryChrome(hwnd: *anyopaque) void { _ = wm.SetWindowPos(h, @ptrFromInt(@as(usize, @bitCast(HWND_TOPMOST))), 0, 0, 0, 0, @bitCast(SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_FRAMECHANGED)); } -/// A carry window above every window while the app is active, and, once it is left, only over the -/// window that owns it (`SDL_SetWindowParent`), which goes behind the other application's windows -/// with the app: left topmost, the glass of a drop's zone stood over them. -pub fn viewportCarryTopmost(hwnd: *anyopaque, on: bool) void { - if (builtin.target.os.tag != .windows) return; - const h: win32.foundation.HWND = @ptrCast(hwnd); - const HWND_TOPMOST: isize = -1; - const HWND_NOTOPMOST: isize = -2; - const SWP_NOSIZE: u32 = 0x0001; - const SWP_NOMOVE: u32 = 0x0002; - const SWP_NOACTIVATE: u32 = 0x0010; - const after: isize = if (on) HWND_TOPMOST else HWND_NOTOPMOST; - _ = win32.ui.windows_and_messaging.SetWindowPos(h, @ptrFromInt(@as(usize, @bitCast(after))), 0, 0, 0, 0, @bitCast(SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE)); -} - /// The frame a carry window never has: caption, sizing border, system menu and its buttons. const carry_ws_frame: isize = 0x00C00000 | 0x00040000 | viewport_ws_sysmenu | 0x00020000 | 0x00010000; diff --git a/backend/src/platform/window_layout.zig b/backend/src/platform/window_layout.zig index cc757a2ca..19c21c0e7 100644 --- a/backend/src/platform/window_layout.zig +++ b/backend/src/platform/window_layout.zig @@ -126,28 +126,6 @@ pub fn originNudged(cap_x: f64, cap_y: f64, cur_x: f64, cur_y: f64, max_delta: f return dx <= max_delta and dy <= max_delta; } -/// The app's windows that are not the user's documents: each sits at a popup's level, over every -/// Space, and is not the main window. -pub const Transient = enum { menu, dialog, overlay, carry }; - -/// Whether a transient window leaves the screen while the app is not the active one. Left up, it -/// stands over another application's windows. A dialog is in the main window's stacking, and a -/// quiet run is never active, so neither goes. The OS applies it (`hidesOnDeactivate`), so it holds -/// with no frame of ours running; a drag keeps its windows because pressing in the app keeps it -/// active. -pub fn hidesWhenAppLeft(kind: Transient, quiet: bool) bool { - return kind != .dialog and !quiet; -} - -test "hidesWhenAppLeft: menus, overlays and carry windows go with the app, dialogs and quiet runs stay" { - inline for (.{ Transient.menu, Transient.overlay, Transient.carry }) |kind| { - try std.testing.expect(hidesWhenAppLeft(kind, false)); - try std.testing.expect(!hidesWhenAppLeft(kind, true)); - } - try std.testing.expect(!hidesWhenAppLeft(.dialog, false)); - try std.testing.expect(!hidesWhenAppLeft(.dialog, true)); -} - test "chooseTitlebarStrip collapsed returns just the buffer" { const got = chooseTitlebarStrip(.{ .collapsed = true, diff --git a/build/app.zig b/build/app.zig index 15d8a8c2f..34cf3b592 100644 --- a/build/app.zig +++ b/build/app.zig @@ -30,9 +30,6 @@ 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`. @@ -101,10 +98,6 @@ 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, @@ -510,20 +503,6 @@ 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", @@ -618,10 +597,6 @@ 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, @@ -812,10 +787,6 @@ 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, @@ -1079,9 +1050,6 @@ fn addTests( // never-blank visible-range guarantee. std-only by design (see block_heights.zig) so // the rules the preview's scroll stability rests on are testable without a Window. .{ "fizzy-md-block-heights-tests", "plugins/markdown/src/md/block_heights.zig" }, - // What the chat composer decides (Enter, its height cap, fences). std-only, so it tests - // without a Window. - .{ "fizzy-chat-composer-tests", "plugins/chat/src/composer.zig" }, // Fence language tag → file extension, which is the whole of what the markdown plugin // knows about languages: the grammar itself comes from whichever plugin claims that // extension. std-only, so the table is testable without a Window. @@ -1564,11 +1532,6 @@ fn addTests( .core = core_module_test, .sdk = sdk_module_test, }, fizzy_test_module); - const chat_module_test = plugins.chat.addStaticModule(b, target, optimize, .{ - .dvui = dvui_testing_dep.module("dvui_testing"), - .core = core_module_test, - .sdk = sdk_module_test, - }, fizzy_test_module); // A `.zip` opened as a mounted folder, as the web opens the site (`tests/demos.zig`). const archive_module_test = plugins.archive.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), @@ -1629,8 +1592,6 @@ fn addTests( // built above rather than rooting a second one at the widget — a file may belong to only // one module per compilation, and the plugin's own module already owns it. integration_module.addImport("text", text_module_test); - // The chat composer's growth and Enter are drawn behaviour, so a headless frame checks them. - integration_module.addImport("chat", chat_module_test); // Same reasoning for the markdown preview: its block virtualization is a claim about what // gets *drawn*, which only a real headless frame can check. integration_module.addImport("markdown", markdown_module_test); @@ -2103,16 +2064,6 @@ fn addTests( check_integration_step.dependOn(&text_plugin_tests.step); } - { - const chat_plugin_tests = b.addTest(.{ - .name = "fizzy-chat-plugin-tests", - .root_module = chat_module_test, - .filters = test_filters, - }); - test_integration_step.dependOn(&b.addRunArtifact(chat_plugin_tests).step); - check_integration_step.dependOn(&chat_plugin_tests.step); - } - // Give the file extension tests a root; the plugin's lazy exports do not collect them. { const files_tests_module = b.createModule(.{ diff --git a/build/plugins.zig b/build/plugins.zig index 814995929..1a2e5cdb0 100644 --- a/build/plugins.zig +++ b/build/plugins.zig @@ -15,7 +15,6 @@ pub const image = @import("../plugins/image/static/integration.zig"); pub const archive = @import("../plugins/archive/static/integration.zig"); pub const devtools = @import("../plugins/devtools/static/integration.zig"); pub const guides = @import("../plugins/guides/static/integration.zig"); -pub const chat = @import("../plugins/chat/static/integration.zig"); /// Fizzy's own plugins, as entries in the list an application links (`fizzy.plugins`). An entry /// with no module is built by fizzy for each executable, wired to that executable's framework. @@ -27,7 +26,6 @@ pub const entries = struct { pub const markdown: BundledPlugin = .{ .name = "markdown" }; pub const devtools: BundledPlugin = .{ .name = "devtools" }; pub const guides: BundledPlugin = .{ .name = "guides" }; - pub const chat: BundledPlugin = .{ .name = "chat" }; /// What fizzy itself links, in its sidebar order. pub const all = [_]BundledPlugin{ @@ -150,14 +148,6 @@ pub fn link(b: *std.Build, w: Wiring, plugins: []const BundledPlugin) Linked { .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer, ) - else if (std.mem.eql(u8, p.name, "chat")) - chat.addStaticModule( - b, - w.target, - w.optimize, - .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, - w.consumer, - ) else std.debug.panic( "fizzy: plugin '{s}' has no module and is not one of fizzy's own (`fizzy.plugins`)", diff --git a/build/web.zig b/build/web.zig index c2f95795f..542dc447a 100644 --- a/build/web.zig +++ b/build/web.zig @@ -385,11 +385,6 @@ pub fn build( // The far end of a web OAuth round trip (`core.transport.WebOAuth`): the page a provider // redirects back to, which posts its location to the opener and closes. _ = dir.addCopyFile(b.path("web/oauth-callback.html"), "oauth-callback.html"); - // The installable page (relative paths, so /app/ and /testapp/ both work) and the icons it - // and the iOS home-screen entry name. - for ([_][]const u8{ "manifest.webmanifest", "icon-192.png", "icon-512.png" }) |name| { - _ = dir.addCopyFile(b.path(b.pathJoin(&.{ "web", name })), name); - } // The tapes the page can fetch (`?demo=demos/.tape`), built from this commit's scripts. const demos: ?std.Build.LazyPath = if (own) demoTapes(b) else null; if (demos) |tapes| _ = dir.addCopyDirectory(tapes, "demos", .{}); diff --git a/core/lsp/Client.zig b/core/lsp/Client.zig index 8feab0ae6..5b63e9315 100644 --- a/core/lsp/Client.zig +++ b/core/lsp/Client.zig @@ -595,11 +595,7 @@ fn setHoverReturnScratch(self: *Client, gpa: std.mem.Allocator, text: []const u8 /// slot for it and no caller needs it (only `resolveCompletionDocumentation`, which reads the /// cache directly instead). Items that fail to dupe are skipped, matching `runCompletionJob`'s /// existing "partial list beats none" stance. -fn setCompletionReturnScratch( - self: *Client, - gpa: std.mem.Allocator, - cached: []const CachedCompletionItem, -) []const CompletionItem { +fn setCompletionReturnScratch(self: *Client, gpa: std.mem.Allocator, cached: []const CachedCompletionItem) []const CompletionItem { for (self.completion_return_scratch.items) |it| { gpa.free(it.label); gpa.free(it.insert_text); @@ -666,11 +662,7 @@ pub fn hover(self: *Client, path: []const u8, bytes: []const u8, byte_offset: us if (path.len == 0) return null; const gpa = self.config.allocator; - const key: CacheKey = .{ - .path_hash = std.hash.Wyhash.hash(0, path), - .byte_offset = byte_offset, - .content_hash = std.hash.Wyhash.hash(0, bytes), - }; + const key: CacheKey = .{ .path_hash = std.hash.Wyhash.hash(0, path), .byte_offset = byte_offset, .content_hash = std.hash.Wyhash.hash(0, bytes) }; // Serve a still-valid cache hit even when the server has crashed — better than blanking // every tooltip the moment the pipe closes. Only after a miss do we check liveness. @@ -750,10 +742,7 @@ pub fn hover(self: *Client, path: []const u8, bytes: []const u8, byte_offset: us // is actually waiting on. The job currently in `runHoverJob` (already dequeued) still // finishes and caches; only not-yet-started work is dropped. self.dropQueuedHoverJobsLocked(); - self.queue.append( - gpa, - .{ .path = owned_path, .bytes = owned_bytes, .byte_offset = byte_offset, .key = key }, - ) catch { + self.queue.append(gpa, .{ .path = owned_path, .bytes = owned_bytes, .byte_offset = byte_offset, .key = key }) catch { self.queue_lock.unlock(); gpa.free(owned_path); gpa.free(owned_bytes); @@ -905,11 +894,7 @@ pub fn completion(self: *Client, path: []const u8, bytes: []const u8, byte_offse if (!(std.ascii.isAlphanumeric(prev) or prev == '_' or prev == '.' or prev == '@')) return null; const gpa = self.config.allocator; - const key: CacheKey = .{ - .path_hash = std.hash.Wyhash.hash(0, path), - .byte_offset = byte_offset, - .content_hash = std.hash.Wyhash.hash(0, bytes), - }; + const key: CacheKey = .{ .path_hash = std.hash.Wyhash.hash(0, path), .byte_offset = byte_offset, .content_hash = std.hash.Wyhash.hash(0, bytes) }; self.completion_cache_lock.lock(); if (self.completion_cache.get(key)) |entry| { @@ -964,13 +949,7 @@ pub fn completion(self: *Client, path: []const u8, bytes: []const u8, byte_offse /// (same `CacheKey` derivation) so the raw item JSON needed to actually resolve can be found; /// `index` out of range, or no completion cache entry for that key at all (evicted, or the /// original request never completed), is a clean "nothing to resolve" rather than an error. -pub fn resolveCompletionDocumentation( - self: *Client, - path: []const u8, - bytes: []const u8, - byte_offset: usize, - index: usize, -) ?[]const u8 { +pub fn resolveCompletionDocumentation(self: *Client, path: []const u8, bytes: []const u8, byte_offset: usize, index: usize) ?[]const u8 { if (path.len == 0) return null; if (!self.ensureStarted(path)) return null; // zls declares `resolveProvider: false` and always answers `completionItem/resolve` with @@ -979,11 +958,7 @@ pub fn resolveCompletionDocumentation( if (!self.completion_resolve_supported) return null; const gpa = self.config.allocator; - const completion_key: CacheKey = .{ - .path_hash = std.hash.Wyhash.hash(0, path), - .byte_offset = byte_offset, - .content_hash = std.hash.Wyhash.hash(0, bytes), - }; + const completion_key: CacheKey = .{ .path_hash = std.hash.Wyhash.hash(0, path), .byte_offset = byte_offset, .content_hash = std.hash.Wyhash.hash(0, bytes) }; const key: ResolveKey = .{ .completion_key = completion_key, .index = index }; self.resolve_cache_lock.lock(); @@ -1070,22 +1045,14 @@ pub fn signatureHelp(self: *Client, path: []const u8, bytes: []const u8, byte_of if (!insideOpenCall(bytes, byte_offset)) return null; const gpa = self.config.allocator; - const key: CacheKey = .{ - .path_hash = std.hash.Wyhash.hash(0, path), - .byte_offset = byte_offset, - .content_hash = std.hash.Wyhash.hash(0, bytes), - }; + const key: CacheKey = .{ .path_hash = std.hash.Wyhash.hash(0, path), .byte_offset = byte_offset, .content_hash = std.hash.Wyhash.hash(0, bytes) }; self.signature_help_cache_lock.lock(); if (self.signature_help_cache.get(key)) |entry| { // Copy out *while still holding the lock* — see `hover_return_scratch`'s doc comment. const result: ?SignatureHelpResult = if (entry.result) |r| blk: { const label_copy = self.setSignatureHelpReturnScratch(gpa, r.label) orelse break :blk null; - break :blk .{ - .label = label_copy, - .active_param_start = r.active_param_start, - .active_param_end = r.active_param_end, - }; + break :blk .{ .label = label_copy, .active_param_start = r.active_param_start, .active_param_end = r.active_param_end }; } else null; self.signature_help_cache_lock.unlock(); return result; @@ -1164,10 +1131,7 @@ fn ensureStarted(self: *Client, doc_path: []const u8) bool { // `ConcurrencyUnavailable` is a documented transient condition (pool // exhaustion) — leave state as `not_started` so the next hover retries, // rather than permanently disabling the client the way `.unavailable` would. - dvui.log.warn( - "{s}: Io.Group.concurrent(startupThreadMain) failed: {any}", - .{ self.config.language_id, err }, - ); + dvui.log.warn("{s}: Io.Group.concurrent(startupThreadMain) failed: {any}", .{ self.config.language_id, err }); return false; }; self.state.store(.starting, .release); @@ -1205,11 +1169,7 @@ fn startupThreadMain(self: *Client, io: std.Io) void { if (err == error.ShuttingDown) return; dvui.log.warn("{s}: language server unavailable ({any})", .{ self.config.language_id, err }); var msg_buf: [256]u8 = undefined; - const msg = std.mem.print( - &msg_buf, - "language server unavailable ({any})", - .{err}, - ) catch "language server unavailable"; + const msg = std.mem.print(&msg_buf, "language server unavailable ({any})", .{err}) catch "language server unavailable"; self.config.log(self.config.language_id, .warn, msg); self.state.store(.unavailable, .release); self.config.refresh(); @@ -1262,16 +1222,9 @@ fn spawnProcess(self: *Client, io: std.Io) !void { if (self.shutdown.load(.acquire)) return error.ShuttingDown; const spawned = spawnChild(gpa, io, argv, root) catch |err| { - dvui.log.warn( - "{s}: could not spawn \"{s}\" ({any}) — hover/goto-definition disabled for this session", - .{ self.config.language_id, argv[0], err }, - ); + dvui.log.warn("{s}: could not spawn \"{s}\" ({any}) — hover/goto-definition disabled for this session", .{ self.config.language_id, argv[0], err }); var msg_buf: [256]u8 = undefined; - const msg = std.mem.print( - &msg_buf, - "could not spawn \"{s}\" ({any}) — hover/goto-definition disabled for this session", - .{ argv[0], err }, - ) catch "could not spawn language server"; + const msg = std.mem.print(&msg_buf, "could not spawn \"{s}\" ({any}) — hover/goto-definition disabled for this session", .{ argv[0], err }) catch "could not spawn language server"; self.config.log(self.config.language_id, .warn, msg); return err; }; @@ -1955,16 +1908,7 @@ fn cacheHoverResult(self: *Client, io: std.Io, gpa: std.mem.Allocator, key: Cach // for a key whose next answer turned out positive, and linger there unused forever. _ = self.unconfirmed_negative_keys.remove(key); } - self.hover_cache.put( - gpa, - key, - .{ - .text = owned_text, - .seq = self.cache_seq, - .cached_at = cached_at, - .confirmed_negative = confirmed_negative, - }, - ) catch { + self.hover_cache.put(gpa, key, .{ .text = owned_text, .seq = self.cache_seq, .cached_at = cached_at, .confirmed_negative = confirmed_negative }) catch { if (owned_text) |t| gpa.free(t); return; }; @@ -2144,12 +2088,7 @@ fn runCompletionJob(self: *Client, io: std.Io, pc: PendingCompletion) !void { /// in the cache and evicts the oldest entry if it's now over `completion_cache_limit`. /// `owned_items`, if non-null, must already be owned by `gpa` (the slice and every owned field /// inside each entry, `raw_json` included) — this function takes ownership. -fn cacheCompletionResult( - self: *Client, - gpa: std.mem.Allocator, - key: CacheKey, - owned_items: ?[]CachedCompletionItem, -) void { +fn cacheCompletionResult(self: *Client, gpa: std.mem.Allocator, key: CacheKey, owned_items: ?[]CachedCompletionItem) void { self.completion_cache_lock.lock(); defer self.completion_cache_lock.unlock(); self.completion_cache_seq += 1; @@ -2258,12 +2197,7 @@ fn runSignatureHelpJob(self: *Client, io: std.Io, ps: PendingSignatureHelp) !voi /// `SignatureHelpCacheEntry.result`) in the cache and evicts the oldest entry if it's now over /// `signature_help_cache_limit`. `owned_result`, if non-null, must already have a gpa-owned /// `label` — this function takes ownership. -fn cacheSignatureHelpResult( - self: *Client, - gpa: std.mem.Allocator, - key: CacheKey, - owned_result: ?SignatureHelpResult, -) void { +fn cacheSignatureHelpResult(self: *Client, gpa: std.mem.Allocator, key: CacheKey, owned_result: ?SignatureHelpResult) void { self.signature_help_cache_lock.lock(); defer self.signature_help_cache_lock.unlock(); self.signature_help_cache_seq += 1; @@ -2619,11 +2553,7 @@ fn resolveSignatureHelp(result: std.json.Value) ?ResolvedSignatureHelp { return switch (param_label) { .string => |s| blk: { const idx = std.mem.find(u8, label, s) orelse break :blk no_emphasis; - break :blk ResolvedSignatureHelp{ - .label = label, - .active_param_start = idx, - .active_param_end = idx + s.len, - }; + break :blk ResolvedSignatureHelp{ .label = label, .active_param_start = idx, .active_param_end = idx + s.len }; }, .array => |arr| blk: { if (arr.items.len != 2) break :blk no_emphasis; @@ -2652,12 +2582,7 @@ const TextEditSpan = struct { start: usize, end: usize, new_text: []const u8 }; /// correctly: edits are sorted by descending start offset and applied back-to-front, so each /// edit's byte range (computed against the *original* `bytes`/positions) stays valid — an /// earlier edit's insert/delete would otherwise shift every later offset. -fn applyTextEdits( - gpa: std.mem.Allocator, - bytes: []const u8, - edits: []const std.json.Value, - encoding: Protocol.PositionEncoding, -) ?[]u8 { +fn applyTextEdits(gpa: std.mem.Allocator, bytes: []const u8, edits: []const std.json.Value, encoding: Protocol.PositionEncoding) ?[]u8 { var spans: std.ArrayList(TextEditSpan) = .empty; defer spans.deinit(gpa); @@ -2672,16 +2597,8 @@ fn applyTextEdits( const end_char = jsonInt(end_pos, "character") orelse continue; if (start_line < 0 or start_char < 0 or end_line < 0 or end_char < 0) continue; - const start = Protocol.positionToByteOffset( - bytes, - .{ .line = @intCast(start_line), .character = @intCast(start_char) }, - encoding, - ); - const end = Protocol.positionToByteOffset( - bytes, - .{ .line = @intCast(end_line), .character = @intCast(end_char) }, - encoding, - ); + const start = Protocol.positionToByteOffset(bytes, .{ .line = @intCast(start_line), .character = @intCast(start_char) }, encoding); + const end = Protocol.positionToByteOffset(bytes, .{ .line = @intCast(end_line), .character = @intCast(end_char) }, encoding); if (end < start) continue; spans.append(gpa, .{ .start = start, .end = end, .new_text = new_text }) catch continue; } @@ -2800,12 +2717,7 @@ fn syncDocument(self: *Client, io: std.Io, path: []const u8, bytes: []const u8) if (send_open) { try self.sendNotification(io, "textDocument/didOpen", .{ - .textDocument = .{ - .uri = uri, - .languageId = self.config.language_id, - .version = @as(i32, 1), - .text = bytes, - }, + .textDocument = .{ .uri = uri, .languageId = self.config.language_id, .version = @as(i32, 1), .text = bytes }, }); } else { try self.sendNotification(io, "textDocument/didChange", .{ @@ -2839,10 +2751,7 @@ fn send(self: *Client, io: std.Io, value: anytype) !void { Protocol.writeMessage(gpa, &w.interface, value) catch |err| { // `WriteFailed` is std.Io.Writer's generic wrapper error; the real underlying OS // error (if any) is stashed on the concrete File.Writer for exactly this reason. - dvui.log.warn( - "{s}: send failed: {any} (underlying file-writer error: {any})", - .{ self.config.language_id, err, w.err }, - ); + dvui.log.warn("{s}: send failed: {any} (underlying file-writer error: {any})", .{ self.config.language_id, err, w.err }); return err; }; } diff --git a/core/lsp/UriUtil.zig b/core/lsp/UriUtil.zig index 9111e2c75..af231f83e 100644 --- a/core/lsp/UriUtil.zig +++ b/core/lsp/UriUtil.zig @@ -65,9 +65,7 @@ pub fn uriToPath(allocator: std.mem.Allocator, uri: []const u8) ![]u8 { if (comptime is_windows) { // `file:///C:/Users/...` decodes to `/C:/Users/...` above; strip the leading slash and // swap separators back to `\` so this round-trips into a usable native Windows path. - if (out.items.len >= 3 and out.items[0] == '/' and - std.ascii.isAlphabetic(out.items[1]) and out.items[2] == ':') - { + if (out.items.len >= 3 and out.items[0] == '/' and std.ascii.isAlphabetic(out.items[1]) and out.items[2] == ':') { _ = out.orderedRemove(0); } for (out.items) |*c| { diff --git a/core/transport/NativeTransport.zig b/core/transport/NativeTransport.zig index 2724d7195..bfa1fd467 100644 --- a/core/transport/NativeTransport.zig +++ b/core/transport/NativeTransport.zig @@ -114,13 +114,7 @@ pub fn transport(self: *NativeTransport) vfs.http.Transport { const vtable: vfs.http.Transport.VTable = .{ .request = request, .cancel = cancel, .pump = pump }; -fn request( - ptr: *anyopaque, - allocator: std.mem.Allocator, - req: vfs.http.Request, - cb: vfs.http.DoneFn, - ctx: ?*anyopaque, -) vfs.Error!vfs.http.Job { +fn request(ptr: *anyopaque, allocator: std.mem.Allocator, req: vfs.http.Request, cb: vfs.http.DoneFn, ctx: ?*anyopaque) vfs.Error!vfs.http.Job { const self: *NativeTransport = @ptrCast(@alignCast(ptr)); if (comptime builtin.target.cpu.arch == .wasm32) return error.Unsupported; const a = self.gpa; @@ -175,11 +169,7 @@ fn worker(job: *Job) void { // By its host alone: a URL's path and query can carry tokens, and a trace is for sharing. var name_buf: [64]u8 = undefined; const host = if (std.Uri.parse(job.url)) |u| (if (u.host) |h| h.percent_encoded else "") else |_| ""; - const timed = @import("../profile/Spans.zig").span(std.mem.print( - &name_buf, - "http {t} {s}", - .{ job.method, host }, - ) catch "http"); + const timed = @import("../profile/Spans.zig").span(std.mem.print(&name_buf, "http {t} {s}", .{ job.method, host }) catch "http"); const result = perform(job); timed.end(); const self = job.owner; diff --git a/core/transport/WebOAuth.zig b/core/transport/WebOAuth.zig index 433e4f4c1..4811b1845 100644 --- a/core/transport/WebOAuth.zig +++ b/core/transport/WebOAuth.zig @@ -19,12 +19,7 @@ comptime { const wasm = struct { extern "fizzy" fn fizzy_web_oauth_open(url_ptr: [*]const u8, url_len: usize) void; - extern "fizzy" fn fizzy_web_oauth_open_page( - html_ptr: [*]const u8, - html_len: usize, - hash_ptr: [*]const u8, - hash_len: usize, - ) void; + extern "fizzy" fn fizzy_web_oauth_open_page(html_ptr: [*]const u8, html_len: usize, hash_ptr: [*]const u8, hash_len: usize) void; /// Writes the callback URL into `buf`; returns the length it needs (may exceed `len`). extern "fizzy" fn fizzy_web_oauth_callback_url(buf: [*]u8, len: usize) usize; }; @@ -55,13 +50,7 @@ pub fn begin(allocator: std.mem.Allocator, url: []const u8, cb: DoneFn, ctx: ?*a /// than one served beside the app: the page opens from a same-origin blob URL with `hash` as /// its fragment, and posts `{ fizzyOAuth }` to the opener like `oauth-callback.html` does. /// How a plugin loaded at runtime brings its own picker — nothing of it needs serving. -pub fn beginPage( - allocator: std.mem.Allocator, - html: []const u8, - hash: []const u8, - cb: DoneFn, - ctx: ?*anyopaque, -) error{Busy}!void { +pub fn beginPage(allocator: std.mem.Allocator, html: []const u8, hash: []const u8, cb: DoneFn, ctx: ?*anyopaque) error{Busy}!void { if (pending != null) return error.Busy; pending = .{ .allocator = allocator, .cb = cb, .ctx = ctx }; wasm.fizzy_web_oauth_open_page(html.ptr, html.len, hash.ptr, hash.len); diff --git a/core/transport/WebTransport.zig b/core/transport/WebTransport.zig index 411e77baa..ba804dd2f 100644 --- a/core/transport/WebTransport.zig +++ b/core/transport/WebTransport.zig @@ -58,13 +58,7 @@ pub fn transport(allocator: std.mem.Allocator) vfs.http.Transport { const vtable: vfs.http.Transport.VTable = .{ .request = request, .cancel = cancel, .pump = pump }; -fn request( - _: *anyopaque, - allocator: std.mem.Allocator, - req: vfs.http.Request, - cb: vfs.http.DoneFn, - ctx: ?*anyopaque, -) vfs.Error!vfs.http.Job { +fn request(_: *anyopaque, allocator: std.mem.Allocator, req: vfs.http.Request, cb: vfs.http.DoneFn, ctx: ?*anyopaque) vfs.Error!vfs.http.Job { const a = gpa orelse return error.Unsupported; const p = try a.create(Pending); errdefer a.destroy(p); @@ -77,8 +71,7 @@ fn request( for (req.headers) |h| { // The line format is the protocol between Zig and JS; a CR/LF in a value would end // the line early (or trap the frame inside `Headers.append`), so it is refused here. - if (std.mem.findAny(u8, h.name, "\r\n:") != null or - std.mem.findAny(u8, h.value, "\r\n") != null) return error.Http; + if (std.mem.findAny(u8, h.name, "\r\n:") != null or std.mem.findAny(u8, h.value, "\r\n") != null) return error.Http; try lines.appendSlice(a, h.name); try lines.appendSlice(a, ": "); try lines.appendSlice(a, h.value); @@ -90,17 +83,7 @@ fn request( p.id = id; try pending.put(a, id, p); const method = req.method.asSlice(); - wasm.fizzy_web_request( - id, - method.ptr, - method.len, - req.url.ptr, - req.url.len, - lines.items.ptr, - lines.items.len, - req.body.ptr, - req.body.len, - ); + wasm.fizzy_web_request(id, method.ptr, method.len, req.url.ptr, req.url.len, lines.items.ptr, lines.items.len, req.body.ptr, req.body.len); return .{ .id = id }; } diff --git a/core/widgets/DropZones.zig b/core/widgets/DropZones.zig index a66ecc084..0db908e0e 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 = @import("DockingWidget/Layout.zig").Side; +pub const Side = enum { left, right, top, bottom }; 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 bc3361d3d..6bd078b5a 100644 --- a/core/widgets/ScrollArea.zig +++ b/core/widgets/ScrollArea.zig @@ -35,9 +35,6 @@ const Touch = struct { x: Fling = .{}, y: Fling = .{}, dragging: bool = false, - coast_start_ns: i128 = 0, - coast_origin: dvui.Point = .{}, - coast_velocity: dvui.Point = .{}, /// The finger that owns the drag; another finger's release or motion is not this area's. finger: dvui.enums.Button = .none, }; @@ -59,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.?; - var self: ScrollArea = .{ + const self: ScrollArea = .{ .area = area, .si = area.si, .touch = dvui.dataGetPtrDefault(null, c.data().id, "_touch", Touch, .{}), @@ -80,14 +77,6 @@ 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; } @@ -136,11 +125,10 @@ pub fn deinit(self: *ScrollArea) void { t.dragging = false; _ = t.x.releaseWindowed(tuning, window_s); _ = t.y.releaseWindowed(tuning, window_s); - t.coast_start_ns = dvui.frameTimeNS(); - t.coast_origin = self.si.viewport.topLeft(); - t.coast_velocity = .{ .x = t.x.vel, .y = t.y.vel }; } + 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 = .{}; @@ -199,9 +187,9 @@ fn coast(self: *ScrollArea) void { const t = self.touch; const si = self.si; var moved: dvui.Point = .{}; - const elapsed = @as(f64, @floatFromInt(dvui.frameTimeNS() - t.coast_start_ns)) / std.time.ns_per_s; - if (t.x.coasting) moved.x = coastPosition(&t.x, t.coast_origin.x, t.coast_velocity.x, elapsed) - si.viewport.x; - if (t.y.coasting) moved.y = coastPosition(&t.y, t.coast_origin.y, t.coast_velocity.y, elapsed) - si.viewport.y; + if (t.x.step(tuning)) |d| moved.x = d; + if (t.y.step(tuning)) |d| moved.y = d; + if (!moved.nonZero()) return; self.scrollBy(moved); if (si.viewport.x < 0 or si.viewport.x > si.scrollMax(.horizontal)) t.x.cancel(); @@ -209,18 +197,6 @@ fn coast(self: *ScrollArea) void { if (t.x.coasting or t.y.coasting) dvui.refresh(null, @src(), self.data().id); } -// Evaluate from the release, rounding the position once rather than accumulating f32 deltas. -// Clamp time at the stop speed so the final position also agrees across frame schedules. -fn coastPosition(fling: *Fling, origin: f32, velocity: f32, elapsed: f64) f32 { - const decay: f64 = tuning.decay; - const duration = @log(@as(f64, @abs(velocity)) / tuning.stop) / decay; - const time = std.math.clamp(elapsed, 0, duration); - const keep = @exp(-decay * time); - fling.vel = @floatCast(velocity * keep); - fling.coasting = elapsed < duration; - return @floatCast(origin + velocity / decay * (1 - keep)); -} - fn scrollBy(self: *ScrollArea, by: dvui.Point) void { const c = &self.area.scroll.?; self.si.viewport.x += by.x; @@ -236,7 +212,6 @@ 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 { @@ -249,14 +224,13 @@ 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 }); - // Read the final viewport after the area's event handling and edge correction. + // After the area's `deinit`, which is where the coast moves it: the frame's final place. 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; @@ -299,59 +273,28 @@ fn flick() !f32 { return t_viewport_y - at_release; } -/// How far the content has been drawn to move from the release at each of 150 steps of 33 ms, the clock +/// How far the viewport has coasted 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: *[150]f32) !void { +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; - _ = 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. + 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 frameAfter(gaps_ms[gaps_ms.len - 1]); for (out) |*slot| { for (gaps_ms) |g| try frameAfter(g); - slot.* = at_release - t_raw_y; + slot.* = t_viewport_y - at_release; } } test "the coast is a function of time, not of how the frames cut it" { - var steady: [150]f32 = undefined; - try coastTrace(&.{ 16, 17 }, &steady); - var jittery: [150]f32 = undefined; + var steady: [15]f32 = undefined; + try coastTrace(&.{33}, &steady); + var jittery: [15]f32 = undefined; try coastTrace(&.{ 25, 8 }, &jittery); - var max_error: f32 = 0; - for (steady, jittery) |a, b| max_error = @max(max_error, @abs(a - b)); - std.debug.print("coast: steady final={d:.6}, jittery final={d:.6}, max difference={d:.6} points\n", .{ steady[149], jittery[149], max_error }); - try std.testing.expectApproxEqAbs(@as(f32, 0), max_error, 0.0001); -} - -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; - std.debug.print("coast: gap={d:.0} ms, displacement={d:.6} points\n", .{ gap_ms, displacement }); - 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); + for (steady, jittery) |a, b| try std.testing.expectApproxEqAbs(a, b, 0.01); } test "a slow coast draws at a new place every frame, then snaps at rest" { diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index 79ad4b5b9..f1e858700 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 animates toward it and reports a click or key press in - /// `Branch.requested`. + /// state; the branch only eases toward it (`Branch.expanded` is what is drawn) and reports + /// a click or key press in `Branch.requested`. expanded: bool = false, // if null, uses widget id @@ -427,7 +427,8 @@ 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, - /// The caller's open state for this frame. + /// 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. 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. @@ -435,7 +436,6 @@ 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 = init_opts.expanded; + self.expanded = if (dvui.dataGet(null, self.wd.id, "_expanded", bool)) |e| e else init_opts.expanded; } // can call this after init before install @@ -567,7 +567,11 @@ 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); @@ -774,32 +778,38 @@ pub const Branch = struct { ); const toggled = clicked or self.key_toggle; - const want = self.init_options.expanded; - if (toggled) self.requested = !want; - if (self.requested != null) dvui.refresh(null, @src(), self.data().id); + const want = self.init_options.expanded != toggled; + if (toggled) self.requested = want; const anim = self.anim.?; - 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) { + // 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) { // Always expand the inner box to fill the animation const expander_opts = dvui.Options{ .expand = .both }; @@ -813,7 +823,7 @@ pub const Branch = struct { self.can_expand = true; - return self.draw_children; + return self.expanded; } pub fn targetRectScale(self: *Branch) ?dvui.RectScale { @@ -900,7 +910,7 @@ pub const Branch = struct { if (self.carried_source) self.tree.carried_depth -= 1; if (self.can_expand) { - if (self.draw_children) { + if (self.expanded) { if (self.expander_clip_prev) |p| { dvui.clipSet(p); self.expander_clip_prev = null; @@ -910,6 +920,8 @@ 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/AGENT_WORK.md b/docs/AGENT_WORK.md index a6b2875b7..174417899 100644 --- a/docs/AGENT_WORK.md +++ b/docs/AGENT_WORK.md @@ -152,7 +152,8 @@ Three places, in order: the worker's **local gates** (step 3), which decide whet worth pushing; **cross-review** by a different worker; and **CI at ready**. A draft PR runs only the light `changes` job (`ci.yml`); `ci-ok` is skipped on drafts. 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, +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. Workflow changes require the maintainer's explicit yes before merge. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 472454fb8..ed0f78a94 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -350,6 +350,7 @@ 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 c2826e8d9..7836bba99 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -220,8 +220,7 @@ 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 - sentence fragment before the caret (up to 60 code points). Replacements, native input and - composition updates become backspaces and text. + 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, diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 59dceace9..ee91fe4be 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -49,7 +49,7 @@ Four things in that split cost time on the move to 0.17: - **A tarball `zig fetch` cached breaks the next build's hash.** `zig fetch ` prints the right hash and saves the tarball to the global cache (`~/.cache/zig/p/.tar.gz`). A build that finds it there unpacks it with its top folder kept: no `build.zig.zon` at the package's - root, an unnamed `N-V-...` hash, and "hash mismatch". It is not the URL: it hit GitHub + root, an unnamed `N-V-…` hash, and "hash mismatch". It is not the URL: it hit GitHub `archive/.tar.gz` URLs (dvui-dev `bd7736d`) and fizzy's own `fizzy-sdk-v0.2.30.tar.gz` alike. Delete `p/.tar.gz` after a `zig fetch` and the build fetches the package itself. fizzyedit/plugin-build-action's repin does (fizzyedit/plugin-build-action#4). @@ -59,7 +59,7 @@ Four things in that split cost time on the move to 0.17: Three that the release builds (`zig build package`, MSVC) hit and the macOS build never shows: - **An `*-windows-msvc` exe must link `oldnames` itself.** MSVC's C runtime spells the POSIX - calls `_getcwd`, `_chdir`, ..., and `oldnames.lib` maps the plain names onto them. Zig 0.16 + calls `_getcwd`, `_chdir`, …, and `oldnames.lib` maps the plain names onto them. Zig 0.16 linked it into every MSVC exe; 0.17 does not, so C that calls `getcwd` (dvui's tinyfiledialogs, fizzy's startup) fails to link with unresolved symbols. `build/exe.zig` adds it for `abi == .msvc`; any other MSVC exe built from this tree needs the same line. @@ -114,10 +114,12 @@ Three that the release builds (`zig build package`, MSVC) hit and the macOS buil ## Window behaviour notes: clean-room hygiene -Write `docs/window-notes/` in our own words from observed behaviour or documentation read, -citing only public OS documentation. Do not consult source code or copy code, comments or -tables from SDL, GLFW or another implementation while writing a note. Our patch descriptions and -recorded pitfalls can identify a behaviour to investigate; patch files mix prose and -implementation hunks, so read only the commit-message part above the first `---`. -Separate recorded observations, documented contracts and cases still awaiting observation, -and include platform and verification limits so a proposed test is not mistaken for a pass. +Write `docs/window-notes/` from behaviour observed or documentation read, never from +source code open in another window. Do not copy code, comments or tables from SDL, +GLFW or another implementation. Our patch descriptions and recorded pitfalls can +identify a behaviour to investigate; patch files mix prose and implementation hunks, +so read only the commit-message part above the first `---` and never open the diff +hunks while writing a note; read the prose, not implementation hunks, and +state the behaviour in our own words. Cite only public OS documentation. Separate +recorded observations, documented contracts and cases still awaiting observation; +include platform and verification limits so a proposed test is not mistaken for a pass. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 77e1715e7..8c7cb7ac8 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1327,7 +1327,6 @@ The SDK ships definitions for the services in this ecosystem, in | `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | | `"automation"` | the application (fizzy: `app/automation/Service.zig`, over its live driver) | `play` a live tape — input on the app as it is, built with the `tape` module and handed over as ZON or binary bytes — and get a ticket to ask its `outcome` (finished, or interrupted / timed out / stopped before which op); `stop` your own tape; `settled` when nothing is in flight; `snapshot` / `snapshotText` for what is on screen as ZON, a line per widget a person acts on or a tape aims at (role, name, tag, rect, parent — `replay.Snapshot`). One tape at a time, refused while a demo plays; a person's input stops it. For plugin tests, macros, tutorials | | `"demos"` | the application (fizzy: `src/editor/Demos.zig`, over its demo player) | `list` every demo the app can play — its own, and each one a plugin registered with `host.registerDemo` (a name, a title, the tape as ZON or binary bytes, copied by the host and dropped when the plugin goes) — with each one's length, chapters and where it came from; `play` one by name, from a chapter if asked; `current` names the one playing; `generation` moves when the list does. A demo opens on a keyframe and sets the person's session aside untouched while it plays, unsaved work and all; refused only while a live tape drives. For a guides browser, a plugin's own demos, an agent | -| `"chat.provider"` | whoever has a conversation: the host app, a team plugin (the `chat` plugin registers a small in-memory one when nothing else has) | `messages` (the conversation, oldest first), `send` a message (false when refused), `attach` (the provider decides what that means), `status` (`ready`, `busy`, `offline`). The `chat` plugin draws the messages and a composer in the sidebar from it | Everything a plugin might reasonably ask *fizzy* for lives on `Host` instead — `openFile`, `closeDocById`, `docFromPath`, `revealPosition`, `setProjectFolder`, `isPathIgnored`, … — where it diff --git a/plugins/chat/build.zig b/plugins/chat/build.zig deleted file mode 100644 index 6102a115c..000000000 --- a/plugins/chat/build.zig +++ /dev/null @@ -1,13 +0,0 @@ -//! Standalone build for the chat plugin — the canonical third-party shape. -//! `cd plugins/chat && zig build` produces `chat.`. The fizzy-internal -//! static-embed build lives separately in `static/` and is driven by the root build. -const std = @import("std"); -const fizzy = @import("fizzy"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const plugin = fizzy.plugin.create(b, .{ .target = target, .optimize = optimize }); - fizzy.plugin.install(b, plugin.lib, .{}); -} diff --git a/plugins/chat/build.zig.zon b/plugins/chat/build.zig.zon deleted file mode 100644 index 5764e1a04..000000000 --- a/plugins/chat/build.zig.zon +++ /dev/null @@ -1,19 +0,0 @@ -.{ - .name = .chat, - .version = "0.1.0", - .minimum_zig_version = "0.17.0", - .paths = .{ - "build.zig", - "build.zig.zon", - "plugin.zig", - "plugin.zig.zon", - "src", - "static", - }, - .fingerprint = 0x659df2aa507f8c19, - .dependencies = .{ - .fizzy = .{ - .path = "../../sdk", - }, - }, -} diff --git a/plugins/chat/plugin.zig b/plugins/chat/plugin.zig deleted file mode 100644 index e80740975..000000000 --- a/plugins/chat/plugin.zig +++ /dev/null @@ -1,87 +0,0 @@ -//! Chat: a conversation and its composer, over the `chat.provider` service. One surface for the -//! sidebar (`chat.view`) draws the provider's messages and a composer; whoever has a conversation -//! (the host app, a team plugin) registers the provider and this shows it. Owns no conversation: -//! when nothing offers one it registers a small in-memory one, so the surface can be tried alone. -//! -//! Commands: `chat.show` reveals the view wherever it lives. -const std = @import("std"); -const sdk = @import("fizzy_sdk"); -const dvui = @import("dvui"); -pub const DemoProvider = @import("src/DemoProvider.zig"); -pub const View = @import("src/View.zig"); - -/// Injected at build time from `plugin.zig.zon`. -pub const plugin_options = @import("fizzy_plugin_options"); -pub const plugin_id = plugin_options.id; - -pub const view_id = "chat.view"; - -var plugin: sdk.Plugin = .{ - .state = undefined, - .vtable = &vtable, - .id = plugin_id, - .display_name = plugin_options.name, -}; - -const vtable: sdk.Plugin.VTable = .{ .deinit = deinit }; - -var host_ptr: ?*sdk.Host = null; -var view: View = .{}; -var demo: DemoProvider = undefined; -var demo_api: sdk.services.chat.Api = undefined; -var demo_registered = false; - -pub fn register(host: *sdk.Host) !void { - host_ptr = host; - plugin.state = @ptrCast(host); - try host.registerPlugin(&plugin); - try host.registerSurface(.{ - .id = view_id, - .owner = &plugin, - .title = "Chat", - .icon = .{ .tvg = dvui.entypo.chat }, - .keywords = sdk.keywords.ide.sidebar, - .scrolls_itself = true, - .draw = drawView, - }); - try host.registerCommand(.{ - .id = sdk.Plugin.commandId(plugin_id, "show"), - .owner = &plugin, - .title = "Show Chat", - .run = cmdShow, - .icon = dvui.entypo.chat, - }); - if (host.getServiceTyped(sdk.services.chat.Api) == null) { - demo = .{ .allocator = host.allocator }; - demo_api = demo.api(); - try host.registerService(sdk.services.chat.Api, &demo_api, &plugin); - demo_registered = true; - } -} - -fn deinit(state: *anyopaque) void { - const host: *sdk.Host = @ptrCast(@alignCast(state)); - view.deinit(host.allocator); - view = .{}; - if (demo_registered) demo.deinit(); - demo_registered = false; - host_ptr = null; -} - -// lint-allow(anyerror): the surface draw signature is the SDK's -fn drawView(_: ?*anyopaque) anyerror!dvui.App.Result { - const host = host_ptr orelse return .ok; - view.draw(host.allocator, host.getServiceTyped(sdk.services.chat.Api)); - return .ok; -} - -/// "Show Chat": the view, wherever it lives, its place opened if it was shut. -// lint-allow(anyerror): the command run signature is the SDK's -fn cmdShow(state: *anyopaque) anyerror!void { - const host: *sdk.Host = @ptrCast(@alignCast(state)); - _ = host.revealSurface(view_id); -} - -test { - _ = DemoProvider; -} diff --git a/plugins/chat/plugin.zig.zon b/plugins/chat/plugin.zig.zon deleted file mode 100644 index 5de97db02..000000000 --- a/plugins/chat/plugin.zig.zon +++ /dev/null @@ -1,8 +0,0 @@ -.{ - .id = "chat", - .name = "Chat", - .version = "0.1.0", - .min_sdk_version = "", - .description = "A conversation and its composer, over the chat.provider service: whoever has a conversation shows it here.", - .tags = .{ "chat", "ai" }, -} diff --git a/plugins/chat/src/DemoProvider.zig b/plugins/chat/src/DemoProvider.zig deleted file mode 100644 index 8cecb1dcb..000000000 --- a/plugins/chat/src/DemoProvider.zig +++ /dev/null @@ -1,68 +0,0 @@ -//! A conversation kept in memory, so the chat surface can be tried with no team behind it. It -//! answers every message by quoting it. The chat plugin registers it only when no other plugin -//! has offered `chat.provider`. -const std = @import("std"); -const sdk = @import("fizzy_sdk"); - -const Api = sdk.services.chat.Api; - -const DemoProvider = @This(); - -allocator: std.mem.Allocator, -log: std.ArrayList(Api.Message) = .empty, - -pub fn deinit(self: *DemoProvider) void { - for (self.log.items) |m| self.allocator.free(m.text); - self.log.deinit(self.allocator); -} - -pub fn api(self: *DemoProvider) Api { - return .{ .ctx = self, .vtable = &vtable }; -} - -const vtable: Api.VTable = .{ - .messages = messages, - .send = send, - .attach = attach, - .status = status, -}; - -fn messages(ctx: *anyopaque) []const Api.Message { - const self: *DemoProvider = @ptrCast(@alignCast(ctx)); - return self.log.items; -} - -fn send(ctx: *anyopaque, text: []const u8) bool { - const self: *DemoProvider = @ptrCast(@alignCast(ctx)); - self.append(.user, text) catch return false; - var buf: [256]u8 = undefined; - const quoted = text[0..@min(text.len, 200)]; - const reply = std.fmt.bufPrint(&buf, "You said: {s}", .{quoted}) catch return true; - self.append(.assistant, reply) catch {}; - return true; -} - -fn attach(ctx: *anyopaque) void { - const self: *DemoProvider = @ptrCast(@alignCast(ctx)); - self.append(.system, "The demo conversation has nowhere to keep an attachment.") catch {}; -} - -fn status(_: *anyopaque) Api.Status { - return .ready; -} - -fn append(self: *DemoProvider, role: Api.Role, text: []const u8) !void { - const owned = try self.allocator.dupe(u8, text); - errdefer self.allocator.free(owned); - try self.log.append(self.allocator, .{ .role = role, .text = owned }); -} - -test "sending adds the message and its reply" { - var p: DemoProvider = .{ .allocator = std.testing.allocator }; - defer p.deinit(); - const a = p.api(); - try std.testing.expect(a.send("hello")); - try std.testing.expectEqual(@as(usize, 2), a.messages().len); - try std.testing.expectEqual(Api.Role.user, a.messages()[0].role); - try std.testing.expectEqualStrings("You said: hello", a.messages()[1].text); -} diff --git a/plugins/chat/src/View.zig b/plugins/chat/src/View.zig deleted file mode 100644 index 48f12396a..000000000 --- a/plugins/chat/src/View.zig +++ /dev/null @@ -1,181 +0,0 @@ -//! The chat surface: the provider's messages above, the composer below. Drawn from the theme and -//! the app's glass only, so another app's look is the chat's look. -//! -//! The composer is a rounded pane of glass with a plus (attach) on its left and a send button on -//! its right. Its text grows with what is typed up to `composer.max_lines`, then scrolls. Enter -//! sends on a desktop and is a new line on a phone (`composer.enter`). -const std = @import("std"); -const sdk = @import("fizzy_sdk"); -const dvui = @import("dvui"); -const core = @import("core"); -const composer = @import("composer.zig"); - -const Api = sdk.services.chat.Api; - -const View = @This(); - -/// The composer's text, kept across frames. -text: std.ArrayList(u8) = .empty, -/// Messages seen last frame: the list scrolls to the end when this grows. -seen: usize = 0, -/// The composer's text entry as last drawn, for a test to read: its widget id, its rect and its -/// scroll state. -entry_id: dvui.Id = .zero, -entry_rect: dvui.Rect = .{}, -entry_scroll: dvui.ScrollInfo = .{}, - -pub fn deinit(self: *View, gpa: std.mem.Allocator) void { - self.text.deinit(gpa); -} - -pub fn draw(self: *View, gpa: std.mem.Allocator, provider: ?*Api) void { - var column = dvui.box(@src(), .{}, .{ .expand = .both, .background = false }); - defer column.deinit(); - - const api = provider orelse return note("Nothing is offering a conversation (chat.provider)."); - self.messages(api.*); - self.compose(gpa, api.*, column.data()); -} - -fn messages(self: *View, api: Api) void { - var scroll = core.widgets.ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .auto_overlay }, .{ .expand = .both, .background = false }); - defer scroll.deinit(); - - const log = api.messages(); - for (log, 0..) |m, i| message(m, i); - - if (log.len != self.seen) { - self.seen = log.len; - scroll.si.scrollToOffset(.vertical, std.math.floatMax(f32)); - dvui.refresh(null, @src(), null); - } -} - -fn message(m: Api.Message, i: usize) void { - const theme = dvui.themeGet(); - var box = dvui.box(@src(), .{}, .{ .expand = .horizontal, .id_extra = i, .padding = .{ .x = 8, .y = 4, .w = 8, .h = 4 }, .background = false }); - defer box.deinit(); - - const who = switch (m.role) { - .user => "You", - .assistant => "Reply", - .system => "", - }; - if (who.len > 0) dvui.labelNoFmt(@src(), who, .{}, .{ .color_text = .{ .color = theme.color(.control, .text).opacity(0.6) }, .font = theme.font_body.larger(-2) }); - - var segments: composer.Segments = .{ .text = m.text }; - var n: usize = 0; - while (segments.next()) |s| : (n += 1) { - if (s.body.len == 0 and s.kind == .text) continue; - switch (s.kind) { - .text => { - var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .id_extra = n, .background = false }); - defer tl.deinit(); - tl.addText(s.body, .{}); - }, - .code => { - var block = dvui.box(@src(), .{}, .{ - .expand = .horizontal, - .id_extra = n, - .background = true, - .color_fill = .{ .color = theme.color(.control, .fill) }, - .corners = core.corners.all(core.corners.small), - .padding = .all(6), - .margin = .{ .y = 2, .h = 2 }, - }); - defer block.deinit(); - var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false, .font = theme.font_mono }); - defer tl.deinit(); - tl.addText(s.body, .{}); - }, - } - } -} - -fn compose(self: *View, gpa: std.mem.Allocator, api: Api, parent: *const dvui.WidgetData) void { - const theme = dvui.themeGet(); - const radius = core.corners.card + 4; - var pane = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .background = false, - .corners = core.corners.all(radius), - .margin = .all(8), - .padding = .all(6), - }); - defer pane.deinit(); - const r = pane.data().borderRectScale().r; - const corners = core.corners.all(radius); - if (!core.dialogs.frostPane(pane.data().id, r, corners, pane.data().rectScale().s)) { - r.fill(corners.scale(pane.data().rectScale().s, dvui.CornerRect.Physical), .{ .color = .{ .color = core.dialogs.dialogFill() }, .fade = 1 }); - } - - if (iconButton(@src(), dvui.entypo.plus, "Attach", true)) api.attach(); - - const line_h = theme.font_body.lineHeight(); - const entry_src = @src(); - const entry_id = pane.data().id.extendId(entry_src, 0); - const mobile = core.platform.formFactor() == .mobile; - var send = false; - for (dvui.events()) |*e| { - if (e.handled or e.target_widgetId != entry_id) continue; - const ke = switch (e.evt) { - .key => |k| k, - else => continue, - }; - if (ke.code != .enter or ke.action == .up) continue; - const shift = ke.mod.has(.lshift) or ke.mod.has(.rshift); - if (composer.enter(shift, mobile) == .send) { - e.handle(@src(), parent); - send = ke.action == .down; - } - } - - var entry = dvui.textEntry(entry_src, .{ - .multiline = true, - .break_lines = true, - .placeholder = "Message", - .text = .{ .array_list = .{ .backing = &self.text, .allocator = gpa } }, - }, .{ - .expand = .horizontal, - .gravity_y = 0.5, - .background = false, - .min_size_content = .{ .w = 1, .h = line_h }, - .max_size_content = .{ .w = std.math.floatMax(f32), .h = line_h * composer.max_lines }, - }); - defer entry.deinit(); - self.entry_id = entry.data().id; - self.entry_rect = entry.data().rect; - self.entry_scroll = entry.scroll.si.*; - - const text = composer.sendable(self.text.items); - if (iconButton(@src(), dvui.entypo.paper_plane, "Send", text != null and api.status() != .offline)) send = true; - if (send) { - if (text) |t| if (api.send(t)) { - self.text.clearRetainingCapacity(); - entry.textSet("", false); - }; - } -} - -fn iconButton(src: std.lang.SourceLocation, glyph: []const u8, label: []const u8, enabled: bool) bool { - const theme = dvui.themeGet(); - var btn: dvui.ButtonWidget = undefined; - btn.init(src, .{}, .{ - .gravity_y = 1.0, - .min_size_content = .{ .w = 18, .h = 18 }, - .corners = core.corners.all(core.corners.control), - .background = false, - .label = .{ .text = label }, - }); - defer btn.deinit(); - if (enabled) btn.processEvents(); - btn.drawBackground(); - if (enabled) btn.drawFocus(); - const color = theme.color(.control, .text).opacity(if (enabled) 1 else 0.4); - core.icon.icon(@src(), label, glyph, .{ .stroke_color = .white, .fill_color = .white }, .{ .color_text = .{ .color = color }, .expand = .ratio, .gravity_x = 0.5, .gravity_y = 0.5 }); - return enabled and btn.clicked(); -} - -fn note(text: []const u8) void { - dvui.labelNoFmt(@src(), text, .{}, .{ .color_text = .{ .color = dvui.themeGet().color(.control, .text).opacity(0.6) }, .padding = .all(6) }); -} diff --git a/plugins/chat/src/composer.zig b/plugins/chat/src/composer.zig deleted file mode 100644 index e76b9e44c..000000000 --- a/plugins/chat/src/composer.zig +++ /dev/null @@ -1,121 +0,0 @@ -//! What the composer and the message list decide, std-only so it tests without a Window: what -//! Enter does, how many lines the composer shows, and where a message's ``` fences begin and end. -const std = @import("std"); - -/// Lines the composer grows to; past this it scrolls (View caps the entry at this many lines). -pub const max_lines: usize = 8; - -pub const Enter = enum { send, newline }; - -/// Enter sends on a desktop and is a new line on a phone, where the send button sends; Shift+Enter -/// is a new line anywhere. -pub fn enter(shift: bool, mobile: bool) Enter { - return if (shift or mobile) .newline else .send; -} - -/// `text` with the whitespace around it removed, or null when nothing is left to send. -pub fn sendable(text: []const u8) ?[]const u8 { - const t = std.mem.trim(u8, text, " \t\r\n"); - return if (t.len == 0) null else t; -} - -pub const Segment = struct { - kind: enum { text, code }, - body: []const u8, - /// The word after the opening fence, for a code segment; empty if none. - lang: []const u8 = "", -}; - -/// Splits a message into plain text and finished ``` fences, in order. A fence is finished when a -/// later line is a bare ```; an unfinished one stays text. -pub const Segments = struct { - text: []const u8, - pos: usize = 0, - - pub fn next(self: *Segments) ?Segment { - const rest = self.text[self.pos..]; - if (rest.len == 0) return null; - var line_start: usize = 0; - while (line_start < rest.len) { - const line_end = std.mem.findScalarPos(u8, rest, line_start, '\n') orelse rest.len; - const line = std.mem.trimEnd(u8, rest[line_start..line_end], "\r"); - if (std.mem.startsWith(u8, line, "```")) { - if (closingFence(rest, line_end)) |close| { - if (line_start > 0) { - self.pos += line_start; - return .{ .kind = .text, .body = std.mem.trim(u8, rest[0..line_start], "\n") }; - } - self.pos += close.after; - return .{ - .kind = .code, - .body = rest[@min(line_end + 1, close.body_end)..close.body_end], - .lang = std.mem.trim(u8, line[3..], " \t"), - }; - } - break; - } - line_start = line_end + 1; - } - self.pos = self.text.len; - return .{ .kind = .text, .body = std.mem.trim(u8, rest, "\n") }; - } -}; - -const Close = struct { body_end: usize, after: usize }; - -/// The line after `open_end` that is a bare ```: where the code before it ends and the text after -/// it begins. -fn closingFence(rest: []const u8, open_end: usize) ?Close { - var start = open_end + 1; - while (start <= rest.len) { - const end = std.mem.findScalarPos(u8, rest, start, '\n') orelse rest.len; - if (std.mem.eql(u8, std.mem.trim(u8, rest[start..end], " \t\r"), "```")) { - return .{ .body_end = if (start > 0) start - 1 else start, .after = @min(end + 1, rest.len) }; - } - if (end == rest.len) return null; - start = end + 1; - } - return null; -} - -test "Enter sends on a desktop and breaks the line on a phone or with Shift" { - try std.testing.expectEqual(Enter.send, enter(false, false)); - try std.testing.expectEqual(Enter.newline, enter(true, false)); - try std.testing.expectEqual(Enter.newline, enter(false, true)); - try std.testing.expectEqual(Enter.newline, enter(true, true)); -} - -test "only text with something in it can be sent" { - try std.testing.expect(sendable(" \n\t") == null); - try std.testing.expectEqualStrings("hi", sendable(" hi\n").?); -} - -test "a finished fence is a code segment between its text" { - var it: Segments = .{ .text = "before\n```zig\nconst a = 1;\n```\nafter" }; - const a = it.next().?; - try std.testing.expectEqual(.text, a.kind); - try std.testing.expectEqualStrings("before", a.body); - const b = it.next().?; - try std.testing.expectEqual(.code, b.kind); - try std.testing.expectEqualStrings("zig", b.lang); - try std.testing.expectEqualStrings("const a = 1;", b.body); - const c = it.next().?; - try std.testing.expectEqualStrings("after", c.body); - try std.testing.expect(it.next() == null); -} - -test "an unfinished fence stays text" { - var it: Segments = .{ .text = "look\n```\nnot closed" }; - const a = it.next().?; - try std.testing.expectEqual(.text, a.kind); - try std.testing.expectEqualStrings("look\n```\nnot closed", a.body); - try std.testing.expect(it.next() == null); -} - -test "an empty fence is an empty code segment" { - var it: Segments = .{ .text = "```\n```" }; - const a = it.next().?; - try std.testing.expectEqual(.code, a.kind); - try std.testing.expectEqualStrings("", a.body); - try std.testing.expect(it.next() == null); -} diff --git a/plugins/chat/static/integration.zig b/plugins/chat/static/integration.zig deleted file mode 100644 index 4dd2a2dd9..000000000 --- a/plugins/chat/static/integration.zig +++ /dev/null @@ -1,63 +0,0 @@ -//! Chat plugin — fizzy-internal static-embed + bundled-dylib module graph. -//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. -const std = @import("std"); -const helpers = @import("../../shared/build/helpers.zig"); - -pub const id = "chat"; -pub const installDylib = helpers.installDylib; - -const module_path = "plugins/chat/plugin.zig"; -const zon_path = "plugins/chat/plugin.zig.zon"; - -pub const ModuleImports = struct { - dvui: *std.Build.Module, - core: *std.Build.Module, - sdk: *std.Build.Module, - proxy_bridge: ?*std.Build.Module = null, -}; - -fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { - module.addImport("dvui", imports.dvui); - module.addImport("core", imports.core); - module.addImport("fizzy_sdk", imports.sdk); - if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); -} - -/// Static `@import("chat")` module for exe / web / tests. -pub fn addStaticModule( - b: *std.Build, - target: std.Build.ResolvedTarget, - optimize: std.lang.OptimizeMode, - imports: ModuleImports, - consumer: *std.Build.Module, -) *std.Build.Module { - const mod = helpers.addStaticModule(b, .{ - .import_name = id, - .root_source_file = b.path(module_path), - .target = target, - .optimize = optimize, - }, consumer); - // Shared with `addDylib` below via `pluginOptionsFor`'s per-manifest memoization — both - // link modes must attach the *same* options step (see its doc comment for why). - mod.addOptions(helpers.plugin_options_import, helpers.pluginOptionsFor(b, zon_path)); - applyImports(mod, imports); - return mod; -} - -/// Native dynamic library bundled beside the app (`chat.dylib` / `.dll` / `.so`). -pub fn addDylib( - b: *std.Build, - target: std.Build.ResolvedTarget, - optimize: std.lang.OptimizeMode, - imports: ModuleImports, -) *std.Build.Step.Compile { - const created = helpers.addDylib(b, .{ - .root_source_file = b.path(module_path), - .manifest_zon_path = zon_path, - .sdk = imports.sdk, - .target = target, - .optimize = optimize, - }); - applyImports(created.module, imports); - return created.lib; -} diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index f35d9f30f..ea3ce2a27 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -231,40 +231,29 @@ 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 -/// shown, if any, and the answer over the person's room and that pane's. Null before the person's +/// 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, room: ?core.placement.Size, answer: ?core.placement.Result }; +const Beside = struct { other: ?u64, answer: ?core.placement.Result }; -/// A pane other than `here` that is not sliding shut. -fn otherPane(self: *Workbench, here: u64) ?u64 { - for (self.workspaces.values()) |ws| { - if (ws.grouping != here and !self.paneClosing(ws.grouping)) return ws.grouping; - } - return null; -} - -fn besideAnswer(self: *Workbench, here: u64) Beside { - const room = self.paneSize(here) orelse return .{ .other = self.otherPane(here), .room = null, .answer = null }; - // `place` takes the first of these whatever its size, so a pane is a candidate only if the - // person can be sent to it: drawn, and in a place of the app's. +fn besideAnswer(self: *Workbench) Beside { + const here = self.open_workspace_grouping; + 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 }; var others: [1]core.placement.Size = undefined; - var other: ?u64 = null; - for (self.workspaces.values()) |ws| { - if (ws.grouping == here or self.paneClosing(ws.grouping)) continue; - if (self.places) |p| if (!p.placed(p.ctx, ws.grouping)) continue; - others[0] = self.paneSize(ws.grouping) orelse continue; - other = ws.grouping; - break; - } - const places: []const core.placement.Size = if (other != null) &others else &.{}; - return .{ .other = other, .room = room, .answer = core.placement.place(room, places) }; + 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) }; } /// 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(self.open_workspace_grouping); + const asked = self.besideAnswer(); // 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) { @@ -274,12 +263,11 @@ pub fn besideGrouping(self: *Workbench) u64 { }; } -/// 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) { +/// 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) { .split => sideFor(room.w, room.h), .into, .tab => null, }; diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index a4eef97db..b9ae713ac 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -161,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(self.grouping) != null) core.anchor.mark(pane_box, "workbench.beside-room", .{}); + runtime.workbench().besideEdge() != null) core.anchor.mark(pane_box, "workbench.beside-room", .{}); if (tabs.len > 0) { self.dropSnapshot(); diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh index f866248e8..9e7d1ce17 100755 --- a/scripts/check-examples.sh +++ b/scripts/check-examples.sh @@ -20,8 +20,6 @@ # # 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 @@ -50,32 +48,29 @@ 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)" -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 +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 +(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 +) # The plugin build installs the plugin: into a profile of its own, not this machine's fizzy. export FIZZY_PROFILE="$work/profile" @@ -112,6 +107,8 @@ 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 @@ -122,10 +119,8 @@ 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 -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 +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. @@ -143,63 +138,3 @@ if ! grep -q 'fizzy\.plugins\.workbench' "$work/log-app-only.txt"; then 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