diff --git a/AGENTS.md b/AGENTS.md index c17775474..953ce5725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register( - **`markdown`** — `.md` preview utility plugin. - **`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. - `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.** diff --git a/build/plugins.zig b/build/plugins.zig index 2e8d484a8..f7d1b8469 100644 --- a/build/plugins.zig +++ b/build/plugins.zig @@ -14,6 +14,7 @@ pub const markdown = @import("../plugins/markdown/static/integration.zig"); 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"); /// 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. @@ -24,9 +25,10 @@ pub const entries = struct { pub const archive: BundledPlugin = .{ .name = "archive" }; pub const markdown: BundledPlugin = .{ .name = "markdown" }; pub const devtools: BundledPlugin = .{ .name = "devtools" }; + pub const guides: BundledPlugin = .{ .name = "guides" }; /// What fizzy itself links, in its sidebar order. - pub const all = [_]BundledPlugin{ @This().workbench, @This().text, @This().image, @This().archive, @This().markdown, @This().devtools }; + pub const all = [_]BundledPlugin{ @This().workbench, @This().text, @This().image, @This().archive, @This().markdown, @This().devtools, @This().guides }; }; /// Whether `plugins` links fizzy's own `name`, rather than leaving it out or bringing a module of @@ -92,6 +94,8 @@ pub fn link(b: *std.Build, w: Wiring, plugins: []const BundledPlugin) Linked { archive.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) else if (std.mem.eql(u8, p.name, "devtools")) devtools.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + else if (std.mem.eql(u8, p.name, "guides")) + guides.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`)", .{p.name}); // A plugin this target does not build (markdown for the native exe on wasm) is left out. diff --git a/plugins/guides/build.zig b/plugins/guides/build.zig new file mode 100644 index 000000000..a49a0e8f5 --- /dev/null +++ b/plugins/guides/build.zig @@ -0,0 +1,13 @@ +//! Standalone build for the guides plugin — the canonical third-party shape. +//! `cd plugins/guides && zig build` produces `guides.`. 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/guides/build.zig.zon b/plugins/guides/build.zig.zon new file mode 100644 index 000000000..18d063bd4 --- /dev/null +++ b/plugins/guides/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = .guides, + .version = "0.1.0", + .minimum_zig_version = "0.17.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "plugin.zig", + "plugin.zig.zon", + "src", + "static", + }, + .fingerprint = 0x4d7795efb8fc0178, + .dependencies = .{ + .fizzy = .{ + .path = "../../sdk", + }, + }, +} diff --git a/plugins/guides/plugin.zig b/plugins/guides/plugin.zig new file mode 100644 index 000000000..5cbe8a80d --- /dev/null +++ b/plugins/guides/plugin.zig @@ -0,0 +1,65 @@ +//! Guides: every demo the app can play, a click away (fizzyedit/plans#30). One surface, a view on +//! the rail (`guides.view`) listing what the `demos` service lists — fizzy's own tour first, then +//! each a plugin registered (`Host.registerDemo`) — each with its length and chapters. A click on +//! a guide plays it from the start, a click on a chapter from there; the one playing is marked. +//! +//! It is drawn from what any plugin can reach (`sdk.services.demos`), which is the point of it +//! being a plugin: an app on fizzy bundles it or leaves it out, and one with no demo player gets +//! a view saying so. +//! +//! Commands: `guides.show` reveals the view wherever it lives. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const dvui = @import("dvui"); +const List = @import("src/List.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 = "guides.view"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = plugin_id, + .display_name = plugin_options.name, +}; + +const vtable: sdk.Plugin.VTable = .{}; + +var host_ptr: ?*sdk.Host = null; + +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 = "Guides", + .icon = .{ .tvg = dvui.entypo.graduation_cap }, + .keywords = sdk.keywords.ide.sidebar, + .scrolls_itself = true, + .draw = drawView, + }); + try host.registerCommand(.{ + .id = sdk.Plugin.commandId(plugin_id, "show"), + .owner = &plugin, + .title = "Show Guides", + .run = cmdShow, + .icon = dvui.entypo.graduation_cap, + }); +} + +fn drawView(_: ?*anyopaque) anyerror!dvui.App.Result { + const host = host_ptr orelse return .ok; + List.draw(host.getServiceTyped(sdk.services.demos.Api)); + return .ok; +} + +/// "Show Guides": the view, wherever it lives, its place opened if it was shut. +fn cmdShow(state: *anyopaque) anyerror!void { + const host: *sdk.Host = @ptrCast(@alignCast(state)); + _ = host.revealSurface(view_id); +} diff --git a/plugins/guides/plugin.zig.zon b/plugins/guides/plugin.zig.zon new file mode 100644 index 000000000..a6c136ad0 --- /dev/null +++ b/plugins/guides/plugin.zig.zon @@ -0,0 +1,8 @@ +.{ + .id = "guides", + .name = "Guides", + .version = "0.1.0", + .min_sdk_version = "", + .description = "Every demo the app can play, a click away: fizzy's tour and each plugin's own, by chapter.", + .tags = .{ "demos", "help" }, +} diff --git a/plugins/guides/src/List.zig b/plugins/guides/src/List.zig new file mode 100644 index 000000000..cb113da2b --- /dev/null +++ b/plugins/guides/src/List.zig @@ -0,0 +1,78 @@ +//! The guides view's one list: a row per demo (its title and length), and under it a row per +//! chapter (its title and where it starts). A row plays from where it says; the demo playing is +//! drawn as selected. The service refuses a demo over unsaved work or while a live tape drives, +//! and tells the person why itself. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const dvui = @import("dvui"); +const core = @import("core"); + +const Api = sdk.services.demos.Api; + +pub fn draw(service: ?*Api) void { + var scroll = dvui.scrollArea(@src(), .{ .horizontal = .none, .vertical_bar = .auto_overlay }, .{ .expand = .both, .background = false }); + defer scroll.deinit(); + var body = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .padding = .all(6) }); + defer body.deinit(); + + const api = service orelse { + dvui.labelNoFmt(@src(), "This app plays no demos.", .{}, .{ .color_text = .{ .color = dim() } }); + return; + }; + const demos = api.list(); + if (demos.len == 0) { + dvui.labelNoFmt(@src(), "No guides yet.", .{}, .{ .color_text = .{ .color = dim() } }); + return; + } + const playing = api.current(); + for (demos, 0..) |d, i| { + const current = if (playing) |p| std.mem.eql(u8, p, d.name) else false; + if (row(i * 1000, d.title, d.ms, current, .guide)) _ = api.play(d.name, 0); + for (d.chapters, 0..) |c, j| { + // The first chapter is where the guide starts: its row would say the same as the guide's. + if (c.at == 0) continue; + if (row(i * 1000 + j + 1, c.title, c.at, false, .chapter)) _ = api.play(d.name, c.at); + } + } +} + +const Kind = enum { guide, chapter }; + +/// A row the width of the view, `title` on the left and `ms` as a time on the right: a guide's +/// length, or where a chapter starts. True when clicked. +fn row(id_extra: usize, title: []const u8, ms: u32, selected: bool, kind: Kind) bool { + const theme = dvui.themeGet(); + var button: dvui.ButtonWidget = undefined; + button.init(@src(), .{ .draw_focus = true }, .{ + .id_extra = id_extra, + .expand = .horizontal, + .margin = .{ .x = if (kind == .chapter) 14 else 0, .y = if (kind == .guide) 4 else 0 }, + .padding = .{ .x = 6, .w = 6, .y = 3, .h = 3 }, + .color_fill = .{ .color = if (selected) theme.color(.highlight, .fill) else core.widgets.hoverRestFill(theme.color(.window, .fill_hover)) }, + .color_fill_hover = .{ .color = theme.color(.window, .fill_hover) }, + .color_fill_press = .{ .color = theme.color(.window, .fill_press) }, + }); + defer button.deinit(); + button.processEvents(); + button.drawBackground(); + + var line = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); + defer line.deinit(); + const text_color: dvui.Color = if (selected) theme.color(.highlight, .text) else if (kind == .chapter) dim() else theme.color(.window, .text); + // The time first: a box gives its children room in order, so it always fits and a long title + // is the one cut short. + var buf: [16]u8 = undefined; + dvui.labelNoFmt(@src(), clock(&buf, ms), .{}, .{ .gravity_x = 1, .gravity_y = 0.5, .margin = .{ .x = 8 }, .color_text = .{ .color = if (selected) text_color else dim() } }); + dvui.labelNoFmt(@src(), title, .{}, .{ .color_text = .{ .color = text_color }, .gravity_y = 0.5 }); + return button.clicked(); +} + +/// `ms` as m:ss. +fn clock(buf: []u8, ms: u32) []const u8 { + const s = ms / 1000; + return std.fmt.bufPrint(buf, "{d}:{d:0>2}", .{ s / 60, s % 60 }) catch ""; +} + +fn dim() dvui.Color { + return dvui.themeGet().color(.window, .text).opacity(0.6); +} diff --git a/plugins/guides/static/integration.zig b/plugins/guides/static/integration.zig new file mode 100644 index 000000000..cfea50b5a --- /dev/null +++ b/plugins/guides/static/integration.zig @@ -0,0 +1,63 @@ +//! Guides 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 = "guides"; +pub const installDylib = helpers.installDylib; + +const module_path = "plugins/guides/plugin.zig"; +const zon_path = "plugins/guides/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("guides")` module for exe / web / tests. +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.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 (`guides.dylib` / `.dll` / `.so`). +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.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; +}