Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/AUTOMATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<file>.zon` (or `.tape`) plays a tape file, a live one through the live driver |
| Web | `?demo=tour` plays a bundled demo; `?demo=<url>.zon` (or `.tape`) fetches a tape and plays it. Add `&storage=<name>` to an embed so it keeps its own settings and layout |
Expand Down
1 change: 1 addition & 0 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions sdk/src/Demo.zig
Original file line number Diff line number Diff line change
@@ -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,
70 changes: 70 additions & 0 deletions sdk/src/Host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {}
Expand Down
5 changes: 4 additions & 1 deletion sdk/src/sdk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
};

Expand Down
87 changes: 87 additions & 0 deletions sdk/src/services/demos.zig
Original file line number Diff line number Diff line change
@@ -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);
}
};
2 changes: 1 addition & 1 deletion sdk/src/version.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 18 additions & 6 deletions src/editor/Demo.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading