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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
6 changes: 5 additions & 1 deletion build/plugins.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions plugins/guides/build.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Standalone build for the guides plugin — the canonical third-party shape.
//! `cd plugins/guides && zig build` produces `guides.<dylib|dll|so>`. 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, .{});
}
19 changes: 19 additions & 0 deletions plugins/guides/build.zig.zon
Original file line number Diff line number Diff line change
@@ -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",
},
},
}
65 changes: 65 additions & 0 deletions plugins/guides/plugin.zig
Original file line number Diff line number Diff line change
@@ -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);
}
8 changes: 8 additions & 0 deletions plugins/guides/plugin.zig.zon
Original file line number Diff line number Diff line change
@@ -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" },
}
78 changes: 78 additions & 0 deletions plugins/guides/src/List.zig
Original file line number Diff line number Diff line change
@@ -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);
}
63 changes: 63 additions & 0 deletions plugins/guides/static/integration.zig
Original file line number Diff line number Diff line change
@@ -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;
}
Loading