Fizzy is a near-empty shell — a window, a frame loop, and a menu/sidebar/panel layout — with
no editing features of its own. Everything the user sees (the file explorer, tabs/splits, the
pixel-art editor, the text editor) is contributed by plugins that register against a stable
SDK. The same plugin source compiles two ways: statically into the app, or as a runtime dynamic
library (.dylib/.so/.dll) that any third-party author can build and ship independently.
This doc is written progressively: it starts from an empty folder and ends with your plugin installable from the in-app store. Skim §1 for the mental model, then follow §2 onward in order the first time; use it as reference after that.
- The mental model
- Your first plugin, from an empty folder
- Making it do something: the SDK contract
- Two plugins working together
- Compatibility: SDK version and the ABI fingerprint
- Publishing to the store
- Reference
┌─────────────────────────────────────────────────────────┐
│ Shell (Editor) │
│ window · frame loop · menu/sidebar/panel layout · docs │
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Host │◄──────►│ EditorAPI │ │
│ │ registries │ reach │ (shell read/util surface │ │
│ │ + services │ back │ arena, folder, docs, …) │ │
│ └──────┬───────┘ └──────────────────────────┘ │
└──────────┼──────────────────────────────────────────────┘
│ register(host) + vtable calls
┌──────────┴───────────────┐ ┌────────────────────────┐
│ workbench plugin │ │ pixi plugin │
│ file tree · tabs/splits │ │ canvas editor │
└──────────────────────────┘ └────────────────────────┘
plugins never import each other — they meet only at the SDK
| Type | Role |
|---|---|
Host |
What the shell hands every plugin. Holds the registries (the shell iterates these instead of hardcoding panes) + a service locator for inter-plugin APIs. |
Plugin |
A plugin's identity + vtable of optional hooks. The shell calls these; a plugin implements only what it needs. |
DocHandle |
Opaque handle to an open document: { ptr, id, owner: *Plugin }. The shell stores these per tab and routes every document operation to owner — it never inspects ptr. |
regions |
The contribution structs a plugin registers: SidebarView, BottomView, CenterProvider, MenuContribution, Command, LanguageSupport, … There is no settings region here — see sdk.settings below. |
sdk.settings (settings.zig) |
Comptime settings API: sdk.settings.Schema(struct { … }) over Value(T, .{ .description = … }) cells derives a persisted-values type + a SettingsSchema you register with the Host. The shell's settings pane draws it for you — no hand-rolled dvui settings section. |
dylib / dvui_context |
The C-ABI entry contract + dvui-context injection used when a plugin is loaded as a runtime library. |
The shell owns no features. Each frame it iterates the Host registries and draws whatever
plugins contributed. Adding a pane, panel tab, menu, document type, or settings section is a
Host.register* call from inside a plugin's register — never a shell edit.
| Mode | Who | Targets | How it registers |
|---|---|---|---|
| Static | Built-in plugins (text, image, workbench, markdown) — always shipped with the app |
all, incl. web | shell calls plugin.register(&host) directly at startup |
| Dynamic | Third-party plugins (e.g. pixi) |
desktop only (no dlopen on web) |
shell dlopens the library and calls its fizzy_plugin_register C entry, which calls the same register(&host) |
Built-in plugins live in this repo and ship inside the signed app bundle; they are never
distributed or versioned separately. The dynamic path is the one this doc is mostly about: an
external Zig project depends on the SDK, implements the same Plugin interface, and ships a
loadable library — no different from a built-in except where it's registered from.
A plugin is a small, fixed set of files — no scaffolding tool needed. This section builds the smallest possible one by hand so every piece is visible; §3 grows it into something useful.
my-plugin/
plugin.zig.zon # identity only: id, name, version, min_sdk_version
build.zig.zon # fizzy dependency + .paths listing everything below
build.zig # fizzy.plugin.create + .install
plugin.zig # register(host) + Plugin vtable + your state — the one file you write
src/ # optional — split plugin.zig's code across files as it grows
Four files at the root, full stop. There is no root.zig and no per-plugin hub module to
maintain — the build generates the dylib's C-ABI entry point for you (see §2.4), and identity
lives in exactly one place: plugin.zig.zon.
// plugin.zig.zon
.{
.id = "my_plugin",
.name = "My Plugin",
.version = "0.1.0",
.min_sdk_version = "",
}This is identity only — id/name/version/min_sdk_version. There is no
hooks/contributes/settings list: capability is unaudited, and
plugin.zig's register(host) + vtable is the sole source of truth for what your plugin
actually does.
id— stable, snake_case, must match the installed dylib's basename (my_plugin.dylib/.so/.dll). This is also thePlugin.idyou set inplugin.zig— nothing enforces the two match beyond the loader's filename check, so keep them identical by hand.name— user-facing display name (store listing, plugin settings section header).version— your own release semver, validated (must parse asstd.SemanticVersion). Bump it on every release; the CI tag must equal it (see §6).min_sdk_version— minimum host SDK version required to load. Empty string ("") means "whatever SDK this build was compiled against, no floor enforced" — the build fills that in from the fizzy commit you pinned.
The build reads this file at configure time (fizzy.plugin.create, via readManifest) and also
embeds its raw text verbatim into the dylib (fizzy_plugin_manifest_zon), so a disabled/unloaded
plugin's identity can still be probed without a full register. There is no on-disk .zon
sidecar next to the installed dylib — the plugins directory holds only the built binary.
Fizzy isn't a package you install separately — a plugin depends on the fizzy repo itself as a
Zig package, pinned by a sdk-v<sdk_version> tag (e.g. sdk-v0.1.35), not an arbitrary
commit SHA. That tag is pushed automatically at the exact commit where the matching sdk_version
was recorded in src/sdk/version.zig (see §5) — pin against it and the ref you're reading in
build.zig.zon tells you, at a glance, which SDK contract you're building against. Use
zig fetch to fill in the hash — pin the SDK release asset, not the git archive of the tag:
mkdir my-plugin && cd my-plugin
zig fetch --save=fizzy \
https://github.com/fizzyedit/fizzy/releases/download/sdk-v0.1.42/fizzy-sdk-v0.1.42.tar.gzwhich produces:
// build.zig.zon
.{
.name = .my_plugin,
.version = "0.1.0",
.minimum_zig_version = "0.16.0",
.paths = .{ "build.zig", "build.zig.zon", "plugin.zig", "plugin.zig.zon", "src" },
.fingerprint = 0x0000000000000000, // zig fills this in on first `zig build`
.dependencies = .{
.fizzy = .{
.url = "https://github.com/fizzyedit/fizzy/releases/download/sdk-v0.1.42/fizzy-sdk-v0.1.42.tar.gz",
.hash = "<hash zig fetch printed>",
},
},
}Pick the sdk-v* tag that matches the SDK version you intend to support (see §5) — every
sdk_version bump gets a tag and a fizzy-sdk-v* release asset automatically
(.github/workflows/sdk-tag.yml + scripts/pack-sdk.sh). Built-in plugins in this repo use
.path = "../../../sdk" instead of a URL, since they live next to the fizzy checkout — a real
third-party plugin always pins a URL + hash against that release asset, because CI and other
users won't have a sibling checkout.
Depend on fizzy's sdk/ package (not the repo root). The root zon owns the editor and lists
app-only deps (Velopack, …) that must never enter a plugin's zig-pkg. Zig cannot depend on a
subdirectory of a URL archive, so the release asset is the sdk/ package with src/core +
src/sdk vendored in. Do not pin
https://github.com/fizzyedit/fizzy/archive/refs/tags/sdk-v….tar.gz — that is the full monorepo.
Locally: .path = "../fizzy/sdk" (or equivalent) while developing against an unreleased SDK.
sdk-v* tags are a separate namespace from fizzy's own v* app-release tags (§5) — they don't
trigger the app's build/package pipeline and never will (the release workflow's trigger is a glob
on the ref's start, and sdk-v... doesn't start with v).
const std = @import("std");
const fizzy = @import("fizzy");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const created = fizzy.plugin.create(b, .{ .target = target, .optimize = optimize });
fizzy.plugin.install(b, created.lib, .{});
}fizzy.plugin.create/.install (defined in fizzy's sdk/plugin_sdk.zig, exposed as
fizzy.plugin from the sdk/ package's build.zig) are the entire build-side surface a plugin
needs — no name/version to pass: create reads both (plus id and min_sdk_version)
straight from plugin.zig.zon (§2.2), so there is exactly one source of truth. Depending on the
sdk/ package exports the fizzy_sdk/core/dvui modules your plugin links against (the
.plugin_sdk = true option is still accepted for compatibility and is a no-op).
create returns .{ lib, module }: lib is the dylib artifact to pass to .install;
module is your plugin.zig module — attach any extra imports/options there (not to
lib.root_module, which is a generated wrapper, not your code) if your plugin needs extra
libraries (vendored C, packed assets). See fizzy.plugin.addCModule and
pixi's build.zig for a worked example
with vendored C deps.
Under create, the dylib's C-ABI entry point (the five symbols the host looks up at dlopen
time — ABI fingerprint check, the register entry, and injection hooks for the
allocator/*Host/dvui-context/render-bridge — see §7 for the full list) is a tiny generated
root module, not a file in your repo: there is no root.zig to copy or maintain. It reads your
plugin's identity from plugin.zig.zon and calls straight into your plugin.zig's register.
The smallest plugin that compiles and loads — no visible UI yet, just registration. Must
declare pub const plugin_options = @import("fizzy_plugin_options"); — the build-injected module
carrying plugin.zig.zon's parsed identity (.id, .name, .version, .min_sdk_version,
.manifest_zon). The generated dylib root (§2.4) reaches its own copy of that identity through
this export rather than importing the module itself, so it must be present verbatim under this
exact name even if you never read it yourself:
const sdk = @import("fizzy_sdk");
pub const plugin_options = @import("fizzy_plugin_options");
var plugin: sdk.Plugin = .{
.state = undefined,
.vtable = &vtable,
.id = plugin_options.id,
.display_name = plugin_options.name,
};
const vtable: sdk.Plugin.VTable = .{
.deinit = deinit,
};
var plugin_state: State = .{};
const State = struct {};
pub fn register(host: *sdk.Host) !void {
plugin.state = @ptrCast(&plugin_state);
try host.registerPlugin(&plugin);
}
fn deinit(_: *anyopaque) void {}No pub const manifest, no C-ABI boilerplate, and no hand-typed id/display_name string that
has to be kept in sync with plugin.zig.zon by convention — plugin_options is the single source
of truth for both this file and the generated dylib root.
Why pub const plugin_options is required, not optional: attaching the identity options
module to two separately-rooted modules within one build graph (the generated root and
plugin.zig) makes Zig's build system refuse it outright — "file exists in modules
'fizzy_plugin_options' and 'fizzy_plugin_options0'" — the moment either one actually references
the import. Exposing it once on plugin.zig and having the generated root reach through that
single export avoids the double attachment entirely.
zig build installThis builds the plugin for your current OS and drops my_plugin/my_plugin.<ext> — its own
directory, one per plugin — straight into the fizzy plugins directory the editor scans on
startup: no --prefix, no manual copy:
| OS | Plugins directory |
|---|---|
| macOS | ~/Library/Application Support/fizzy/plugins/ |
| Linux | ~/.config/fizzy/plugins/ |
| Windows | %LOCALAPPDATA%/fizzy/plugins/ |
Relaunch Fizzy. Your plugin registers, but since it contributes no sidebar view or menu yet, there's nothing to see — that's expected. Section 3 adds a visible pane.
zig build install also leaves zig-out/my_plugin/my_plugin.<ext> behind, which is what
packaging / the release CI in §6 grabs. Your plugin's own directory (Host.pluginInstallDir(id))
is also where it can store its own assets/data at runtime, if it needs to.
Fastest way to start a real plugin: copy
src/plugins/text/instead of typing all of the above by hand. It's an always-compiling document-owning editor plugin with every file already in the right place. For a minimal utility plugin (menus/views only), seesrc/plugins/markdown/.
pub fn register(host: *sdk.Host) !void {
plugin.state = @ptrCast(&plugin_state);
try host.registerPlugin(&plugin); // identity + vtable — always first
try host.registerSidebarView(.{ .id = "my_plugin.hello", .owner = &plugin, .title = "My Plugin", .draw = drawHello });
try host.registerBottomView(.{ … }); // a bottom-panel tab
try host.registerCenterProvider(.{ … }); // takes over the whole center region (workbench-style)
try host.registerMenu(.{ … }); // a top-level menubar entry
try host.registerMenuSection(.{ … }); // inject an item into a menu you don't own
try host.registerNativeMenuItem(.{ … }); // native macOS NSMenu leaf (mirrors a Menu*/MenuSection item)
try host.registerService("my_plugin", &api, &plugin); // an API other plugins can look up
try host.registerCommand(.{ … }); // an invocable action (see 3.4)
try host.registerFileRowFillColor(.{ … }); // tint file-tree rows for your file types
try host.registerFileIcon(.{ … }); // draw a custom file-tree icon
try host.registerPluginIcon(.{ … }); // optional loaded-plugin store icon fallback
}Each contribution struct (defined in src/sdk/regions.zig) takes a
stable, namespaced id, the owning *Plugin, and a draw/resolver fn. The shell renders the
set — and shows a tab strip automatically when more than one plugin contributes to the same
region. Everything registered here is torn down automatically on unload (disable/uninstall via
the plugin store) and re-added on load.
Store card icons (ICON.png). The Plugins tab fetches ICON.png from your plugin
repository (same homepage URL and subdirectory convention as README.md) so icons appear in
the store before installation. Commit ICON.png at your repo root (or under
src/plugins/<id>/ for built-ins in the fizzy monorepo). No copy goes into the central
fizzyedit/plugins registry — only README.md and
ICON.png are pulled from your repo at browse time. registerPluginIcon remains an optional
fallback when a loaded plugin has no fetchable ICON.png (e.g. a sideloaded dylib with no known
homepage).
There is no registerSettingsSection and no author-written settings schema in
plugin.zig.zon. Persisted settings are declared as a plain Zig struct (build-options-style,
comptime-derived) whose every field is a self-describing sdk.settings.Value cell, then
registered from register(host):
const MySettings = sdk.settings.Schema(struct {
insert_spaces_on_tab: sdk.settings.Value(bool, .{
.description = "Insert spaces instead of a tab character when pressing Tab.",
}) = .init(true),
tab_size: sdk.settings.Value(enum(u8) { two = 2, four = 4, eight = 8 }, .{
.description = "How many columns a tab occupies.",
}) = .init(.four),
format_on_save: sdk.settings.Value(bool, .{
.description = "Reformat the document each time it is saved.",
}) = .init(false),
});
var settings: MySettings.Cells = .{};
pub fn register(host: *sdk.Host) !void {
plugin.state = @ptrCast(&plugin_state);
try host.registerPlugin(&plugin);
MySettings.load(host, plugin.id, &settings); // apply any persisted blob
try MySettings.register(host, &plugin, .{
.title = "My Plugin",
.value = &settings, // shell draws shared controls
});
}
// Read a value with `.get()`, write one with `.set()`:
if (settings.format_on_save.get()) formatDocument(doc);Every setting describes itself. A cell's second (comptime) parameter is
settings.Options{ description, name, min, max, step }, and description has no default — a
setting declared without one is a compile error. The pane has a permanent place for it (see the
row shape below), so there is no shell-side table of strings that can drift from the plugin that
owns the setting. Metadata lives entirely in the type: @sizeOf(Value(T, …)) == @sizeOf(T),
and only the payload is ever stored or persisted.
The shell draws each setting as one group, VSCode-style: the name (derived from the field name —
insert_spaces_on_tab → Insert spaces on tab, or Options.name to override), the zon key
underneath in smaller dim mono, the wrapped description, and then the control at full width.
Booleans are the exception — their checkbox sits before the description on one line. Both the
name and the key are matched by the settings search, so users can find a row by either.
Schema(T) comptime-walks T's cells into a Setting table (key, label, description,
kind) — each entry's kind: Kind is a union(TypeTag) carrying only the metadata that payload
type actually uses (IntKind{min,max,choices}, FloatKind{min,max,step}, EnumKind{choices},
void for bool/string/color/other), rather than one flat struct with every type's bounds fields
present on every entry — and generates Cells (= T), Payloads (the bare-value mirror that is
what actually round-trips through zon), load/store (through
Host.loadPluginSettings/storePluginSettings), applyZon (parse a blob directly into the live
value — the primitive Access.applyBlob wraps with a settingsChanged notification, used by
external-change reconciliation, see below), and register (wires a SettingsSchema + typed
Access vtable into the Host).
Any type is a legal setting. bool/int/float/enum/[]const u8 get dedicated controls;
anything else std.zon can round-trip (a struct, an array, an optional) is TypeTag.other and
the pane draws it as its zon text in an editable entry — text that doesn't parse simply doesn't
commit. The on-disk shape is always the bare payload (.tab_size = 8), never the cell, so
settings.zon files written before cells existed keep loading unchanged.
Every plugin's block lives as a real, hand-editable nested ZON struct literal keyed by plugin
id inside the shell's own {config}/settings.zon:
.plugins = .{
.text = .{ .enabled = true, .settings = .{ .tab_size = 8 } },
.pixi = .{ .settings = .{ .grid_size = 16 } }, // disabled (`.enabled` omitted = false)
}
String fields own their memory. A []const u8 setting's bytes belong to the schema —
allocated with the host allocator by applyZon (loading, or an external hand-edit) and by the
settings pane's text entry through Access.setString — unless the slice is still exactly the
default you declared, which lives in your plugin image's constant data. Schema(T) tests that by
pointer identity, so it never hands a string literal to the allocator, and a persisted value that
happens to equal the default is still schema-owned (comparisons for persistence are by content,
so it still drops out of settings.zon). What this means for you: if your settings struct has a
string field, call MySettings.deinit(&settings) when you tear that value down (plugin
deinit). It resets the value to your declared defaults and is a no-op for a struct of only
scalars/enums. Don't free a string field yourself, and don't assign one directly if you want it
persisted — go through the settings pane or applyZon, or you'll leak the schema's copy.
Author fields live under .settings so they can never collide with the shell-reserved
.enabled. Only fields whose payload differs from the cell's declared default is written; an all-default
value removes that plugin's .settings (and if also disabled, the whole .plugins.<id> entry).
src/editor/SettingsPluginsZon.zig locates/composes fields by source byte-span via the same
std.zig.Ast/ZonGen machinery std.zon.parse uses, so nothing else in the file is
re-serialized. Reads (Host.loadPluginSettings) return the .settings sub-blob for the plugin
currently calling register(). A plugin's settings survive uninstall (only the dylib/directory
is deleted); reinstalling picks them back up. Whether the merge touches disk at all is decided
once, over the whole composed file.
Enabled state is per-plugin. .plugins.<id>.enabled defaults to false (absent = disabled).
Store installs write .enabled = true immediately (auto-load as before); a dylib dropped
straight into plugins/ shows up as a disabled entry with no settings.zon write until the user
enables it from the UI.
settings.zon is watched live. A background watcher (nightwatch,
recursive over <config>/) picks up external changes — a hand edit, another tool, a newly
dropped-in plugin directory — while fizzy is running and reconciles them into every loaded
plugin's live value via Access.applyBlob, calling settingsChanged exactly as an in-app edit
would. A plugin implementing settingsChanged should not assume it only fires from the user's
own settings-pane interaction. This is on by default and not configurable; if the watcher can't
start (platform quirk, resource limit) fizzy still launches normally, it just doesn't get live
reconciliation that session.
Plugins do not draw settings. They register a typed value + field metadata only. The shell's
PluginSettingsPane renders shared controls (checkbox / slider / enum dropdown / …) so every
plugin's settings look the same. Edits go through Access and call persist, which stores the
zon blob and notifies Plugin.VTable.settingsChanged.
Loaded-only. A schema only exists in the Host's registry while the owning plugin is actually registered — there's no dylib settings probe and no embedded settings-zon export. A disabled plugin gets an Enabled-toggle-only row instead of its fields.
Plugin.vtable is generic: every field is an optional fn pointer taking the plugin's opaque
state, and none names a domain feature. Group by purpose:
- Lifecycle —
deinit,initPlugin. - Document ownership —
fileTypePriority(ext)(claim file extensions),loadDocument/loadDocumentFromBytes/createDocument,saveDocument,reloadDocument(optional — shell document watcher reloads clean open tabs when the file changes on disk),closeDocument,isDirty,undo/redo/canUndo/canRedo, plus opaque document-buffer management for the async load path. - Document metadata at the workbench boundary —
bindDocumentToPane,documentGrouping,documentPath,setDocumentPath, dirty/save indicators. These keepDocHandleopaque so the file-management plugin never sees a plugin-specific type. - Rendering —
drawDocument(doc)(the document's content in a tab/pane),drawDocumentInfobar(doc, rect)(active-doc status in the shell-sized infobar slot). - Per-frame phases —
beginFrame,prepareFrame,tickKeybinds,tickOpenDocuments,tickActiveDocument,drawOverlay,endFrame,needsContinuousRepaint. A plugin does its own domain work inside these generic phases (see the lifecycle table below for exactly when each fires). - Folder lifecycle —
onFolderClose/onFolderOpen. - Save protocol —
saveNeedsConfirmation(doc)+requestSaveConfirmation(doc, mode, …). - Contributions —
contributeMenu,contributeKeybinds. - New document —
requestNewDocumentDialog.
Editing actions are deliberately not hooks. Copy, paste, transform, delete-selection mean
different things per editor, so they're Commands (§3.4), not part of this contract. A
file-management plugin like workbench implements none of the document hooks; an editor plugin
like pixi implements the document + rendering hooks but contributes no file tree.
Every vtable field is an optional fn pointer, so the type system requires nothing. But to function as an editor (open/draw/save files) you must implement the document cluster:
fileTypePriority·documentStackSize·documentStackAlign·loadDocument·documentIdFromBuffer·registerOpenDocument·documentPtr·deinitDocumentBuffer·drawDocument·saveDocument·isDirty
Everything else is genuinely optional — implement only what your plugin needs. Use
Plugin.assertEditorVTable(vtable) / Plugin.assertUtilityVTable(vtable) at compile time to
catch a vtable shaped for the wrong kind of plugin (see §3.7 for the shapes).
[broadcast] = called for every plugin at that point; [active-doc] = called as
doc.owner.hook(doc) only for the focused document; [requested] = only fires after you call the
paired host.* request. Call sites are in src/editor/Editor.zig (verify line numbers with
grep — they drift):
| Hook | Model | When / where |
|---|---|---|
beginFrame |
broadcast | top of the draw, before workspace rebuild (renderFrame) |
prepareFrame |
requested | after layout, before draw — only when pending_composite_warmup was set by host.requestPrepareFrame() |
needsContinuousRepaint |
broadcast | the shell's "should I keep repainting vs idle" decision |
tickOpenDocuments |
broadcast | early per-frame tick; return true → request a follow-up anim frame |
drawDocument(doc) |
active-doc | center region, when the workbench draws the focused tab |
tickActiveDocument(id) |
broadcast | inside the active document container (has the timer-anchor id) |
endFrame |
broadcast | defer at the end of the document-container block |
tickKeybinds |
broadcast | after the center draw, before the shell's global keybinds |
drawOverlay |
broadcast | right after tickKeybinds, on top of the frame |
Outside the frame loop: onFolderClose / onFolderOpen fire [broadcast] from
setProjectFolder / closeProjectFolder; saveNeedsConfirmation / requestSaveConfirmation
fire [active-doc] from the save / close / quit-all paths; loadDocument runs on a background
load-worker thread (touch only the host allocator + the given buffer, no dvui).
Plugin code can't import the shell, so the shell injects pointers into the plugin once at
load — the allocator and the *Host — via the fizzy_plugin_set_globals C export. exportEntry
catches them into the SDK itself, so your code just reads:
sdk.allocator()— the persistent host allocator.sdk.host()— the shell*Host: registries, services, and theEditorAPIread surface (open folder, active doc, arena allocator, save dialogs).sdk.refresh()— wake the app event loop for another frame. Safe from any thread (LSP workers, load jobs, PTY readers). Call this when background work finishes and the UI may be idle with no mouse/keyboard events — otherwise a sleeping draw loop will not pick up the new state until unrelated input arrives. Prefer this overdvui.refreshfrom plugin dylibs — it doesn't require capturing a*dvui.Windowpointer. Equivalent tosdk.host().refresh().
Your own state is just a variable you own — a module-level var, adopted into plugin.state
during register and read back via @ptrCast in your vtable functions. There is no separate
Globals/storage file to write.
Anything a plugin invokes rather than implements as a shell callback — plugin-specific
features (pixi's Pack Project) and editing actions whose meaning varies per editor (Copy,
Paste, Transform, Delete Selection) — is a Command, not a vtable hook. Register it once;
the shell triggers it by id via host.runCommand("<id>") without knowing what it does:
try host.registerCommand(.{
.id = "pixi.packProject", // plugin-namespaced
.owner = &plugin,
.title = "Pack Project",
.run = packProjectCommand, // fn(state) anyerror!void — resolves its own context
.isEnabled = packProjectEnabled, // optional gate
});Per-owner action convention. The shell's built-in Edit menu/keybinds (Copy copy, Paste
paste, Transform transform, accept acceptEdit, cancel cancelEdit, delete
deleteSelection) and Grid Layout (gridLayout) dispatch to "<active_owner_id>.<action>". So
focusing a pixi doc runs "pixi.copy"; a second editor answers the same shell actions by
registering its own "<its_id>.copy", …transform, etc. An action the owner didn't register is
simply a no-op for its documents.
Menu rows name a command, and get its chord for free. A menu item is never told what shortcut to display — it names the command it runs and the shell resolves the chord from the live keymap, so a rebind in the Keyboard Shortcuts pane shows up in both menu bars immediately:
// in-app menu (inside a `registerMenuSection` draw callback)
if (host.drawMenuItem("Grid Layout…", "pixi.gridLayout")) { … }
// macOS NSMenu leaf
try host.registerNativeMenuItem(.{ …, .command = "pixi.gridLayout", .sf_symbol = "square.grid.3x3", .run = … });Both fields are optional; omit them for a row with no command behind it, which then simply
carries no accelerator. drawMenuItem's second parameter used to be a dvui bind name — a
separate flat namespace plugin commands have no entry in — so it never resolved to anything.
Command palette flattening. Document verbs that the shell forwards (copy, paste, undo,
redo, deleteSelection, acceptEdit, cancelEdit) appear once in the palette as the
Fizzy stub (fizzy.copy, …) — greyed when no active document offers that action. Plugin
implementations (text.copy, pixi.copy, …) stay registered for dispatch/menus/keybinds but are
hidden from the palette so the same title isn't listed N times. Verbs with no Fizzy stub
(Transform, Grid Layout) show only the active owner's command when present. Global commands
(pixi.packProject, fizzy.save, …) always appear as their own rows.
host.allocator(==sdk.allocator()) — the persistent heap allocator. Use it for anything that outlives a frame (documents, caches, registry entries). You own every allocation and must free it.host.arena()— a per-frame scratch allocator, reset at the end of every frame. Never free from it, never hold a pointer into it past the current frame.
Do not capture dvui.currentWindow().gpa as "the allocator." The shell creates the dvui
window with host.allocator — today they're the same instance, but treat host.allocator as the
contract. Mixing allocators is the one memory bug the type system can't catch.
Files inside src/** must not @import("fizzy") or reach into the shell. Allowed:
@import("fizzy_sdk"), @import("core"), @import("dvui") (wired onto your module by
fizzy.plugin.create), and sibling files in your own src/ tree. This is what lets the same
sources compile as a standalone dylib whether they ship in-repo or from a third-party project.
Useful things already inside core, so you never need to add a dependency for them:
core.lsp.Client (§7), and core.fuzzy — the zf-backed matcher every filter box in the shell
uses. Build a fuzzy.Query once from your filter text, then fuzzy.score / fuzzy.scoreBest
each candidate and fuzzy.sort the survivors; fuzzy.highlight gives you the matched byte
indices for tinting a label. Scores are costs — lower is better, and matching is
case-insensitive until the query itself contains a capital.
Informal categories, not a declared field — plugin.zig.zon carries identity only (§2.2).
What actually determines a plugin's shape is which vtable hooks it implements, checked at
compile time by Plugin.assertEditorVTable/assertUtilityVTable (§3.6).
| Shape | Implements | Example |
|---|---|---|
| Editor | Document vtable cluster + optional panes/commands | pixi, text |
| Shell | Center provider + file tree, no documents | workbench |
| Utility | Menus/commands/settings only, no document hooks | markdown (preview only) |
The text plugin remains the sole owner of every text-like file (.txt, .md, .zig,
.json, …). Language/format plugins are utility plugins that register a
LanguageSupport entry — optional hooks looked up by file extension:
try host.registerLanguageSupport(.{
.id = "json",
.owner = &plugin,
.vtable = &.{
.supportsPreview = jsonPreview.supportsPreview,
.treeSitterHighlight = jsonHighlight.treeSitterHighlight,
.previewPane = jsonPreview.previewPane,
},
});Every hook is optional and independent — implement only what your plugin offers:
| Hook | Purpose |
|---|---|
treeSitterHighlight(state, ext) ?TreeSitterHighlight |
Tree-sitter grammar, query, and capture→style table for syntax highlighting |
previewPane(state, ext, bytes, id_extra, gpa) !void |
Draw a read-only preview pane (markdown render, JSON tree, …) |
supportsPreview(state, ext) bool |
Optional gate; defaults to checking whether previewPane is populated |
documentOpened(state, ext, path, bytes) void |
Non-blocking; fired when text opens/reloads a document — LSP plugins use this to spawn the server and didOpen before first hover |
hover(state, ext, path, bytes, byte_offset) ?HoverResult |
Non-blocking; hover text for the token at byte_offset. Called every frame the mouse dwells over a token — see §3.9 |
gotoDefinition(state, ext, path, bytes, byte_offset) ?DefinitionLocation |
May block briefly; Ctrl/Cmd-click jump target |
completion(state, ext, path, bytes, byte_offset) ?[]const CompletionItem |
Non-blocking; ghost-text + dropdown candidates at the cursor |
resolveCompletionDocumentation(state, ext, path, bytes, byte_offset, index) ?[]const u8 |
Non-blocking; lazy-loaded doc text for one completion candidate |
signatureHelp(state, ext, path, bytes, byte_offset) ?SignatureHelpResult |
Non-blocking; active-call signature while the cursor sits inside open parens |
supportsFormat(state, ext) bool |
Non-blocking gate for a "Format Document" menu item |
format(state, ext, path, bytes) ?[]const u8 |
May block briefly; whole-document reformat on explicit user action |
The text editor calls host.treeSitterHighlightFor(ext), host.previewProviderFor(ext), and
host.documentOpenedFor(ext, path, bytes) (on open/reload). When a preview provider exists,
TextEditor splits the tab into raw editor + preview panes. The hover/gotoDefinition/
completion/signatureHelp/format hooks follow the same first-registered-provider-wins
lookup, keyed off ext.
See src/sdk/language.zig for every hook's full doc comment (three-state hover convention,
why gotoDefinition returns an LSP Position instead of a resolved byte offset, the
completion ghost-text/dropdown split, …) — those types are the single source of truth, not
duplicated here.
If your language plugin talks to an existing language server (zls, clangd, rust-analyzer,
gopls, omnisharp, …) rather than implementing hover/completion/etc. itself, you don't write a
JSON-RPC client from scratch. core.lsp.Client — a Fizzy-provided module every plugin build
already imports (core is wired into Modules for every plugin, same as fizzy_sdk/dvui) — is a
complete, server-agnostic LSP client: process lifecycle, JSON-RPC framing, per-request-kind
caching/debouncing/negative-caching, capability negotiation (position encoding,
completionItem/resolve support), and answering server-initiated requests
(workspace/configuration, client/registerCapability, …). It backs the bundled zig plugin
(zls) today; See src/core/lsp/Client.zig's own doc comments for the full threading model.
Your plugin supplies only what's specific to your server, via Client.Config:
const core = @import("core");
const Client = core.lsp.Client;
var client: Client = .{};
fn isCppFile(ext: []const u8) bool {
return std.ascii.eqlIgnoreCase(ext, ".cpp") or std.ascii.eqlIgnoreCase(ext, ".hpp");
// ... etc
}
fn getFolder() ?[]const u8 {
return sdk.host().folder();
}
fn logWarn(source: []const u8, msg: []const u8) void {
sdk.host().logLine(.warn, source, msg);
}
pub fn configure() void {
client.configure(.{
.command = &.{ "clangd", "--background-index" },
.language_id = "cpp",
.allocator = sdk.allocator(),
.getFolder = getFolder,
.logWarn = logWarn,
.refresh = sdk.refresh,
// Optional — most servers treat an absent/null value as "use defaults". Several
// (rust-analyzer, gopls) are near-unusable without server-specific settings here.
.initialization_options = null,
});
}Then:
- Call
configure()once from yourregister(host)— that's the earliest pointsdk.allocator()/sdk.host()are valid;Configcan't be a comptime default on the file-scopevar client: Client = .{};every plugin declares for exactly that reason. - Wire
client.onFolderOpen/client.onFolderClose/client.deinitinto yourPluginvtable'sonFolderOpen/onFolderClose/deinit— the client updates/restarts against the project root on folder open (spawn itself is lazy / warm-up-driven) and tears down on close/unload. - Register a
LanguageSupportwhose hooks are thin wrappers: gate on your extension list, then delegate straight to the matchingclient.*method. ImplementdocumentOpenedasclient.warmUp(path, bytes)so the server starts when a matching file is opened (not on first hover). Other hooks map toclient.hover/.gotoDefinition/.completion/.resolveCompletionDocumentation/.signatureHelp/.format— seefizzyedit/zig'ssrc/Lsp.zigfor the reference wrapper, and itsplugin.zigfor wiring it intoregister(host).
You do not need to handle JSON-RPC framing, threading, request/response id correlation,
position-encoding negotiation, or server-initiated requests yourself — all of that is generic
LSP-spec behavior core.lsp.Client already implements once, for every server.
The crucial property: they do not import each other. They collaborate entirely through the
SDK — grep confirms zero cross-imports in either tree. This is the model to copy for any second
editor plugin.
pixi.register:
- Claims its file types via
fileTypePriority(.fiz,.png, …). registerSidebarView×3 — Tools, Sprites, Project.registerBottomView— the Sprites panel tab.sdk.settings.Schema(…).register,registerFileRowFillColor.- Implements the document + rendering vtable hooks (load/save/undo/
drawDocument/…).
workbench.register:
registerSidebarView— the Files tree.registerCenterProvider— owns the entire center region: tabs/splits + canvas layout.registerService("workbench", …)— the file-management API other plugins use instead of importing workbench (open/close/save a doc, list open docs, file-tree ops, row decorators).
user clicks foo.fiz in workbench's Files tree
│
▼
host.pluginForExtension(".fiz") ──► pixi (highest fileTypePriority)
│
▼
pixi.loadDocument(path) ──► builds its File, returns an opaque buffer
│
▼
shell inserts DocHandle{ id, ptr=File, owner=pixi } into Editor.open_files
│
▼
workbench (center provider) draws a tab for it, and to render the body calls
doc.owner.drawDocument(doc)
│
▼
pixi draws its canvas inside the workbench tab/split
Every later action follows the same rule — save, dirty-dot, undo/redo, grouping, path, and the
infobar status all route to doc.owner; workbench never knows it's a pixel-art file. A new editor
plugin drops in with no shell or workbench changes: register its file types, implement the
document + drawDocument hooks, and its documents coexist in the same tabs/splits.
Fizzy uses three independent versions:
| Version | Owner | Purpose |
|---|---|---|
| App version | Fizzy release (build.zig.zon) |
User-facing editor release; does not gate plugin loading |
| SDK version | src/sdk/version.zig (sdk_version) |
ABI contract; bumps when the plugin boundary changes. Every bump gets an sdk-v<version> git tag and a fizzy-sdk-v* release asset (auto-pushed by CI — see §2.3) as the pin point for plugin build.zig.zons |
| Plugin version | Author plugin.zig.zon .version |
Plugin's own release semver — the single source of truth, forwarded into the build (fizzy_plugin_options) and embedded in the dylib's fizzy_plugin_manifest_zon/fizzy_plugin_version exports |
At load time the host checks, in order:
- ABI fingerprint (
fizzy_plugin_abi_fingerprint) — a compile-time structural hash over every type that crosses the boundary (Host/Plugin/DocHandle/EditorAPIvtables, the dvui types passed through them, the C entry-symbol signatures). Host and plugin each compute it from their own sources; any mismatch is a hard reject — this is the real compatibility gate, not a semver check. There is no ABI version to hand-bump. - SDK version —
host.sdk_versionmust satisfyplugin.min_sdk_version. - Filename ↔ id — the plugin's
plugin.zig.zon.id(==Plugin.id) must match the installed filename's basename.
A prebuilt plugin dylib is valid for exactly one (zig version, dvui version, SDK contract)
tuple — a plugin links its own dvui and operates on the host's injected dvui globals, so host
and plugin must share the same dvui and the same compiler. The goal is to make that coupling
rare and deliberate, not something that breaks on every release:
- App version ≠ SDK version. A Fizzy release that doesn't touch the boundary, the pinned
dvui, or the compiler keeps the same fingerprint, so already-installed plugins keep loading. dvuiand zig are pinned and bumped deliberately/batched, not tracked at tip.- The store matches binaries on the fingerprint. When it changes, that's the (announced) signal for authors to rebuild; until they do, the store shows "needs a rebuild for Fizzy SDK x.y" instead of offering an incompatible binary.
CI enforces the pairing on the fizzy side: zig build test-sdk-version fails at compile time if
the live shape fingerprint (dylib.sdk_shape_fingerprint) drifts from the recorded literal
(recorded_sdk_shape_fingerprint in src/sdk/version.zig) without an accompanying sdk_version
bump.
On the plugin side, fizzy.plugin.install wires a check build step that prints what your
pinned sdk-v* tag computes for sdk_version + ReleaseFast abi_fingerprint. The release
action derives those from the built dylib — you do not copy them into workflow YAML. A legacy
release.yml that still hand-copies them is still diffed here so a stale pin fails at
zig build time.
Continuing the tutorial: your plugin builds and loads locally. Making it installable by anyone else from Fizzy's in-app Plugins tab is three steps — pin fizzy properly, add one CI workflow, and register once.
plugin.zig.zon is the identity source of truth for CI. The reusable release action reads
id / version / min_sdk_version from that file; the pushed tag must equal .version.
fizzy_sdk_version and abi_fingerprint are read from the built ReleaseFast dylib (they are
whatever your pinned sdk-v* tag embeds) — never hand-copied into workflow YAML. There is no
sidecar asset to publish alongside the dylib — the release only ships one binary per target plus
manifest.json (§6.3).
Drop this in as .github/workflows/release.yml — a thin caller into
fizzyedit/plugin-build-action @v3:
name: Release
on:
push:
tags: ["v*"]
jobs:
build:
uses: fizzyedit/plugin-build-action/.github/workflows/build.yml@v3
permissions:
contents: write
with:
zig-version: "0.16.0"Bump the sdk-v* tag your build.zig.zon pins when you want a new SDK; the next tag's manifest picks up
the new fingerprint automatically. Tagging is all it takes to ship:
git tag v0.1.0 && git push origin v0.1.0tag v0.1.0
│
▼ build.yml — cross-compiles all 6 targets from ubuntu (ReleaseFast); sdk-meta.json → fingerprint
per-target binary + sha256, for each of:
macos-aarch64 macos-x86_64
linux-x86_64 linux-aarch64
windows-x86_64 windows-aarch64
│
▼ assemble_manifest.py — merges fragments, accumulates prior releases from the last manifest
GitHub Release assets:
my_plugin-<os-arch>.<ext> × 6
manifest.json
manifest.json lists one releases[] entry per (version, abi_fingerprint) — never
rewritten, only appended to — so users on an older SDK keep matching an older binary instead of
seeing "incompatible":
{
"id": "my_plugin",
"releases": [
{
"version": "0.1.0",
"min_sdk_version": "0.9.0",
"abi_fingerprint": "0x...",
"fizzy_sdk_version": "0.9.0",
"published": "2026-07-02",
"downloads": {
"macos-aarch64": { "url": "https://github.com/<you>/<repo>/releases/download/v0.1.0/my_plugin-macos-aarch64.dylib", "sha256": "..." },
"macos-x86_64": { "url": "...", "sha256": "..." },
"linux-x86_64": { "url": "...", "sha256": "..." },
"linux-aarch64": { "url": "...", "sha256": "..." },
"windows-x86_64":{ "url": "...", "sha256": "..." },
"windows-aarch64":{ "url": "...", "sha256": "..." }
}
}
]
}fizzyedit/plugins is a decentralized registry — it
builds nothing and hosts no binaries, just a pointer to your self-hosted manifest.json. Open a
one-time PR adding registry/<id>.json:
{
"id": "my_plugin",
"name": "My Plugin",
"description": "What it does, one line.",
"author": "you",
"homepage": "https://github.com/you/my-plugin",
"tags": ["editor"],
"manifest_url": "https://github.com/you/my-plugin/releases/latest/download/manifest.json"
}Required: id (must equal the filename stem and your plugin.zig.zon's .id), name, manifest_url.
Also commit README.md and ICON.png in your plugin repo (repo root for standalone plugins) —
the store fetches both from homepage for the card icon and README viewer; nothing is duplicated
into this registry.
Plugin ids are globally unique. The PR runs a validation workflow that checks the entry
structurally (hard fail) and tries to fetch your manifest (warning only — brief hosting downtime
doesn't block the PR). This is the only PR you ever open — every future release is just a new
tag; your manifest republishes itself and the aggregator picks it up automatically.
registry/<id>.json ──manifest_url──► your GitHub Release's manifest.json
│
│ scheduled "store ingest" (on merge, every 6h, or manual)
▼
registry.db (SQLite — durable history; one author's outage never affects another)
│ store export
▼
plugins/catalog/summary.json ← every plugin's browse metadata, no releases
plugins/catalog/<abi_fingerprint>/releases.json ← one shard per SDK generation, newest release only
│
│ published to GitHub Pages
▼
https://plugins.fizzyed.it/catalog/
│
│ fetched by the running app
▼
Fizzy's in-app Plugins tab:
1. fetch summary.json → the browse list
2. fetch releases.json for MY abi_fingerprint
3. pick the release's download for my os-arch, verify its sha256
4. write {config}/plugins/{id}/{id}.{ext} → dlopen on next launch
The catalog is split by fingerprint on purpose: each running Fizzy build only ever needs — and
only ever fetches — its own shard, which by construction holds at most one release per plugin, not
the whole version history. Development installs (zig build install) skip the store entirely and
drop straight into the plugins directory, exactly like §2.6.
| Path | Role |
|---|---|
src/sdk/sdk.zig |
SDK entry — re-exports everything below |
src/sdk/Host.zig |
Registries + service locator + register* methods |
src/sdk/Plugin.zig |
Plugin identity + the vtable of hooks |
src/sdk/DocHandle.zig |
Opaque document handle (owner-routed) |
src/sdk/EditorAPI.zig |
Shell read/utility surface plugins reach back through |
src/sdk/regions.zig |
Sidebar/bottom/center/menu/settings/command contribution structs |
src/sdk/language.zig |
LanguageSupport registry — documentOpened/hover/goto-definition/completion/signature-help/format/highlighting/preview hooks looked up by file extension |
src/core/lsp/Client.zig |
Server-agnostic LSP client (JSON-RPC framing, caching, threading) shared by every language plugin — see §3.9 |
src/sdk/dylib.zig, dvui_context.zig |
Runtime-library C entry contract + dvui injection |
src/sdk/version.zig |
SDK version + ABI fingerprint CI lock |
src/sdk/manifest.zig |
Manifest — the identity-only plugin.zig.zon shape (id/name/version/min_sdk_version) + parse/free, read back out of a loaded dylib at runtime. The typed shape actually baked into a dylib's C-ABI exports is dylib.Identity (build-injected, never runtime-parsed) |
src/sdk/settings.zig |
Comptime settings API (sdk.settings.Schema(T)) — see §3.1.1 |
src/editor/SettingsPluginsZon.zig |
ZON-AST byte-span surgery for settings.zon's merged .plugins.<id> fields — shell-only, not part of the SDK |
src/editor/SettingsWatcher.zig |
Thin nightwatch adapter for live external settings.zon / dropped-in plugin reconciliation (see above) — shell-only, not part of the SDK |
sdk/plugin_sdk.zig |
fizzy.plugin.create / .install / .addCModule — the build-side API a plugin's build.zig calls |
src/plugins/text/ |
Canonical document-owning editor plugin — copy to start a new editor plugin |
src/plugins/image/ |
Read-only image viewer (PNG/JPG/JPEG) with zoom/pan |
src/plugins/workbench/ |
Reference file-management (shell-profile) plugin |
fizzyedit/pixi |
Reference third-party editor plugin, incl. vendored C deps + packed assets |
fizzyedit/zig |
Reference LSP-backed language plugin (zls) — see §3.9 |
fizzyedit/plugins |
The store registry/aggregator |
fizzyedit/plugin-build-action |
Reusable release CI (6-target build + manifest) |
| Export | Purpose |
|---|---|
fizzy_plugin_abi_fingerprint |
Must match host or load is rejected |
fizzy_plugin_sdk_version / _min_sdk_version / _version / _id / _name |
Identity, read from plugin.zig.zon at build time (fizzy_plugin_options) |
fizzy_plugin_manifest_zon |
The plugin's embedded plugin.zig.zon source text — lets the loader probe identity (and self-heal a missing on-disk sidecar, historically) without a full register |
fizzy_plugin_register |
Calls your plugin.zig's register(host) |
fizzy_plugin_set_globals |
Host injects allocator + *Host into the SDK (sdk.allocator() / sdk.host()) |
fizzy_plugin_set_dvui_context |
Host injects live dvui window/io before draw |
fizzy_plugin_set_render_bridge |
Host injects the dvui proxy render bridge |
{config}/plugins/{id}/{id}.dylib # macOS
{config}/plugins/{id}/{id}.so # Linux
{config}/plugins/{id}/{id}.dll # Windows
{exe}/plugins/{id}/{id}.{ext} # bundled built-ins
One directory per plugin (Host.pluginInstallDir(id) gives a plugin this path at runtime, for
its own assets/data) — no on-disk .zon sidecar next to the dylib. The plugin's plugin.zig.zon
.id must match both the directory name and the dylib's filename basename.
Built-ins ship inside the signed app and compile two ways — statically into the
native/web/test binaries and (desktop) as a bundled dylib. Their folder matches the
canonical §2 shape (plugin.zig + plugin.zig.zon + build.zig + build.zig.zon), and each
builds standalone with cd src/plugins/<name> && zig build. The only extra is fizzy-internal
glue kept out of the plugin contract:
src/plugins/<name>/
plugin.zig # register + vtable (+ shell re-exports for static @import("<name>"))
plugin.zig.zon # identity only
build.zig # fizzy.plugin.create + install
build.zig.zon
src/ # optional implementation files (named imports: sdk/dvui/…)
static/ # fizzy-internal: static embed + bundled dylib wiring
integration.zig
static/integration.zig defines addStaticModule (linked into the app) and addDylib (the
bundled dylib); the root build aggregates every plugin's integration in
build/plugins.zig. Built-ins register in
Editor.zig postInit via try <name>_mod.register(&editor.host).
(pixi used to be built-in; it now ships only through the store path in §6.)