diff --git a/build/app.zig b/build/app.zig index 87cbd8d30..42c2247a5 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1686,6 +1686,7 @@ fn addTests( // And the workbench's panes: whether a float lies over one is a reading of the window's // subwindows, which a headless frame with a float in it has. integration_module.addImport("workbench", workbench_module_test); + integration_module.addImport("dashboard", dashboard_module_test); integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") }); // The synthetic document with the 48KB table of wrapping cells — the case table-row culling // exists for, and the one it could get wrong. Generated by `markdown_sample_tables.py` beside diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 51107f50b..e5ebd7696 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1327,7 +1327,7 @@ 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 | -| `"dashboard.cards"` | the application, a team layer or a plugin (the `dashboard` plugin registers a demo of three cards only when none is there) | `list` the cards that need the person (id, title, source, the full body, and actions with an emphasis), `act` with the card and action the person picked (the provider does it and drops the card, so it leaves the count), `generation` moves when the list does. Cards in, actions out: the `dashboard` plugin draws them one at a time and knows nothing of where they come from. The provider owns every string | +| `"dashboard.cards"` | the application, a team layer or a plugin (the `dashboard` plugin shows a demo of three cards, without registering it, while none is there) | `list` the cards that need the person (id, title, source, the full body, and actions with an emphasis), `act` with the card and action the person picked (the provider does it and drops the card, so it leaves the count), `generation` moves when the list does. Cards in, actions out: the `dashboard` plugin draws them one at a time and knows nothing of where they come from. The provider owns every string | Everything a plugin might reasonably ask *fizzy* for lives on `Host` instead — `openFile`, `closeDocById`, `docFromPath`, `revealPosition`, `setProjectFolder`, `isPathIgnored`, … — where it diff --git a/plugins/dashboard/plugin.zig b/plugins/dashboard/plugin.zig index 2abbab8d4..e336175ee 100644 --- a/plugins/dashboard/plugin.zig +++ b/plugins/dashboard/plugin.zig @@ -4,15 +4,16 @@ //! them and hears which action the person picked. The plugin owns no cards and no colours of its //! own; it draws from the host's theme, so another app's look applies. //! -//! With no provider registered when it loads, it registers a small demo of three cards -//! (`src/demo_cards.zig`) so it can be tried; an app with a provider of its own never sees them. +//! With no provider registered, it shows a small demo of three cards (`src/demo_cards.zig`) so it +//! can be tried. The demo is never registered as the service, so a provider loaded at any time, +//! before or after this plugin, is the one listed; an app with one of its own never sees them. //! //! Commands: `dashboard.show` reveals the view wherever it lives; `dashboard.status` answers the //! carousel's state as ZON (how many cards, which is shown, how many are drawn), for an agent. const std = @import("std"); const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); -const View = @import("src/View.zig"); +pub const View = @import("src/View.zig"); const demo_cards = @import("src/demo_cards.zig"); /// Injected at build time from `plugin.zig.zon`. @@ -21,8 +22,6 @@ pub const plugin_id = plugin_options.id; pub const view_id = "dashboard.view"; -const Api = sdk.services.dashboard_cards.Api; - var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, @@ -38,10 +37,7 @@ pub fn register(host: *sdk.Host) !void { host_ptr = host; plugin.state = @ptrCast(host); try host.registerPlugin(&plugin); - if (host.getServiceTyped(Api) == null) { - demo_cards.reset(); - try host.registerService(Api, &demo_cards.api, &plugin); - } + demo_cards.reset(); try host.registerSurface(.{ .id = view_id, .owner = &plugin, @@ -87,7 +83,7 @@ fn cmdShow(state: *anyopaque) anyerror!void { fn cmdStatus(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { const host: *sdk.Host = @ptrCast(@alignCast(state)); - const count = if (host.getServiceTyped(Api)) |api| api.list().len else 0; + const count = View.provider(host).list().len; var out: std.Io.Writer.Allocating = .init(call.arena); try out.writer.print(".{{ .count = {d}, .index = {d}, .drawn = {d} }}\n", .{ count, View.carousel.index, View.drawn }); call.result = out.written(); @@ -96,4 +92,5 @@ fn cmdStatus(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { test { _ = @import("src/Carousel.zig"); _ = demo_cards; + _ = View; } diff --git a/plugins/dashboard/src/View.zig b/plugins/dashboard/src/View.zig index 3730fa0d9..ac486cc10 100644 --- a/plugins/dashboard/src/View.zig +++ b/plugins/dashboard/src/View.zig @@ -11,6 +11,7 @@ const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); const core = @import("core"); const Carousel = @import("Carousel.zig"); +const demo_cards = @import("demo_cards.zig"); const Api = sdk.services.dashboard_cards.Api; const Card = Api.Card; @@ -23,23 +24,39 @@ pub var carousel: Carousel = .{}; /// agent reads to know the carousel shows one at a time. pub var drawn: usize = 0; +/// The provider whose cards are shown: whichever the host has, else the demo's. The demo is a +/// fallback and not a registered service, so a provider loaded after the dashboard is the one +/// listed from its first frame. +pub fn provider(host: *sdk.Host) *Api { + return host.getServiceTyped(Api) orelse &demo_cards.api; +} + +/// An action the person picked this frame. The strings are the provider's, valid until `act`. +const Pick = struct { card_id: []const u8, action_id: []const u8 }; + pub fn draw(host: *sdk.Host) void { drawn = 0; var column = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(8) }); defer column.deinit(); - const api = host.getServiceTyped(Api) orelse { - _ = header(0, 0, null); - return note("This app has no cards to show."); - }; + const api = provider(host); + // Finish reading cards before `act` can free them. + const picked = drawCards(api) orelse return; + api.act(picked.card_id, picked.action_id); +} + +fn drawCards(api: *Api) ?Pick { const cards = api.list(); - const ids = host.arena().alloc(u64, cards.len) catch return; + const ids = dvui.currentWindow().arena().alloc(u64, cards.len) catch return null; for (cards, ids) |c, *id| id.* = Carousel.hashId(c.id); carousel.sync(ids); const go = header(carousel.index, cards.len, ids); if (go) |dir| _ = carousel.go(ids, dir); - if (cards.len == 0) return note("Nothing needs you."); + if (cards.len == 0) { + note("Nothing needs you."); + return null; + } var stage = dvui.overlay(@src(), .{ .expand = .both }); defer stage.deinit(); @@ -50,18 +67,20 @@ pub fn draw(host: *sdk.Host) void { const t = carousel.progress(dvui.frameTimeNS(), core.motion.durationMs(slide_ms)); const slide = carousel.slide; const shown = cards[carousel.index]; + var picked: ?Pick = null; if (t == null or slide == null) { - drawCard(api, shown, 0, .{ .x = 0, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); - return; + drawCard(&picked, shown, 0, .{ .x = 0, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); + return picked; } // Still moving: the next frame is wanted, and `slide` is what this frame ends on (it was // dropped by `progress` at the end, so its last frame is the card in place). dvui.refresh(null, @src(), stage.data().id); const off = Carousel.offsets(slide.?.dir, core.motion.settle(t.?)); - drawCard(api, shown, 0, .{ .x = off.entering * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); + drawCard(&picked, shown, 0, .{ .x = off.entering * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); if (slide.?.from) |f| { - drawCard(api, cards[f.index], 1, .{ .x = off.leaving * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); + drawCard(&picked, cards[f.index], 1, .{ .x = off.leaving * stage_rect.w, .y = 0, .w = stage_rect.w, .h = stage_rect.h }); } + return picked; } /// The title on the left; on the right the count and the two buttons. Returns the way the person @@ -98,7 +117,7 @@ fn button(name: []const u8, glyph: []const u8, enabled: bool, tag: []const u8, i } /// One card in `rect` (within the stage), whole: its source, title and body, then its actions. -fn drawCard(api: *Api, card: Card, id_extra: usize, rect: dvui.Rect) void { +fn drawCard(picked: *?Pick, card: Card, id_extra: usize, rect: dvui.Rect) void { drawn += 1; const theme = dvui.themeGet(); var box = dvui.box(@src(), .{ .dir = .vertical }, .{ @@ -135,8 +154,9 @@ fn drawCard(api: *Api, card: Card, id_extra: usize, rect: dvui.Rect) void { .primary => .highlight, .destructive => .err, }; - if (dvui.button(@src(), a.label, .{}, .{ .id_extra = i, .style = style, .margin = .{ .w = 6 } })) { - api.act(card.id, a.id); + const tag = dvui.currentWindow().arena().print("dashboard.action.{d}.{d}", .{ id_extra, i }) catch null; + if (dvui.button(@src(), a.label, .{}, .{ .id_extra = i, .tag = tag, .style = style, .margin = .{ .w = 6 } })) { + picked.* = .{ .card_id = card.id, .action_id = a.id }; } } } @@ -148,3 +168,134 @@ fn note(text: []const u8) void { fn dim() dvui.Color { return dvui.themeGet().color(.control, .text).opacity(0.6); } + +/// A provider whose cards live on the heap and are freed when one is answered, as a real one's do. +const HeapProvider = struct { + gpa: std.mem.Allocator, + cards: []Card = &.{}, + generation: u32 = 0, + answered: ?[]const u8 = null, + + fn add(self: *HeapProvider, id: []const u8, labels: []const []const u8) !void { + const actions = try self.gpa.alloc(Api.Action, labels.len); + for (actions, labels) |*a, l| a.* = .{ .id = try self.gpa.dupe(u8, l), .label = try self.gpa.dupe(u8, l) }; + const cards = try self.gpa.realloc(self.cards, self.cards.len + 1); + cards[cards.len - 1] = .{ .id = try self.gpa.dupe(u8, id), .title = try self.gpa.dupe(u8, id), .body = try self.gpa.dupe(u8, id), .actions = actions }; + self.cards = cards; + } + + fn freeCards(self: *HeapProvider) void { + for (self.cards) |c| { + for (c.actions) |a| { + self.gpa.free(a.id); + self.gpa.free(a.label); + } + self.gpa.free(c.actions); + self.gpa.free(c.id); + self.gpa.free(c.title); + self.gpa.free(c.body); + } + self.gpa.free(self.cards); + self.cards = &.{}; + } + + fn list(ctx: *anyopaque) []const Card { + const self: *HeapProvider = @ptrCast(@alignCast(ctx)); + return self.cards; + } + + fn act(ctx: *anyopaque, card_id: []const u8, action_id: []const u8) void { + const self: *HeapProvider = @ptrCast(@alignCast(ctx)); + self.answered = self.gpa.dupe(u8, action_id) catch null; + _ = card_id; + self.freeCards(); + self.generation +%= 1; + } + + fn currentGeneration(ctx: *anyopaque) u32 { + const self: *HeapProvider = @ptrCast(@alignCast(ctx)); + return self.generation; + } + + const vtable: Api.VTable = .{ .list = list, .act = act, .generation = currentGeneration }; + + fn api(self: *HeapProvider) Api { + return .{ .ctx = self, .vtable = &vtable }; + } +}; + +var test_host: *sdk.Host = undefined; + +fn testFrame() !dvui.App.Result { + draw(test_host); + return .ok; +} + +test "an action picked on a card is acted on after the card is drawn, not while the provider's list is in use" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 400 } }); + defer t.deinit(); + var host: sdk.Host = .init(std.testing.allocator); + defer host.deinit(); + test_host = &host; + + var heap: HeapProvider = .{ .gpa = std.testing.allocator }; + defer { + heap.freeCards(); + if (heap.answered) |a| heap.gpa.free(a); + } + try heap.add("card", &.{ "first", "second", "third" }); + var api = heap.api(); + try host.registerService(Api, &api, null); + + try dvui.testing.settle(testFrame); + try dvui.testing.moveTo("dashboard.action.0.0"); + try dvui.testing.click(.left); + // `act` frees the card the loop was walking; a later action button read from it would be a + // use after free, which the testing allocator and the filled freed memory turn into a crash. + _ = try dvui.testing.step(testFrame); + try std.testing.expectEqualStrings("first", heap.answered orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(@as(usize, 0), heap.cards.len); + try dvui.testing.settle(testFrame); +} + +test "a provider registered after the dashboard is the one listed, over the demo's cards" { + var host: sdk.Host = .init(std.testing.allocator); + defer host.deinit(); + try @import("../plugin.zig").register(&host); + try std.testing.expectEqual(&demo_cards.api, provider(&host)); + + var heap: HeapProvider = .{ .gpa = std.testing.allocator }; + defer heap.freeCards(); + try heap.add("mine", &.{"ok"}); + var api = heap.api(); + try host.registerService(Api, &api, null); + + try std.testing.expectEqual(&api, provider(&host)); + try std.testing.expectEqualStrings("mine", provider(&host).list()[0].id); +} + +test "cards with the same action id have distinct tags during a slide" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 400 } }); + defer t.deinit(); + var host: sdk.Host = .init(std.testing.allocator); + defer host.deinit(); + test_host = &host; + carousel = .{}; + defer carousel = .{}; + + var heap: HeapProvider = .{ .gpa = std.testing.allocator }; + defer heap.freeCards(); + try heap.add("one", &.{"approve"}); + try heap.add("two", &.{"approve"}); + var api = heap.api(); + try host.registerService(Api, &api, null); + + try dvui.testing.settle(testFrame); + const ids = [_]u64{ Carousel.hashId("one"), Carousel.hashId("two") }; + try std.testing.expect(carousel.go(&ids, .next)); + _ = try dvui.testing.step(testFrame); + try std.testing.expectEqual(@as(usize, 2), drawn); + const entering = try dvui.testing.tagGet("dashboard.action.0.0"); + const leaving = try dvui.testing.tagGet("dashboard.action.1.0"); + try std.testing.expect(entering.id != leaving.id); +} diff --git a/plugins/dashboard/src/demo_cards.zig b/plugins/dashboard/src/demo_cards.zig index 70535011d..32bed66e0 100644 --- a/plugins/dashboard/src/demo_cards.zig +++ b/plugins/dashboard/src/demo_cards.zig @@ -1,6 +1,7 @@ //! A tiny `dashboard.cards` provider of three made-up cards, so the dashboard can be tried before -//! an app has a provider of its own. The plugin registers it only when no other provider is there -//! (`plugin.zig`). Answering a card (any action) drops it, as a real provider does. +//! an app has a provider of its own. The view falls back to it when the host has none +//! (`View.provider`); it is never registered as the service. Answering a card (any action) drops +//! it, as a real provider does. const std = @import("std"); const sdk = @import("fizzy_sdk"); diff --git a/tests/integration.zig b/tests/integration.zig index 48214d971..bf28fd139 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -13050,7 +13050,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(host.callCommand("dashboard.show", "", host.arena()) == .ok); for (0..4) |_| _ = try dvui.testing.step(headlessFrame); try std.testing.expectEqualStrings("dashboard.view", host.selectionFor(sdk.keywords.ide.sidebar) orelse ""); - const cards = host.getServiceTyped(sdk.services.dashboard_cards.Api) orelse return error.TestUnexpectedResult; + const cards = @import("dashboard").View.provider(host); const before = try Reader.read(host); try std.testing.expectEqual(@as(usize, 3), before.count); try std.testing.expectEqual(@as(usize, 1), before.drawn);