diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 3958a9056..d302a1583 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -25,7 +25,8 @@ a `Stage`; fizzy's glass overlay and its plugin service are host framework (`app | Where | How | |---|---| -| Command palette | **Demo: A Tour of Fizzy**, **Demo: Markdown, Previewed as You Type**; **Demo: Play / Pause**, **Demo: Restart**, **Demo: Stop** | +| Command palette | **Demo: A Tour of Fizzy**, **Demo: Markdown, Previewed as You Type**; **Demo: Play / Pause**, **Demo: Restart**, **Demo: Stop**; **Demo: Play by Name…** (`fizzy.demo.play`, with a `name` and a start `at`) plays any demo the `demos` service lists, a plugin's too | +| A plugin | `host.registerDemo(.{ .name, .title, .tape })` in `register` adds its demo to the list; the `demos` service (`sdk.services.demos`) lists every demo and plays one by name | | Menu | Help › Take the Tour | | Desktop | `FIZZY_DEMO=tour fizzy` plays from launch (how a screen recording is made); `FIZZY_DEMO=.zon` (or `.tape`) plays a tape file, a live one through the live driver | | Web | `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`) fetches a tape and plays it. Add `&storage=` to an embed so it keeps its own settings and layout | diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index e63b28a8a..767bb1593 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1322,6 +1322,7 @@ The SDK ships definitions for the services in this ecosystem, in | `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | | `"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 while it plays; refused over unsaved work or while a live tape drives. For a guides browser, a plugin's own demos, an agent | Everything a plugin might reasonably ask *fizzy* for lives on `Host` instead — `openFile`, `closeDocById`, `docFromPath`, `revealPosition`, `setProjectFolder`, `isPathIgnored`, … — where it diff --git a/sdk/src/Demo.zig b/sdk/src/Demo.zig new file mode 100644 index 000000000..709e5ff6b --- /dev/null +++ b/sdk/src/Demo.zig @@ -0,0 +1,20 @@ +//! A demo a plugin ships: a tape that opens on a keyframe and plays the real app, as fizzy's own +//! tour does (`docs/AUTOMATION.md`). Registered with `Host.registerDemo` in `register`, it is +//! listed and played beside fizzy's bundled demos through the `demos` service +//! (`sdk.services.demos`): by a guides browser, a command (`fizzy.demo.play`), an agent. +//! +//! The tape crosses the boundary as bytes only, ZON (`Tape.write`) or binary (`tape.binary`): a +//! plugin builds it with the SDK's `tape` module (`tape.Script`, then `finish` and an encode) and +//! hands the encoding over. The host keeps its own copy of everything here, so nothing points +//! into the plugin's image once `registerDemo` returns, and drops it when the plugin goes. +const Plugin = @import("Plugin.zig"); + +/// Stable id: what `fizzy.demo.play` and the `demos` service name it by. Unique among every demo +/// the app has, its own included: a second of one name is refused. +name: []const u8, +/// What a person reads in a list of guides. +title: []const u8, +/// The tape, encoded. Checked when the app lists it: one that does not load as a demo (a live +/// tape, a step no plugin applies) is left out of the list, with a warning. +tape: []const u8, +owner: ?*Plugin = null, diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 6b9f62555..2f6892535 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -34,6 +34,7 @@ pub const RailItemContribution = @import("menus.zig").RailItemContribution; pub const OpenAction = @import("menus.zig").OpenAction; pub const accounts = @import("accounts.zig"); pub const Command = @import("Command.zig"); +pub const Demo = @import("Demo.zig"); pub const Claim = @import("Claim.zig"); pub const Step = @import("Step.zig"); pub const Author = @import("Author.zig"); @@ -260,6 +261,10 @@ menu_context: ?EditorAPI.MenuContext = null, rail_items: std.ArrayListUnmanaged(RailItemContribution) = .empty, /// More ways to open something, listed beside fizzy's own (`OpenAction`). open_actions: std.ArrayListUnmanaged(OpenAction) = .empty, +/// Plugins' demos (`registerDemo`), each string and tape the host's own copy. +demos: std.ArrayListUnmanaged(Demo) = .empty, +/// Moves whenever `demos` changes, so the app lists them again only then. +demos_generation: u32 = 0, /// Who the user is signed in as, per service (`accounts.Provider`). The host draws the disc. account_providers: std.ArrayListUnmanaged(accounts.Provider) = .empty, /// Pure-data menu leaf items the native (macOS NSMenu) menu builder consumes; see @@ -383,6 +388,8 @@ pub fn deinit(self: *Host) void { self.menu_sections.deinit(self.allocator); self.rail_items.deinit(self.allocator); self.open_actions.deinit(self.allocator); + for (self.demos.items) |d| self.freeDemo(d); + self.demos.deinit(self.allocator); self.account_providers.deinit(self.allocator); self.native_menu_items.deinit(self.allocator); self.commands.deinit(self.allocator); @@ -1052,6 +1059,7 @@ fn dropRegistrations(self: *Host, plugin: *Plugin) void { removeOwned(MenuSectionContribution, &self.menu_sections, plugin); removeOwned(OpenAction, &self.open_actions, plugin); removeOwned(RailItemContribution, &self.rail_items, plugin); + self.removeDemosOf(plugin); removeOwned(accounts.Provider, &self.account_providers, plugin); removeOwned(NativeMenuItem, &self.native_menu_items, plugin); self.removeCommandsOf(plugin); @@ -1600,6 +1608,41 @@ pub fn registerRailItem(self: *Host, item: RailItemContribution) !void { try self.rail_items.append(self.allocator, item); } +/// Add a demo the app lists and plays beside its own — see `Demo`. Copied: nothing of `demo` is +/// kept. A name already registered is refused (`error.DemoExists`); the app refuses one of its own +/// names when it lists them. +pub fn registerDemo(self: *Host, demo: Demo) !void { + for (self.demos.items) |d| if (std.mem.eql(u8, d.name, demo.name)) return error.DemoExists; + const name = try self.allocator.dupe(u8, demo.name); + errdefer self.allocator.free(name); + const title = try self.allocator.dupe(u8, demo.title); + errdefer self.allocator.free(title); + const tape = try self.allocator.dupe(u8, demo.tape); + errdefer self.allocator.free(tape); + try self.demos.append(self.allocator, .{ .name = name, .title = title, .tape = tape, .owner = demo.owner }); + self.demos_generation +%= 1; +} + +fn freeDemo(self: *Host, d: Demo) void { + self.allocator.free(d.name); + self.allocator.free(d.title); + self.allocator.free(d.tape); +} + +fn removeDemosOf(self: *Host, plugin: *Plugin) void { + var w: usize = 0; + for (self.demos.items) |d| { + if (d.owner == plugin) { + self.freeDemo(d); + continue; + } + self.demos.items[w] = d; + w += 1; + } + if (w != self.demos.items.len) self.demos_generation +%= 1; + self.demos.items.len = w; +} + /// Add a way to open something beside Open Folder / Open Files — see `OpenAction`. pub fn registerOpenAction(self: *Host, action: OpenAction) !void { try self.open_actions.append(self.allocator, action); @@ -2990,6 +3033,33 @@ test "pluginForExtension honors a user assignment and ignores a stale one" { Fake.override = null; } +test "registerDemo keeps its own copy, refuses a name twice, and a plugin's demos go with it" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var plugin = Plugin{ .state = undefined, .vtable = &vtable, .id = "guide", .display_name = "Guide" }; + try host.registerPlugin(&plugin); + + var name_buf = "tour2".*; + var tape_buf = ".{}".*; + const before = host.demos_generation; + try host.registerDemo(.{ .name = &name_buf, .title = "Another tour", .tape = &tape_buf, .owner = &plugin }); + // Nothing points into what the plugin handed over. + name_buf[0] = 'x'; + tape_buf[0] = 'x'; + try testing.expectEqualStrings("tour2", host.demos.items[0].name); + try testing.expectEqualStrings(".{}", host.demos.items[0].tape); + try testing.expect(host.demos_generation != before); + + try testing.expectError(error.DemoExists, host.registerDemo(.{ .name = "tour2", .title = "Again", .tape = "" })); + try testing.expectEqual(@as(usize, 1), host.demos.items.len); + + const registered = host.demos_generation; + host.unregisterPlugin(&plugin); + try testing.expectEqual(@as(usize, 0), host.demos.items.len); + try testing.expect(host.demos_generation != registered); +} + test "unregisterPlugin removes a plugin's contributions, service, and resets active ids" { const noopDraw = struct { fn f(_: ?*anyopaque) anyerror!void {} diff --git a/sdk/src/sdk.zig b/sdk/src/sdk.zig index 71f97f854..8ce17c7e9 100644 --- a/sdk/src/sdk.zig +++ b/sdk/src/sdk.zig @@ -70,6 +70,8 @@ pub const MenuSectionContribution = menus.MenuSectionContribution; pub const NativeMenuItem = menus.NativeMenuItem; /// A named action fizzy invokes by id, without knowing what it does. pub const Command = @import("Command.zig"); +/// A demo a plugin ships (`Host.registerDemo`), played through the `demos` service. +pub const Demo = @import("Demo.zig"); pub const Claim = @import("Claim.zig"); /// One step of a document's history, recorded (`Host.recordStep`) and applied (`Plugin.applyStep`). pub const Step = @import("Step.zig"); @@ -109,7 +111,7 @@ pub const Manifest = @import("Manifest.zig"); /// Services: capability offered by name and version rather than by the ABI. /// -/// `"files"` and `"automation"` are the application's; the rest are plugins'. Every one of them is +/// `"files"`, `"automation"` and `"demos"` are the application's; the rest are plugins'. Every one of them is /// optional — a caller handles null and offers what it can, which is what lets one plugin build /// run in applications that have wildly different amounts of machinery. pub const services = struct { @@ -118,6 +120,7 @@ pub const services = struct { pub const markdown = @import("services/markdown.zig"); pub const wikilink = @import("services/wikilink.zig"); pub const automation = @import("services/automation.zig"); + pub const demos = @import("services/demos.zig"); pub const plugins = @import("services/plugins.zig"); }; diff --git a/sdk/src/services/demos.zig b/sdk/src/services/demos.zig new file mode 100644 index 000000000..5bb52d027 --- /dev/null +++ b/sdk/src/services/demos.zig @@ -0,0 +1,87 @@ +//! The `demos` service: the demos the app can play, and playing one. A demo opens on a keyframe +//! and plays the real app, setting the person's session aside while it plays and giving it back +//! when it ends (`docs/AUTOMATION.md`). Not a live tape, which plays on whatever is open: that is +//! the `automation` service. +//! +//! The app's own demos are listed here (fizzy's tour), with every demo a plugin registers +//! (`Host.registerDemo`) and, on the web, the guides hosted beside the page. A guides browser lists +//! them and plays the one a person picks; an agent can play one by name. +//! +//! Every call is on the UI thread. Optional, like every service: an app with no demo player +//! registers none. + +pub const Api = struct { + /// Bump whenever this struct's layout changes: `getServiceTyped` refuses a provider whose + /// version differs rather than reinterpreting one shape as another across `dlopen`. + pub const service_version: u32 = 1; + pub const service_name = "demos"; + + ctx: *anyopaque, + vtable: *const VTable, + + pub const Origin = enum(u8) { + /// Built into the app. + bundled, + /// Registered by a plugin (`Host.registerDemo`). + plugin, + /// Hosted beside the web page, fetched when it is played. + hosted, + }; + + pub const Chapter = struct { + /// Where it begins, in demo milliseconds. + at: u32, + title: []const u8, + }; + + pub const Info = struct { + name: []const u8, + title: []const u8, + /// How long it plays, start to end, in demo milliseconds. + ms: u32, + chapters: []const Chapter, + origin: Origin, + }; + + pub const Play = enum(u8) { + /// Playing from the next frame. + started, + /// No demo of that name. + unknown, + /// Something else is driving the app (a live tape): try again once it is done. + busy, + /// The person has unsaved work open, which a demo would set aside: they are told so. + unsaved, + }; + + pub const VTable = struct { + list: *const fn (ctx: *anyopaque) []const Info, + play: *const fn (ctx: *anyopaque, name: []const u8, at_ms: u32) Play, + current: *const fn (ctx: *anyopaque) ?[]const u8, + generation: *const fn (ctx: *anyopaque) u32, + }; + + /// Every demo the app can play, its own first. Owned by the app and valid until `generation` + /// changes: a caller keeping a name or a title past that copies it. + pub fn list(self: Api) []const Info { + return self.vtable.list(self.ctx); + } + + /// Play the demo named `name` from `at_ms` (0: from the start; a chapter's `at` starts at that + /// chapter). A demo already playing is replaced. + pub fn play(self: Api, name: []const u8, at_ms: u32) Play { + return self.vtable.play(self.ctx, name, at_ms); + } + + /// The name of the demo loaded now (playing, paused or at its end), or null. Owned by the + /// app, valid for the frame. + pub fn current(self: Api) ?[]const u8 { + return self.vtable.current(self.ctx); + } + + /// Moves whenever `list` would answer differently: a plugin's demos came or went, or the + /// hosted guides arrived. Cheap to ask every frame. + pub fn generation(self: Api) u32 { + return self.vtable.generation(self.ctx); + } +}; diff --git a/sdk/src/version.zig b/sdk/src/version.zig index fbeb856d5..a26dfa022 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xabf10ed6afd68ff2; +pub const recorded_sdk_shape_fingerprint: u64 = 0xb37557f4594fec7b; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 998fefd29..d1267a4b4 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -179,10 +179,9 @@ pub fn play(self: *Demo, name: []const u8) !void { /// One that opens on no keyframe is a live tape, played on the app as it is (`live`). pub fn playTape(self: *Demo, bytes: []const u8) !void { const gpa = self.editor.app.gpa; - var check = automation.Input.check; - check.steps = stepsCheck; - var owned = automation.Tape.load(gpa, bytes, check) catch |err| switch (err) { + var owned = loadDemoTape(gpa, bytes) catch |err| switch (err) { error.NoKeyframeAtStart => { + var check = automation.Input.check; check.live = true; const live = automation.Tape.load(gpa, bytes, check) catch |e| { dvui.log.err("demo: could not read the live tape: {t}", .{e}); @@ -205,6 +204,14 @@ pub fn playTape(self: *Demo, bytes: []const u8) !void { self.player.load(owned, .{}); } +/// `bytes` as a demo's tape, checked as fizzy plays one: its chords in the keymap's spelling, its +/// steps ones a loaded plugin applies (`stepsCheck`). +pub fn loadDemoTape(gpa: std.mem.Allocator, bytes: []const u8) !automation.Tape.Owned { + var check = automation.Input.check; + check.steps = stepsCheck; + return automation.Tape.load(gpa, bytes, check); +} + /// `FIZZY_DEMO` naming a tape file (`.zon` or `.tape`) rather than a bundled demo: read in the /// app's `main`, while the working directory is still the one it was launched from, and played /// once the editor is up (`Editor.postInit`). Owned by the allocator it was read with. @@ -243,17 +250,22 @@ fn queue(self: *Demo, p: Pending) void { /// A demo replaces the open documents, so it waits for unsaved work to be saved or closed. A /// demo's own documents are not anyone's work: one demo may replace another. fn mayStart(self: *Demo) bool { + return self.startable() == .started; +} + +/// Whether a demo can start now (`.started`), and if not why, told to the person as a toast too. +pub fn startable(self: *Demo) sdk.services.demos.Api.Play { if (self.live.playing()) { dvui.toast(@src(), .{ .message = "Something is driving fizzy right now — the demo can start once it has finished." }); - return false; + return .busy; } for (self.editor.app.open_files.values()) |doc| { if (!doc.owner.isDirty(doc)) continue; if (std.mem.startsWith(u8, doc.owner.documentPath(doc), "demo://")) continue; dvui.toast(@src(), .{ .message = "Save or close your changes first — a demo replaces the open files while it plays." }); - return false; + return .unsaved; } - return true; + return .started; } /// Once a frame, before anything reads events. diff --git a/src/editor/Demos.zig b/src/editor/Demos.zig new file mode 100644 index 000000000..f1edf2d04 --- /dev/null +++ b/src/editor/Demos.zig @@ -0,0 +1,168 @@ +//! The `demos` service (`sdk.services.demos`): every demo fizzy can play, and playing one. Fizzy's +//! own (`Demo.catalog`) first, then each a plugin registered (`Host.registerDemo`). +//! +//! The list is built when it is first asked for and again only once the host's demos change +//! (`Host.demos_generation`): each bundled script is built once to read its length and chapters, +//! and each plugin's tape is loaded as a demo would be, so one that would not play (a live tape, +//! a step no plugin applies, a name fizzy already has) is left out with a warning rather than +//! listed and then refused. +const Demos = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const sdk = fizzy.sdk; +const automation = @import("app").automation; +const Editor = @import("Editor.zig"); +const Demo = @import("Demo.zig"); + +const Api = sdk.services.demos.Api; + +editor: *Editor, +/// The value registered (`Host.registerService`), pointing back here. +api: Api = undefined, +/// Holds `infos` and every string and chapter list in them, until the next build. +arena: std.heap.ArenaAllocator, +infos: std.ArrayListUnmanaged(Api.Info) = .empty, +/// The host's demos generation `infos` was built for; null: never built. +built_for: ?u32 = null, +/// What `play` last started, while it is the tape loaded: its name (gpa), and the tape's ops, +/// which no other load shares, to tell it from a tape loaded another way since. +playing: ?struct { name: []u8, ops: [*]const automation.Tape.Op } = null, + +/// Inert until `init`: nothing listed, and `deinit` has nothing to free. +pub const detached: Demos = .{ .editor = undefined, .arena = .init(std.heap.page_allocator) }; + +pub fn init(editor: *Editor) Demos { + return .{ .editor = editor, .arena = .init(editor.app.gpa) }; +} + +/// Fill in `api`. `self` must not move afterwards (it is the service's context). +pub fn bind(self: *Demos) void { + self.api = .{ .ctx = self, .vtable = &vtable }; +} + +pub fn deinit(self: *Demos) void { + self.forgetPlaying(); + self.arena.deinit(); +} + +const vtable: Api.VTable = .{ + .list = list, + .play = play, + .current = current, + .generation = generation, +}; + +fn from(ctx: *anyopaque) *Demos { + return @ptrCast(@alignCast(ctx)); +} + +fn generation(ctx: *anyopaque) u32 { + return from(ctx).editor.app.host.demos_generation; +} + +fn list(ctx: *anyopaque) []const Api.Info { + const self = from(ctx); + if (self.built_for != self.editor.app.host.demos_generation) self.build(); + return self.infos.items; +} + +fn build(self: *Demos) void { + _ = self.arena.reset(.retain_capacity); + self.infos = .empty; + self.built_for = self.editor.app.host.demos_generation; + const gpa = self.editor.app.gpa; + for (&Demo.catalog.entries) |*e| { + var script: automation.Script = .init(gpa, e.name, e.title); + script.check = automation.Input.check; + e.build(&script) catch |err| { + script.deinit(); + dvui.log.warn("demos: '{s}' did not build: {t}", .{ e.name, err }); + continue; + }; + var owned = script.finish() catch |err| { + dvui.log.warn("demos: '{s}' did not build: {t}", .{ e.name, err }); + continue; + }; + defer owned.deinit(); + self.add(e.name, e.title, owned.tape, .bundled) catch return; + } + for (self.editor.app.host.demos.items) |d| { + if (self.find(d.name) != null) { + dvui.log.warn("demos: a plugin's demo is named '{s}', as one fizzy has: it is left out", .{d.name}); + continue; + } + var owned = Demo.loadDemoTape(gpa, d.tape) catch |err| { + dvui.log.warn("demos: '{s}' does not play as a demo: {t}", .{ d.name, err }); + continue; + }; + defer owned.deinit(); + self.add(d.name, d.title, owned.tape, .plugin) catch return; + } +} + +fn add(self: *Demos, name: []const u8, title: []const u8, tape: automation.Tape, origin: Api.Origin) !void { + const a = self.arena.allocator(); + const chapters = try a.alloc(Api.Chapter, tape.chapters.len); + for (tape.chapters, chapters) |c, *out| out.* = .{ .at = c.at, .title = try a.dupe(u8, c.title) }; + try self.infos.append(a, .{ + .name = try a.dupe(u8, name), + .title = try a.dupe(u8, title), + .ms = tape.duration(), + .chapters = chapters, + .origin = origin, + }); +} + +fn find(self: *const Demos, name: []const u8) ?*const Api.Info { + for (self.infos.items) |*i| if (std.mem.eql(u8, i.name, name)) return i; + return null; +} + +fn play(ctx: *anyopaque, name: []const u8, at_ms: u32) Api.Play { + const self = from(ctx); + _ = list(ctx); + const info = self.find(name) orelse return .unknown; + const demo = &self.editor.demo; + const ready = demo.startable(); + if (ready != .started) return ready; + switch (info.origin) { + .bundled => demo.play(info.name) catch return .unknown, + .plugin => { + const d = for (self.editor.app.host.demos.items) |d| { + if (std.mem.eql(u8, d.name, info.name)) break d; + } else return .unknown; + demo.playTape(d.tape) catch return .unknown; + }, + // Fetched when played: the web's hosted guides (fizzyedit/plans#30, step 3). + .hosted => return .unknown, + } + const tape = demo.player.tape() orelse return .unknown; + self.forgetPlaying(); + if (self.editor.app.gpa.dupe(u8, info.name)) |n| { + self.playing = .{ .name = n, .ops = tape.ops.ptr }; + } else |_| {} + if (at_ms > 0) demo.player.seek(@floatFromInt(at_ms)); + return .started; +} + +fn current(ctx: *anyopaque) ?[]const u8 { + const self = from(ctx); + const p = self.playing orelse return null; + const tape = self.editor.demo.player.tape() orelse { + self.forgetPlaying(); + return null; + }; + if (tape.ops.ptr != p.ops) { + self.forgetPlaying(); + return null; + } + return p.name; +} + +fn forgetPlaying(self: *Demos) void { + const p = self.playing orelse return; + self.editor.app.gpa.free(p.name); + self.playing = null; +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index f601141ae..c7139b1da 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -95,6 +95,7 @@ pub const FileLoadJob = workbench_mod.FileLoadJob; pub const sdk = fizzy.sdk; /// Demo automation: fizzy's stage for `app.automation`, and the demos it ships with. pub const Demo = @import("Demo.zig"); +const Demos = @import("Demos.zig"); const TapeTabs = @import("TapeTabs.zig"); /// A float taken out of the main window into an OS window of its own: on by default on macOS, /// `FIZZY_POPOUT=1` elsewhere (`plans/POPOUT_WINDOWS_PLAN.md`). @@ -131,6 +132,8 @@ command_palette: @import("CommandPalette.zig") = .{}, /// The demo player and fizzy's stage for it. Attached in `postInit`, at the Editor's final /// address (the stage's context is this field). demo: Demo = .detached, +/// Every demo fizzy can play, as the `demos` service lists them. Bound in `postInit`, beside `demo`. +demos: Demos = .detached, /// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). tape_tabs: TapeTabs = .{}, /// The recording setting as last applied (`tick`); null before the first frame. @@ -2367,6 +2370,10 @@ pub fn postInit(editor: *Editor) !void { // The `automation` service: plugins play live tapes on the app as it is (`automation.Service`). // `editor.demo` was attached above, at its final address. try editor.app.host.registerService(sdk.services.automation.Api, &editor.demo.service.api, null); + // The `demos` service: fizzy's demos and plugins' (`Host.registerDemo`), listed and played. + editor.demos = .init(editor); + editor.demos.bind(); + try editor.app.host.registerService(sdk.services.demos.Api, &editor.demos.api, null); // Live external-edit reconciliation for settings.zon + dropped-in plugin discovery (see // R11/R12 in plans/PLUGIN_MANIFEST_PLAN.md). Must happen here, in `postInit`, not `init` — @@ -6812,6 +6819,7 @@ pub fn deinit(editor: *Editor) !void { // nothing it changed is written below. const demo_was_active = editor.demo.active(); editor.demo.deinit(); + editor.demos.deinit(); editor.tape_tabs.deinit(editor); editor.black_box.deinit(editor.app.gpa); editor.app.host.recorder = null; diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index bf71337bc..5b9790c5e 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -197,6 +197,7 @@ const automation_commands = [_]FizzyCommand{ .{ .id = "fizzy.demo.restart", .title = "Demo: Restart", .bind = null, .run = cmdDemoRestart, .isEnabled = demoLoaded, .icon = icons.tvg.lucide.@"rotate-ccw" }, .{ .id = "fizzy.demo.stop", .title = "Demo: Stop", .bind = null, .run = cmdDemoStop, .isEnabled = demoLoaded, .icon = icons.tvg.lucide.@"circle-stop" }, .{ .id = "fizzy.demo.playTape", .title = "Demo: Play a Tape…", .bind = null, .run = cmdPlayTape, .params = TapePath.params, .runWith = TapePath.bind(cmdPlayTapePath), .icon = icons.tvg.lucide.@"circle-play" }, + .{ .id = "fizzy.demo.play", .title = "Demo: Play by Name…", .bind = null, .params = DemoByName.params, .runWith = DemoByName.bind(cmdDemoPlayNamed), .icon = icons.tvg.lucide.@"circle-play" }, .{ .id = "fizzy.demo.speed", .title = "Demo: Speed…", .bind = null, .run = cmdDemoSpeedStep, .isEnabled = demoLoaded, .params = DemoSpeed.params, .runWith = DemoSpeed.bind(cmdDemoSpeed), .icon = icons.tvg.lucide.gauge }, } ++ demoCatalogCommands(); @@ -237,6 +238,23 @@ fn playTapeFile(editor: *Editor, path: []const u8) !void { }; } +const DemoByName = sdk.Command.Params(struct { + name: sdk.Command.Arg([]const u8, .{ .description = "The demo to play, by name: fizzy's own (\"tour\") or one a plugin registered. The `demos` service lists them." }) = .init(""), + at: sdk.Command.Arg(u32, .{ .description = "Where to start, in demo milliseconds: a chapter's start, say. 0 is the beginning." }) = .init(0), +}); + +/// Any demo the `demos` service lists (`Demos`), fizzy's or a plugin's. +fn cmdDemoPlayNamed(state: *anyopaque, args: DemoByName.Args, call: *sdk.Command.Call) anyerror!void { + if (args.name.len == 0) return call.fail("name the demo to play: the `demos` service lists them", .{}); + const api = editorFromState(state).demos.api; + switch (api.play(args.name, args.at)) { + .started => {}, + .unknown => return call.fail("no demo named '{s}'", .{args.name}), + .busy => return call.fail("something else is driving fizzy: a demo can start once it has finished", .{}), + .unsaved => return call.fail("there is unsaved work open, which a demo would set aside", .{}), + } +} + const DemoSpeed = sdk.Command.Params(struct { rate: sdk.Command.Arg(f32, .{ .description = "How many times the tape's own pace: 0.25, 0.5, 1, 2 or 4. 0 steps to the next.", .min = 0, .max = 4 }) = .init(0), }); diff --git a/tests/demos.zig b/tests/demos.zig index 34f176f43..e06c28986 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -10,6 +10,7 @@ const dvui = @import("dvui"); const fizzy = @import("fizzy"); const automation = @import("app").automation; const workbench = @import("workbench"); +const sdk = fizzy.sdk; /// The app's frame as `Entry` runs it: the demo player, the frame target, the editor. fn headlessFrame() !dvui.App.Result { @@ -138,8 +139,59 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la try std.testing.expectEqual(@as(u32, 0), p.mismatches); try std.testing.expectEqual(@as(u32, 0), p.timeouts); } + + // The `demos` service lists them, fizzy's own in its order, with each one's length and chapters. + const api = app.editor.demos.api; + const catalog = fizzy.Editor.Demo.catalog; + try std.testing.expectEqual(catalog.entries.len, api.list().len); + for (catalog.entries, api.list()) |e, info| { + try std.testing.expectEqualStrings(e.name, info.name); + try std.testing.expectEqual(sdk.services.demos.Api.Origin.bundled, info.origin); + try std.testing.expect(info.ms > 0); + } + + // A plugin's demo is listed beside them; one reusing a name fizzy has, and a live tape, are not. + const generation = api.generation(); + const guide = try encodedDemo("guide", true); + defer gpa.free(guide); + const live = try encodedDemo("live", false); + defer gpa.free(live); + const host = &app.editor.app.host; + try host.registerDemo(.{ .name = "a-guide", .title = "A guide", .tape = guide }); + try host.registerDemo(.{ .name = "tour", .title = "Not the tour", .tape = guide }); + try host.registerDemo(.{ .name = "a-live-tape", .title = "Live", .tape = live }); + try std.testing.expect(api.generation() != generation); + const listed = api.list(); + try std.testing.expectEqual(catalog.entries.len + 1, listed.len); + const added = listed[listed.len - 1]; + try std.testing.expectEqualStrings("a-guide", added.name); + try std.testing.expectEqualStrings("A guide", added.title); + try std.testing.expectEqual(sdk.services.demos.Api.Origin.plugin, added.origin); + try std.testing.expectEqual(@as(usize, 2), added.chapters.len); + + // It plays by name, to its end, named the one playing while it does. + try std.testing.expectEqual(sdk.services.demos.Api.Play.unknown, api.play("no-such-demo", 0)); + try std.testing.expectEqual(sdk.services.demos.Api.Play.started, api.play("a-guide", 0)); + try std.testing.expectEqualStrings("a-guide", api.current().?); + var frames: usize = 0; + while (p.state != .ended and frames < 2_000) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try std.testing.expectEqual(automation.Player.State.ended, p.state); + try std.testing.expectEqual(@as(u32, 0), p.timeouts); + + // From a chapter, it starts there. + const second = added.chapters[1].at; + try std.testing.expectEqual(sdk.services.demos.Api.Play.started, api.play("a-guide", second)); + frames = 0; + while (p.state == .seeking and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try std.testing.expect(p.seq.now >= @as(f64, @floatFromInt(second))); + + // A tape loaded some other way is not the one the service started. + try app.editor.demo.play("markdown"); + try std.testing.expectEqual(@as(?[]const u8, null), api.current()); + p.unload(); try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(@as(?[]const u8, null), api.current()); try std.testing.expectEqualStrings(site, app.editor.app.folder orelse ""); try std.testing.expectEqual(docs_before, app.editor.app.open_files.count()); @@ -167,3 +219,20 @@ fn openZipFromBytes(editor: *fizzy.Editor, name: []const u8, files: []const stru try std.testing.expect(std.mem.startsWith(u8, folder, "zip://")); return gpa.dupe(u8, folder); } + +/// A tape a plugin might register, encoded: a demo opening on a keyframe of one file, in two +/// chapters, or (`demo` false) a live tape of the same pauses, which a demo list leaves out. +fn encodedDemo(name: []const u8, demo: bool) ![]u8 { + const gpa = std.testing.allocator; + var s: automation.Script = .init(gpa, name, name); + errdefer s.deinit(); + s.check.live = !demo; + if (demo) try s.keyframe(.{ .root = "demo://guide", .files = &.{.{ .path = "notes.md", .text = "# Notes\n" }}, .open = &.{"notes.md"} }); + try s.chapter("One"); + s.pause(600); + try s.chapter("Two"); + s.pause(600); + var owned = try s.finish(); + defer owned.deinit(); + return automation.binary.encode(gpa, owned.tape, .{}); +}