From d048de1d14db2710b113a053b3a81689ee255e32 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Wed, 17 Jun 2026 14:42:18 -0500
Subject: [PATCH 01/49] Begin looking at editor split
---
spikes/shared-globals/README.md | 40 +++++++++++++++++
spikes/shared-globals/build.zig | 47 ++++++++++++++++++++
spikes/shared-globals/build.zig.zon | 14 ++++++
spikes/shared-globals/core.zig | 50 ++++++++++++++++++++++
spikes/shared-globals/host.zig | 55 ++++++++++++++++++++++++
spikes/shared-globals/plugin.zig | 28 ++++++++++++
src/editor/Editor.zig | 9 ++++
src/fizzy.zig | 3 ++
src/sdk/DocHandle.zig | 17 ++++++++
src/sdk/Host.zig | 58 +++++++++++++++++++++++++
src/sdk/Plugin.zig | 66 +++++++++++++++++++++++++++++
src/sdk/sdk.zig | 9 ++++
12 files changed, 396 insertions(+)
create mode 100644 spikes/shared-globals/README.md
create mode 100644 spikes/shared-globals/build.zig
create mode 100644 spikes/shared-globals/build.zig.zon
create mode 100644 spikes/shared-globals/core.zig
create mode 100644 spikes/shared-globals/host.zig
create mode 100644 spikes/shared-globals/plugin.zig
create mode 100644 src/sdk/DocHandle.zig
create mode 100644 src/sdk/Host.zig
create mode 100644 src/sdk/Plugin.zig
create mode 100644 src/sdk/sdk.zig
diff --git a/spikes/shared-globals/README.md b/spikes/shared-globals/README.md
new file mode 100644
index 00000000..8c3b635a
--- /dev/null
+++ b/spikes/shared-globals/README.md
@@ -0,0 +1,40 @@
+# Spike: driving host dvui state from a prebuilt plugin dylib
+
+Validates the load-bearing mechanism for fizzy's runtime native-plugin architecture
+(see `~/.claude/plans/i-would-like-to-glowing-stroustrup.md`): can a **prebuilt
+plugin dynamic library**, compiling its **own copy** of the dvui-like code, render
+into the **host's** dvui state across the `dlopen` boundary?
+
+`core.zig` stands in for dvui (a `current_window` global, an `ft2lib` global, a
+`Window` carrying a per-frame arena, and a `label()` "widget" that uses all three).
+The host exe and the plugin dylib each compile `core.zig` independently.
+
+Run: `zig build run`
+
+## Findings (macOS/arm64, Zig 0.16.0)
+
+- **Globals are NOT auto-shared.** Even with `rdynamic` + `allow_shlib_undefined`,
+ the host and plugin each get their own `current_window` (different addresses).
+ macOS two-level namespace ⇒ no automatic interposition. So the "one shared
+ `libdvui`" idea is out.
+- **Mechanism B (context injection) works.** The host owns the dvui state; before
+ invoking the plugin's draw it sets the plugin's `current_window` + `ft2lib`. The
+ plugin's own statically-compiled `label()` then:
+ - mutates the **host's** `Window` (`widget_count` 1→4),
+ - allocates strings in the **host's** arena (round-tripped),
+ - uses the **host's** `FreeType` handle (`shape_calls` 1→4).
+- Works because struct layout is identical (same pinned source/version) and it's
+ pure pointer-passing — so it ports to Linux/Windows unchanged, and the shared
+ allocator means **no cross-allocator free hazard**.
+
+## Design consequence
+
+Plugins statically compile dvui + the SDK; the host injects its handful of dvui
+globals each frame (`current_window` per-frame; `io`/`ft2lib`/`debug` at init — all
+public `pub var`, so no dvui patch needed). Pinned Zig + SDK version + a load-time
+ABI gate keep struct layouts compatible.
+
+## Not covered here (validate in-fizzy at Phase 4)
+
+Real GPU rendering with a live backend — but that's the host's job; the plugin only
+records draw commands into the shared Window's render list.
diff --git a/spikes/shared-globals/build.zig b/spikes/shared-globals/build.zig
new file mode 100644
index 00000000..2f469a9a
--- /dev/null
+++ b/spikes/shared-globals/build.zig
@@ -0,0 +1,47 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ // The shared "dvui-like" source. Imported by both artifacts; each compiles
+ // its own copy (as dvui would be compiled into host and plugin alike).
+ const core_mod = b.createModule(.{
+ .root_source_file = b.path("core.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+
+ // Plugin: a dynamic library, prebuilt and dlopen'd at runtime.
+ const plugin = b.addLibrary(.{
+ .name = "plugin",
+ .linkage = .dynamic,
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("plugin.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ plugin.root_module.addImport("core", core_mod);
+ // Allow symbols to be resolved at load time (needed if we test Mechanism A).
+ plugin.linker_allow_shlib_undefined = true;
+ b.installArtifact(plugin);
+
+ // Host: the near-empty exe that owns the Window and loads the plugin.
+ const host = b.addExecutable(.{
+ .name = "host",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("host.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ host.root_module.addImport("core", core_mod);
+ // Export the host's dynamic symbols so a plugin could interpose (Mechanism A).
+ host.rdynamic = true;
+ b.installArtifact(host);
+
+ const run = b.addRunArtifact(host);
+ run.step.dependOn(b.getInstallStep());
+ b.step("run", "build everything and run the host").dependOn(&run.step);
+}
diff --git a/spikes/shared-globals/build.zig.zon b/spikes/shared-globals/build.zig.zon
new file mode 100644
index 00000000..d4fa5fdb
--- /dev/null
+++ b/spikes/shared-globals/build.zig.zon
@@ -0,0 +1,14 @@
+.{
+ .name = .shared_globals_spike,
+ .version = "0.0.0",
+ .fingerprint = 0xc23fd395f515e0c8,
+ .minimum_zig_version = "0.16.0",
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "core.zig",
+ "host.zig",
+ "plugin.zig",
+ },
+ .dependencies = .{},
+}
diff --git a/spikes/shared-globals/core.zig b/spikes/shared-globals/core.zig
new file mode 100644
index 00000000..a0d43feb
--- /dev/null
+++ b/spikes/shared-globals/core.zig
@@ -0,0 +1,50 @@
+//! Stand-in for dvui: a global immediate-mode context pointer plus a "widget"
+//! call that reads the global and mutates the shared Window. Both the host exe
+//! and the plugin dylib compile THIS source independently (as dvui would be
+//! compiled into each), so each binary gets its own copy of these globals.
+//! The spike answers: can the plugin still drive the host's dvui state — its
+//! Window, its per-frame arena allocator, and its FreeType handle?
+const std = @import("std");
+
+/// Stand-in for dvui's FT_Library handle (`dvui.ft2lib`, dvui.zig:346): a host-
+/// owned resource the plugin must use, not reinitialize.
+pub const FreeType = struct {
+ shape_calls: u32 = 0,
+};
+
+pub const Window = struct {
+ widget_count: u32 = 0,
+ magic: u64 = 0xDEADBEEF,
+ /// Stand-in for dvui's per-frame arena, which lives in the Window. Plugins
+ /// allocate widget data through this — i.e. the HOST's allocator.
+ arena: ?std.mem.Allocator = null,
+};
+
+/// Mirrors `dvui.current_window` (dvui.zig:416) — the shared immediate-mode context.
+pub var current_window: ?*Window = null;
+/// Mirrors `dvui.ft2lib` — a global library handle that must be injected too.
+pub var ft2lib: ?*FreeType = null;
+
+/// Mirrors a dvui widget constructor: reads the global, allocates label text in
+/// the Window's arena, shapes it via the FreeType handle, mutates the Window.
+pub fn label(text: []const u8) ![]u8 {
+ const w = current_window orelse return error.NoCurrentWindow;
+ std.debug.assert(w.magic == 0xDEADBEEF); // layout/pointer sanity across boundary
+ const ft = ft2lib orelse return error.NoFreeType;
+
+ const arena = w.arena orelse return error.NoArena;
+ const copy = try arena.dupe(u8, text); // allocate via the HOST's allocator
+ ft.shape_calls += 1; // touch the HOST's FreeType handle
+ w.widget_count += 1;
+ return copy;
+}
+
+pub fn setCurrentWindow(w: ?*Window) void {
+ current_window = w;
+}
+pub fn setFreeType(ft: ?*FreeType) void {
+ ft2lib = ft;
+}
+pub fn currentWindowAddr() usize {
+ return @intFromPtr(¤t_window);
+}
diff --git a/spikes/shared-globals/host.zig b/spikes/shared-globals/host.zig
new file mode 100644
index 00000000..646744f6
--- /dev/null
+++ b/spikes/shared-globals/host.zig
@@ -0,0 +1,55 @@
+//! The near-empty host exe. It owns the dvui state (Window + per-frame arena +
+//! FreeType handle), then dlopens the plugin and lets it draw into that state —
+//! modelling fizzy's shell driving a plugin's render across the dylib boundary.
+const std = @import("std");
+const builtin = @import("builtin");
+const core = @import("core");
+
+pub fn main() !void {
+ // The host owns the per-frame arena (as dvui's Window owns its arena).
+ var arena_inst = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+ defer arena_inst.deinit();
+
+ var ft = core.FreeType{}; // host owns the FreeType handle
+ var win = core.Window{ .arena = arena_inst.allocator() };
+ core.setCurrentWindow(&win);
+ core.setFreeType(&ft);
+ _ = try core.label("host-drawn"); // host renders 1 widget itself
+ std.debug.print("[host] after host label(): widget_count={d} shape_calls={d}\n", .{ win.widget_count, ft.shape_calls });
+
+ const ext = switch (builtin.os.tag) {
+ .macos => "dylib",
+ .windows => "dll",
+ else => "so",
+ };
+ var buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&buf, "zig-out/lib/libplugin.{s}", .{ext});
+
+ var lib = try std.DynLib.open(path);
+ defer lib.close();
+
+ const set_ctx = lib.lookup(*const fn (?*core.Window, ?*core.FreeType) callconv(.c) void, "plugin_set_context") orelse return error.SymMissing;
+ const draw = lib.lookup(*const fn () callconv(.c) usize, "plugin_draw") orelse return error.SymMissing;
+ const plugin_global_addr = lib.lookup(*const fn () callconv(.c) usize, "plugin_current_window_addr") orelse return error.SymMissing;
+
+ std.debug.print("[host] host current_window @ {x}, plugin current_window @ {x} ({s})\n", .{
+ core.currentWindowAddr(),
+ plugin_global_addr(),
+ if (core.currentWindowAddr() == plugin_global_addr()) "SHARED" else "SEPARATE → inject",
+ });
+
+ // Mechanism B: inject the host's dvui state into the plugin.
+ set_ctx(&win, &ft);
+ const last_len = draw(); // plugin renders 3 labels via host arena + host FreeType
+
+ std.debug.print("[host] plugin allocated last string len={d} (expect 9 for \"readme.md\")\n", .{last_len});
+ std.debug.print("[host] after plugin draw: widget_count={d} (expect 4) shape_calls={d} (expect 4)\n", .{ win.widget_count, ft.shape_calls });
+
+ const ok = win.widget_count == 4 and ft.shape_calls == 4 and last_len == 9 and win.magic == 0xDEADBEEF;
+ if (ok) {
+ std.debug.print("\n[host] ✅ SUCCESS: plugin drove the host's Window, allocated in the host's arena, and used the host's FreeType handle — across the dylib boundary.\n", .{});
+ } else {
+ std.debug.print("\n[host] ❌ FAIL: count={d} shape={d} len={d} magic={x}\n", .{ win.widget_count, ft.shape_calls, last_len, win.magic });
+ return error.SpikeFailed;
+ }
+}
diff --git a/spikes/shared-globals/plugin.zig b/spikes/shared-globals/plugin.zig
new file mode 100644
index 00000000..54d7d65b
--- /dev/null
+++ b/spikes/shared-globals/plugin.zig
@@ -0,0 +1,28 @@
+//! A prebuilt plugin dylib. It imports `core` (the dvui stand-in) and compiles
+//! its OWN copy of that code. It draws by calling core.label(), exactly as a real
+//! fizzy plugin would call dvui.label() to render into the host's window — using
+//! the host's Window, the host's arena allocator, and the host's FreeType handle.
+const std = @import("std");
+const core = @import("core");
+
+/// Mechanism B: the host injects its dvui state into the plugin's own globals
+/// before asking it to draw. (current_window per-frame; ft2lib at init.)
+export fn plugin_set_context(w: ?*core.Window, ft: ?*core.FreeType) callconv(.c) void {
+ core.setCurrentWindow(w);
+ core.setFreeType(ft);
+}
+
+/// The plugin "renders" three labels into the current Window. Returns the length
+/// of the last allocated string (proving it allocated via the host's arena).
+export fn plugin_draw() callconv(.c) usize {
+ const a = core.label("file.fiz") catch return 0;
+ const b = core.label("sprite.png") catch return 0;
+ const c = core.label("readme.md") catch return 0;
+ _ = a;
+ _ = b;
+ return c.len;
+}
+
+export fn plugin_current_window_addr() callconv(.c) usize {
+ return core.currentWindowAddr();
+}
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 1efd2106..e964b038 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -40,6 +40,9 @@ pub const Menu = @import("Menu.zig");
pub const FileLoadJob = @import("FileLoadJob.zig");
pub const PackJob = @import("PackJob.zig");
+pub const sdk = fizzy.sdk;
+pub const Host = sdk.Host;
+
/// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels.
/// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame
arena: std.heap.ArenaAllocator,
@@ -49,6 +52,9 @@ palette_folder: []const u8,
atlas: fizzy.Internal.Atlas,
+/// Plugin registry + service locator exposed to plugins
+host: Host,
+
settings: Settings = undefined,
recents: Recents = undefined,
@@ -260,6 +266,7 @@ pub fn init(
},
.tools = try .init(app.allocator),
.themes = .empty,
+ .host = .init(app.allocator),
};
editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }));
@@ -3372,6 +3379,8 @@ pub fn deinit(editor: *Editor) !void {
editor.explorer.deinit();
+ editor.host.deinit();
+
editor.tools.deinit(fizzy.app.allocator);
editor.ignore.deinit(fizzy.app.allocator);
diff --git a/src/fizzy.zig b/src/fizzy.zig
index a1876ff3..58f670e1 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -71,6 +71,9 @@ pub const Sprite = @import("Sprite.zig");
/// builds, where `builtin.os.tag` is always `.freestanding`.
pub const platform = @import("platform.zig");
+/// Plugin SDK surface
+pub const sdk = @import("sdk/sdk.zig");
+
/// Custom dvui stuff
pub const dvui = @import("dvui.zig");
diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig
new file mode 100644
index 00000000..bd1d980f
--- /dev/null
+++ b/src/sdk/DocHandle.zig
@@ -0,0 +1,17 @@
+//! An opaque handle to an open document. The shell stores these per tab/workspace
+//! and never inspects `ptr` — it only routes operations to `owner` (the plugin
+//! that opened the document and knows how to render/save/undo it). For pixel art
+//! `ptr` is a `*fizzy.Internal.File`; a text plugin would point it at its own type.
+//!
+//! Phase 0: defined but not yet produced/consumed anywhere (see the modular-editor
+//! plan). Wired into the open/render/save path in Phase 3.
+const Plugin = @import("Plugin.zig");
+
+pub const DocHandle = @This();
+
+/// Plugin-owned, opaque document state.
+ptr: *anyopaque,
+/// The plugin that owns this document.
+owner: *Plugin,
+/// Shell-assigned stable identifier for tabs/workspaces.
+id: u64,
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
new file mode 100644
index 00000000..e2cdc065
--- /dev/null
+++ b/src/sdk/Host.zig
@@ -0,0 +1,58 @@
+//! The services the shell exposes to plugins, and the registries it owns. Plugins
+//! receive a `*Host` instead of reaching into editor globals. Today the Host is
+//! embedded in `Editor`; as the shell shrinks (Phases 1-3) more of the editor's
+//! responsibilities move behind it.
+//!
+//! Phase 0: holds the plugin registry + service locator. Nothing is registered
+//! yet — the existing pixel-art code still uses globals directly.
+const std = @import("std");
+const Plugin = @import("Plugin.zig");
+
+pub const Host = @This();
+
+allocator: std.mem.Allocator,
+
+/// All registered plugins (static today; runtime-loaded dylibs in Phase 4).
+plugins: std.ArrayListUnmanaged(*Plugin) = .empty,
+
+/// Service locator for inter-plugin APIs: name -> opaque service vtable. E.g. the
+/// workbench plugin registers "workbench" so editor plugins can place tabs and
+/// draw per-branch explorer decorations without a compile-time dependency on it.
+services: std.StringHashMapUnmanaged(*anyopaque) = .empty,
+
+pub fn init(allocator: std.mem.Allocator) Host {
+ return .{ .allocator = allocator };
+}
+
+pub fn deinit(self: *Host) void {
+ self.plugins.deinit(self.allocator);
+ self.services.deinit(self.allocator);
+}
+
+pub fn registerPlugin(self: *Host, plugin: *Plugin) !void {
+ try self.plugins.append(self.allocator, plugin);
+}
+
+pub fn registerService(self: *Host, name: []const u8, service: *anyopaque) !void {
+ try self.services.put(self.allocator, name, service);
+}
+
+pub fn getService(self: *Host, name: []const u8) ?*anyopaque {
+ return self.services.get(name);
+}
+
+/// The registered plugin with the highest priority (lowest value) for `ext`, or
+/// null if none claims it. Used in Phase 3 to route file opens to the right plugin.
+pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin {
+ var best: ?*Plugin = null;
+ var best_priority: u8 = 255;
+ for (self.plugins.items) |plugin| {
+ if (plugin.fileTypePriority(ext)) |p| {
+ if (best == null or p < best_priority) {
+ best = plugin;
+ best_priority = p;
+ }
+ }
+ }
+ return best;
+}
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
new file mode 100644
index 00000000..d48b96f1
--- /dev/null
+++ b/src/sdk/Plugin.zig
@@ -0,0 +1,66 @@
+//! A feature module that plugs into the editor shell. Today plugins are compiled
+//! in and registered statically; the same vtable shape is what a prebuilt plugin
+//! dylib will expose at runtime (validated in `spikes/shared-globals/`). All hooks
+//! are optional function pointers taking the plugin's own opaque `state`, so a
+//! plugin implements only what it needs (e.g. the workbench plugin has no
+//! `drawDocument`; an editor plugin does).
+//!
+//! Cross-boundary types may be normal Zig types (not strict C-ABI): host and
+//! plugins are pinned to the same SDK build, so layouts match. Only the dlopen
+//! entry symbols (added in Phase 4) need `callconv(.c)`.
+//!
+//! Phase 0: type definition only; nothing constructs or calls plugins yet.
+const std = @import("std");
+const dvui = @import("dvui");
+const DocHandle = @import("DocHandle.zig");
+
+pub const Plugin = @This();
+
+/// Opaque, plugin-owned state passed back to every vtable call.
+state: *anyopaque,
+vtable: *const VTable,
+
+/// Stable, unique identifier (snake_case), e.g. "pixelart", "workbench".
+id: []const u8,
+/// User-facing name shown in UI.
+display_name: []const u8,
+
+pub const VTable = struct {
+ /// Tear down `state`. Called when the plugin is unregistered / app shuts down.
+ deinit: ?*const fn (state: *anyopaque) void = null,
+
+ /// Priority for opening files with extension `ext` (including the dot, e.g.
+ /// ".fiz"); lower value wins. `null` = this plugin does not handle `ext`.
+ /// Mirrors dvui-editor's fileTypePriority. A plugin may claim many extensions.
+ fileTypePriority: ?*const fn (state: *anyopaque, ext: []const u8) ?u8 = null,
+
+ // ---- document lifecycle (operates on the plugin's own type via DocHandle) ----
+ openDocument: ?*const fn (state: *anyopaque, path: []const u8) anyerror!DocHandle = null,
+ saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+ closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
+ isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+ redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+
+ // ---- render hooks (the plugin draws its own dvui UI into the host window) ----
+ /// Draw the plugin's explorer/sidebar pane (left region).
+ drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null,
+ /// Draw an open document (center/workspace region).
+ drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+ /// Draw the plugin's bottom panel content.
+ drawBottomPanel: ?*const fn (state: *anyopaque) anyerror!void = null,
+
+ // ---- shell contributions ----
+ contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null,
+ contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null,
+};
+
+// Thin wrappers so callers don't repeat the optional-vtable dance.
+
+pub fn fileTypePriority(self: Plugin, ext: []const u8) ?u8 {
+ return if (self.vtable.fileTypePriority) |f| f(self.state, ext) else null;
+}
+
+pub fn deinit(self: Plugin) void {
+ if (self.vtable.deinit) |f| f(self.state);
+}
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
new file mode 100644
index 00000000..6e8940aa
--- /dev/null
+++ b/src/sdk/sdk.zig
@@ -0,0 +1,9 @@
+//! Fizzy plugin SDK — the surface a plugin module depends on.
+//!
+//! Phase 0 of the modular-editor plan: type definitions + registries only.
+//! Nothing routes through these yet; the shell still drives pixel art directly.
+//! Subsequent phases move file management, the workspace/tabs system, and the
+//! pixel-art editor behind this boundary, ending with runtime dylib loading.
+pub const Host = @import("Host.zig");
+pub const Plugin = @import("Plugin.zig");
+pub const DocHandle = @import("DocHandle.zig");
From 603bf54f5482cd02abe8ac2feb43f7eb932bac3e Mon Sep 17 00:00:00 2001
From: foxnne
Date: Wed, 17 Jun 2026 14:59:24 -0500
Subject: [PATCH 02/49] phase 1 - file tree and workbench
---
src/editor/Workspace.zig | 10 +++++++++-
src/editor/widgets/FileWidget.zig | 30 +++++++++++++++++++-----------
src/internal/File.zig | 8 ++++++--
3 files changed, 34 insertions(+), 14 deletions(-)
diff --git a/src/editor/Workspace.zig b/src/editor/Workspace.zig
index a2b0e5de..3b942cf7 100644
--- a/src/editor/Workspace.zig
+++ b/src/editor/Workspace.zig
@@ -61,6 +61,14 @@ pub fn init(grouping: u64) Workspace {
};
}
+/// Recover the typed workspace currently drawing `file` from its opaque slot
+/// handle (`File.EditorData.workspace_handle`, set each frame in `drawCanvas`).
+/// Returns null before the file has been laid out this session.
+pub fn ofFile(file: *fizzy.Internal.File) ?*Workspace {
+ const handle = file.editor.workspace_handle orelse return null;
+ return @ptrCast(@alignCast(handle));
+}
+
const handle_size = 10;
const handle_dist = 60;
@@ -877,7 +885,7 @@ pub fn drawCanvas(self: *Workspace) !void {
const file = &fizzy.editor.open_files.values()[self.open_file_index];
file.editor.canvas.id = canvas_vbox.data().id;
- file.editor.workspace = self;
+ file.editor.workspace_handle = self;
if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) {
defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{});
diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig
index f9a6989c..d24d5e3c 100644
--- a/src/editor/widgets/FileWidget.zig
+++ b/src/editor/widgets/FileWidget.zig
@@ -17,6 +17,7 @@ const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
const CanvasWidget = @import("CanvasWidget.zig");
+const Workspace = fizzy.Editor.Workspace;
const icons = @import("icons");
init_options: InitOptions,
@@ -641,12 +642,19 @@ const BubblePanShared = struct {
tool_not_pointer: bool,
};
+/// The workspace currently drawing this file, recovered from the file's opaque
+/// slot handle. Valid during draw/processEvents — the shell sets the handle each
+/// frame (in `Workspace.drawCanvas`) before invoking the widget.
+fn workspace(self: *FileWidget) *Workspace {
+ return Workspace.ofFile(self.init_options.file).?;
+}
+
/// Same read-only state as `drawSpriteBubbles` uses for `BubblePanShared` (no animation side effects).
fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared {
if (self.init_options.file.editor.transform != null) return null;
if (self.resize_data_point != null) return null;
- if (self.init_options.file.editor.workspace.columns_drag_index != null) return null;
- if (self.init_options.file.editor.workspace.rows_drag_index != null) return null;
+ if (self.workspace().columns_drag_index != null) return null;
+ if (self.workspace().rows_drag_index != null) return null;
if (self.removed_sprite_indices != null) return null;
if (!(self.active() or self.hovered())) return null;
@@ -4509,7 +4517,7 @@ pub fn drawLayers(self: *FileWidget) void {
if (self.removed_sprite_indices != null) {
self.drawCellReorderPreview();
return;
- } else if (file.editor.workspace.columns_drag_index != null or file.editor.workspace.rows_drag_index != null) {
+ } else if (self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null) {
self.drawColumnRowReorderPreview();
return;
} else {
@@ -4709,17 +4717,17 @@ fn drawCanvasCheckerboardBackground(self: *FileWidget) void {
fn drawColumnRowReorderPreview(self: *FileWidget) void {
const file = self.init_options.file;
- const workspace = file.editor.workspace;
- if (workspace.columns_drag_index == null and workspace.rows_drag_index == null) return;
+ const ws = self.workspace();
+ if (ws.columns_drag_index == null and ws.rows_drag_index == null) return;
- const axis: ReorderAxis = if (workspace.columns_drag_index != null) .columns else .rows;
+ const axis: ReorderAxis = if (ws.columns_drag_index != null) .columns else .rows;
const target_index = switch (axis) {
- .columns => workspace.columns_target_index,
- .rows => workspace.rows_target_index,
+ .columns => ws.columns_target_index,
+ .rows => ws.rows_target_index,
};
const removed_index = switch (axis) {
- .columns => workspace.columns_drag_index,
- .rows => workspace.rows_drag_index,
+ .columns => ws.columns_drag_index,
+ .rows => ws.rows_drag_index,
} orelse return;
self.drawReorderPreviewForAxis(file, axis, target_index, removed_index);
@@ -5629,7 +5637,7 @@ pub fn processResize(self: *FileWidget) void {
pub fn processEvents(self: *FileWidget) void {
const transform = self.init_options.file.editor.transform != null;
- const reorder = self.init_options.file.editor.workspace.columns_drag_index != null or self.init_options.file.editor.workspace.rows_drag_index != null or self.removed_sprite_indices != null;
+ const reorder = self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null or self.removed_sprite_indices != null;
// Try to ensure that selected animation frame index is valid
if (self.init_options.file.selected_animation_index) |ai| {
diff --git a/src/internal/File.zig b/src/internal/File.zig
index 0747db78..b03f789f 100644
--- a/src/internal/File.zig
+++ b/src/internal/File.zig
@@ -52,8 +52,12 @@ editor: EditorData = .{},
///
/// Also, the fields here tend to be directly coupled with the UI library
pub const EditorData = struct {
- // Only valid while file widget is drawing the file
- workspace: *fizzy.Editor.Workspace = undefined,
+ /// Opaque slot handle to the workspace currently drawing this file. Set by the
+ /// shell each frame before the file is drawn; recovered in the editor layer via
+ /// `Editor.Workspace.ofFile`. Opaque so this internal data type does not
+ /// type-depend on the editor's `Workspace` (lets `File` move into a plugin).
+ /// Only valid while the file widget is drawing the file.
+ workspace_handle: ?*anyopaque = null,
canvas: fizzy.dvui.CanvasWidget = .{},
layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
From d2df1974b75377595d70720e97c526c44cbf81ea Mon Sep 17 00:00:00 2001
From: foxnne
Date: Wed, 17 Jun 2026 15:05:12 -0500
Subject: [PATCH 03/49] Extract files.zig + workspace/tabs into a Workbench
module
---
src/editor/Editor.zig | 11 ++++++
src/editor/explorer/files.zig | 21 ++++++++++++
src/workbench/Workbench.zig | 63 +++++++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+)
create mode 100644 src/workbench/Workbench.zig
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index e964b038..967347fb 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -43,6 +43,10 @@ pub const PackJob = @import("PackJob.zig");
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
+/// Workbench (Phase 1): file-management home — currently the per-branch
+/// decoration registry for the explorer; grows to own files + tabs/splits.
+pub const Workbench = @import("../workbench/Workbench.zig");
+
/// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels.
/// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame
arena: std.heap.ArenaAllocator,
@@ -55,6 +59,9 @@ atlas: fizzy.Internal.Atlas,
/// Plugin registry + service locator exposed to plugins
host: Host,
+/// File-management workbench (per-branch explorer decorations, …)
+workbench: Workbench,
+
settings: Settings = undefined,
recents: Recents = undefined,
@@ -267,8 +274,11 @@ pub fn init(
.tools = try .init(app.allocator),
.themes = .empty,
.host = .init(app.allocator),
+ .workbench = .init(app.allocator),
};
+ try editor.workbench.registerBuiltins();
+
editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }));
// Start the long-lived save-queue worker. All .fiz async saves get
@@ -3380,6 +3390,7 @@ pub fn deinit(editor: *Editor) !void {
editor.explorer.deinit();
editor.host.deinit();
+ editor.workbench.deinit();
editor.tools.deinit(fizzy.app.allocator);
diff --git a/src/editor/explorer/files.zig b/src/editor/explorer/files.zig
index 18b2f379..6e5dc33c 100644
--- a/src/editor/explorer/files.zig
+++ b/src/editor/explorer/files.zig
@@ -435,6 +435,27 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind
}
}
}
+ } else if (kind == .file) {
+ // File row: label expands and pushes plugin-registered decorations
+ // (e.g. the unsaved dot) to the right edge of the row.
+ var row = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .background = false,
+ .padding = dvui.Rect.all(0),
+ .margin = dvui.Rect.all(0),
+ .id_extra = id_extra,
+ });
+ defer row.deinit();
+ dvui.label(@src(), "{s}", .{label}, .{
+ .color_text = color,
+ .padding = padding,
+ .margin = dvui.Rect.all(0),
+ .id_extra = id_extra,
+ .font = font,
+ .expand = .horizontal,
+ .gravity_y = 0.5,
+ });
+ fizzy.editor.workbench.drawBranchDecorations(full_path, id_extra);
} else {
dvui.label(@src(), "{s}", .{label}, .{
.color_text = color,
diff --git a/src/workbench/Workbench.zig b/src/workbench/Workbench.zig
new file mode 100644
index 00000000..77b85be9
--- /dev/null
+++ b/src/workbench/Workbench.zig
@@ -0,0 +1,63 @@
+//! The Workbench owns cross-cutting file-management UI: today the per-branch
+//! decoration registry for the file explorer; in later Phase 1 work it grows to
+//! own the file tree, the open/load flow, and the tabs/splits system, then becomes
+//! a standalone plugin exposing this as a service (`workbench-api`).
+//!
+//! Per-branch decorations let any plugin draw a right-justified icon on a file row
+//! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after
+//! the label, so an expanding label pushes them to the right edge.
+const std = @import("std");
+const dvui = @import("dvui");
+const icons = @import("icons");
+const fizzy = @import("../fizzy.zig");
+
+pub const Workbench = @This();
+
+/// A hook to draw a decoration on a file row. `ctx` is decorator-owned (null for
+/// stateless built-ins). `path` is the file's absolute path; `id_extra` is the
+/// row's disambiguator (pass through to any dvui widget drawn).
+pub const BranchDecorator = struct {
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque, path: []const u8, id_extra: usize) void,
+};
+
+allocator: std.mem.Allocator,
+decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty,
+
+pub fn init(allocator: std.mem.Allocator) Workbench {
+ return .{ .allocator = allocator };
+}
+
+pub fn deinit(self: *Workbench) void {
+ self.decorators.deinit(self.allocator);
+}
+
+/// Register the decorations the shell ships with. Called once after the editor is
+/// constructed. (Plugins register their own via `registerBranchDecorator`.)
+pub fn registerBuiltins(self: *Workbench) !void {
+ try self.registerBranchDecorator(.{ .draw = &drawUnsavedDot });
+}
+
+pub fn registerBranchDecorator(self: *Workbench, decorator: BranchDecorator) !void {
+ try self.decorators.append(self.allocator, decorator);
+}
+
+/// Called by the file explorer for each file row (inside the row's hbox).
+pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize) void {
+ for (self.decorators.items) |decorator| decorator.draw(decorator.ctx, path, id_extra);
+}
+
+/// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the
+/// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent.
+fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void {
+ const file = fizzy.editor.getFileFromPath(path) orelse return;
+ if (!file.dirty()) return;
+ dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{
+ .stroke_color = dvui.themeGet().color(.window, .text),
+ }, .{
+ .gravity_x = 1.0,
+ .gravity_y = 0.5,
+ .padding = dvui.Rect.all(2),
+ .id_extra = id_extra,
+ });
+}
From cca4e43a0dc50a21c04e28d5c8474bb5d8d8d585 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Wed, 17 Jun 2026 15:19:04 -0500
Subject: [PATCH 04/49] Relocate files.zig + workspace/tabs into Workbench;
formal workbench-api Host service
---
src/App.zig | 3 +
src/editor/Editor.zig | 22 ++-
src/editor/explorer/Explorer.zig | 2 +-
src/{editor => workbench}/FileLoadJob.zig | 0
src/workbench/Workbench.zig | 188 ++++++++++++++++++-
src/{editor => workbench}/Workspace.zig | 0
src/{editor/explorer => workbench}/files.zig | 95 ++++++----
7 files changed, 269 insertions(+), 41 deletions(-)
rename src/{editor => workbench}/FileLoadJob.zig (100%)
rename src/{editor => workbench}/Workspace.zig (100%)
rename src/{editor/explorer => workbench}/files.zig (94%)
diff --git a/src/App.zig b/src/App.zig
index 65e64e30..661a68c7 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -160,6 +160,9 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.editor = try allocator.create(Editor);
fizzy.editor.* = Editor.init(fizzy.app) catch unreachable;
+ // Second-stage init that needs the editor at its final heap address (e.g.
+ // registering the workbench-api service whose `ctx` is this pointer).
+ fizzy.editor.postInit() catch unreachable;
// `Packer` works on web now that `zstbi.c` compiles for wasm32-freestanding
// (`STBI_NO_STDLIB` + the `fizzy_stbi_libc.c` shims). The web pack flow
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 967347fb..03949549 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -30,14 +30,14 @@ pub const Dialogs = @import("dialogs/Dialogs.zig");
pub const Transform = @import("Transform.zig");
pub const Keybinds = @import("Keybinds.zig");
-pub const Workspace = @import("Workspace.zig");
+pub const Workspace = @import("../workbench/Workspace.zig");
pub const Explorer = @import("explorer/Explorer.zig");
pub const IgnoreRules = @import("explorer/IgnoreRules.zig");
pub const Panel = @import("panel/Panel.zig");
pub const Sidebar = @import("Sidebar.zig");
pub const Infobar = @import("Infobar.zig");
pub const Menu = @import("Menu.zig");
-pub const FileLoadJob = @import("FileLoadJob.zig");
+pub const FileLoadJob = @import("../workbench/FileLoadJob.zig");
pub const PackJob = @import("PackJob.zig");
pub const sdk = fizzy.sdk;
@@ -462,6 +462,24 @@ pub fn init(
return editor;
}
+/// Second-stage init that needs the editor at its FINAL heap address. `init`
+/// builds an `Editor` by value and the caller copies it to the heap, so anything
+/// that captures `&editor.*` (e.g. a service whose `ctx` is the editor pointer)
+/// must run here — not in `init`, where it would point at the stack temporary.
+/// Called from `App.AppInit` right after the heap copy. (The built-in branch
+/// decorators registered in `init` are exempt: they store fn pointers, not `&editor`.)
+pub fn postInit(editor: *Editor) !void {
+ // The workbench-api is the file explorer's programmatic surface and drives OS
+ // file management (open/create/rename/delete/move on disk). The web build has
+ // no filesystem API, so the file explorer / workbench service is left out there
+ // for now. Keeping the registration behind a comptime gate also keeps the
+ // service's native-only fn bodies out of wasm analysis entirely (the codebase's
+ // dead-branch convention; see `web_main.zig`).
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ editor.workbench.initService(editor);
+ try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api);
+}
+
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void {
const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" });
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 56bb771c..a935c0b2 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -13,7 +13,7 @@ const nfd = @import("nfd");
pub const Explorer = @This();
-pub const files = @import("files.zig");
+pub const files = @import("../../workbench/files.zig");
pub const Tools = @import("tools.zig");
pub const Sprites = @import("sprites.zig");
// pub const animations = @import("animations.zig");
diff --git a/src/editor/FileLoadJob.zig b/src/workbench/FileLoadJob.zig
similarity index 100%
rename from src/editor/FileLoadJob.zig
rename to src/workbench/FileLoadJob.zig
diff --git a/src/workbench/Workbench.zig b/src/workbench/Workbench.zig
index 77b85be9..16dcae66 100644
--- a/src/workbench/Workbench.zig
+++ b/src/workbench/Workbench.zig
@@ -1,7 +1,9 @@
-//! The Workbench owns cross-cutting file-management UI: today the per-branch
-//! decoration registry for the file explorer; in later Phase 1 work it grows to
-//! own the file tree, the open/load flow, and the tabs/splits system, then becomes
-//! a standalone plugin exposing this as a service (`workbench-api`).
+//! The Workbench is the file-management home of the editor. Its module now owns
+//! the file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the
+//! workspace/tabs/splits system (`Workspace.zig`); in a later phase it becomes a
+//! standalone plugin. It exposes its capabilities to other plugins through the
+//! `workbench-api` Host service (`Workbench.Api`) so they never reach into the
+//! `fizzy.editor` globals.
//!
//! Per-branch decorations let any plugin draw a right-justified icon on a file row
//! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after
@@ -10,6 +12,7 @@ const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
const fizzy = @import("../fizzy.zig");
+const files = @import("files.zig");
pub const Workbench = @This();
@@ -24,6 +27,12 @@ pub const BranchDecorator = struct {
allocator: std.mem.Allocator,
decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty,
+/// The `workbench-api` service instance handed to plugins. Its `ctx` must be the
+/// editor's FINAL heap address, so it's filled in by `initService` from
+/// `Editor.postInit` (after `Editor.init`'s by-value result is copied to the heap),
+/// not during `init` where `&editor.*` would point at a stack temporary.
+api: Api = undefined,
+
pub fn init(allocator: std.mem.Allocator) Workbench {
return .{ .allocator = allocator };
}
@@ -32,6 +41,12 @@ pub fn deinit(self: *Workbench) void {
self.decorators.deinit(self.allocator);
}
+/// Build the `workbench-api` service. `editor_ctx` is the host's heap `*Editor`,
+/// passed opaquely so the API has no compile-time dependency back on the editor.
+pub fn initService(self: *Workbench, editor_ctx: *anyopaque) void {
+ self.api = .{ .ctx = editor_ctx, .vtable = &service_vtable };
+}
+
/// Register the decorations the shell ships with. Called once after the editor is
/// constructed. (Plugins register their own via `registerBranchDecorator`.)
pub fn registerBuiltins(self: *Workbench) !void {
@@ -61,3 +76,168 @@ fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void {
.id_extra = id_extra,
});
}
+
+// ============================================================================
+// workbench-api — the formal Host service
+// ============================================================================
+
+/// The capabilities the workbench exposes to other plugins, retrieved via
+/// `host.getService(Workbench.Api.service_name)` and `@ptrCast` to `*Api`. Plugins
+/// drive file management through this instead of touching `fizzy.editor`: they open
+/// documents, place them in tab groups/splits, mutate the file tree, and decorate
+/// explorer rows.
+///
+/// Cross-boundary types are normal Zig (host + plugins share one pinned SDK build),
+/// so this is a plain vtable struct; only the dlopen entry symbols (Phase 4) need
+/// `callconv(.c)`. The implementation lives below; `ctx` is the host's `*Editor`.
+pub const Api = struct {
+ /// Service-locator key for `host.registerService` / `host.getService`.
+ pub const service_name = "workbench";
+
+ ctx: *anyopaque,
+ vtable: *const VTable,
+
+ pub const VTable = struct {
+ // ---- open documents + tab/split placement ----
+ /// Open `path` into workspace `grouping` (the tab group / split target).
+ /// Returns true if newly opened (false if already open or unowned).
+ open: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool,
+ /// The currently focused workspace grouping — the default placement target.
+ currentGrouping: *const fn (ctx: *anyopaque) u64,
+ /// Allocate a fresh grouping id for a new tab group / split.
+ newGrouping: *const fn (ctx: *anyopaque) u64,
+ /// Close the open document whose file id is `id`.
+ close: *const fn (ctx: *anyopaque, id: u64) anyerror!void,
+ /// Save the active document.
+ save: *const fn (ctx: *anyopaque) anyerror!void,
+ /// True if `path` is currently open in some workspace.
+ isOpen: *const fn (ctx: *anyopaque, path: []const u8) bool,
+
+ // ---- list open documents (no plugin-specific type leaks the boundary) ----
+ /// Number of currently open documents.
+ openCount: *const fn (ctx: *anyopaque) usize,
+ /// Absolute path of the open document at `index`, or null if out of range.
+ openPathAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8,
+
+ // ---- file-tree operations ----
+ createFile: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
+ createDir: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
+ rename: *const fn (ctx: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void,
+ delete: *const fn (ctx: *anyopaque, path: []const u8) void,
+ /// Move `path` into directory `target_dir`. Returns true if it moved.
+ move: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool,
+
+ // ---- explorer row decorations ----
+ registerBranchDecorator: *const fn (ctx: *anyopaque, decorator: BranchDecorator) anyerror!void,
+ };
+
+ // Thin wrappers so callers skip the `self.vtable.x(self.ctx, …)` dance.
+ pub fn open(self: Api, path: []const u8, grouping: u64) !bool {
+ return self.vtable.open(self.ctx, path, grouping);
+ }
+ pub fn currentGrouping(self: Api) u64 {
+ return self.vtable.currentGrouping(self.ctx);
+ }
+ pub fn newGrouping(self: Api) u64 {
+ return self.vtable.newGrouping(self.ctx);
+ }
+ pub fn close(self: Api, id: u64) !void {
+ return self.vtable.close(self.ctx, id);
+ }
+ pub fn save(self: Api) !void {
+ return self.vtable.save(self.ctx);
+ }
+ pub fn isOpen(self: Api, path: []const u8) bool {
+ return self.vtable.isOpen(self.ctx, path);
+ }
+ pub fn openCount(self: Api) usize {
+ return self.vtable.openCount(self.ctx);
+ }
+ pub fn openPathAt(self: Api, index: usize) ?[]const u8 {
+ return self.vtable.openPathAt(self.ctx, index);
+ }
+ pub fn createFile(self: Api, path: []const u8) !void {
+ return self.vtable.createFile(self.ctx, path);
+ }
+ pub fn createDir(self: Api, path: []const u8) !void {
+ return self.vtable.createDir(self.ctx, path);
+ }
+ pub fn rename(self: Api, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void {
+ return self.vtable.rename(self.ctx, path, new_path, kind);
+ }
+ pub fn delete(self: Api, path: []const u8) void {
+ return self.vtable.delete(self.ctx, path);
+ }
+ pub fn move(self: Api, path: []const u8, target_dir: []const u8) !bool {
+ return self.vtable.move(self.ctx, path, target_dir);
+ }
+ pub fn registerBranchDecorator(self: Api, decorator: BranchDecorator) !void {
+ return self.vtable.registerBranchDecorator(self.ctx, decorator);
+ }
+};
+
+const service_vtable: Api.VTable = .{
+ .open = svcOpen,
+ .currentGrouping = svcCurrentGrouping,
+ .newGrouping = svcNewGrouping,
+ .close = svcClose,
+ .save = svcSave,
+ .isOpen = svcIsOpen,
+ .openCount = svcOpenCount,
+ .openPathAt = svcOpenPathAt,
+ .createFile = svcCreateFile,
+ .createDir = svcCreateDir,
+ .rename = svcRename,
+ .delete = svcDelete,
+ .move = svcMove,
+ .registerBranchDecorator = svcRegisterBranchDecorator,
+};
+
+inline fn editorOf(ctx: *anyopaque) *fizzy.Editor {
+ return @ptrCast(@alignCast(ctx));
+}
+
+fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool {
+ return editorOf(ctx).openFilePath(path, grouping);
+}
+fn svcCurrentGrouping(ctx: *anyopaque) u64 {
+ return editorOf(ctx).currentGroupingID();
+}
+fn svcNewGrouping(ctx: *anyopaque) u64 {
+ return editorOf(ctx).newGroupingID();
+}
+fn svcClose(ctx: *anyopaque, id: u64) anyerror!void {
+ return editorOf(ctx).closeFileID(id);
+}
+fn svcSave(ctx: *anyopaque) anyerror!void {
+ return editorOf(ctx).save();
+}
+fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool {
+ return editorOf(ctx).getFileFromPath(path) != null;
+}
+fn svcOpenCount(ctx: *anyopaque) usize {
+ return editorOf(ctx).open_files.count();
+}
+fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 {
+ const editor = editorOf(ctx);
+ if (index >= editor.open_files.count()) return null;
+ return editor.open_files.values()[index].path;
+}
+fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void {
+ return files.createFilePath(path);
+}
+fn svcCreateDir(_: *anyopaque, path: []const u8) anyerror!void {
+ return files.createDirPath(path);
+}
+fn svcRename(_: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void {
+ return files.renamePath(path, new_path, kind);
+}
+fn svcDelete(_: *anyopaque, path: []const u8) void {
+ files.deletePath(path);
+}
+fn svcMove(_: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool {
+ return files.moveOnePath(path, target_dir, dvui.currentWindow().arena());
+}
+fn svcRegisterBranchDecorator(ctx: *anyopaque, decorator: BranchDecorator) anyerror!void {
+ return editorOf(ctx).workbench.registerBranchDecorator(decorator);
+}
diff --git a/src/editor/Workspace.zig b/src/workbench/Workspace.zig
similarity index 100%
rename from src/editor/Workspace.zig
rename to src/workbench/Workspace.zig
diff --git a/src/editor/explorer/files.zig b/src/workbench/files.zig
similarity index 94%
rename from src/editor/explorer/files.zig
rename to src/workbench/files.zig
index 6e5dc33c..ba988b14 100644
--- a/src/editor/explorer/files.zig
+++ b/src/workbench/files.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const builtin = @import("builtin");
@@ -408,31 +408,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind
}
if (!std.mem.eql(u8, label, te.getText()) and te.getText().len > 0 and valid_path) {
- switch (kind) {
- .directory => {
- std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ label, te.getText() });
-
- for (fizzy.editor.open_files.values()) |*file| {
- if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) {
- const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path";
- fizzy.app.allocator.free(file.path);
- file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name });
- }
- }
- },
- .file => {
- std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ label, te.getText() });
-
- if (fizzy.editor.getFileFromPath(full_path)) |file| {
- fizzy.app.allocator.free(file.path);
- file.path = fizzy.app.allocator.dupe(u8, new_path) catch {
- dvui.log.err("Failed to duplicate path: {s}", .{new_path});
- return error.FailedToDuplicatePath;
- };
- }
- },
- else => {},
- }
+ try renamePath(full_path, new_path, kind);
}
}
} else if (kind == .file) {
@@ -774,13 +750,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
dvui.log.err("Failed to collect selection paths: {any}", .{err});
break :blk &[_][]const u8{};
};
- for (top) |del_path| {
- if (pathIsDirAbsolute(del_path)) {
- std.Io.Dir.deleteDirAbsolute(dvui.io, del_path) catch dvui.log.err("Failed to delete folder: {s}", .{del_path});
- } else {
- std.Io.Dir.deleteFileAbsolute(dvui.io, del_path) catch dvui.log.err("Failed to delete file: {s}", .{del_path});
- }
- }
+ for (top) |del_path| deletePath(del_path);
}
}
}
@@ -1256,7 +1226,7 @@ fn applyFileMove(unique_id: dvui.Id, tree: *fizzy.dvui.TreeWidget, target_dir: [
dvui.dataRemove(null, unique_id, "removed_path");
}
-fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.Allocator) !bool {
+pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.Allocator) !bool {
const base = std.fs.path.basename(source_path);
const new_path = try std.fs.path.join(arena, &.{ target_dir, base });
if (std.mem.eql(u8, source_path, new_path)) return false;
@@ -1276,6 +1246,63 @@ fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.A
return true;
}
+// ---- workbench-api file-tree operations -------------------------------------
+// The functions below are the disk-mutating primitives behind both the explorer's
+// inline actions (rename/delete above) and the `workbench-api` Host service. They
+// keep any matching open document's `path` field in sync so tabs don't dangle.
+
+/// Rename `full_path` to `new_path`. A directory rename rewrites the `path` of
+/// every open document beneath it; a file rename rewrites that document. Logs and
+/// continues on a filesystem failure (matches the explorer's inline behavior).
+pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void {
+ switch (kind) {
+ .directory => {
+ std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
+
+ for (fizzy.editor.open_files.values()) |*file| {
+ if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) {
+ const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path";
+ fizzy.app.allocator.free(file.path);
+ file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name });
+ }
+ }
+ },
+ .file => {
+ std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
+
+ if (fizzy.editor.getFileFromPath(full_path)) |file| {
+ fizzy.app.allocator.free(file.path);
+ file.path = fizzy.app.allocator.dupe(u8, new_path) catch {
+ dvui.log.err("Failed to duplicate path: {s}", .{new_path});
+ return error.FailedToDuplicatePath;
+ };
+ }
+ },
+ else => {},
+ }
+}
+
+/// Delete `path` from disk (a directory must be empty — mirrors the explorer's
+/// inline Delete). Logs and continues on failure.
+pub fn deletePath(path: []const u8) void {
+ if (pathIsDirAbsolute(path)) {
+ std.Io.Dir.deleteDirAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete folder: {s}", .{path});
+ } else {
+ std.Io.Dir.deleteFileAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete file: {s}", .{path});
+ }
+}
+
+/// Create an empty file at absolute `path`.
+pub fn createFilePath(path: []const u8) !void {
+ var handle = try std.Io.Dir.createFileAbsolute(dvui.io, path, .{});
+ handle.close(dvui.io);
+}
+
+/// Create a directory at absolute `path` (parents must already exist).
+pub fn createDirPath(path: []const u8) !void {
+ try std.Io.Dir.createDirAbsolute(dvui.io, path, .default_dir);
+}
+
/// Remove stale selections whose underlying file no longer exists (e.g. moved by a multi-drag).
pub fn pruneMissingSelections() void {
var i: usize = 0;
From daa4d1af5a633d2c2c802625a3b8fcfbeb04f172 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Wed, 17 Jun 2026 15:39:36 -0500
Subject: [PATCH 05/49] Begin phase 2
Phase 3a
---
src/editor/Editor.zig | 72 ++++++++++++---
src/editor/Keybinds.zig | 53 +++--------
src/editor/Menu.zig | 26 +++++-
src/editor/Sidebar.zig | 38 ++++----
src/editor/explorer/Explorer.zig | 39 ++------
src/editor/panel/Panel.zig | 37 ++++++--
src/editor/widgets/FileWidget.zig | 4 +-
src/internal/File.zig | 5 +-
src/internal/History.zig | 17 ++--
src/pixelart/plugin.zig | 143 ++++++++++++++++++++++++++++++
src/sdk/Host.zig | 104 ++++++++++++++++++++++
src/sdk/Plugin.zig | 18 ++++
src/sdk/regions.zig | 54 +++++++++++
src/sdk/sdk.zig | 7 ++
src/workbench/Workspace.zig | 2 +-
src/workbench/plugin.zig | 68 ++++++++++++++
16 files changed, 552 insertions(+), 135 deletions(-)
create mode 100644 src/pixelart/plugin.zig
create mode 100644 src/sdk/regions.zig
create mode 100644 src/workbench/plugin.zig
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 03949549..0e319873 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -468,16 +468,54 @@ pub fn init(
/// must run here — not in `init`, where it would point at the stack temporary.
/// Called from `App.AppInit` right after the heap copy. (The built-in branch
/// decorators registered in `init` are exempt: they store fn pointers, not `&editor`.)
+/// Stable shell-builtin contribution id.
+pub const view_settings = "shell.settings";
+
pub fn postInit(editor: *Editor) !void {
+ // Register plugin contributions (sidebar/bottom/center/menus). These are the
+ // near-empty shell's content: it iterates the Host registries rather than
+ // hardcoding panes. Web-safe — the draw fns reach the same inline code the
+ // editor tick already runs on wasm. Order = sidebar order.
+ try @import("../workbench/plugin.zig").register(&editor.host);
+ try @import("../pixelart/plugin.zig").register(&editor.host);
+
+ // Shell built-in: Settings (owner = null; not a plugin).
+ try editor.host.registerSidebarView(.{
+ .id = view_settings,
+ .icon = dvui.entypo.cog,
+ .title = "Settings",
+ .draw = drawSettingsPane,
+ });
+
+ // Menu bar contributions (non-macOS in-app bar). The draw code still lives in
+ // the shell's `Menu.zig`; Phase 3 moves the File/Edit bodies into the workbench
+ // / pixel-art plugins, which will then self-register. Order = bar order.
+ try editor.host.registerMenu(.{ .id = "workbench.menu.file", .draw = Menu.drawFileMenu });
+ try editor.host.registerMenu(.{ .id = "pixelart.menu.edit", .draw = Menu.drawEditMenu });
+ try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu });
+ try editor.host.registerMenu(.{ .id = "shell.menu.help", .draw = Menu.drawHelpMenu });
+
+ // Keybind contributions: each plugin registers its own binds into the window's
+ // keybind map. The shell already registered its global/navigation/region binds
+ // in `Keybinds.register` (during `init`, before this runs), so the two halves
+ // are disjoint — no `putNoClobber` clash. Runs on all targets (web included).
+ const window = dvui.currentWindow();
+ for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window);
+
// The workbench-api is the file explorer's programmatic surface and drives OS
// file management (open/create/rename/delete/move on disk). The web build has
- // no filesystem API, so the file explorer / workbench service is left out there
- // for now. Keeping the registration behind a comptime gate also keeps the
- // service's native-only fn bodies out of wasm analysis entirely (the codebase's
- // dead-branch convention; see `web_main.zig`).
- if (comptime builtin.target.cpu.arch == .wasm32) return;
- editor.workbench.initService(editor);
- try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api);
+ // no filesystem API, so the workbench *service* is left out there for now.
+ // Keeping it behind a comptime gate also keeps its native-only fn bodies out of
+ // wasm analysis entirely (the codebase's dead-branch convention; see
+ // `web_main.zig`).
+ if (comptime builtin.target.cpu.arch != .wasm32) {
+ editor.workbench.initService(editor);
+ try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api);
+ }
+}
+
+fn drawSettingsPane(_: ?*anyopaque) anyerror!void {
+ try Explorer.settings.draw();
}
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
@@ -1176,9 +1214,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
}
if (editor.panel.paned.showFirst()) {
- const result = try editor.drawWorkspaces(0);
- if (result != .ok) {
- return result;
+ if (editor.host.activeCenter()) |center| {
+ const result = try center.draw(center.ctx);
+ if (result != .ok) {
+ return result;
+ }
}
}
} else {
@@ -1943,7 +1983,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
- editor.explorer.pane = .files;
+ editor.host.setActiveSidebarView(@import("../workbench/plugin.zig").view_files);
editor.project = Project.load(fizzy.app.allocator) catch null;
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
@@ -2329,7 +2369,7 @@ pub fn processPackJob(editor: *Editor) void {
}
fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
job.result_consumed = true;
- editor.explorer.pane = .project;
+ editor.host.setActiveSidebarView(@import("../pixelart/plugin.zig").view_project);
const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null;
showPackToast("Project packed", toast_canvas);
} else blk: {
@@ -3255,13 +3295,17 @@ fn processPendingSaveAs(editor: *Editor) void {
pub fn undo(editor: *Editor) !void {
if (editor.activeFile()) |file| {
- try file.history.undoRedo(file, .undo);
+ if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
+ try plugin.undo(.{ .ptr = file, .owner = plugin, .id = file.id });
+ }
}
}
pub fn redo(editor: *Editor) !void {
if (editor.activeFile()) |file| {
- try file.history.undoRedo(file, .redo);
+ if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
+ try plugin.redo(.{ .ptr = file, .owner = plugin, .id = file.id });
+ }
}
}
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index cc66b279..0852fd4c 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -6,60 +6,29 @@ const dvui = @import("dvui");
pub const Keybinds = @This();
+/// Register the shell's own global / navigation / region binds. File-management
+/// binds and pixel-art editing binds are contributed by the workbench and
+/// pixel-art plugins (their `contributeKeybinds`), which `Editor.postInit` invokes
+/// after the plugins register. This runs during `Editor.init`, before postInit, so
+/// the shell binds land first; the split is disjoint, so no `putNoClobber` clashes.
+///
+/// Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for
+/// wasm32-freestanding, so macOS web users would otherwise get the Windows (Ctrl)
+/// bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`-derived
+/// choice on web and uses `os.tag` on native.
pub fn register() !void {
const window = dvui.currentWindow();
- // Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for
- // wasm32-freestanding, so macOS web users would otherwise get the Windows
- // (Ctrl) bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`-
- // derived choice on web and uses `os.tag` on native.
+ // Region toggles (explorer / workspace) are platform-dependent.
if (fizzy.platform.isMacOS()) {
- try window.keybinds.putNoClobber(window.gpa, "open_folder", .{ .key = .f, .command = true });
- try window.keybinds.putNoClobber(window.gpa, "new_file", .{ .key = .n, .command = true });
- try window.keybinds.putNoClobber(window.gpa, "open_files", .{ .key = .o, .command = true });
- try window.keybinds.putNoClobber(window.gpa, "undo", .{ .key = .z, .command = true, .shift = false });
- try window.keybinds.putNoClobber(window.gpa, "redo", .{ .key = .z, .command = true, .shift = true });
- try window.keybinds.putNoClobber(window.gpa, "zoom", .{ .command = true });
- try window.keybinds.putNoClobber(window.gpa, "save", .{ .command = true, .key = .s });
- try window.keybinds.putNoClobber(window.gpa, "save_as", .{ .command = true, .shift = true, .key = .s });
- try window.keybinds.putNoClobber(window.gpa, "save_all", .{ .command = true, .alt = true, .key = .s });
- try window.keybinds.putNoClobber(window.gpa, "sample", .{ .control = true });
- try window.keybinds.putNoClobber(window.gpa, "transform", .{ .command = true, .key = .t });
- try window.keybinds.putNoClobber(window.gpa, "grid_layout", .{ .command = true, .key = .g });
try window.keybinds.putNoClobber(window.gpa, "explorer", .{ .command = true, .key = .e });
try window.keybinds.putNoClobber(window.gpa, "workspace", .{ .command = true, .key = .w });
- try window.keybinds.putNoClobber(window.gpa, "export", .{ .command = true, .key = .p });
- try window.keybinds.putNoClobber(window.gpa, "delete_selection_contents", .{ .key = .backspace });
} else {
- try window.keybinds.putNoClobber(window.gpa, "open_folder", .{ .key = .f, .control = true });
- try window.keybinds.putNoClobber(window.gpa, "new_file", .{ .key = .n, .control = true });
- try window.keybinds.putNoClobber(window.gpa, "open_files", .{ .key = .o, .control = true });
- try window.keybinds.putNoClobber(window.gpa, "undo", .{ .key = .z, .control = true, .shift = false });
- try window.keybinds.putNoClobber(window.gpa, "redo", .{ .key = .z, .control = true, .shift = true });
- try window.keybinds.putNoClobber(window.gpa, "zoom", .{ .control = true });
- try window.keybinds.putNoClobber(window.gpa, "save", .{ .control = true, .key = .s });
- try window.keybinds.putNoClobber(window.gpa, "save_as", .{ .control = true, .shift = true, .key = .s });
- try window.keybinds.putNoClobber(window.gpa, "save_all", .{ .control = true, .alt = true, .key = .s });
- try window.keybinds.putNoClobber(window.gpa, "sample", .{ .alt = true });
- try window.keybinds.putNoClobber(window.gpa, "transform", .{ .control = true, .key = .t });
- try window.keybinds.putNoClobber(window.gpa, "grid_layout", .{ .control = true, .key = .g });
try window.keybinds.putNoClobber(window.gpa, "explorer", .{ .control = true, .key = .e });
try window.keybinds.putNoClobber(window.gpa, "workspace", .{ .control = true, .key = .w });
- try window.keybinds.putNoClobber(window.gpa, "export", .{ .control = true, .key = .p });
- try window.keybinds.putNoClobber(window.gpa, "delete_selection_contents", .{ .key = .delete });
}
try window.keybinds.putNoClobber(window.gpa, "shift", .{ .shift = true });
- try window.keybinds.putNoClobber(window.gpa, "increase_stroke_size", .{ .key = .right_bracket });
- try window.keybinds.putNoClobber(window.gpa, "decrease_stroke_size", .{ .key = .left_bracket });
-
- try window.keybinds.putNoClobber(window.gpa, "quick_tools", .{ .key = .space });
-
- try window.keybinds.putNoClobber(window.gpa, "pencil", .{ .key = .d, .command = false, .control = false, .alt = false, .shift = false });
- try window.keybinds.putNoClobber(window.gpa, "eraser", .{ .key = .e, .command = false, .control = false, .alt = false, .shift = false });
- try window.keybinds.putNoClobber(window.gpa, "bucket", .{ .key = .b, .command = false, .control = false, .alt = false, .shift = false });
- try window.keybinds.putNoClobber(window.gpa, "selection", .{ .key = .s, .command = false, .control = false, .alt = false, .shift = false });
- try window.keybinds.putNoClobber(window.gpa, "pointer", .{ .key = .escape });
try window.keybinds.putNoClobber(window.gpa, "up", .{ .key = .up });
try window.keybinds.putNoClobber(window.gpa, "down", .{ .key = .down });
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index 47a3b99f..2445be5a 100644
--- a/src/editor/Menu.zig
+++ b/src/editor/Menu.zig
@@ -24,6 +24,19 @@ pub fn draw() !dvui.App.Result {
dvui.themeSet(theme);
}
+ // The shell owns only the menu bar container + theme; the top-level menus are
+ // plugin (and shell built-in) contributions, drawn in registration order.
+ for (fizzy.editor.host.menus.items) |*menu| {
+ menu.draw(menu.ctx) catch |err| {
+ dvui.log.err("Menu contribution failed: {any}", .{err});
+ };
+ }
+
+ return .ok;
+}
+
+/// File menu (workbench contribution).
+pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
if (menuItem(@src(), "File", .{ .submenu = true }, .{
.expand = .horizontal,
//.color_accent = dvui.themeGet().color(.window, .fill),
@@ -160,7 +173,10 @@ pub fn draw() !dvui.App.Result {
fw.close();
}
}
+}
+/// Edit menu (pixel-art contribution).
+pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
if (menuItem(
@src(),
"Edit",
@@ -280,7 +296,10 @@ pub fn draw() !dvui.App.Result {
}
}
}
+}
+/// View menu (shell built-in).
+pub fn drawViewMenu(_: ?*anyopaque) anyerror!void {
if (menuItem(@src(), "View", .{ .submenu = true }, .{
.expand = .horizontal,
.color_text = dvui.themeGet().color(.control, .text),
@@ -322,8 +341,11 @@ pub fn draw() !dvui.App.Result {
fw.close();
}
}
+}
- // Help — matches the macOS native Help menu so the two menubars stay congruent.
+/// Help menu (shell built-in). Matches the macOS native Help menu so the two
+/// menubars stay congruent.
+pub fn drawHelpMenu(_: ?*anyopaque) anyerror!void {
if (menuItem(@src(), "Help", .{ .submenu = true }, .{
.expand = .horizontal,
.color_text = dvui.themeGet().color(.control, .text),
@@ -354,8 +376,6 @@ pub fn draw() !dvui.App.Result {
fw.close();
}
}
-
- return .ok;
}
pub fn menuItemWithHotkey(src: std.builtin.SourceLocation, label_str: []const u8, hotkey: dvui.enums.Keybind, enabled: bool, init_opts: dvui.MenuItemWidget.InitOptions, opts: dvui.Options) ?dvui.Rect.Natural {
diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig
index d2cebba4..51dad7ea 100644
--- a/src/editor/Sidebar.zig
+++ b/src/editor/Sidebar.zig
@@ -5,7 +5,7 @@ const dvui = @import("dvui");
const App = fizzy.App;
const Editor = fizzy.Editor;
-const Pane = @import("explorer/Explorer.zig").Pane;
+const SidebarView = fizzy.sdk.SidebarView;
pub const Sidebar = @This();
@@ -32,28 +32,20 @@ pub fn draw(_: Sidebar) !Action {
});
defer vbox.deinit();
- const options = [_]struct { pane: Pane, icon: []const u8 }{
- .{ .pane = .files, .icon = dvui.entypo.folder },
- .{ .pane = .tools, .icon = dvui.entypo.pencil },
- .{ .pane = .sprites, .icon = dvui.entypo.grid },
- //.{ .pane = .animations, .icon = dvui.entypo.controller_play },
- //.{ .pane = .keyframe_animations, .icon = dvui.entypo.key },
- .{ .pane = .project, .icon = dvui.entypo.box },
- .{ .pane = .settings, .icon = dvui.entypo.cog },
- };
-
var ret: Action = .none;
- for (options) |option| {
- const a = try drawOption(option.pane, option.icon, 20);
+ // One icon per registered sidebar view (plugins contribute these; the shell
+ // owns none of them itself). Registration order is the display order.
+ for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| {
+ const a = try drawOption(view, i, 20);
if (a != .none) ret = a;
}
return ret;
}
-fn drawOption(option: Pane, icon: []const u8, size: f32) !Action {
- const selected = option == fizzy.editor.explorer.pane;
+fn drawOption(view: *const SidebarView, index: usize, size: f32) !Action {
+ const selected = fizzy.editor.host.isActiveSidebarView(view.id);
var ret: Action = .none;
const theme = dvui.themeGet();
@@ -61,7 +53,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action {
var bw: dvui.ButtonWidget = undefined;
bw.init(@src(), .{}, .{
- .id_extra = @intFromEnum(option),
+ .id_extra = index,
.min_size_content = .{ .h = size },
});
defer bw.deinit();
@@ -80,16 +72,17 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action {
dvui.icon(
@src(),
- @tagName(option),
- icon,
+ view.id,
+ view.icon,
.{ .fill_color = color },
.{
+ .id_extra = index,
.min_size_content = .{ .h = size },
},
);
if (bw.clicked()) {
- // Tapping the icon for the pane that's already showing toggles the explorer
+ // Tapping the icon for the view that's already showing toggles the explorer
// closed (same effect as the floating collapse button). We *report* the intent
// here; Editor.zig invokes `peekClose` / `open` after `editor.explorer.paned` has
// been recreated for this frame. Doing the call directly here would dereference
@@ -98,7 +91,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action {
if (selected and explorer_visible) {
ret = .close;
} else {
- fizzy.editor.explorer.pane = option;
+ fizzy.editor.host.setActiveSidebarView(view.id);
ret = .open;
}
dvui.refresh(null, @src(), null);
@@ -110,7 +103,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action {
.active_rect = bw.data().rectScale().r,
.delay = 350_000,
}, .{
- .id_extra = @intFromEnum(option),
+ .id_extra = index,
.color_fill = dvui.themeGet().color(.window, .fill),
.border = dvui.Rect.all(0),
.box_shadow = .{
@@ -144,7 +137,8 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action {
.background = false,
.padding = dvui.Rect.all(4),
});
- tl2.format("{s}", .{fizzy.Editor.Explorer.title(option, true)}, .{
+ const tip = std.ascii.allocUpperString(dvui.currentWindow().arena(), view.title) catch view.title;
+ tl2.format("{s}", .{tip}, .{
.font = dvui.Font.theme(.heading),
});
tl2.deinit();
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index a935c0b2..eeeacf9c 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -23,7 +23,6 @@ pub const settings = @import("settings.zig");
sprites: Sprites = .{},
tools: Tools = .{},
-pane: Pane = .files,
paned: *fizzy.dvui.PanedWidget = undefined,
scroll_info: dvui.ScrollInfo = .{
.horizontal = .auto,
@@ -43,16 +42,6 @@ closed: bool = false,
peek_open: bool = false,
collapse_btn_anim_started: bool = false,
-pub const Pane = enum(u32) {
- files,
- tools,
- sprites,
- animations,
- keyframe_animations,
- project,
- settings,
-};
-
pub fn init() Explorer {
return .{
.open_branches = .init(fizzy.app.allocator),
@@ -64,18 +53,6 @@ pub fn deinit(self: *Explorer) void {
self.open_branches.deinit();
}
-pub fn title(pane: Pane, all_caps: bool) []const u8 {
- return switch (pane) {
- .files => if (all_caps) "FILES" else "Files",
- .tools => if (all_caps) "TOOLS" else "Tools",
- .sprites => if (all_caps) "SPRITES" else "Sprites",
- .animations => if (all_caps) "ANIMATIONS" else "Animations",
- .keyframe_animations => if (all_caps) "KEYFRAME ANIMATIONS" else "Keyframe Animations",
- .project => if (all_caps) "PROJECT" else "Project",
- .settings => if (all_caps) "SETTINGS" else "Settings",
- };
-}
-
pub fn close(explorer: *Explorer) void {
explorer.paned.animateSplit(0.0, dvui.easing.outQuint);
explorer.closed = true;
@@ -136,7 +113,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
.background = false,
});
- if (explorer.pane != .files) {
+ if (!fizzy.editor.host.isActiveSidebarView(@import("../../workbench/plugin.zig").view_files)) {
fizzy.editor.file_tree_data_id = null;
if (fizzy.editor.tab_drag_from_tree_path) |p| {
fizzy.app.allocator.free(p);
@@ -144,13 +121,8 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
}
}
- switch (explorer.pane) {
- .files => try files.draw(),
- .settings => try settings.draw(),
- .project => try project.draw(),
- .tools => try explorer.tools.draw(),
- .sprites => try explorer.sprites.draw(),
- else => {},
+ if (fizzy.editor.host.activeSidebarView()) |view| {
+ try view.draw(view.ctx);
}
const vertical_scroll = scroll.si.offset(.vertical);
@@ -269,8 +241,9 @@ pub fn hovered(explorer: *Explorer) bool {
return fizzy.dvui.hovered(explorer.paned.data());
}
-pub fn drawHeader(explorer: *Explorer) !void {
- const header_title = title(explorer.pane, true);
+pub fn drawHeader(_: *Explorer) !void {
+ const view = fizzy.editor.host.activeSidebarView() orelse return;
+ const header_title = std.ascii.allocUpperString(dvui.currentWindow().arena(), view.title) catch view.title;
dvui.labelNoFmt(@src(), header_title, .{}, .{ .font = dvui.Font.theme(.heading) });
}
diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig
index bb82654d..e4f5496c 100644
--- a/src/editor/panel/Panel.zig
+++ b/src/editor/panel/Panel.zig
@@ -14,23 +14,18 @@ pub const Panel = @This();
pub const Sprites = @import("sprites.zig");
sprites: Sprites = .{},
-pane: Pane = .sprites,
paned: *fizzy.dvui.PanedWidget = undefined,
scroll_info: dvui.ScrollInfo = .{
.horizontal = .auto,
},
-pub const Pane = enum(u32) {
- sprites,
-};
-
pub fn init() Panel {
return .{};
}
pub fn deinit(_: *Panel) void {}
-pub fn draw(panel: *Panel) !dvui.App.Result {
+pub fn draw(_: *Panel) !dvui.App.Result {
// var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &panel.scroll_info }, .{
// .expand = .both,
// });
@@ -55,9 +50,35 @@ pub fn draw(panel: *Panel) !dvui.App.Result {
});
defer vbox.deinit();
- switch (panel.pane) {
- .sprites => try panel.sprites.draw(),
+ const host = &fizzy.editor.host;
+
+ // Tab strip across registered bottom views; one active at a time. With a single
+ // view we skip the strip so the panel looks exactly as before (no lone tab).
+ if (host.bottom_views.items.len > 1) try drawTabStrip(host);
+
+ if (host.activeBottomView()) |view| {
+ try view.draw(view.ctx);
}
return .ok;
}
+
+fn drawTabStrip(host: *fizzy.Editor.Host) !void {
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .background = false,
+ });
+ defer hbox.deinit();
+
+ const theme = dvui.themeGet();
+ for (host.bottom_views.items, 0..) |*view, i| {
+ const selected = host.isActiveBottomView(view.id);
+ if (dvui.button(@src(), view.title, .{ .draw_focus = false }, .{
+ .id_extra = i,
+ .style = if (selected) .highlight else .window,
+ .color_text = if (selected) theme.color(.highlight, .text) else theme.color(.window, .text),
+ })) {
+ host.setActiveBottomView(view.id);
+ }
+ }
+}
diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig
index d24d5e3c..9bc4415a 100644
--- a/src/editor/widgets/FileWidget.zig
+++ b/src/editor/widgets/FileWidget.zig
@@ -1597,7 +1597,7 @@ pub fn drawSpriteBubble(
self.init_options.file.collapseAnimationSelectionToPrimary();
self.init_options.file.editor.animations_scroll_to_index = anim_index;
fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
- fizzy.editor.explorer.pane = .sprites;
+ fizzy.editor.host.setActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites);
var anim = self.init_options.file.animations.get(anim_index);
if (anim.frames.len == 0) {
@@ -4580,7 +4580,7 @@ pub fn drawLayers(self: *FileWidget) void {
const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect);
// Draw the origins when in the sprites pane
- if (fizzy.editor.explorer.pane == .sprites) {
+ if (fizzy.editor.host.isActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites)) {
const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] };
const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y };
diff --git a/src/internal/File.zig b/src/internal/File.zig
index b03f789f..e78a9881 100644
--- a/src/internal/File.zig
+++ b/src/internal/File.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const fizzy = @import("../fizzy.zig");
+const pixelart = @import("../pixelart/plugin.zig");
const zip = @import("zip");
const dvui = @import("dvui");
@@ -757,7 +758,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
return error.FileLoadError;
}
-fn isFlatImageExtension(ext: []const u8) bool {
+pub fn isFlatImageExtension(ext: []const u8) bool {
return std.mem.eql(u8, ext, ".png") or
std.mem.eql(u8, ext, ".jpg") or
std.mem.eql(u8, ext, ".jpeg");
@@ -2678,7 +2679,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i:
.dest_pixels_before = dest_pixels_before,
.dest_mask_before = dest_mask_before,
} });
- fizzy.editor.explorer.pane = .tools;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
}
pub fn duplicateLayer(self: *File, index: usize) !u64 {
diff --git a/src/internal/History.zig b/src/internal/History.zig
index dc0efa2c..2e2cf362 100644
--- a/src/internal/History.zig
+++ b/src/internal/History.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const fizzy = @import("../fizzy.zig");
+const pixelart = @import("../pixelart/plugin.zig");
const zgui = @import("zgui");
const History = @This();
const Editor = fizzy.Editor;
@@ -387,7 +388,7 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
file.editor.layer_composite_dirty = true;
file.editor.split_composite_dirty = true;
file.selected_layer_index = lm.source_index;
- fizzy.editor.explorer.pane = .tools;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
@@ -429,7 +430,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
.up => dest_i,
.down => dest_i - 1,
};
- fizzy.editor.explorer.pane = .tools;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
@@ -618,7 +619,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
//try file.editor.selected_sprites.append(sprite_index);
}
- fizzy.editor.explorer.pane = .sprites;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites);
},
.layers_order => |*layers_order| {
file.editor.layer_composite_dirty = true;
@@ -673,7 +674,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
layer_restore_delete.action = .restore;
},
}
- fizzy.editor.explorer.pane = .tools;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
},
.layer_name => |*layer_name| {
@@ -681,7 +682,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]);
file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name);
layer_name.name = name;
- fizzy.editor.explorer.pane = .tools;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
},
.layer_settings => |*layer_settings| {
const idx = layer_settings.index;
@@ -700,7 +701,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
if (visibility_changed) {
file.editor.split_composite_dirty = true;
}
- fizzy.editor.explorer.pane = .tools;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
},
.animation_restore_delete => |*animation_restore_delete| {
const a = animation_restore_delete.action;
@@ -726,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
}
},
}
- fizzy.editor.explorer.pane = .sprites;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites);
},
.animation_name => |*animation_name| {
const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]);
fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]);
file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name);
animation_name.name = name;
- fizzy.editor.explorer.pane = .sprites;
+ fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites);
},
.animation_settings => {},
.animation_order => |*animation_order| {
diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig
new file mode 100644
index 00000000..d7c0cb20
--- /dev/null
+++ b/src/pixelart/plugin.zig
@@ -0,0 +1,143 @@
+//! The pixel-art editor plugin. Phase 2 thin shim — the pixel-art stack still
+//! lives inline under `src/editor/` (Phase 3 relocates it whole behind this
+//! plugin). For now its contributions point at the existing draw entry points
+//! through the `fizzy.*` globals. Registered from `Editor.postInit`.
+const std = @import("std");
+const fizzy = @import("../fizzy.zig");
+const dvui = @import("dvui");
+const sdk = fizzy.sdk;
+
+const DocHandle = sdk.DocHandle;
+const Internal = fizzy.Internal;
+
+/// Stable contribution ids (plugin-namespaced) referenced across modules.
+pub const view_tools = "pixelart.tools";
+pub const view_sprites = "pixelart.sprites";
+pub const view_project = "pixelart.project";
+pub const bottom_sprites = "pixelart.sprites_panel";
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "pixelart",
+ .display_name = "Pixel Art",
+};
+
+const vtable: sdk.Plugin.VTable = .{
+ .fileTypePriority = fileTypePriority,
+ .contributeKeybinds = contributeKeybinds,
+ .isDirty = isDirty,
+ .undo = undo,
+ .redo = redo,
+};
+
+/// A `DocHandle` whose `ptr` is one of this plugin's `*Internal.File`s. The shell
+/// gets the owning plugin from the file-type registry and round-trips the document
+/// back through these hooks, so it never needs to know the concrete pixel-art type.
+fn docFile(doc: DocHandle) *Internal.File {
+ return @ptrCast(@alignCast(doc.ptr));
+}
+
+/// Priority for opening `ext` (lower wins). Pixel art owns its native `.fiz`/`.pixi`
+/// and flat-image `.png`/`.jpg`/`.jpeg`; native formats win over flat images when
+/// some future plugin also claims an image type.
+fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 {
+ if (Internal.File.isFizzyExtension(ext)) return 0;
+ if (Internal.File.isFlatImageExtension(ext)) return 10;
+ return null;
+}
+
+fn isDirty(_: *anyopaque, doc: DocHandle) bool {
+ return docFile(doc).dirty();
+}
+
+fn undo(_: *anyopaque, doc: DocHandle) anyerror!void {
+ const file = docFile(doc);
+ try file.history.undoRedo(file, .undo);
+}
+
+fn redo(_: *anyopaque, doc: DocHandle) anyerror!void {
+ const file = docFile(doc);
+ try file.history.undoRedo(file, .redo);
+}
+
+pub fn register(host: *sdk.Host) !void {
+ try host.registerPlugin(&plugin);
+ try host.registerSidebarView(.{
+ .id = view_tools,
+ .owner = &plugin,
+ .icon = dvui.entypo.pencil,
+ .title = "Tools",
+ .draw = drawTools,
+ });
+ try host.registerSidebarView(.{
+ .id = view_sprites,
+ .owner = &plugin,
+ .icon = dvui.entypo.grid,
+ .title = "Sprites",
+ .draw = drawSprites,
+ });
+ try host.registerSidebarView(.{
+ .id = view_project,
+ .owner = &plugin,
+ .icon = dvui.entypo.box,
+ .title = "Project",
+ .draw = drawProject,
+ });
+ try host.registerBottomView(.{
+ .id = bottom_sprites,
+ .owner = &plugin,
+ .title = "Sprites",
+ .draw = drawSpritesPanel,
+ });
+}
+
+fn drawTools(_: ?*anyopaque) anyerror!void {
+ try fizzy.editor.explorer.tools.draw();
+}
+fn drawSprites(_: ?*anyopaque) anyerror!void {
+ try fizzy.editor.explorer.sprites.draw();
+}
+fn drawProject(_: ?*anyopaque) anyerror!void {
+ try fizzy.Editor.Explorer.project.draw();
+}
+fn drawSpritesPanel(_: ?*anyopaque) anyerror!void {
+ try fizzy.editor.panel.sprites.draw();
+}
+
+/// Pixel-art editing + tool keybinds. The shell registers its own global/region
+/// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see
+/// `Keybinds.register` for why `fizzy.platform.isMacOS()` (not `builtin`) is used.
+fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void {
+ if (fizzy.platform.isMacOS()) {
+ try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .command = true });
+ try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .command = true, .shift = false });
+ try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .command = true, .shift = true });
+ try win.keybinds.putNoClobber(win.gpa, "zoom", .{ .command = true });
+ try win.keybinds.putNoClobber(win.gpa, "sample", .{ .control = true });
+ try win.keybinds.putNoClobber(win.gpa, "transform", .{ .command = true, .key = .t });
+ try win.keybinds.putNoClobber(win.gpa, "grid_layout", .{ .command = true, .key = .g });
+ try win.keybinds.putNoClobber(win.gpa, "export", .{ .command = true, .key = .p });
+ try win.keybinds.putNoClobber(win.gpa, "delete_selection_contents", .{ .key = .backspace });
+ } else {
+ try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .control = true });
+ try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .control = true, .shift = false });
+ try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .control = true, .shift = true });
+ try win.keybinds.putNoClobber(win.gpa, "zoom", .{ .control = true });
+ try win.keybinds.putNoClobber(win.gpa, "sample", .{ .alt = true });
+ try win.keybinds.putNoClobber(win.gpa, "transform", .{ .control = true, .key = .t });
+ try win.keybinds.putNoClobber(win.gpa, "grid_layout", .{ .control = true, .key = .g });
+ try win.keybinds.putNoClobber(win.gpa, "export", .{ .control = true, .key = .p });
+ try win.keybinds.putNoClobber(win.gpa, "delete_selection_contents", .{ .key = .delete });
+ }
+
+ try win.keybinds.putNoClobber(win.gpa, "increase_stroke_size", .{ .key = .right_bracket });
+ try win.keybinds.putNoClobber(win.gpa, "decrease_stroke_size", .{ .key = .left_bracket });
+ try win.keybinds.putNoClobber(win.gpa, "quick_tools", .{ .key = .space });
+
+ try win.keybinds.putNoClobber(win.gpa, "pencil", .{ .key = .d, .command = false, .control = false, .alt = false, .shift = false });
+ try win.keybinds.putNoClobber(win.gpa, "eraser", .{ .key = .e, .command = false, .control = false, .alt = false, .shift = false });
+ try win.keybinds.putNoClobber(win.gpa, "bucket", .{ .key = .b, .command = false, .control = false, .alt = false, .shift = false });
+ try win.keybinds.putNoClobber(win.gpa, "selection", .{ .key = .s, .command = false, .control = false, .alt = false, .shift = false });
+ try win.keybinds.putNoClobber(win.gpa, "pointer", .{ .key = .escape });
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index e2cdc065..00bc7bb3 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -7,9 +7,15 @@
//! yet — the existing pixel-art code still uses globals directly.
const std = @import("std");
const Plugin = @import("Plugin.zig");
+const regions = @import("regions.zig");
pub const Host = @This();
+pub const SidebarView = regions.SidebarView;
+pub const BottomView = regions.BottomView;
+pub const CenterProvider = regions.CenterProvider;
+pub const MenuContribution = regions.MenuContribution;
+
allocator: std.mem.Allocator,
/// All registered plugins (static today; runtime-loaded dylibs in Phase 4).
@@ -20,6 +26,24 @@ plugins: std.ArrayListUnmanaged(*Plugin) = .empty,
/// draw per-branch explorer decorations without a compile-time dependency on it.
services: std.StringHashMapUnmanaged(*anyopaque) = .empty,
+// ---- shell region registries (Phase 2) -------------------------------------
+// The shell iterates these instead of hardcoded enums/switches. Items keep their
+// registration order, which is the order they appear in the UI.
+
+/// Left-region (explorer) views, one per sidebar icon.
+sidebar_views: std.ArrayListUnmanaged(SidebarView) = .empty,
+/// Bottom-panel views (shown as a tab strip).
+bottom_views: std.ArrayListUnmanaged(BottomView) = .empty,
+/// Center ("main window") providers; the active one draws the whole center.
+center_providers: std.ArrayListUnmanaged(CenterProvider) = .empty,
+/// Menubar contributions (non-macOS in-app menu bar).
+menus: std.ArrayListUnmanaged(MenuContribution) = .empty,
+
+/// Active selection by contribution id (null = use the first registered).
+active_sidebar_view: ?[]const u8 = null,
+active_bottom_view: ?[]const u8 = null,
+active_center: ?[]const u8 = null,
+
pub fn init(allocator: std.mem.Allocator) Host {
return .{ .allocator = allocator };
}
@@ -27,6 +51,10 @@ pub fn init(allocator: std.mem.Allocator) Host {
pub fn deinit(self: *Host) void {
self.plugins.deinit(self.allocator);
self.services.deinit(self.allocator);
+ self.sidebar_views.deinit(self.allocator);
+ self.bottom_views.deinit(self.allocator);
+ self.center_providers.deinit(self.allocator);
+ self.menus.deinit(self.allocator);
}
pub fn registerPlugin(self: *Host, plugin: *Plugin) !void {
@@ -41,6 +69,82 @@ pub fn getService(self: *Host, name: []const u8) ?*anyopaque {
return self.services.get(name);
}
+// ---- region registration (called from a plugin's register / postInit) -------
+
+pub fn registerSidebarView(self: *Host, view: SidebarView) !void {
+ try self.sidebar_views.append(self.allocator, view);
+ if (self.active_sidebar_view == null) self.active_sidebar_view = view.id;
+}
+
+pub fn registerBottomView(self: *Host, view: BottomView) !void {
+ try self.bottom_views.append(self.allocator, view);
+ if (self.active_bottom_view == null) self.active_bottom_view = view.id;
+}
+
+pub fn registerCenterProvider(self: *Host, provider: CenterProvider) !void {
+ try self.center_providers.append(self.allocator, provider);
+ if (self.active_center == null) self.active_center = provider.id;
+}
+
+pub fn registerMenu(self: *Host, menu: MenuContribution) !void {
+ try self.menus.append(self.allocator, menu);
+}
+
+// ---- active selection ------------------------------------------------------
+
+pub fn setActiveSidebarView(self: *Host, id: []const u8) void {
+ self.active_sidebar_view = id;
+}
+
+pub fn isActiveSidebarView(self: *Host, id: []const u8) bool {
+ const active = self.active_sidebar_view orelse return false;
+ return std.mem.eql(u8, active, id);
+}
+
+/// The currently active sidebar view, or the first registered as a fallback.
+pub fn activeSidebarView(self: *Host) ?*SidebarView {
+ if (self.active_sidebar_view) |id| {
+ for (self.sidebar_views.items) |*v| {
+ if (std.mem.eql(u8, v.id, id)) return v;
+ }
+ }
+ if (self.sidebar_views.items.len > 0) return &self.sidebar_views.items[0];
+ return null;
+}
+
+pub fn setActiveBottomView(self: *Host, id: []const u8) void {
+ self.active_bottom_view = id;
+}
+
+pub fn isActiveBottomView(self: *Host, id: []const u8) bool {
+ const active = self.active_bottom_view orelse return false;
+ return std.mem.eql(u8, active, id);
+}
+
+pub fn activeBottomView(self: *Host) ?*BottomView {
+ if (self.active_bottom_view) |id| {
+ for (self.bottom_views.items) |*v| {
+ if (std.mem.eql(u8, v.id, id)) return v;
+ }
+ }
+ if (self.bottom_views.items.len > 0) return &self.bottom_views.items[0];
+ return null;
+}
+
+pub fn setActiveCenter(self: *Host, id: []const u8) void {
+ self.active_center = id;
+}
+
+pub fn activeCenter(self: *Host) ?*CenterProvider {
+ if (self.active_center) |id| {
+ for (self.center_providers.items) |*p| {
+ if (std.mem.eql(u8, p.id, id)) return p;
+ }
+ }
+ if (self.center_providers.items.len > 0) return &self.center_providers.items[0];
+ return null;
+}
+
/// The registered plugin with the highest priority (lowest value) for `ext`, or
/// null if none claims it. Used in Phase 3 to route file opens to the right plugin.
pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin {
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index d48b96f1..96b55b46 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -61,6 +61,24 @@ pub fn fileTypePriority(self: Plugin, ext: []const u8) ?u8 {
return if (self.vtable.fileTypePriority) |f| f(self.state, ext) else null;
}
+pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void {
+ if (self.vtable.contributeKeybinds) |f| try f(self.state, win);
+}
+
+// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ----
+
+pub fn isDirty(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.isDirty) |f| f(self.state, doc) else false;
+}
+
+pub fn undo(self: Plugin, doc: DocHandle) !void {
+ if (self.vtable.undo) |f| try f(self.state, doc);
+}
+
+pub fn redo(self: Plugin, doc: DocHandle) !void {
+ if (self.vtable.redo) |f| try f(self.state, doc);
+}
+
pub fn deinit(self: Plugin) void {
if (self.vtable.deinit) |f| f(self.state);
}
diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig
new file mode 100644
index 00000000..9b09b617
--- /dev/null
+++ b/src/sdk/regions.zig
@@ -0,0 +1,54 @@
+//! Shell region contributions. A plugin's `register(host)` imperatively adds as
+//! many of these as it wants (multiple sidebar icons, bottom-panel views, center
+//! providers, menubar entries). The near-empty shell owns no features of its own —
+//! it just iterates these registries (see `Host`) and draws whatever plugins
+//! contributed. Built-in shell items (e.g. Settings) register with `owner = null`.
+//!
+//! `ctx` is contribution-owned opaque state passed back to its `draw` fn (null for
+//! contributions that reach through the `fizzy.*` globals directly). `id`s are
+//! stable and plugin-namespaced (e.g. "pixelart.sprites") so selection state and
+//! cross-plugin references survive without a compile-time dependency.
+const dvui = @import("dvui");
+const Plugin = @import("Plugin.zig");
+
+/// A left-region (explorer) view, selected by its sidebar icon. Exactly one
+/// sidebar view is active at a time; its `draw` fills the left pane.
+pub const SidebarView = struct {
+ id: []const u8,
+ owner: ?*Plugin = null,
+ /// Icon byte slice (tvg/entypo) shown in the sidebar rail.
+ icon: []const u8,
+ /// User-facing title (sidebar tooltip + pane header).
+ title: []const u8,
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque) anyerror!void,
+};
+
+/// A bottom-panel view. The panel shows a tab strip across all registered views;
+/// the active one's `draw` fills the panel body.
+pub const BottomView = struct {
+ id: []const u8,
+ owner: ?*Plugin = null,
+ title: []const u8,
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque) anyerror!void,
+};
+
+/// A center ("main window") provider. The active provider draws the ENTIRE center
+/// region and may render a single view or its own recursive tabs/splits. The
+/// workbench registers one (its tabs/splits + canvas); others may take over.
+pub const CenterProvider = struct {
+ id: []const u8,
+ owner: ?*Plugin = null,
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque) anyerror!dvui.App.Result,
+};
+
+/// A menubar contribution. Its `draw` adds top-level menu(s) to the in-app menu
+/// bar (non-macOS). A plugin may register several.
+pub const MenuContribution = struct {
+ id: []const u8,
+ owner: ?*Plugin = null,
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque) anyerror!void,
+};
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 6e8940aa..8390a064 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -7,3 +7,10 @@
pub const Host = @import("Host.zig");
pub const Plugin = @import("Plugin.zig");
pub const DocHandle = @import("DocHandle.zig");
+
+/// Shell region contribution types (sidebar / bottom / center / menu).
+pub const regions = @import("regions.zig");
+pub const SidebarView = regions.SidebarView;
+pub const BottomView = regions.BottomView;
+pub const CenterProvider = regions.CenterProvider;
+pub const MenuContribution = regions.MenuContribution;
diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig
index 3b942cf7..b85b1226 100644
--- a/src/workbench/Workspace.zig
+++ b/src/workbench/Workspace.zig
@@ -119,7 +119,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
}
}
- if (fizzy.editor.explorer.pane == .project) {
+ if (fizzy.editor.host.isActiveSidebarView(@import("../pixelart/plugin.zig").view_project)) {
self.drawProject();
} else {
self.drawTabs();
diff --git a/src/workbench/plugin.zig b/src/workbench/plugin.zig
new file mode 100644
index 00000000..8fb6afdd
--- /dev/null
+++ b/src/workbench/plugin.zig
@@ -0,0 +1,68 @@
+//! The workbench plugin: file management. Phase 2 thin shim — its contributions
+//! point at the existing draw entry points through the `fizzy.*` globals rather
+//! than owning new code. Later phases move more behind it until it becomes a
+//! runtime-loaded dylib. Registered from `Editor.postInit`.
+const std = @import("std");
+const fizzy = @import("../fizzy.zig");
+const dvui = @import("dvui");
+const sdk = fizzy.sdk;
+const files = @import("files.zig");
+
+/// Stable contribution ids (plugin-namespaced) referenced across modules.
+pub const view_files = "workbench.files";
+pub const center_workspaces = "workbench.workspaces";
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "workbench",
+ .display_name = "Workbench",
+};
+
+const vtable: sdk.Plugin.VTable = .{
+ .contributeKeybinds = contributeKeybinds,
+};
+
+pub fn register(host: *sdk.Host) !void {
+ try host.registerPlugin(&plugin);
+ try host.registerSidebarView(.{
+ .id = view_files,
+ .owner = &plugin,
+ .icon = dvui.entypo.folder,
+ .title = "Files",
+ .draw = drawFiles,
+ });
+ // The workbench owns the center "main window": the tabs/splits layout + canvas.
+ try host.registerCenterProvider(.{
+ .id = center_workspaces,
+ .owner = &plugin,
+ .draw = drawCenter,
+ });
+}
+
+fn drawFiles(_: ?*anyopaque) anyerror!void {
+ try files.draw();
+}
+
+fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result {
+ return fizzy.editor.drawWorkspaces(0);
+}
+
+/// File-management keybinds (open / save). The shell registers its own
+/// global/region binds in `Keybinds.register`; this fills in the file half.
+/// Platform: see `Keybinds.register` for why `fizzy.platform.isMacOS()` is used.
+fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void {
+ if (fizzy.platform.isMacOS()) {
+ try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .command = true });
+ try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .command = true });
+ try win.keybinds.putNoClobber(win.gpa, "save", .{ .command = true, .key = .s });
+ try win.keybinds.putNoClobber(win.gpa, "save_as", .{ .command = true, .shift = true, .key = .s });
+ try win.keybinds.putNoClobber(win.gpa, "save_all", .{ .command = true, .alt = true, .key = .s });
+ } else {
+ try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .control = true });
+ try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .control = true });
+ try win.keybinds.putNoClobber(win.gpa, "save", .{ .control = true, .key = .s });
+ try win.keybinds.putNoClobber(win.gpa, "save_as", .{ .control = true, .shift = true, .key = .s });
+ try win.keybinds.putNoClobber(win.gpa, "save_all", .{ .control = true, .alt = true, .key = .s });
+ }
+}
From 468d36fa142ae40c6496c2309648e60c46adb6c7 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Wed, 17 Jun 2026 20:42:46 -0500
Subject: [PATCH 06/49] Phase 3a
---
src/editor/Editor.zig | 43 ++++++++++++++++++++++-----
src/pixelart/plugin.zig | 34 +++++++++++++++++++++
src/sdk/Plugin.zig | 56 +++++++++++++++++++++++++++++------
src/workbench/FileLoadJob.zig | 32 +++++++++++++-------
4 files changed, 137 insertions(+), 28 deletions(-)
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 0e319873..68689d3f 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -2044,8 +2044,16 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool {
return false;
}
+ // Resolve the owning plugin from the file-type registry before spawning. No owner
+ // means no plugin claims this extension — reject here rather than spawning a worker
+ // that would only fail with InvalidFile.
+ const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse {
+ dvui.log.warn("No plugin handles file: {s}", .{path});
+ return false;
+ };
+
// Spawn a worker. The job owns the path string we'll key the map by.
- const job = try FileLoadJob.create(fizzy.app.allocator, path, grouping);
+ const job = try FileLoadJob.create(fizzy.app.allocator, path, owner, grouping);
errdefer job.destroy();
try editor.loading_jobs.put(fizzy.app.allocator, job.path, job);
@@ -2082,14 +2090,20 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin
}
}
- const loaded = fizzy.Internal.File.fromBytes(path, bytes) catch |err| {
+ const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse {
+ fizzy.app.allocator.free(path);
+ return error.InvalidExtension;
+ };
+
+ var file: fizzy.Internal.File = undefined;
+ const handled = owner.loadDocumentFromBytes(path, bytes, &file) catch |err| {
fizzy.app.allocator.free(path);
return err;
};
- var file = loaded orelse {
+ if (!handled) {
fizzy.app.allocator.free(path);
return error.InvalidFile;
- };
+ }
file.editor.grouping = grouping;
return file;
}
@@ -3105,7 +3119,9 @@ pub fn save(editor: *Editor) !void {
editor.requestWebSaveDialog(.save);
return;
}
- try file.saveAsync();
+ if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
+ try plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id });
+ }
}
/// Browser: pick download filename/extension before encoding (`processPendingSaveAs`).
@@ -3125,7 +3141,8 @@ pub fn saveAll(editor: *Editor) !void {
if (!file.dirty()) continue;
if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue;
if (file.shouldConfirmFlatRasterSave()) continue;
- file.saveAsync() catch |err| {
+ const plugin = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse continue;
+ plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }) catch |err| {
dvui.log.err("Save All: file {s} failed: {s}", .{ file.path, @errorName(err) });
};
}
@@ -3332,6 +3349,16 @@ pub fn closeFile(editor: *Editor, index: usize) !void {
try editor.closeFileID(file.id);
}
+/// Tear down a file's resources via its owning plugin, falling back to a direct
+/// `deinit` when no plugin claims the extension. The shell still owns removing the
+/// entry from `open_files`; this only releases the document's own resources.
+fn closeDocumentResources(editor: *Editor, file: *fizzy.Internal.File) void {
+ if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
+ if (plugin.closeDocument(.{ .ptr = file, .owner = plugin, .id = file.id })) return;
+ }
+ file.deinit();
+}
+
pub fn rawCloseFile(editor: *Editor, index: usize) !void {
//editor.open_file_index = 0;
var file = editor.open_files.values()[index];
@@ -3347,7 +3374,7 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void {
}
}
- file.deinit();
+ editor.closeDocumentResources(&file);
editor.open_files.orderedRemoveAt(index);
}
@@ -3365,7 +3392,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
}
}
}
- file.deinit();
+ editor.closeDocumentResources(file);
_ = editor.open_files.orderedRemove(id);
}
}
diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig
index d7c0cb20..b69c2fbb 100644
--- a/src/pixelart/plugin.zig
+++ b/src/pixelart/plugin.zig
@@ -3,6 +3,7 @@
//! plugin). For now its contributions point at the existing draw entry points
//! through the `fizzy.*` globals. Registered from `Editor.postInit`.
const std = @import("std");
+const builtin = @import("builtin");
const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
@@ -26,7 +27,11 @@ var plugin: sdk.Plugin = .{
const vtable: sdk.Plugin.VTable = .{
.fileTypePriority = fileTypePriority,
.contributeKeybinds = contributeKeybinds,
+ .loadDocument = loadDocument,
+ .loadDocumentFromBytes = loadDocumentFromBytes,
.isDirty = isDirty,
+ .saveDocument = saveDocument,
+ .closeDocument = closeDocument,
.undo = undo,
.redo = redo,
};
@@ -47,10 +52,39 @@ fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 {
return null;
}
+/// Load `path` into the shell-owned `*Internal.File` at `out_doc`. Runs on the shell's
+/// load worker thread; `File.fromPath` is the pixel-art loader (still resident in the
+/// editor tree, relocated whole into this plugin in Phase 3b/3c).
+fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void {
+ // Web loads via bytes only (`loadDocumentFromBytes`); the comptime guard keeps the
+ // disk-reading `File.fromPath` path (Dir.cwd / posix.AT) out of the wasm binary.
+ if (comptime builtin.target.cpu.arch == .wasm32) return error.Unsupported;
+ const file = try Internal.File.fromPath(path) orelse return error.InvalidFile;
+ @as(*Internal.File, @ptrCast(@alignCast(out_doc))).* = file;
+}
+
+/// As `loadDocument`, from in-memory bytes (browser file picker; synchronous).
+fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void {
+ const file = try Internal.File.fromBytes(path, bytes) orelse return error.InvalidFile;
+ @as(*Internal.File, @ptrCast(@alignCast(out_doc))).* = file;
+}
+
fn isDirty(_: *anyopaque, doc: DocHandle) bool {
return docFile(doc).dirty();
}
+/// Persist the document. The shell handles the Save-As / flat-raster / web-download
+/// policy before routing here; this just runs the pixel-art async save.
+fn saveDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
+ try docFile(doc).saveAsync();
+}
+
+/// Release the document's resources. The shell removes it from `open_files` and
+/// fixes up the active-tab index; this just frees the pixel-art `File`.
+fn closeDocument(_: *anyopaque, doc: DocHandle) void {
+ docFile(doc).deinit();
+}
+
fn undo(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
try file.history.undoRedo(file, .undo);
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index 96b55b46..dc1836fc 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -1,15 +1,12 @@
//! A feature module that plugs into the editor shell. Today plugins are compiled
//! in and registered statically; the same vtable shape is what a prebuilt plugin
-//! dylib will expose at runtime (validated in `spikes/shared-globals/`). All hooks
-//! are optional function pointers taking the plugin's own opaque `state`, so a
-//! plugin implements only what it needs (e.g. the workbench plugin has no
-//! `drawDocument`; an editor plugin does).
+//! dylib will expose at runtime. All hooks are optional function pointers taking
+//! the plugin's own opaque `state`, so a plugin implements only what it needs
+//! (e.g. the workbench plugin has no `drawDocument`; an editor plugin does).
//!
//! Cross-boundary types may be normal Zig types (not strict C-ABI): host and
//! plugins are pinned to the same SDK build, so layouts match. Only the dlopen
-//! entry symbols (added in Phase 4) need `callconv(.c)`.
-//!
-//! Phase 0: type definition only; nothing constructs or calls plugins yet.
+//! entry symbols need `callconv(.c)`.
const std = @import("std");
const dvui = @import("dvui");
const DocHandle = @import("DocHandle.zig");
@@ -31,11 +28,18 @@ pub const VTable = struct {
/// Priority for opening files with extension `ext` (including the dot, e.g.
/// ".fiz"); lower value wins. `null` = this plugin does not handle `ext`.
- /// Mirrors dvui-editor's fileTypePriority. A plugin may claim many extensions.
+ /// A plugin may claim many extensions.
fileTypePriority: ?*const fn (state: *anyopaque, ext: []const u8) ?u8 = null,
// ---- document lifecycle (operates on the plugin's own type via DocHandle) ----
- openDocument: ?*const fn (state: *anyopaque, path: []const u8) anyerror!DocHandle = null,
+ /// Load the document at `path`, constructing the plugin's own document value in
+ /// place at `out_doc`. The shell owns the typed buffer behind `out_doc` (for pixel
+ /// art a `*Internal.File`); the SDK stays type-agnostic. Runs on the shell's load
+ /// worker thread, so it must only touch the host allocator + the given buffer.
+ loadDocument: ?*const fn (state: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void = null,
+ /// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used
+ /// for extension detection + display name. Synchronous (web has no load worker).
+ loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null,
saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
@@ -67,10 +71,44 @@ pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void {
// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ----
+/// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin
+/// handled it; `false` means this plugin exposes no loader (the shell should treat the
+/// open as failed). See the `loadDocument` vtable field for the threading contract.
+pub fn loadDocument(self: Plugin, path: []const u8, out_doc: *anyopaque) !bool {
+ if (self.vtable.loadDocument) |f| {
+ try f(self.state, path, out_doc);
+ return true;
+ }
+ return false;
+}
+
+/// `loadDocument`, but from in-memory `bytes` (browser file picker).
+pub fn loadDocumentFromBytes(self: Plugin, path: []const u8, bytes: []const u8, out_doc: *anyopaque) !bool {
+ if (self.vtable.loadDocumentFromBytes) |f| {
+ try f(self.state, path, bytes, out_doc);
+ return true;
+ }
+ return false;
+}
+
pub fn isDirty(self: Plugin, doc: DocHandle) bool {
return if (self.vtable.isDirty) |f| f(self.state, doc) else false;
}
+pub fn saveDocument(self: Plugin, doc: DocHandle) !void {
+ if (self.vtable.saveDocument) |f| try f(self.state, doc);
+}
+
+/// Tear down an open document. Returns whether the plugin handled it, so the shell
+/// can fall back to its own teardown when no plugin claims the document.
+pub fn closeDocument(self: Plugin, doc: DocHandle) bool {
+ if (self.vtable.closeDocument) |f| {
+ f(self.state, doc);
+ return true;
+ }
+ return false;
+}
+
pub fn undo(self: Plugin, doc: DocHandle) !void {
if (self.vtable.undo) |f| try f(self.state, doc);
}
diff --git a/src/workbench/FileLoadJob.zig b/src/workbench/FileLoadJob.zig
index 22b06b50..ef7119cd 100644
--- a/src/workbench/FileLoadJob.zig
+++ b/src/workbench/FileLoadJob.zig
@@ -1,10 +1,11 @@
-//! Background file-load job. Owns a worker thread that runs `Internal.File.fromPath` off the
-//! main thread so large files don't stall the editor. The main thread polls `done` each frame
-//! via `Editor.processLoadingJobs`; once true, the result is moved into `editor.open_files`.
+//! Background file-load job. Owns a worker thread that runs the owning plugin's loader
+//! (`owner.loadDocument`) off the main thread so large files don't stall the editor. The
+//! main thread polls `done` each frame via `Editor.processLoadingJobs`; once true, the
+//! result is moved into `editor.open_files`.
//!
-//! Cancellation is best-effort: `Internal.File.fromPath` is monolithic, so we can only
-//! observe cancellation AFTER it returns. The worker checks the flag, frees the loaded file
-//! if cancelled, and exits.
+//! Cancellation is best-effort: the plugin loader is monolithic, so we can only observe
+//! cancellation AFTER it returns. The worker checks the flag, frees the loaded file if
+//! cancelled, and exits.
//!
//! Ownership / threading model:
//! - `path` is owned by the job, freed in `destroy()`.
@@ -33,6 +34,11 @@ allocator: std.mem.Allocator,
/// Absolute path. Owned by this job.
path: []u8,
+/// Plugin that owns this file's extension (resolved on the main thread before spawn).
+/// The worker routes the load through `owner.loadDocument` instead of hardcoding the
+/// pixel-art loader, so open is decoupled from any one editor plugin.
+owner: *fizzy.sdk.Plugin,
+
/// Workspace grouping the file should land in once loaded.
target_grouping: u64,
@@ -66,7 +72,7 @@ result: ?fizzy.Internal.File = null,
/// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`.
err: ?anyerror = null,
-pub fn create(allocator: std.mem.Allocator, path: []const u8, target_grouping: u64) !*FileLoadJob {
+pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.Plugin, target_grouping: u64) !*FileLoadJob {
const path_copy = try allocator.dupe(u8, path);
errdefer allocator.free(path_copy);
@@ -74,6 +80,7 @@ pub fn create(allocator: std.mem.Allocator, path: []const u8, target_grouping: u
job.* = .{
.allocator = allocator,
.path = path_copy,
+ .owner = owner,
.target_grouping = target_grouping,
.window = dvui.currentWindow(),
.started_at_ns = perf.nanoTimestamp(),
@@ -107,17 +114,20 @@ pub fn workerMain(job: *FileLoadJob) void {
job.phase.store(@intFromEnum(Phase.reading), .release);
- const maybe_file = fizzy.Internal.File.fromPath(job.path) catch |e| {
+ // Route the actual load through the owning plugin (filled into a stack buffer the
+ // shell owns; the plugin knows its concrete document type). Mirrors the inline-value
+ // model below — no heap handoff.
+ var file: fizzy.Internal.File = undefined;
+ const handled = job.owner.loadDocument(job.path, &file) catch |e| {
job.err = e;
job.phase.store(@intFromEnum(Phase.failed), .release);
return;
};
-
- const file = maybe_file orelse {
+ if (!handled) {
job.err = error.InvalidFile;
job.phase.store(@intFromEnum(Phase.failed), .release);
return;
- };
+ }
// Cancellation check post-load: if the user closed the tab / quit while we were loading,
// discard the file rather than publishing it.
From 1105e29660a1b2a29e1a2171133334cf2976735a Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 08:05:01 -0500
Subject: [PATCH 07/49] Phase 3b
---
src/editor/dialogs/GridLayout.zig | 5 +-
src/editor/widgets/CanvasBridge.zig | 23 +++++++++
src/editor/widgets/CanvasWidget.zig | 75 +++++++++++++++++++----------
src/editor/widgets/FileWidget.zig | 34 +++++++++++++
src/editor/widgets/ImageWidget.zig | 3 ++
5 files changed, 113 insertions(+), 27 deletions(-)
create mode 100644 src/editor/widgets/CanvasBridge.zig
diff --git a/src/editor/dialogs/GridLayout.zig b/src/editor/dialogs/GridLayout.zig
index bb55014e..94e09510 100644
--- a/src/editor/dialogs/GridLayout.zig
+++ b/src/editor/dialogs/GridLayout.zig
@@ -12,6 +12,7 @@ const std = @import("std");
const NewFile = @import("NewFile.zig");
const CanvasWidget = @import("../widgets/CanvasWidget.zig");
+const CanvasBridge = @import("../widgets/CanvasBridge.zig");
const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig");
const builtin = @import("builtin");
@@ -108,7 +109,7 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void {
// `prev_size` matches `data_size` and `second_center` is false, so `install` skips the
// rescale/recenter pass and the preview ends up offscreen / at a stale zoom. Resetting to
// a fresh widget forces a fit-to-pane on the next frame.
- preview_canvas = .{ .pointer_scope = .dialog };
+ preview_canvas = .{};
left_scroll = .{ .horizontal = .auto };
dialog_middle_scroll = .{ .horizontal = .auto, .vertical = .auto };
preview_pane_fit_w = 0;
@@ -594,6 +595,8 @@ fn renderPreview(
.id = dlg_id.update("glp_cv"),
.data_size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) },
.center = false,
+ .pan_zoom_scheme = CanvasBridge.scheme(),
+ .hooks = .{ .pointerInputSuppressed = CanvasBridge.dialogSuppressed },
}, .{
.expand = .both,
.background = true,
diff --git a/src/editor/widgets/CanvasBridge.zig b/src/editor/widgets/CanvasBridge.zig
new file mode 100644
index 00000000..4b1cf339
--- /dev/null
+++ b/src/editor/widgets/CanvasBridge.zig
@@ -0,0 +1,23 @@
+//! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the
+//! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable
+//! viewport; these helpers supply the pixel-art editor's wiring at the install sites.
+const fizzy = @import("../../fizzy.zig");
+const CanvasWidget = @import("CanvasWidget.zig");
+
+/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum.
+pub fn scheme() CanvasWidget.PanZoomScheme {
+ return switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) {
+ .mouse => .mouse,
+ .trackpad => .trackpad,
+ };
+}
+
+/// Suppression hook for a main-scope canvas (the document editing surface, image previews).
+pub fn mainSuppressed(_: ?*anyopaque) bool {
+ return fizzy.dvui.canvasPointerInputSuppressed();
+}
+
+/// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout).
+pub fn dialogSuppressed(_: ?*anyopaque) bool {
+ return fizzy.dvui.dialogCanvasPointerInputSuppressed();
+}
diff --git a/src/editor/widgets/CanvasWidget.zig b/src/editor/widgets/CanvasWidget.zig
index c478bbce..48ae84bd 100644
--- a/src/editor/widgets/CanvasWidget.zig
+++ b/src/editor/widgets/CanvasWidget.zig
@@ -74,8 +74,6 @@ fade_pending: bool = false,
// Saved between `install` and `deinit` so the parent alpha is restored exactly.
prev_alpha: f32 = 1.0,
hovered: bool = false,
-/// `.dialog` for embedded previews (Grid Layout); uses `dialogCanvasPointerInputSuppressed`.
-pointer_scope: enum { main, dialog } = .main,
// Last frame's scroll viewport in physical pixels (latched in `deinit`). Used when the
// scroll container is not installed yet this frame (e.g. UI chrome before `FileWidget`).
sample_viewport_physical: ?dvui.Rect.Physical = null,
@@ -253,10 +251,38 @@ pub fn trackpadPinching(self: *const CanvasWidget) bool {
return (dvui.currentWindow().frame_time_ns - self.trackpad_pinch_last_ns) < window_ns;
}
+/// How wheel/scroll input maps to pan vs. zoom. The owner resolves its own user
+/// preference (mouse vs. trackpad) and passes the result; the canvas stays unaware of
+/// any settings system.
+pub const PanZoomScheme = enum { mouse, trackpad };
+
+/// Owner-supplied reactions to viewport gestures the canvas itself has no opinion about.
+/// Every field is optional: a plain pan/zoom viewport (e.g. an image preview) supplies
+/// none, while an editor supplies hooks that act on its own document/tool state. `ctx` is
+/// passed back to each callback so a plugin can reach its state without globals.
+pub const Hooks = struct {
+ ctx: ?*anyopaque = null,
+ /// An off-artboard press that released without moving or holding (a "tap" on empty
+ /// space). Pixel art uses this to clear the current selection.
+ onEmptyTap: ?*const fn (ctx: ?*anyopaque) void = null,
+ /// An off-artboard press held in place past the hold-menu duration. Pixel art opens
+ /// its radial tool menu at `press_p`.
+ onEmptyHold: ?*const fn (ctx: ?*anyopaque, press_p: dvui.Point.Physical) void = null,
+ /// Whether a modified (ctrl/cmd or shift) off-artboard press should be yielded to the
+ /// owner instead of starting a viewport pan. Pixel art yields it to the selection
+ /// marquee when the pointer tool is active.
+ yieldModifiedEmptyPress: ?*const fn (ctx: ?*anyopaque) bool = null,
+ /// Whether pointer input to this canvas is currently suppressed (e.g. a modal overlay
+ /// owns input this frame). Replaces the old built-in main/dialog scope switch.
+ pointerInputSuppressed: ?*const fn (ctx: ?*anyopaque) bool = null,
+};
+
pub const InitOptions = struct {
id: dvui.Id,
data_size: dvui.Size,
center: bool = false,
+ pan_zoom_scheme: PanZoomScheme = .mouse,
+ hooks: Hooks = .{},
};
pub fn recenter(self: *CanvasWidget) void {
@@ -695,10 +721,10 @@ pub fn updateTouchGesture(self: *CanvasWidget) void {
dvui.captureMouse(null, 0);
}
- // Quick off-artboard tap: finger lifted during the eval window. Resolve as
- // clear-selection here so we never arm hold state from the replayed press.
+ // Quick off-artboard tap: finger lifted during the eval window. Hand it to the
+ // owner (pixel art clears selection) so we never arm hold state from the replayed press.
if (released and !self.pointerOverDrawable(press_p)) {
- fizzy.editor.cancel() catch {};
+ if (self.init_opts.hooks.onEmptyTap) |f| f(self.init_opts.hooks.ctx);
}
// `addEventPointer` uses `win.mouse_pt` for the event position. Push the press
@@ -906,10 +932,8 @@ pub fn mouse(self: *CanvasWidget) ?dvui.Event.Mouse {
}
fn pointerInputSuppressed(self: *const CanvasWidget) bool {
- return switch (self.pointer_scope) {
- .main => fizzy.dvui.canvasPointerInputSuppressed(),
- .dialog => fizzy.dvui.dialogCanvasPointerInputSuppressed(),
- };
+ const hooks = self.init_opts.hooks;
+ return if (hooks.pointerInputSuppressed) |f| f(hooks.ctx) else false;
}
pub fn processEvents(self: *CanvasWidget) void {
@@ -1042,15 +1066,19 @@ pub fn processEvents(self: *CanvasWidget) void {
// same scrub-the-viewport feel as the middle-button pan.
//
// Exception: a left/touch off-artboard press holding ctrl/cmd (add)
- // or shift (subtract) while the pointer tool is active belongs to the
- // sprite-selection marquee — it already claimed the press earlier in
- // FileWidget.processSpriteSelection. Yielding it here keeps our
+ // or shift (subtract) that the owner wants to claim (pixel art: the
+ // sprite-selection marquee, which already claimed the press earlier in
+ // FileWidget.processSpriteSelection). Yielding it here keeps our
// `dragPreStart("scroll_drag")` from clobbering the marquee's drag, so
// the hotkey draws a selection box instead of panning. Middle-button
// pans are never affected.
+ const owner_yields = if (self.init_opts.hooks.yieldModifiedEmptyPress) |f|
+ f(self.init_opts.hooks.ctx)
+ else
+ false;
const sel_marquee_press = me.button.pointer() and me.button != .middle and
(me.mod.matchBind("ctrl/cmd") or me.mod.matchBind("shift")) and
- fizzy.editor.tools.current == .pointer;
+ owner_yields;
if (me.action == .press and !sel_marquee_press and (me.button == .middle or (me.button.pointer() and !self.pointerOverDrawable(me.p)))) {
e.handle(@src(), self.scroll_container.data());
dvui.captureMouse(self.scroll_container.data(), e.num);
@@ -1114,7 +1142,7 @@ pub fn processEvents(self: *CanvasWidget) void {
}
}
} else if (me.action == .wheel_y or me.action == .wheel_x) {
- switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) {
+ switch (self.init_opts.pan_zoom_scheme) {
.mouse => {
const base: f32 = if (me.mod.matchBind("shift")) 1.005 else 1.005;
if ((me.mod.matchBind("shift") and me.mod.matchBind("ctrl/cmd")) or !me.mod.matchBind("shift") and !me.mod.matchBind("ctrl/cmd")) {
@@ -1182,20 +1210,15 @@ pub fn processEvents(self: *CanvasWidget) void {
switch (self.empty) {
.pending => {
if (!still_down) {
- // Lifted without moving or holding → a tap: clear the selection.
- fizzy.editor.cancel() catch {};
+ // Lifted without moving or holding → a tap: hand to the owner (pixel
+ // art clears the selection).
+ if (self.init_opts.hooks.onEmptyTap) |f| f(self.init_opts.hooks.ctx);
self.empty = .idle;
} else if (dvui.frameTimeNS() - self.empty_press_ns >= dvui.currentWindow().hold_menu_duration_ns) {
- // Held in place past the hold duration → open the radial tool menu and
- // release our capture so its buttons can be hovered. Editor keeps it
- // open until a tool is chosen or the user taps outside.
- const rm = &fizzy.editor.tools.radial_menu;
- rm.mouse_position = self.empty_press_p;
- rm.center = self.empty_press_p;
- rm.visible = true;
- rm.opened_by_press = true;
- rm.suppress_next_pointer_release = true;
- rm.outside_click_press_p = null;
+ // Held in place past the hold duration → tell the owner (pixel art opens
+ // its radial tool menu at the press point) and release our capture so its
+ // buttons can be hovered.
+ if (self.init_opts.hooks.onEmptyHold) |f| f(self.init_opts.hooks.ctx, self.empty_press_p);
self.empty = .holding;
if (dvui.captured(self.scroll_container.data().id)) {
dvui.captureMouse(null, 0);
diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig
index 9bc4415a..60187f34 100644
--- a/src/editor/widgets/FileWidget.zig
+++ b/src/editor/widgets/FileWidget.zig
@@ -17,9 +17,36 @@ const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
const CanvasWidget = @import("CanvasWidget.zig");
+const CanvasBridge = @import("CanvasBridge.zig");
const Workspace = fizzy.Editor.Workspace;
const icons = @import("icons");
+// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is
+// otherwise a generic viewport; these supply the editor's behavior at install time. ----
+
+/// Off-artboard tap (no move, no hold) → clear the current selection.
+fn onEmptyTap(_: ?*anyopaque) void {
+ fizzy.editor.cancel() catch {};
+}
+
+/// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press
+/// point. The canvas releases its own capture afterward so the menu buttons can be hovered.
+fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void {
+ const rm = &fizzy.editor.tools.radial_menu;
+ rm.mouse_position = press_p;
+ rm.center = press_p;
+ rm.visible = true;
+ rm.opened_by_press = true;
+ rm.suppress_next_pointer_release = true;
+ rm.outside_click_press_p = null;
+}
+
+/// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's
+/// while the pointer tool is active — yield it instead of starting a viewport pan.
+fn yieldModifiedEmptyPress(_: ?*anyopaque) bool {
+ return fizzy.editor.tools.current == .pointer;
+}
+
init_options: InitOptions,
options: Options,
drag_data_point: ?dvui.Point = null,
@@ -79,6 +106,13 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Optio
.h = @floatFromInt(init_opts.file.height()),
},
.center = init_opts.center,
+ .pan_zoom_scheme = CanvasBridge.scheme(),
+ .hooks = .{
+ .onEmptyTap = onEmptyTap,
+ .onEmptyHold = onEmptyHold,
+ .yieldModifiedEmptyPress = yieldModifiedEmptyPress,
+ .pointerInputSuppressed = CanvasBridge.mainSuppressed,
+ },
}, opts);
return fw;
diff --git a/src/editor/widgets/ImageWidget.zig b/src/editor/widgets/ImageWidget.zig
index 12e8ed47..cf7ec299 100644
--- a/src/editor/widgets/ImageWidget.zig
+++ b/src/editor/widgets/ImageWidget.zig
@@ -1,5 +1,6 @@
pub const ImageWidget = @This();
const CanvasWidget = @import("CanvasWidget.zig");
+const CanvasBridge = @import("CanvasBridge.zig");
init_options: InitOptions,
options: Options,
@@ -35,6 +36,8 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Optio
.w = size.w,
.h = size.h,
},
+ .pan_zoom_scheme = CanvasBridge.scheme(),
+ .hooks = .{ .pointerInputSuppressed = CanvasBridge.mainSuppressed },
}, opts);
return iw;
From 651aa034c213b4e5d08542d74ea57bfb92e606a4 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 08:22:43 -0500
Subject: [PATCH 08/49] Phase 3b continued
---
src/App.zig | 1 -
src/Assets.zig | 276 -----------------
src/fizzy.zig | 2 -
src/tools/watcher/LinuxWatcher.zig | 442 ---------------------------
src/tools/watcher/MacosWatcher.zig | 110 -------
src/tools/watcher/WindowsWatcher.zig | 224 --------------
6 files changed, 1055 deletions(-)
delete mode 100644 src/Assets.zig
delete mode 100644 src/tools/watcher/LinuxWatcher.zig
delete mode 100644 src/tools/watcher/MacosWatcher.zig
delete mode 100644 src/tools/watcher/WindowsWatcher.zig
diff --git a/src/App.zig b/src/App.zig
index 661a68c7..118adb5f 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -16,7 +16,6 @@ const paths = @import("paths.zig");
const App = @This();
const Editor = fizzy.Editor;
const Packer = fizzy.Packer;
-//const Assets = fizzy.Assets;
// App fields
allocator: std.mem.Allocator = undefined,
diff --git a/src/Assets.zig b/src/Assets.zig
deleted file mode 100644
index a8cd63af..00000000
--- a/src/Assets.zig
+++ /dev/null
@@ -1,276 +0,0 @@
-const std = @import("std");
-const zstbi = @import("zstbi");
-const mach = @import("mach");
-const builtin = @import("builtin");
-const fizzy = @import("fizzy.zig");
-
-const Assets = @This();
-
-pub const AssetType = enum {
- texture,
- atlas,
- unsupported,
-};
-
-// Mach module, systems, and main
-pub const mach_module = .assets;
-pub const mach_systems = .{ .init, .listen, .deinit };
-pub const mach_tags = .{ .auto_reload, .path };
-
-const log = std.log.scoped(.watcher);
-const ListenerFn = fn (self: *Assets, path: []const u8, name: []const u8) void;
-const Watcher = switch (builtin.target.os.tag) {
- .linux => @import("tools/watcher/LinuxWatcher.zig"),
- .macos => @import("tools/watcher/MacosWatcher.zig"),
- .windows => @import("tools/watcher/WindowsWatcher.zig"),
- else => @compileError("unsupported platform"),
-};
-
-paths: mach.Objects(.{ .track_fields = false }, struct { value: [:0]const u8 }),
-textures: mach.Objects(.{ .track_fields = false }, fizzy.gfx.Texture),
-atlases: mach.Objects(.{ .track_fields = false }, fizzy.Atlas),
-
-allocator: std.mem.Allocator,
-watcher: Watcher = undefined,
-thread: std.Thread = undefined,
-watching: bool = false,
-
-var gpa: std.heap.DebugAllocator(.{}) = .init;
-
-pub fn init(assets: *Assets) !void {
- const allocator = gpa.allocator();
-
- zstbi.init(allocator);
- assets.* = .{
- .textures = assets.textures,
- .atlases = assets.atlases,
- .paths = assets.paths,
- .allocator = allocator,
- };
-}
-
-pub fn loadTexture(assets: *Assets, path: []const u8, options: fizzy.gfx.Texture.SamplerOptions) !?mach.ObjectID {
- assets.textures.lock();
- defer assets.textures.unlock();
-
- const term_path = try assets.allocator.dupeZ(u8, path);
-
- if (fizzy.gfx.Texture.loadFromFile(term_path, options) catch null) |texture| {
- const texture_id = try assets.textures.new(texture);
- const path_id = try assets.paths.new(.{ .value = term_path });
-
- try assets.textures.setTag(texture_id, Assets, .path, path_id);
-
- return texture_id;
- }
-
- return null;
-}
-
-pub fn loadAtlas(assets: *Assets, path: []const u8) !?mach.ObjectID {
- assets.atlases.lock();
- defer assets.atlases.unlock();
-
- const term_path = try assets.allocator.dupeZ(u8, path);
-
- if (fizzy.Atlas.loadFromFile(assets.allocator, term_path) catch null) |atlas| {
- const atlas_id = try assets.atlases.new(atlas);
- const path_id = try assets.paths.new(.{ .value = term_path });
-
- try assets.atlases.setTag(atlas_id, Assets, .path, path_id);
-
- return atlas_id;
- }
-
- return null;
-}
-
-pub fn reload(assets: *Assets, id: mach.ObjectID) !void {
- if (assets.textures.is(id)) {
- var old_texture = assets.textures.getValue(id);
- defer old_texture.deinitWithoutClear();
-
- if (assets.textures.getTag(id, Assets, .path)) |path_id| {
- const path = assets.paths.get(path_id, .value);
-
- if (fizzy.gfx.Texture.loadFromFile(path, .{
- .address_mode = old_texture.address_mode,
- .copy_dst = old_texture.copy_dst,
- .copy_src = old_texture.copy_src,
- .filter = old_texture.filter,
- .format = old_texture.format,
- .render_attachment = old_texture.render_attachment,
- .storage_binding = old_texture.storage_binding,
- .texture_binding = old_texture.texture_binding,
- }) catch null) |texture| {
- assets.textures.setValueRaw(id, texture);
- }
- }
- } else if (assets.atlases.is(id)) {
- var old_atlas = assets.atlases.getValue(id);
- defer old_atlas.deinit(assets.allocator);
-
- if (assets.atlases.getTag(id, Assets, .path)) |path_id| {
- const path = assets.paths.get(path_id, .value);
-
- if (fizzy.Atlas.loadFromFile(assets.allocator, path) catch null) |atlas| {
- assets.atlases.setValueRaw(id, atlas);
- }
- }
- }
-}
-
-pub fn getTexture(assets: *Assets, id: mach.ObjectID) fizzy.gfx.Texture {
- return assets.textures.getValue(id);
-}
-
-pub fn getAtlas(assets: *Assets, id: mach.ObjectID) fizzy.Atlas {
- return assets.atlases.getValue(id);
-}
-
-/// Returns the watch paths for the currently loaded assets.
-/// Caller owns the memory.
-pub fn getWatchPaths(assets: *Assets, allocator: std.mem.Allocator) ![]const []const u8 {
- var out_paths = std.ArrayList([]const u8).init(allocator);
-
- var paths = assets.paths.slice();
- while (paths.next()) |id| {
- const path = paths.objs.get(id, .value);
- for (out_paths.items) |out_path| {
- if (std.mem.eql(u8, path, out_path)) {
- continue;
- }
- }
- try out_paths.append(path);
- }
-
- return out_paths.toOwnedSlice();
-}
-
-/// Returns the watch directories for the currently loaded assets.
-/// Caller owns the memory.
-pub fn getWatchDirs(assets: *Assets, allocator: std.mem.Allocator) ![]const []const u8 {
- var out_dirs = std.ArrayList([]const u8).init(allocator);
-
- var paths = assets.paths.slice();
- path_blk: while (paths.next()) |id| {
- if (std.fs.path.dirname(paths.objs.get(id, .value))) |new_dir| {
- for (out_dirs.items) |dir| {
- if (std.mem.eql(u8, dir, new_dir)) {
- continue :path_blk;
- }
- }
-
- try out_dirs.append(new_dir);
- }
- }
-
- return out_dirs.toOwnedSlice();
-}
-
-/// Spawns a watch thread for all of the currently registered assets
-/// If you add or change assets, you need to call stopWatch and then watch again to reset the background thread
-pub fn watch(assets: *Assets) !void {
- if (!assets.watching)
- try spawnWatchThread(assets);
-}
-
-/// Stops the asset watching thread
-pub fn stopWatching(assets: *Assets) void {
- assets.stopWatchThread();
-}
-
-fn spawnWatchThread(assets: *Assets) !void {
- assets.watcher = try Watcher.init(assets.allocator);
- assets.thread = try std.Thread.spawn(.{}, listen, .{assets});
- assets.thread.detach();
- assets.watching = true;
-}
-
-fn stopWatchThread(assets: *Assets) void {
- assets.watching = false;
- assets.watcher.stop();
- //assets.thread.join();
- //assets.thread = undefined;
-}
-
-/// Kicks off the listening loop, this will not return
-pub fn listen(assets: *Assets) !void {
- try assets.watcher.listen(assets);
-}
-
-fn comparePaths(allocator: std.mem.Allocator, path1: []const u8, path2: []const u8) !bool {
- const rel_1 = try std.fs.path.relative(allocator, fizzy.app.root_path, path1);
- const rel_2 = try std.fs.path.relative(allocator, fizzy.app.root_path, path2);
-
- defer allocator.free(rel_1);
- defer allocator.free(rel_2);
-
- return std.mem.eql(u8, rel_1, rel_2);
-}
-
-/// Called from the watchers when assets change, this is where we reload our assets based on path.
-pub fn onAssetChange(assets: *Assets, path: []const u8, name: []const u8) void {
- const changed_path = std.fs.path.join(assets.allocator, &.{ path, name }) catch return;
- defer assets.allocator.free(changed_path);
-
- const extension = std.fs.path.extension(name);
-
- var asset_type: AssetType = .unsupported;
-
- if (std.mem.eql(u8, extension, ".png") or std.mem.eql(u8, extension, ".jpg"))
- asset_type = .texture
- else if (std.mem.eql(u8, extension, ".atlas"))
- asset_type = .atlas;
-
- switch (asset_type) {
- .texture => {
- var textures = assets.textures.slice();
- while (textures.next()) |texture_id| {
- if (!assets.textures.hasTag(texture_id, Assets, .auto_reload)) continue;
-
- if (assets.textures.getTag(texture_id, Assets, .path)) |path_id| {
- if (comparePaths(assets.allocator, changed_path, assets.paths.get(path_id, .value)) catch false) {
- assets.reload(texture_id) catch log.debug("Texture failed to reload: {s}", .{changed_path});
- }
- }
- }
- },
- .atlas => {
- var atlases = assets.atlases.slice();
- while (atlases.next()) |atlas_id| {
- if (!assets.atlases.hasTag(atlas_id, Assets, .auto_reload)) continue;
-
- if (assets.atlases.getTag(atlas_id, Assets, .path)) |path_id| {
- if (comparePaths(assets.allocator, changed_path, assets.paths.get(path_id, .value)) catch false) {
- assets.reload(atlas_id) catch log.debug("Atlas failed to reload: {s}", .{changed_path});
- }
- }
- }
- },
- .unsupported => {},
- }
-}
-
-pub fn deinit(assets: *Assets) void {
- assets.stopWatching();
-
- var textures = assets.textures.slice();
- while (textures.next()) |id| {
- var t = assets.textures.getValue(id);
- t.deinit();
- }
-
- var atlases = assets.atlases.slice();
- while (atlases.next()) |id| {
- var a = assets.atlases.getValue(id);
- a.deinit(assets.allocator);
- }
-
- var paths = assets.paths.slice();
- while (paths.next()) |id| {
- assets.allocator.free(assets.paths.get(id, .value));
- }
-
- zstbi.deinit();
-}
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 58f670e1..62c47a6f 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -23,7 +23,6 @@ pub const water_surface = @import("gfx/water_surface.zig");
pub const math = @import("math/math.zig");
pub const App = @import("App.zig");
-pub const Assets = @import("Assets.zig");
pub const Editor = @import("editor/Editor.zig");
pub const Explorer = @import("editor/explorer/Explorer.zig");
pub const Fling = @import("editor/Fling.zig");
@@ -35,7 +34,6 @@ pub const Sidebar = @import("editor/Sidebar.zig");
pub var app: *App = undefined;
pub var editor: *Editor = undefined;
pub var packer: *Packer = undefined;
-pub var assets: *Assets = undefined;
/// Internal types
/// These types contain additional data to support the editor
diff --git a/src/tools/watcher/LinuxWatcher.zig b/src/tools/watcher/LinuxWatcher.zig
deleted file mode 100644
index 790b26a2..00000000
--- a/src/tools/watcher/LinuxWatcher.zig
+++ /dev/null
@@ -1,442 +0,0 @@
-const LinuxWatcher = @This();
-
-const std = @import("std");
-const Assets = @import("../../Assets.zig");
-
-const log = std.log.scoped(.watcher);
-
-notify_fd: std.posix.fd_t,
-
-/// active watch entries
-watch_fds: std.AutoHashMapUnmanaged(std.posix.fd_t, WatchEntry) = .{},
-
-/// direct descendant tracker
-children_fds: std.AutoHashMapUnmanaged(std.posix.fd_t, std.ArrayListUnmanaged(std.posix.fd_t)) = .{},
-
-/// inotify cookie tracker for move events
-cookie_fds: std.AutoHashMapUnmanaged(u32, std.posix.fd_t) = .{},
-
-const TreeKind = enum { input, output };
-
-const WatchEntry = struct {
- dir_path: []const u8,
- name: []const u8,
- kind: TreeKind,
-};
-
-pub fn stop(_: *LinuxWatcher) void {}
-
-pub fn init(
- _: std.mem.Allocator,
-) !LinuxWatcher {
- const notify_fd = try std.posix.inotify_init1(0);
- return .{ .notify_fd = notify_fd };
-}
-
-/// Register `child` with the `parent`
-fn addChild(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- parent: std.posix.fd_t,
- child: std.posix.fd_t,
-) !void {
- const children = try self.children_fds.getOrPut(gpa, parent);
- if (!children.found_existing) {
- children.value_ptr.* = .{};
- }
- try children.value_ptr.append(gpa, child);
-}
-
-/// Remove `child` from the `parent`, if present
-fn removeChild(
- self: *LinuxWatcher,
- parent: std.posix.fd_t,
- child: std.posix.fd_t,
-) ?std.posix.fd_t {
- if (self.children_fds.getEntry(parent)) |entry| {
- for (0.., entry.value_ptr.items) |i, fd| {
- if (child == fd) {
- return entry.value_ptr.swapRemove(i);
- }
- }
- }
- return null;
-}
-
-/// Remove child identified by `name`, if present
-fn removeChildByName(
- self: *LinuxWatcher,
- parent: std.posix.fd_t,
- name: []const u8,
-) ?std.posix.fd_t {
- if (self.children_fds.getEntry(parent)) |entry| {
- for (0.., entry.value_ptr.items) |i, fd| {
- if (self.watch_fds.get(fd)) |data| {
- if (std.mem.eql(u8, data.name, name)) {
- return entry.value_ptr.swapRemove(i);
- }
- }
- }
- }
- return null;
-}
-
-/// Start tracking directory tree and returns the watch descriptor for `root_dir_path`
-/// Register children within the tree
-/// **NOTE**: caller is expected to register the returned watch fd as a child
-fn addTree(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- tree_kind: TreeKind,
- root_dir_path: []const u8,
-) !std.posix.fd_t {
- var root_dir = try std.fs.cwd().openDir(root_dir_path, .{ .iterate = true });
- defer root_dir.close();
- const parent_fd = try self.addDir(gpa, tree_kind, root_dir_path);
-
- // tracker for fds associated with dir paths
- // helps to track children within a recursive walk
- var lookup = std.StringHashMap(std.posix.fd_t).init(gpa);
- defer lookup.deinit();
-
- try lookup.put(root_dir_path, parent_fd);
-
- var it = try root_dir.walk(gpa);
- while (try it.next()) |entry| switch (entry.kind) {
- else => continue,
- .directory => {
- const dir_path = try std.fs.path.join(gpa, &.{ root_dir_path, entry.path });
- const dir_fd = try self.addDir(gpa, tree_kind, dir_path);
- const p_dir = std.fs.path.dirname(dir_path).?;
- const p_fd = lookup.get(p_dir).?;
-
- try self.addChild(gpa, p_fd, dir_fd);
- try lookup.put(dir_path, dir_fd);
- },
- };
-
- return parent_fd;
-}
-
-fn addDir(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- tree_kind: TreeKind,
- dir_path: []const u8,
-) !std.posix.fd_t {
- const mask = Mask.all(&.{
- .IN_ONLYDIR, .IN_CLOSE_WRITE,
- .IN_MOVE, .IN_MOVE_SELF,
- .IN_CREATE, .IN_DELETE,
- .IN_EXCL_UNLINK,
- });
- const watch_fd = try std.posix.inotify_add_watch(
- self.notify_fd,
- dir_path,
- mask,
- );
- const name_copy = try gpa.dupe(u8, std.fs.path.basename(dir_path));
- try self.watch_fds.put(gpa, watch_fd, .{
- .dir_path = dir_path,
- .name = name_copy,
- .kind = tree_kind,
- });
- log.debug("added {s} -> {}", .{ dir_path, watch_fd });
- return watch_fd;
-}
-
-/// Explicitly stop watching a descriptor
-/// **NOTE**: should only be called on an active `fd`
-fn rmWatch(
- self: *LinuxWatcher,
- fd: std.posix.fd_t,
-) void {
- if (self.children_fds.getEntry(fd)) |entry| {
- for (entry.value_ptr.items) |child_fd| {
- self.rmWatch(child_fd);
- }
- self.children_fds.removeByPtr(entry.key_ptr);
- }
- std.posix.inotify_rm_watch(self.notify_fd, fd);
-}
-
-/// Handle the start of the move process
-/// Remove `name`-identified fd from children of `from_fd`
-/// Register `cookie` for the moved fd for future identification
-fn moveDirStart(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- from_fd: std.posix.fd_t,
- cookie: u32,
- name: []const u8,
-) !void {
- const moved_fd = self.removeChildByName(from_fd, name).?;
-
- try self.cookie_fds.put(
- gpa,
- cookie,
- moved_fd,
- );
-}
-
-/// Handle the end of the move process and returns the resulting moved fd
-/// Register the moved fd as a child of `to_fd`
-fn moveDirEnd(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- to_fd: std.posix.fd_t,
- cookie: u32,
- name: []const u8,
-) !std.posix.fd_t {
- const parent = self.watch_fds.get(to_fd).?;
-
- // known cookie - move within watched directories
- if (self.cookie_fds.fetchRemove(cookie)) |entry| {
- const moved_fd = entry.value;
-
- var watch_entry = self.watch_fds.getEntry(moved_fd).?.value_ptr;
- gpa.free(watch_entry.name);
- const name_copy = try gpa.dupe(u8, name);
- watch_entry.name = name_copy;
- watch_entry.kind = parent.kind;
-
- try self.updateDirPath(gpa, moved_fd, parent.dir_path);
- try self.addChild(gpa, to_fd, moved_fd);
- return moved_fd;
- } else { // unknown cookie - move from the outside
- const dir_path = try std.fs.path.join(gpa, &.{ parent.dir_path, name });
- const moved_fd = try self.addTree(gpa, parent.kind, dir_path);
- try self.addChild(gpa, to_fd, moved_fd);
- return moved_fd;
- }
-}
-
-/// Cascade path updates for `fd` and its children
-fn updateDirPath(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- fd: std.posix.fd_t,
- parent_dir: []const u8,
-) !void {
- var data = self.watch_fds.getEntry(fd).?.value_ptr;
- gpa.free(data.dir_path);
- const dir_path = try std.fs.path.join(gpa, &.{ parent_dir, data.name });
- data.dir_path = dir_path;
-
- if (self.children_fds.getEntry(fd)) |entry| {
- for (entry.value_ptr.items) |child_fd| {
- try self.updateDirPath(gpa, child_fd, dir_path);
- }
- }
-}
-
-/// Handle the post-move event
-/// Remove stale cookie waiting for the `moved_fd`, if present
-fn moveDirComplete(
- self: *LinuxWatcher,
- moved_fd: std.posix.fd_t,
-) !void {
- var it = self.cookie_fds.iterator();
- while (it.next()) |entry| {
- // cookie for fd exists - moved outside the watched directory
- if (entry.value_ptr.* == moved_fd) {
- self.rmWatch(moved_fd);
- self.cookie_fds.removeByPtr(entry.key_ptr);
- break;
- }
- }
-}
-
-/// Clean up `fd`-related bookkeeping
-/// **NOTE**: expects `fd` to be a no-longer-watched descriptor
-fn dropWatch(
- self: *LinuxWatcher,
- gpa: std.mem.Allocator,
- fd: std.posix.fd_t,
-) void {
- if (self.watch_fds.fetchRemove(fd)) |entry| {
- gpa.free(entry.value.dir_path);
- gpa.free(entry.value.name);
- }
-
- var it = self.children_fds.keyIterator();
- while (it.next()) |parent_fd| {
- _ = self.removeChild(parent_fd.*, fd);
- }
-
- if (self.children_fds.fetchRemove(fd)) |entry| {
- log.warn("Stopping watch for {d} that has known children: {any}", .{ fd, entry.value });
- }
-}
-
-pub fn listen(
- self: *LinuxWatcher,
- assets: *Assets,
-) !void {
- for (try assets.getWatchDirs(assets.allocator)) |p| {
- _ = try self.addTree(assets.allocator, .input, p);
- }
-
- const Event = std.os.linux.inotify_event;
- const event_size = @sizeOf(Event);
- while (assets.watching) {
- var buffer: [event_size * 10]u8 = undefined;
- const len = try std.posix.read(self.notify_fd, &buffer);
- if (len < 0) @panic("notify fd read error");
-
- var event_data = buffer[0..len];
- while (event_data.len > 0) {
- const event: *Event = @alignCast(@ptrCast(event_data[0..event_size]));
- const parent = self.watch_fds.get(event.wd).?;
- event_data = event_data[event_size + event.len ..];
-
- if (Mask.is(event.mask, .IN_IGNORED)) {
- log.debug("IGNORE {s}", .{parent.dir_path});
- self.dropWatch(assets.allocator, event.wd);
- continue;
- } else if (Mask.is(event.mask, .IN_MOVE_SELF)) {
- if (event.getName() == null) {
- try self.moveDirComplete(event.wd);
- }
- continue;
- }
-
- if (Mask.is(event.mask, .IN_ISDIR)) {
- if (Mask.is(event.mask, .IN_CREATE)) {
- const dir_name = event.getName().?;
- const dir_path = try std.fs.path.join(assets.allocator, &.{
- parent.dir_path,
- dir_name,
- });
-
- log.debug("ISDIR CREATE {s}", .{dir_path});
-
- const new_fd = try self.addTree(assets.allocator, parent.kind, dir_path);
- try self.addChild(assets.allocator, event.wd, new_fd);
- const data = self.watch_fds.get(new_fd).?;
- switch (data.kind) {
- .input => {
- assets.onAssetChange(data.dir_path, "");
- },
- .output => {
- assets.onAssetChange(data.dir_path, "");
- },
- }
- continue;
- } else if (Mask.is(event.mask, .IN_MOVED_FROM)) {
- log.debug("MOVING {s}/{s}", .{ parent.dir_path, event.getName().? });
- try self.moveDirStart(assets.allocator, event.wd, event.cookie, event.getName().?);
- continue;
- } else if (Mask.is(event.mask, .IN_MOVED_TO)) {
- log.debug("MOVED {s}/{s}", .{ parent.dir_path, event.getName().? });
- const moved_fd = try self.moveDirEnd(assets.allocator, event.wd, event.cookie, event.getName().?);
- const moved = self.watch_fds.get(moved_fd).?;
- switch (moved.kind) {
- .input => {
- assets.onAssetChange(moved.dir_path, "");
- },
- .output => {
- assets.onAssetChange(moved.dir_path, "");
- },
- }
- continue;
- }
- } else {
- if (Mask.is(event.mask, .IN_CLOSE_WRITE) or
- Mask.is(event.mask, .IN_MOVED_TO))
- {
- switch (parent.kind) {
- .input => {
- const name = event.getName() orelse continue;
- assets.onAssetChange(parent.dir_path, name);
- },
- .output => {
- const name = event.getName() orelse continue;
- assets.onAssetChange(parent.dir_path, name);
- },
- }
- }
- }
- }
- }
-}
-
-const Mask = struct {
- pub const IN_ACCESS = 0x00000001;
- pub const IN_MODIFY = 0x00000002;
- pub const IN_ATTRIB = 0x00000004;
- pub const IN_CLOSE_WRITE = 0x00000008;
- pub const IN_CLOSE_NOWRITE = 0x00000010;
- pub const IN_CLOSE = (IN_CLOSE_WRITE | IN_CLOSE_NOWRITE);
- pub const IN_OPEN = 0x00000020;
- pub const IN_MOVED_FROM = 0x00000040;
- pub const IN_MOVED_TO = 0x00000080;
- pub const IN_MOVE = (IN_MOVED_FROM | IN_MOVED_TO);
- pub const IN_CREATE = 0x00000100;
- pub const IN_DELETE = 0x00000200;
- pub const IN_DELETE_SELF = 0x00000400;
- pub const IN_MOVE_SELF = 0x00000800;
- pub const IN_ALL_EVENTS = 0x00000fff;
-
- pub const IN_UNMOUNT = 0x00002000;
- pub const IN_Q_OVERFLOW = 0x00004000;
- pub const IN_IGNORED = 0x00008000;
-
- pub const IN_ONLYDIR = 0x01000000;
- pub const IN_DONT_FOLLOW = 0x02000000;
- pub const IN_EXCL_UNLINK = 0x04000000;
- pub const IN_MASK_CREATE = 0x10000000;
- pub const IN_MASK_ADD = 0x20000000;
-
- pub const IN_ISDIR = 0x40000000;
- pub const IN_ONESHOT = 0x80000000;
-
- pub fn is(m: u32, comptime flag: std.meta.DeclEnum(Mask)) bool {
- const f = @field(Mask, @tagName(flag));
- return (m & f) != 0;
- }
-
- pub fn all(comptime flags: []const std.meta.DeclEnum(Mask)) u32 {
- var result: u32 = 0;
- inline for (flags) |f| result |= @field(Mask, @tagName(f));
- return result;
- }
-
- pub fn debugPrint(m: u32) void {
- const flags = .{
- .IN_ACCESS,
- .IN_MODIFY,
- .IN_ATTRIB,
- .IN_CLOSE_WRITE,
- .IN_CLOSE_NOWRITE,
- .IN_CLOSE,
- .IN_OPEN,
- .IN_MOVED_FROM,
- .IN_MOVED_TO,
- .IN_MOVE,
- .IN_CREATE,
- .IN_DELETE,
- .IN_DELETE_SELF,
- .IN_MOVE_SELF,
- .IN_ALL_EVENTS,
-
- .IN_UNMOUNT,
- .IN_Q_OVERFLOW,
- .IN_IGNORED,
-
- .IN_ONLYDIR,
- .IN_DONT_FOLLOW,
- .IN_EXCL_UNLINK,
- .IN_MASK_CREATE,
- .IN_MASK_ADD,
-
- .IN_ISDIR,
- .IN_ONESHOT,
- };
- inline for (flags) |f| {
- if (is(m, f)) {
- std.debug.print("{s} ", .{@tagName(f)});
- }
- }
- }
-};
diff --git a/src/tools/watcher/MacosWatcher.zig b/src/tools/watcher/MacosWatcher.zig
deleted file mode 100644
index a3720b7b..00000000
--- a/src/tools/watcher/MacosWatcher.zig
+++ /dev/null
@@ -1,110 +0,0 @@
-const MacosWatcher = @This();
-
-const std = @import("std");
-const Assets = @import("../../Assets.zig");
-const c = @cImport({
- @cInclude("CoreServices/CoreServices.h");
-});
-
-const log = std.log.scoped(.watcher);
-
-pub fn init(
- allocator: std.mem.Allocator,
-) !MacosWatcher {
- _ = allocator;
-
- return .{};
-}
-
-pub fn callback(
- streamRef: c.ConstFSEventStreamRef,
- clientCallBackInfo: ?*anyopaque,
- numEvents: usize,
- eventPaths: ?*anyopaque,
- eventFlags: ?[*]const c.FSEventStreamEventFlags,
- eventIds: ?[*]const c.FSEventStreamEventId,
-) callconv(.C) void {
- _ = eventIds;
- _ = eventFlags;
- _ = streamRef;
- const ctx: *Context = @alignCast(@ptrCast(clientCallBackInfo));
-
- const paths: [*][*:0]u8 = @alignCast(@ptrCast(eventPaths));
- for (paths[0..numEvents]) |p| {
- const path = std.mem.span(p);
-
- const basename = std.fs.path.basename(path);
- var base_path = path[0 .. path.len - basename.len];
- if (std.mem.endsWith(u8, base_path, "/"))
- base_path = base_path[0 .. base_path.len - 1];
-
- ctx.assets.onAssetChange(base_path, basename);
- }
-}
-
-pub fn stop(_: *MacosWatcher) void {
- c.CFRunLoopStop(c.CFRunLoopGetCurrent());
-}
-
-const Context = struct {
- assets: *Assets,
-};
-pub fn listen(
- _: *MacosWatcher,
- assets: *Assets,
-) !void {
- const in_paths = try assets.getWatchPaths(assets.allocator);
- var macos_paths = try assets.allocator.alloc(c.CFStringRef, in_paths.len);
-
- for (in_paths, macos_paths[0..]) |str, *ref| {
- ref.* = c.CFStringCreateWithCString(
- null,
- str.ptr,
- c.kCFStringEncodingUTF8,
- );
- }
-
- const paths_to_watch: c.CFArrayRef = c.CFArrayCreate(
- null,
- @ptrCast(macos_paths.ptr),
- @intCast(macos_paths.len),
- null,
- );
-
- var ctx: Context = .{
- .assets = assets,
- };
-
- var stream_context: c.FSEventStreamContext = .{ .info = &ctx };
- const stream: c.FSEventStreamRef = c.FSEventStreamCreate(
- null,
- &callback,
- &stream_context,
- paths_to_watch,
- c.kFSEventStreamEventIdSinceNow,
- 0.05,
- c.kFSEventStreamCreateFlagFileEvents,
- );
-
- c.FSEventStreamScheduleWithRunLoop(
- stream,
- c.CFRunLoopGetCurrent(),
- c.kCFRunLoopDefaultMode,
- );
-
- if (c.FSEventStreamStart(stream) == 0) {
- @panic("failed to start the event stream");
- }
-
- // Free allocations before entering the run loop, it will not return
- assets.allocator.free(macos_paths);
- assets.allocator.free(in_paths);
-
- c.CFRunLoopRun();
-
- c.FSEventStreamStop(stream);
- c.FSEventStreamInvalidate(stream);
- c.FSEventStreamRelease(stream);
-
- c.CFRelease(paths_to_watch);
-}
diff --git a/src/tools/watcher/WindowsWatcher.zig b/src/tools/watcher/WindowsWatcher.zig
deleted file mode 100644
index ae7c8bb6..00000000
--- a/src/tools/watcher/WindowsWatcher.zig
+++ /dev/null
@@ -1,224 +0,0 @@
-const WindowsWatcher = @This();
-
-const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
-const windows = std.os.windows;
-const Assets = @import("../../Assets.zig");
-
-const log = std.log.scoped(.watcher);
-
-const notify_filter = windows.FileNotifyChangeFilter{
- .file_name = true,
- .dir_name = true,
- .attributes = false,
- .size = false,
- .last_write = true,
- .last_access = false,
- .creation = false,
- .security = false,
-};
-
-const Error = error{ InvalidHandle, QueueFailed, WaitFailed };
-
-const CompletionKey = usize;
-/// Values should be a multiple of `ReadBufferEntrySize`
-const ReadBufferIndex = u32;
-const ReadBufferEntrySize = 1024;
-
-const WatchEntry = struct {
- dir_path: [:0]const u8,
- dir_handle: windows.HANDLE,
-
- overlap: windows.OVERLAPPED = std.mem.zeroes(windows.OVERLAPPED),
- buf_idx: ReadBufferIndex,
-};
-
-iocp_port: windows.HANDLE,
-entries: std.AutoHashMap(CompletionKey, WatchEntry),
-read_buffer: []u8,
-
-pub fn stop(_: *WindowsWatcher) void {}
-
-pub fn init(
- allocator: std.mem.Allocator,
-) !WindowsWatcher {
- const watcher = WindowsWatcher{
- .iocp_port = windows.INVALID_HANDLE_VALUE,
- .entries = std.AutoHashMap(CompletionKey, WatchEntry).init(allocator),
- .read_buffer = undefined,
- };
-
- return watcher;
-}
-
-fn addPath(
- path: [:0]const u8,
- /// Assumed to increment by 1 after each invocation, starting at 0.
- key: CompletionKey,
- port: *windows.HANDLE,
-) !WatchEntry {
- const dir_handle = CreateFileA(
- path,
- windows.GENERIC_READ, // FILE_LIST_DIRECTORY,
- windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE,
- null,
- windows.OPEN_EXISTING,
- windows.FILE_FLAG_BACKUP_SEMANTICS | windows.FILE_FLAG_OVERLAPPED,
- null,
- );
- if (dir_handle == windows.INVALID_HANDLE_VALUE) {
- log.err(
- "Unable to open directory {s}: {s}",
- .{ path, @tagName(windows.kernel32.GetLastError()) },
- );
- return Error.InvalidHandle;
- }
-
- if (port.* == windows.INVALID_HANDLE_VALUE) {
- port.* = try windows.CreateIoCompletionPort(dir_handle, null, key, 0);
- } else {
- _ = try windows.CreateIoCompletionPort(dir_handle, port.*, key, 0);
- }
-
- return .{
- .dir_path = path,
- .dir_handle = dir_handle,
- .buf_idx = @intCast(ReadBufferEntrySize * key),
- };
-}
-
-pub fn listen(
- watcher: *WindowsWatcher,
- assets: *Assets,
-) !void {
- // Doubles as the number of WatchEntries
- var comp_key: CompletionKey = 0;
-
- const in_paths = try assets.getWatchDirs(assets.allocator);
- defer assets.allocator.free(in_paths);
-
- for (in_paths) |path| {
- const in_path = try assets.allocator.dupeZ(u8, path);
- //defer assets.allocator.free(in_path);
-
- try watcher.entries.put(
- comp_key,
- try addPath(in_path, comp_key, &watcher.iocp_port),
- );
- comp_key += 1;
- }
-
- watcher.read_buffer = try assets.allocator.alloc(u8, ReadBufferEntrySize * comp_key);
- defer assets.allocator.free(watcher.read_buffer);
- // Here we need pointers to both the read_buffer and entry overlapped structs,
- // which we can only do after setting up everything else.
- watcher.entries.lockPointers();
- for (0..comp_key) |key| {
- const entry = watcher.entries.getPtr(key).?;
-
- if (windows.kernel32.ReadDirectoryChangesW(
- entry.dir_handle,
- @ptrCast(@alignCast(&watcher.read_buffer[entry.buf_idx])),
- ReadBufferEntrySize,
- @intFromBool(true),
- notify_filter,
- null,
- &entry.overlap,
- null,
- ) == 0) {
- log.err("ReadDirectoryChanges error: {s}", .{@tagName(windows.kernel32.GetLastError())});
- return Error.QueueFailed;
- }
- }
-
- var dont_care: struct {
- bytes_transferred: windows.DWORD = undefined,
- overlap: ?*windows.OVERLAPPED = undefined,
- } = .{};
-
- var key: CompletionKey = undefined;
- while (assets.watching) {
- // Waits here until any of the directory handles associated with the iocp port
- // have been updated.
- const wait_result = windows.GetQueuedCompletionStatus(
- watcher.iocp_port,
- &dont_care.bytes_transferred,
- &key,
- &dont_care.overlap,
- windows.INFINITE,
- );
- if (wait_result != .Normal) {
- log.err("GetQueuedCompletionStatus error: {s}", .{@tagName(wait_result)});
- return Error.WaitFailed;
- }
-
- const entry = watcher.entries.getPtr(key) orelse @panic("Invalid CompletionKey");
-
- var info_iter = windows.FileInformationIterator(FILE_NOTIFY_INFORMATION){
- .buf = watcher.read_buffer[entry.buf_idx..][0..ReadBufferEntrySize],
- };
- var path_buf: [windows.MAX_PATH]u8 = undefined;
- while (info_iter.next()) |info| {
- const filename: []const u8 = blk: {
- const n = try std.unicode.utf16LeToUtf8(
- &path_buf,
- @as([*]u16, @ptrCast(&info.FileName))[0 .. info.FileNameLength / 2],
- );
- break :blk path_buf[0..n];
- };
-
- // const args = .{ entry.dir_path, filename };
- // switch (info.Action) {
- // windows.FILE_ACTION_ADDED => log.debug("added {s}/{s}", args),
- // windows.FILE_ACTION_REMOVED => log.debug("removed {s}/{s}", args),
- // windows.FILE_ACTION_MODIFIED => log.debug("modified {s}/{s}", args),
- // windows.FILE_ACTION_RENAMED_OLD_NAME => log.debug("renamed_old_name {s}/{s}", args),
- // windows.FILE_ACTION_RENAMED_NEW_NAME => log.debug("renamed_new_name {s}/{s}", args),
- // else => log.debug("Unknown Action {s}/{s}", args),
- // }
-
- assets.onAssetChange(entry.dir_path, filename);
- }
-
- // Re-queue the directory entry
- if (windows.kernel32.ReadDirectoryChangesW(
- entry.dir_handle,
- @ptrCast(@alignCast(&watcher.read_buffer[entry.buf_idx])),
- ReadBufferEntrySize,
- @intFromBool(true),
- notify_filter,
- null,
- &entry.overlap,
- null,
- ) == 0) {
- log.err("ReadDirectoryChanges error: {s}", .{@tagName(windows.kernel32.GetLastError())});
- return Error.QueueFailed;
- }
- }
-
- watcher.entries.unlockPointers();
- var iter = watcher.entries.valueIterator();
- while (iter.next()) |entry| {
- windows.CloseHandle(entry.dir_handle);
- assets.allocator.free(entry.dir_path);
- }
- watcher.entries.deinit();
-}
-
-const FILE_NOTIFY_INFORMATION = extern struct {
- NextEntryOffset: windows.DWORD,
- Action: windows.DWORD,
- FileNameLength: windows.DWORD,
- /// Flexible array member
- FileName: windows.WCHAR,
-};
-
-extern "kernel32" fn CreateFileA(
- lpFileName: windows.LPCSTR,
- dwDesiredAccess: windows.DWORD,
- dwShareMode: windows.DWORD,
- lpSecurityAttributes: ?*windows.SECURITY_ATTRIBUTES,
- dwCreationDisposition: windows.DWORD,
- dwFlagsAndAttributes: windows.DWORD,
- hTemplateFile: ?windows.HANDLE,
-) callconv(windows.WINAPI) windows.HANDLE;
From 6911509f04ec3614081c584c8cb9c405aec9304a Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 08:36:22 -0500
Subject: [PATCH 09/49] Phase 3c
---
src/internal/File.zig | 3 +++
src/pixelart/plugin.zig | 26 ++++++++++++++++++++++++++
src/sdk/Plugin.zig | 12 ++++++++++++
src/workbench/Workspace.zig | 23 +++++------------------
4 files changed, 46 insertions(+), 18 deletions(-)
diff --git a/src/internal/File.zig b/src/internal/File.zig
index e78a9881..4d5a66d0 100644
--- a/src/internal/File.zig
+++ b/src/internal/File.zig
@@ -59,6 +59,9 @@ pub const EditorData = struct {
/// type-depend on the editor's `Workspace` (lets `File` move into a plugin).
/// Only valid while the file widget is drawing the file.
workspace_handle: ?*anyopaque = null,
+ /// Set by the shell each frame before draw: request the canvas recenter this frame
+ /// (true while a workspace/panel pane is mid-animation). Read by the document render.
+ center: bool = false,
canvas: fizzy.dvui.CanvasWidget = .{},
layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig
index b69c2fbb..3137e832 100644
--- a/src/pixelart/plugin.zig
+++ b/src/pixelart/plugin.zig
@@ -34,6 +34,7 @@ const vtable: sdk.Plugin.VTable = .{
.closeDocument = closeDocument,
.undo = undo,
.redo = redo,
+ .drawDocument = drawDocument,
};
/// A `DocHandle` whose `ptr` is one of this plugin's `*Internal.File`s. The shell
@@ -85,6 +86,31 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void {
docFile(doc).deinit();
}
+/// Render the open pixel-art document into the workbench-provided container (the current
+/// dvui parent). The workbench sets `canvas.id` / `workspace_handle` and draws the canvas
+/// chrome around this; here we instantiate the editing widget and the sample magnifier.
+fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
+ const file = docFile(doc);
+ fizzy.perf.canvasPaneDrawn();
+
+ var file_widget = fizzy.dvui.FileWidget.init(@src(), .{
+ .file = file,
+ .center = file.editor.center,
+ }, .{
+ .expand = .both,
+ .background = false,
+ .color_fill = .transparent,
+ });
+ defer file_widget.deinit();
+ file_widget.processEvents();
+
+ if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| {
+ if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) {
+ fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt);
+ }
+ }
+}
+
fn undo(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
try file.history.undoRedo(file, .undo);
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index dc1836fc..d4f5ea2f 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -117,6 +117,18 @@ pub fn redo(self: Plugin, doc: DocHandle) !void {
if (self.vtable.redo) |f| try f(self.state, doc);
}
+// ---- render hook wrappers ----
+
+/// Draw an open document into the current dvui parent (the workbench sets up the
+/// container, then routes here). Returns whether the plugin drew anything.
+pub fn drawDocument(self: Plugin, doc: DocHandle) !bool {
+ if (self.vtable.drawDocument) |f| {
+ try f(self.state, doc);
+ return true;
+ }
+ return false;
+}
+
pub fn deinit(self: Plugin) void {
if (self.vtable.deinit) |f| f(self.state);
}
diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig
index b85b1226..89df2411 100644
--- a/src/workbench/Workspace.zig
+++ b/src/workbench/Workspace.zig
@@ -886,6 +886,7 @@ pub fn drawCanvas(self: *Workspace) !void {
const file = &fizzy.editor.open_files.values()[self.open_file_index];
file.editor.canvas.id = canvas_vbox.data().id;
file.editor.workspace_handle = self;
+ file.editor.center = self.center;
if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) {
defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{});
@@ -907,24 +908,10 @@ pub fn drawCanvas(self: *Workspace) !void {
if (self.grouping != file.editor.grouping) return;
- fizzy.perf.canvasPaneDrawn();
-
- var file_widget = fizzy.dvui.FileWidget.init(@src(), .{
- .file = file,
- .center = self.center,
- }, .{
- .expand = .both,
- .background = false,
- .color_fill = .transparent,
- });
-
- defer file_widget.deinit();
- file_widget.processEvents();
-
- if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| {
- if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) {
- fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt);
- }
+ // Route the document render to its owning plugin (pixel art builds its own
+ // FileWidget). The workbench owns only the container + canvas chrome above.
+ if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
+ _ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id });
}
} else {
var box = workspaceEmptyStateCard(content_color, self.grouping);
From d98879174fa99a15153f742cf3df2b2c7b0f2b2d Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 08:51:03 -0500
Subject: [PATCH 10/49] Phase 3c - part 2
---
src/pixelart/plugin.zig | 32 ++++++++++++++++++++++++---
src/workbench/Workspace.zig | 43 +++++++++++--------------------------
2 files changed, 41 insertions(+), 34 deletions(-)
diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig
index 3137e832..9498c148 100644
--- a/src/pixelart/plugin.zig
+++ b/src/pixelart/plugin.zig
@@ -86,13 +86,39 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void {
docFile(doc).deinit();
}
-/// Render the open pixel-art document into the workbench-provided container (the current
-/// dvui parent). The workbench sets `canvas.id` / `workspace_handle` and draws the canvas
-/// chrome around this; here we instantiate the editing widget and the sample magnifier.
+/// Render the open pixel-art document into the workbench-provided content region (the
+/// current dvui parent). The workbench owns only the container + tab/split frame and sets
+/// `canvas.id` / `workspace_handle` / `center` before routing here; pixel art owns the
+/// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing
+/// widget, and the sample magnifier. The per-workspace ruler/overlay state + draw helpers
+/// still live on `Workspace` for now (recovered via `ofFile`); they relocate here in 3C/2b.
fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
+ const ws = fizzy.Editor.Workspace.ofFile(file) orelse return;
+ const container = dvui.parentGet().data();
+
fizzy.perf.canvasPaneDrawn();
+ if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) {
+ defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{});
+ ws.drawRuler(.horizontal);
+ }
+
+ var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
+ defer canvas_hbox.deinit();
+
+ if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) {
+ defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{});
+ ws.drawRuler(.vertical);
+ }
+
+ ws.drawTransformDialog(container);
+ ws.drawEditPill(container);
+ // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom).
+ ws.drawSampleButton(container);
+
+ if (ws.grouping != file.editor.grouping) return;
+
var file_widget = fizzy.dvui.FileWidget.init(@src(), .{
.file = file,
.center = file.editor.center,
diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig
index 89df2411..43160e95 100644
--- a/src/workbench/Workspace.zig
+++ b/src/workbench/Workspace.zig
@@ -884,32 +884,13 @@ pub fn drawCanvas(self: *Workspace) !void {
}
const file = &fizzy.editor.open_files.values()[self.open_file_index];
+ // The workbench owns only the content region (this container) + tab/split frame;
+ // bind it to the document and route the entire in-region render to the owning
+ // plugin (pixel art draws its rulers, overlays, and editing widget itself).
file.editor.canvas.id = canvas_vbox.data().id;
file.editor.workspace_handle = self;
file.editor.center = self.center;
- if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) {
- defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{});
- self.drawRuler(.horizontal);
- }
-
- var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
- defer canvas_hbox.deinit();
-
- if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) {
- defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .left, .{});
- self.drawRuler(.vertical);
- }
-
- self.drawTransformDialog(canvas_vbox);
- self.drawEditPill(canvas_vbox);
- // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom).
- self.drawSampleButton(canvas_vbox);
-
- if (self.grouping != file.editor.grouping) return;
-
- // Route the document render to its owning plugin (pixel art builds its own
- // FileWidget). The workbench owns only the container + canvas chrome above.
if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
_ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id });
}
@@ -1560,16 +1541,16 @@ pub fn processRowReorder(self: *Workspace) void {
}
}
-pub fn drawTransformDialog(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
+pub fn drawTransformDialog(self: *Workspace, container: *dvui.WidgetData) void {
const file = &fizzy.editor.open_files.values()[self.open_file_index];
if (file.editor.transform) |*transform| {
- var rect = canvas_vbox.data().rect;
+ var rect = container.rect;
rect.w = 0;
rect.h = 0;
var fw: dvui.FloatingWidget = undefined;
fw.init(@src(), .{}, .{
- .rect = .{ .x = canvas_vbox.data().rectScale().r.toNatural().x + 10, .y = canvas_vbox.data().rectScale().r.toNatural().y + 10, .w = 0, .h = 0 },
+ .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 },
.expand = .none,
.background = true,
.color_fill = dvui.themeGet().color(.control, .fill),
@@ -1653,7 +1634,7 @@ pub fn drawTransformDialog(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void
/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a
/// single hamburger circle; tapping toggles the row of action buttons in/out with a
/// width animation.
-pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
+pub fn drawEditPill(self: *Workspace, container: *dvui.WidgetData) void {
const file = fizzy.editor.activeFile() orelse return;
const button_size: f32 = 36;
@@ -1699,7 +1680,7 @@ pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
// Drive the expand/collapse with a dvui animation. Look up the current value, and on
// a toggle click kick off a new animation between the current value and the target.
- const anim_id = dvui.Id.update(canvas_vbox.data().id, "edit_pill_expand");
+ const anim_id = dvui.Id.update(container.id, "edit_pill_expand");
var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0;
if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0);
@@ -1711,7 +1692,7 @@ pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
// the pill follows the workspace exactly: as a split is dragged shut the canvas area
// shrinks, and once it's narrower than the pill we bail and draw nothing this frame —
// so closing splits cleanly hides the menu.
- const wb = canvas_vbox.data().rectScale().r.toNatural();
+ const wb = container.rectScale().r.toNatural();
const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
const canvas_nat = dvui.Rect{
@@ -1935,7 +1916,7 @@ pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders
/// the existing color-dropper magnifier at the touch location. On release we read the
/// color underneath the sample point and apply it to the primary color slot.
-pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
+pub fn drawSampleButton(self: *Workspace, container: *dvui.WidgetData) void {
const file = fizzy.editor.activeFile() orelse return;
const pill_button_size: f32 = 36;
@@ -1949,7 +1930,7 @@ pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
const gap: f32 = 6;
// Anchor against the same canvas-scroll-area rect the pill uses.
- const wb = canvas_vbox.data().rectScale().r.toNatural();
+ const wb = container.rectScale().r.toNatural();
const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
const canvas_nat = dvui.Rect{
@@ -2002,7 +1983,7 @@ pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void {
// Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag
// threshold). Stored via dataSet because the button widget is recreated each frame.
- const drag_state_id = dvui.Id.update(canvas_vbox.data().id, "sample_button_drag");
+ const drag_state_id = dvui.Id.update(container.id, "sample_button_drag");
var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false;
var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false;
From 5816e6fd60e27529f98cbb8bdac076f80c825033 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 09:15:38 -0500
Subject: [PATCH 11/49] Phase 3c - part 2b
---
src/pixelart/plugin.zig | 87 +++++++++++++++++++++++++++++++++
src/sdk/regions.zig | 5 ++
src/workbench/Workspace.zig | 97 ++++++-------------------------------
3 files changed, 107 insertions(+), 82 deletions(-)
diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig
index 9498c148..098520be 100644
--- a/src/pixelart/plugin.zig
+++ b/src/pixelart/plugin.zig
@@ -137,6 +137,92 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
}
}
+/// Take over a workspace pane to show the pixel-art packed-atlas preview (the "Project"
+/// sidebar view's `draw_workspace`). The workbench owns the pane frame and routes here when
+/// `view_project` is the active sidebar view; we cast the opaque handle back to the document
+/// host's `Workspace` and render the whole content region (atlas image or empty-state hint).
+/// Mirrors what `Workspace.drawCanvas` does for documents: reuses the workbench's shared
+/// canvas vbox / empty-state card helpers so switching project ↔ canvas keeps stable widget ids,
+/// and stamps `canvas_rect_physical` (read by the editor's load/save toast overlays).
+fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
+ const Workspace = fizzy.Editor.Workspace;
+ const ws: *Workspace = @ptrCast(@alignCast(workspace_handle));
+
+ var content_color = dvui.themeGet().color(.window, .fill);
+
+ switch (builtin.os.tag) {
+ .macos => {
+ content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
+ },
+ .windows => {
+ content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
+ },
+ else => {},
+ }
+
+ const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32)
+ fizzy.packer.atlas != null
+ else
+ fizzy.editor.folder != null and fizzy.packer.atlas != null;
+
+ // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage).
+ var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping);
+ defer {
+ ws.canvas_rect_physical = canvas_vbox.data().contentRectScale().r;
+ dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural());
+ canvas_vbox.deinit();
+ }
+
+ if (show_packed_atlas) {
+ const atlas = &fizzy.packer.atlas.?;
+ var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{
+ .source = atlas.source,
+ .canvas = &atlas.canvas,
+ .grouping = ws.grouping,
+ }, .{
+ .id_extra = @intCast(ws.grouping),
+ .expand = .both,
+ .background = false,
+ .color_fill = .transparent,
+ });
+ defer image_widget.deinit();
+
+ image_widget.processEvents();
+
+ if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| {
+ if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) {
+ fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt);
+ }
+ }
+ } else {
+ var box = Workspace.workspaceEmptyStateCard(content_color, ws.grouping);
+ defer box.deinit();
+
+ const alpha = dvui.alpha(1.0);
+ dvui.alphaSet(1.0);
+ defer dvui.alphaSet(alpha);
+
+ const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32)
+ "Pack open files to see the preview."
+ else if (fizzy.editor.folder == null)
+ "Open a project folder, then pack to see the preview."
+ else
+ "Pack the project to see the preview.";
+
+ dvui.labelNoFmt(
+ @src(),
+ hint,
+ .{ .align_x = 0.5 },
+ .{
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .color_text = dvui.themeGet().color(.control, .text),
+ .font = dvui.Font.theme(.body),
+ },
+ );
+ }
+}
+
fn undo(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
try file.history.undoRedo(file, .undo);
@@ -169,6 +255,7 @@ pub fn register(host: *sdk.Host) !void {
.icon = dvui.entypo.box,
.title = "Project",
.draw = drawProject,
+ .draw_workspace = drawProjectView,
});
try host.registerBottomView(.{
.id = bottom_sprites,
diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig
index 9b09b617..cfe89cb7 100644
--- a/src/sdk/regions.zig
+++ b/src/sdk/regions.zig
@@ -22,6 +22,11 @@ pub const SidebarView = struct {
title: []const u8,
ctx: ?*anyopaque = null,
draw: *const fn (ctx: ?*anyopaque) anyerror!void,
+ /// Optional: while this view is the active sidebar view, it takes over the workspace
+ /// content region instead of the normal document tabs+canvas. The workbench calls this
+ /// per workspace pane, passing the opaque workspace handle (cast back to the document
+ /// host's `Workspace`). Used by pixel art's "Project" view to show the packed atlas.
+ draw_workspace: ?*const fn (ctx: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void = null,
};
/// A bottom-panel view. The panel shows a tab strip across all registered views;
diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig
index 43160e95..dcea40e9 100644
--- a/src/workbench/Workspace.zig
+++ b/src/workbench/Workspace.zig
@@ -48,7 +48,8 @@ vertical_ruler_width: f32 = 0.0,
edit_pill_expanded: bool = false,
/// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during
-/// `drawCanvas` / `drawProject`. `null` until the workspace has rendered at least once. Used
+/// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view).
+/// `null` until the workspace has rendered at least once. Used
/// by the editor-level load/save toast overlays to center cards over the area the user is
/// actually looking at (rather than the OS window rect).
canvas_rect_physical: ?dvui.Rect.Physical = null,
@@ -119,8 +120,12 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
}
}
- if (fizzy.editor.host.isActiveSidebarView(@import("../pixelart/plugin.zig").view_project)) {
- self.drawProject();
+ // A sidebar view may optionally take over this workspace pane's content region (e.g. pixel
+ // art's "Project" view renders the packed atlas here instead of document tabs+canvas). The
+ // workbench owns only the pane frame; it hands the active view the opaque workspace handle.
+ const active = fizzy.editor.host.activeSidebarView();
+ if (active != null and active.?.draw_workspace != null) {
+ try active.?.draw_workspace.?(active.?.ctx, self);
} else {
self.drawTabs();
try self.drawCanvas();
@@ -130,8 +135,11 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
}
/// Same `@src()` for every call so DVUI sees one stable id when switching between `drawCanvas` and
-/// `drawProject` (avoids first-frame min-size / layout flash). Use `grouping` so multi-workspace panes stay distinct.
-fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget {
+/// a plugin's `draw_workspace` takeover (avoids first-frame min-size / layout flash). Use `grouping`
+/// so multi-workspace panes stay distinct.
+/// `pub` so a plugin's `draw_workspace` takeover (pixel art's Project view) can reuse the exact same
+/// vbox so switching project ↔ canvas does not churn the widget id.
+pub fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget {
return dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .both,
.background = background,
@@ -142,7 +150,8 @@ fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping
/// Rounded “card” behind the project empty state and the homepage. Shared id base + `grouping` so
/// switching project tab ↔ file pane (no open files) does not create a new widget each time.
-fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget {
+/// `pub` so pixel art's Project-view takeover (`draw_workspace`) reuses the identical empty-state card.
+pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget {
return dvui.box(@src(), .{ .dir = .horizontal }, .{
.expand = .both,
.background = true,
@@ -153,82 +162,6 @@ fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWi
});
}
-fn drawProject(self: *Workspace) void {
- var content_color = dvui.themeGet().color(.window, .fill);
-
- switch (builtin.os.tag) {
- .macos => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
- },
- .windows => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
- },
- else => {},
- }
-
- const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32)
- fizzy.packer.atlas != null
- else
- fizzy.editor.folder != null and fizzy.packer.atlas != null;
-
- // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage).
- var canvas_vbox = workspaceMainCanvasVbox(content_color, show_packed_atlas, self.grouping);
- defer {
- self.canvas_rect_physical = canvas_vbox.data().contentRectScale().r;
- dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural());
- canvas_vbox.deinit();
- }
-
- if (show_packed_atlas) {
- const atlas = &fizzy.packer.atlas.?;
- var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{
- .source = atlas.source,
- .canvas = &atlas.canvas,
- .grouping = self.grouping,
- }, .{
- .id_extra = @intCast(self.grouping),
- .expand = .both,
- .background = false,
- .color_fill = .transparent,
- });
- defer image_widget.deinit();
-
- image_widget.processEvents();
-
- if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| {
- if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) {
- fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt);
- }
- }
- } else {
- var box = workspaceEmptyStateCard(content_color, self.grouping);
- defer box.deinit();
-
- const alpha = dvui.alpha(1.0);
- dvui.alphaSet(1.0);
- defer dvui.alphaSet(alpha);
-
- const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32)
- "Pack open files to see the preview."
- else if (fizzy.editor.folder == null)
- "Open a project folder, then pack to see the preview."
- else
- "Pack the project to see the preview.";
-
- dvui.labelNoFmt(
- @src(),
- hint,
- .{ .align_x = 0.5 },
- .{
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .color_text = dvui.themeGet().color(.control, .text),
- .font = dvui.Font.theme(.body),
- },
- );
- }
-}
-
fn drawTabs(self: *Workspace) void {
if (fizzy.editor.open_files.values().len == 0) return;
From 8d8aa3293002c4df1157f83cad6b537bab13c33e Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 09:42:20 -0500
Subject: [PATCH 12/49] Phase 3c - final
---
src/editor/Editor.zig | 4 +
src/editor/widgets/FileWidget.zig | 34 +-
src/pixelart/CanvasData.zig | 1287 +++++++++++++++++++++++++++++
src/pixelart/plugin.zig | 25 +-
src/workbench/Workspace.zig | 1263 +---------------------------
5 files changed, 1351 insertions(+), 1262 deletions(-)
create mode 100644 src/pixelart/CanvasData.zig
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 68689d3f..f76e6b9e 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -1739,6 +1739,7 @@ pub fn rebuildWorkspaces(editor: *Editor) !void {
}
}
+ workspace.deinit();
_ = editor.workspaces.orderedRemove(workspace.grouping);
break;
}
@@ -3478,6 +3479,9 @@ pub fn deinit(editor: *Editor) !void {
editor.explorer.deinit();
+ for (editor.workspaces.values()) |*workspace| workspace.deinit();
+ editor.workspaces.deinit(fizzy.app.allocator);
+
editor.host.deinit();
editor.workbench.deinit();
diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig
index 60187f34..cd4c8590 100644
--- a/src/editor/widgets/FileWidget.zig
+++ b/src/editor/widgets/FileWidget.zig
@@ -19,6 +19,7 @@ pub const FileWidget = @This();
const CanvasWidget = @import("CanvasWidget.zig");
const CanvasBridge = @import("CanvasBridge.zig");
const Workspace = fizzy.Editor.Workspace;
+const CanvasData = @import("../../pixelart/CanvasData.zig");
const icons = @import("icons");
// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is
@@ -683,12 +684,23 @@ fn workspace(self: *FileWidget) *Workspace {
return Workspace.ofFile(self.init_options.file).?;
}
+/// The pixel-art per-pane `CanvasData` for the pane drawing this file, or null if none is
+/// attached yet. Holds the column/row reorder drag state this widget reads while previewing.
+fn canvasData(self: *FileWidget) ?*CanvasData {
+ return CanvasData.fromWorkspace(self.workspace());
+}
+
+/// True while a column or row is mid-drag in this pane's rulers.
+fn columnRowReorderActive(self: *FileWidget) bool {
+ const cd = self.canvasData() orelse return false;
+ return cd.columns_drag_index != null or cd.rows_drag_index != null;
+}
+
/// Same read-only state as `drawSpriteBubbles` uses for `BubblePanShared` (no animation side effects).
fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared {
if (self.init_options.file.editor.transform != null) return null;
if (self.resize_data_point != null) return null;
- if (self.workspace().columns_drag_index != null) return null;
- if (self.workspace().rows_drag_index != null) return null;
+ if (self.columnRowReorderActive()) return null;
if (self.removed_sprite_indices != null) return null;
if (!(self.active() or self.hovered())) return null;
@@ -4551,7 +4563,7 @@ pub fn drawLayers(self: *FileWidget) void {
if (self.removed_sprite_indices != null) {
self.drawCellReorderPreview();
return;
- } else if (self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null) {
+ } else if (self.columnRowReorderActive()) {
self.drawColumnRowReorderPreview();
return;
} else {
@@ -4751,17 +4763,17 @@ fn drawCanvasCheckerboardBackground(self: *FileWidget) void {
fn drawColumnRowReorderPreview(self: *FileWidget) void {
const file = self.init_options.file;
- const ws = self.workspace();
- if (ws.columns_drag_index == null and ws.rows_drag_index == null) return;
+ const cd = self.canvasData() orelse return;
+ if (cd.columns_drag_index == null and cd.rows_drag_index == null) return;
- const axis: ReorderAxis = if (ws.columns_drag_index != null) .columns else .rows;
+ const axis: ReorderAxis = if (cd.columns_drag_index != null) .columns else .rows;
const target_index = switch (axis) {
- .columns => ws.columns_target_index,
- .rows => ws.rows_target_index,
+ .columns => cd.columns_target_index,
+ .rows => cd.rows_target_index,
};
const removed_index = switch (axis) {
- .columns => ws.columns_drag_index,
- .rows => ws.rows_drag_index,
+ .columns => cd.columns_drag_index,
+ .rows => cd.rows_drag_index,
} orelse return;
self.drawReorderPreviewForAxis(file, axis, target_index, removed_index);
@@ -5671,7 +5683,7 @@ pub fn processResize(self: *FileWidget) void {
pub fn processEvents(self: *FileWidget) void {
const transform = self.init_options.file.editor.transform != null;
- const reorder = self.workspace().columns_drag_index != null or self.workspace().rows_drag_index != null or self.removed_sprite_indices != null;
+ const reorder = self.columnRowReorderActive() or self.removed_sprite_indices != null;
// Try to ensure that selected animation frame index is valid
if (self.init_options.file.selected_animation_index) |ai| {
diff --git a/src/pixelart/CanvasData.zig b/src/pixelart/CanvasData.zig
new file mode 100644
index 00000000..5367f544
--- /dev/null
+++ b/src/pixelart/CanvasData.zig
@@ -0,0 +1,1287 @@
+//! The pixel-art plugin's per-workspace-pane data. Each plugin that renders documents into a
+//! workbench pane will typically want a struct like this to hold its per-pane state; pixel art
+//! uses it for the canvas UI that wraps a document inside the workbench-provided content region:
+//! the column/row rulers, the floating Edit pill and color-sample button, the transform dialog,
+//! and the grid (column/row) reorder drag state, plus the matching draw helpers.
+//!
+//! It is pixel-art-owned and lives per pane. The plugin lazily allocates one (`ensure`) and
+//! stashes the pointer in the workbench `Workspace.plugin_view_state` opaque slot; the workbench
+//! never dereferences it and frees it through `plugin_view_destroy` when the pane is torn down.
+//! State the shell itself needs (the pane's physical content rect, used to center load/save
+//! toasts) intentionally stays on `Workspace`.
+const std = @import("std");
+const dvui = @import("dvui");
+const fizzy = @import("../fizzy.zig");
+const icons = @import("icons");
+
+const Workspace = fizzy.Editor.Workspace;
+const File = fizzy.Internal.File;
+
+const CanvasData = @This();
+
+// Grid (column/row) reorder drag state. Set by the rulers (`drawRulerContent`), consumed by
+// `FileWidget` (reorder preview) and committed by `processColumnReorder`/`processRowReorder`.
+columns_drag_name: []const u8 = undefined,
+columns_drag_index: ?usize = null,
+columns_target_id: ?dvui.Id = null,
+columns_target_index: ?usize = null,
+columns_removed_index: ?usize = null,
+columns_insert_before_index: ?usize = null,
+
+rows_drag_name: []const u8 = undefined,
+rows_drag_index: ?usize = null,
+rows_target_id: ?dvui.Id = null,
+rows_target_index: ?usize = null,
+rows_removed_index: ?usize = null,
+rows_insert_before_index: ?usize = null,
+
+horizontal_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given },
+vertical_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given },
+
+horizontal_ruler_height: f32 = 0.0,
+vertical_ruler_width: f32 = 0.0,
+
+/// Floating Edit-pill quick-access bar collapse state. Starts collapsed (single hamburger
+/// button); the user toggles to expand the full action row.
+edit_pill_expanded: bool = false,
+
+pub fn init(grouping: u64) CanvasData {
+ return .{
+ .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag",
+ .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag",
+ };
+}
+
+/// The drag names are intentionally not freed here: `init` may have fallen back to a static
+/// string literal on (effectively impossible) OOM, and freeing a literal is UB. This matches
+/// the pre-relocation behavior where the names lived on `Workspace` and were never freed.
+pub fn deinit(_: *CanvasData) void {}
+
+/// Get the pixel-art chrome for `ws`, lazily allocating it and registering its teardown on
+/// first use. Called from the plugin's `drawDocument` each frame a document pane renders.
+pub fn ensure(ws: *Workspace) *CanvasData {
+ if (ws.plugin_view_state) |p| return @ptrCast(@alignCast(p));
+ const self = fizzy.app.allocator.create(CanvasData) catch @panic("OOM allocating CanvasData");
+ self.* = CanvasData.init(ws.grouping);
+ ws.plugin_view_state = self;
+ ws.plugin_view_destroy = destroyOpaque;
+ return self;
+}
+
+/// The data already attached to `ws`, or null if none exists yet (e.g. the pane has not
+/// drawn a document this session). `FileWidget` uses this for its read-only reorder checks.
+/// Only pixel art writes `plugin_view_state`, so the cast is sound.
+pub fn fromWorkspace(ws: *Workspace) ?*CanvasData {
+ const p = ws.plugin_view_state orelse return null;
+ return @ptrCast(@alignCast(p));
+}
+
+/// `plugin_view_destroy` target: free the chrome when the workbench tears down its pane.
+fn destroyOpaque(state: *anyopaque) void {
+ const self: *CanvasData = @ptrCast(@alignCast(state));
+ self.deinit();
+ fizzy.app.allocator.destroy(self);
+}
+
+pub const RulerOrientation = enum {
+ horizontal,
+ vertical,
+};
+
+pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation) void {
+ const font = dvui.Font.theme(.body).larger(-1);
+
+ const largest_label = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{file.rows - 1}) catch {
+ dvui.log.err("Failed to allocate largest label", .{});
+ return;
+ };
+ const largest_label_size = font.textSize(largest_label);
+ const natural_scale = dvui.currentWindow().natural_scale;
+ const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical);
+ const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding;
+
+ const ruler_thickness: f32 = switch (orientation) {
+ .horizontal => blk: {
+ self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding;
+ break :blk self.horizontal_ruler_height;
+ },
+ .vertical => blk: {
+ self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding);
+ break :blk self.vertical_ruler_width;
+ },
+ };
+
+ switch (orientation) {
+ .horizontal => {
+ var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ });
+ defer canvas_hbox.deinit();
+
+ var corner_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .none,
+ .min_size_content = .{ .h = self.vertical_ruler_width, .w = self.vertical_ruler_width },
+ .background = true,
+ .color_fill = dvui.themeGet().color(.window, .fill),
+ });
+ corner_box.deinit();
+
+ var top_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .min_size_content = .{ .h = ruler_thickness, .w = ruler_thickness },
+ .background = true,
+ .color_fill = dvui.themeGet().color(.window, .fill),
+ });
+ defer top_box.deinit();
+
+ self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, null);
+ },
+ .vertical => {
+ var ruler_box = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .vertical,
+ .min_size_content = .{ .w = ruler_thickness, .h = 1.0 },
+ .background = true,
+ .color_fill = dvui.themeGet().color(.window, .fill),
+ });
+ defer ruler_box.deinit();
+
+ self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, largest_label_phys);
+ },
+ }
+}
+
+/// `largest_row_index_*` come from `drawRuler` (widest row index string and its measured size in physical pixels).
+fn drawRulerContent(
+ self: *CanvasData,
+ file: *File,
+ font: dvui.Font,
+ orientation: RulerOrientation,
+ ruler_size: f32,
+ largest_row_index_label: []const u8,
+ largest_row_index_size_phys: ?dvui.Size.Physical,
+) void {
+ const scale = file.editor.canvas.scale;
+ const canvas = file.editor.canvas;
+
+ switch (orientation) {
+ .horizontal => {
+ self.horizontal_scroll_info.virtual_size.w = canvas.scroll_info.virtual_size.w;
+ self.horizontal_scroll_info.virtual_size.h = ruler_size;
+ self.horizontal_scroll_info.viewport.w = canvas.scroll_info.viewport.w;
+ self.horizontal_scroll_info.viewport.x = canvas.scroll_info.viewport.x;
+ },
+ .vertical => {
+ self.vertical_scroll_info.virtual_size.h = canvas.scroll_info.virtual_size.h;
+ self.vertical_scroll_info.virtual_size.w = ruler_size;
+ self.vertical_scroll_info.viewport.h = canvas.scroll_info.viewport.h;
+ self.vertical_scroll_info.viewport.y = canvas.scroll_info.viewport.y;
+ },
+ }
+
+ const scroll_info = switch (orientation) {
+ .horizontal => &self.horizontal_scroll_info,
+ .vertical => &self.vertical_scroll_info,
+ };
+
+ var scroll_area = dvui.scrollArea(@src(), .{
+ .scroll_info = scroll_info,
+ .container = true,
+ .process_events_after = true,
+ .horizontal_bar = .hide,
+ .vertical_bar = .hide,
+ }, .{ .expand = .both });
+ defer scroll_area.deinit();
+
+ const scale_rect = switch (orientation) {
+ .horizontal => dvui.Rect{ .x = -canvas.origin.x, .y = 0, .w = 0, .h = 0 },
+ .vertical => dvui.Rect{ .x = 0, .y = -canvas.origin.y, .w = 0, .h = 0 },
+ };
+ var scaler = dvui.scale(@src(), .{ .scale = &file.editor.canvas.scale }, .{ .rect = scale_rect });
+ defer scaler.deinit();
+
+ const outer_rect: dvui.Rect = switch (orientation) {
+ .horizontal => .{
+ .x = 0,
+ .y = 0,
+ .w = @as(f32, @floatFromInt(file.width())),
+ .h = ruler_size / scale,
+ },
+ .vertical => .{
+ .x = 0,
+ .y = 0,
+ .w = ruler_size / scale,
+ .h = @as(f32, @floatFromInt(file.height())),
+ },
+ };
+ var outer_box = dvui.box(@src(), .{ .dir = switch (orientation) {
+ .horizontal => .horizontal,
+ .vertical => .horizontal,
+ } }, .{
+ .expand = .none,
+ .rect = outer_rect,
+ });
+ defer outer_box.deinit();
+
+ const drag_name = switch (orientation) {
+ .horizontal => self.columns_drag_name,
+ .vertical => self.rows_drag_name,
+ };
+
+ var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{
+ .expand = .both,
+ .margin = dvui.Rect.all(0),
+ .padding = dvui.Rect.all(0),
+ .background = false,
+ .corner_radius = dvui.Rect.all(0),
+ });
+ defer reorder.deinit();
+
+ const reorder_box_dir: dvui.enums.Direction = switch (orientation) {
+ .horizontal => .horizontal,
+ .vertical => .vertical,
+ };
+ var reorder_box = dvui.box(@src(), .{ .dir = reorder_box_dir }, .{
+ .expand = .both,
+ .background = false,
+ .corner_radius = dvui.Rect.all(0),
+ .margin = dvui.Rect.all(0),
+ .padding = dvui.Rect.all(0),
+ });
+ defer reorder_box.deinit();
+
+ const ruler_stroke_color = dvui.themeGet().color(.control, .fill_hover).lighten(switch (orientation) {
+ .horizontal => 2.0,
+ .vertical => 0.0,
+ });
+
+ const edge_stroke_points = switch (orientation) {
+ .horizontal => .{
+ reorder_box.data().rectScale().r.topRight(),
+ reorder_box.data().rectScale().r.bottomRight(),
+ },
+ .vertical => .{
+ reorder_box.data().rectScale().r.bottomRight(),
+ reorder_box.data().rectScale().r.bottomLeft(),
+ },
+ };
+ defer dvui.Path.stroke(.{ .points = &edge_stroke_points }, .{
+ .color = ruler_stroke_color,
+ .thickness = 1.0,
+ });
+
+ const count = switch (orientation) {
+ .horizontal => file.columns,
+ .vertical => file.rows,
+ };
+ const cell_min_size: dvui.Size = switch (orientation) {
+ .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 },
+ .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) },
+ };
+ const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) {
+ .horizontal => .any_y,
+ .vertical => .any_x,
+ };
+ const reorder_expand: dvui.Options.Expand = switch (orientation) {
+ .horizontal => .vertical,
+ .vertical => .horizontal,
+ };
+
+ // Shared layout width for every row tick (widest index string); actual glyph size may differ per cell.
+ const vertical_row_layout_size_phys: ?dvui.Size.Physical = switch (orientation) {
+ .vertical => largest_row_index_size_phys,
+ .horizontal => null,
+ };
+
+ // Captured during iteration: the highlighted target slot (drop location) screen rect.
+ var target_rs_screen: ?dvui.RectScale = null;
+
+ var index: usize = 0;
+ while (index < count) : (index += 1) {
+ var reorderable = reorder.reorderable(@src(), .{
+ .mode = reorder_mode,
+ .clamp_to_edges = true,
+ }, .{
+ .expand = reorder_expand,
+ .id_extra = index,
+ .padding = dvui.Rect.all(0),
+ .margin = dvui.Rect.all(0),
+ .min_size_content = cell_min_size,
+ });
+ defer reorderable.deinit();
+
+ if (reorderable.targetRectScale()) |trs| {
+ target_rs_screen = trs;
+ }
+
+ var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill);
+
+ if (fizzy.dvui.hovered(reorderable.data())) {
+ button_color = dvui.themeGet().color(.control, .fill_hover);
+ dvui.cursorSet(.hand);
+ }
+
+ var cell_box: dvui.BoxWidget = undefined;
+ cell_box.init(@src(), .{ .dir = .horizontal }, .{
+ .expand = .both,
+ .background = true,
+ .color_fill = button_color,
+ .id_extra = index,
+ });
+
+ switch (orientation) {
+ .horizontal => {
+ if (reorderable.floating()) {
+ self.columns_drag_index = index;
+ reorder.reorderable_size.h = 0.0;
+ dvui.cursorSet(.hand);
+ }
+ if (reorderable.removed()) self.columns_removed_index = index;
+ if (reorderable.insertBefore()) self.columns_insert_before_index = index;
+ if (reorderable.targetID()) |target_id| self.columns_target_id = target_id;
+ if (self.columns_drag_index) |_| {
+ var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt);
+ mouse_pt.y = 0.0;
+ mouse_pt.x = std.math.clamp(mouse_pt.x, 0.0, @as(f32, @floatFromInt(file.width() - 1)));
+ self.columns_target_index = file.columnIndex(mouse_pt);
+ }
+ },
+ .vertical => {
+ if (reorderable.floating()) {
+ self.rows_drag_index = index;
+ reorder.reorderable_size.w = 0.0;
+ dvui.cursorSet(.hand);
+ }
+ if (reorderable.removed()) self.rows_removed_index = index;
+ if (reorderable.insertBefore()) self.rows_insert_before_index = index;
+ if (reorderable.targetID()) |target_id| self.rows_target_id = target_id;
+ if (self.rows_drag_index) |_| {
+ var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt);
+ mouse_pt.x = 0.0;
+ mouse_pt.y = std.math.clamp(mouse_pt.y, 0.0, @as(f32, @floatFromInt(file.height() - 1)));
+ self.rows_target_index = file.rowIndex(mouse_pt);
+ }
+ },
+ }
+
+ {
+ defer cell_box.deinit();
+
+ // The dragged item's cell_box is parented to the reorderable's floating widget
+ // (rendered at the mouse position). We collapse that floating widget to h/w = 0
+ // above, but `dvui.renderText` is not clipped by that, so the label would still
+ // appear at the cursor. Skip the visible cell rendering entirely while floating;
+ // the dragged label is drawn over the highlighted target slot below instead.
+ if (!reorderable.floating()) {
+ cell_box.drawBackground();
+
+ const label = switch (orientation) {
+ .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(index)) catch {
+ dvui.log.err("Failed to allocate label", .{});
+ return;
+ },
+ .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{index}) catch {
+ dvui.log.err("Failed to allocate label", .{});
+ return;
+ },
+ };
+
+ self.drawRulerLabel(.{
+ .font = font,
+ .label = label,
+ .rect = cell_box.data().rectScale().r,
+ .color = dvui.themeGet().color(.control, .text).opacity(0.5),
+ .mode = switch (orientation) {
+ .horizontal => .horizontal,
+ .vertical => .vertical,
+ },
+ .largest_label = if (orientation == .vertical) largest_row_index_label else null,
+ .ref_size_physical = vertical_row_layout_size_phys,
+ });
+
+ const cell_rect = cell_box.data().rectScale().r;
+ const cell_stroke_points = switch (orientation) {
+ .horizontal => .{ cell_rect.topLeft(), cell_rect.bottomLeft() },
+ .vertical => .{ cell_rect.topLeft(), cell_rect.topRight() },
+ };
+ dvui.Path.stroke(.{ .points = &cell_stroke_points }, .{ .color = ruler_stroke_color, .thickness = 2.0 });
+ }
+
+ loop: for (dvui.events()) |*e| {
+ if (!cell_box.matchEvent(e)) continue;
+
+ switch (e.evt) {
+ .mouse => |me| {
+ if (me.action == .press and me.button.pointer()) {
+ e.handle(@src(), cell_box.data());
+ dvui.captureMouse(cell_box.data(), e.num);
+ dvui.dragPreStart(me.p, .{
+ .size = reorderable.data().rectScale().r.size(),
+ .offset = reorderable.data().rectScale().r.topLeft().diff(me.p),
+ });
+ } else if (me.action == .release and me.button.pointer()) {
+ dvui.captureMouse(null, e.num);
+ dvui.dragEnd();
+ switch (orientation) {
+ .horizontal => self.columns_drag_index = null,
+ .vertical => self.rows_drag_index = null,
+ }
+ } else if (me.action == .motion) {
+ if (dvui.captured(cell_box.data().id)) {
+ e.handle(@src(), cell_box.data());
+ if (dvui.dragging(me.p, null)) |_| {
+ reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0);
+ break :loop;
+ }
+ }
+ }
+ },
+ else => {},
+ }
+ }
+ }
+ }
+
+ const final_slot_id = switch (orientation) {
+ .horizontal => file.columns,
+ .vertical => file.rows,
+ };
+ if (reorder.needFinalSlot()) {
+ var reorderable = reorder.reorderable(@src(), .{
+ .mode = reorder_mode,
+ .last_slot = true,
+ .clamp_to_edges = true,
+ }, .{
+ .expand = reorder_expand,
+ .id_extra = final_slot_id,
+ .padding = dvui.Rect.all(0),
+ .margin = dvui.Rect.all(0),
+ .min_size_content = cell_min_size,
+ });
+ defer reorderable.deinit();
+
+ if (reorderable.targetRectScale()) |trs| {
+ target_rs_screen = trs;
+ }
+
+ if (reorderable.insertBefore()) {
+ switch (orientation) {
+ .horizontal => self.columns_insert_before_index = final_slot_id,
+ .vertical => self.rows_insert_before_index = final_slot_id,
+ }
+ }
+ }
+
+ // Drag overlay: draw the dragged column/row label on the highlighted target slot in
+ // highlight-text color (no extra fill, the reorderable's own focus fill is the
+ // background) and a thick err-colored marker line at the dragged-from position in the
+ // ruler that lines up with the equivalent indicator in the file canvas.
+ const drag_idx_for_overlay = switch (orientation) {
+ .horizontal => self.columns_drag_index,
+ .vertical => self.rows_drag_index,
+ };
+ if (drag_idx_for_overlay) |di| {
+ const target_idx_opt = switch (orientation) {
+ .horizontal => self.columns_target_index,
+ .vertical => self.rows_target_index,
+ };
+ const same_slot = target_idx_opt == di;
+
+ if (target_rs_screen) |trs| {
+ const drag_label_opt: ?[]const u8 = switch (orientation) {
+ .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(di)) catch null,
+ .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{di}) catch null,
+ };
+ if (drag_label_opt) |drag_label| {
+ if (same_slot) {
+ // Reorderable still draws theme focus fill for the drop target; paint control
+ // hover on top so "no move" matches ruler button hover styling.
+ trs.r.fill(.all(0), .{ .color = dvui.themeGet().color(.control, .fill_hover), .fade = 1.0 });
+ }
+ self.drawRulerLabel(.{
+ .font = font,
+ .label = drag_label,
+ .rect = trs.r,
+ .color = if (same_slot)
+ dvui.themeGet().color(.control, .text).opacity(0.5)
+ else
+ dvui.themeGet().color(.highlight, .text),
+ .mode = switch (orientation) {
+ .horizontal => .horizontal,
+ .vertical => .vertical,
+ },
+ .largest_label = if (orientation == .vertical) largest_row_index_label else null,
+ .ref_size_physical = vertical_row_layout_size_phys,
+ });
+ }
+ }
+
+ // Use the canvas data->screen mapping for the cross-axis position so the marker
+ // line aligns exactly with the err indicator drawn over the file canvas grid.
+ // The other axis uses the ruler's own screen extents so the line fills the ruler.
+ const target_idx_for_line = switch (orientation) {
+ .horizontal => self.columns_target_index,
+ .vertical => self.rows_target_index,
+ };
+ if (target_idx_for_line) |ti| {
+ if (di != ti) {
+ const removed_data_rect = switch (orientation) {
+ .horizontal => file.columnRect(di),
+ .vertical => file.rowRect(di),
+ };
+ const removed_canvas_screen = file.editor.canvas.screenFromDataRect(removed_data_rect);
+ const ruler_screen = outer_box.data().contentRectScale().r;
+ const err_color = dvui.themeGet().color(.err, .fill);
+ const thickness = 3.0 * dvui.currentWindow().natural_scale;
+ switch (orientation) {
+ .horizontal => {
+ const edge_x = if (di < ti)
+ removed_canvas_screen.x
+ else
+ removed_canvas_screen.x + removed_canvas_screen.w;
+ dvui.Path.stroke(.{ .points = &.{
+ .{ .x = edge_x, .y = ruler_screen.y },
+ .{ .x = edge_x, .y = ruler_screen.y + ruler_screen.h },
+ } }, .{ .thickness = thickness, .color = err_color });
+ },
+ .vertical => {
+ const edge_y = if (di < ti)
+ removed_canvas_screen.y
+ else
+ removed_canvas_screen.y + removed_canvas_screen.h;
+ dvui.Path.stroke(.{ .points = &.{
+ .{ .x = ruler_screen.x, .y = edge_y },
+ .{ .x = ruler_screen.x + ruler_screen.w, .y = edge_y },
+ } }, .{ .thickness = thickness, .color = err_color });
+ },
+ }
+ }
+ }
+ }
+}
+
+pub const TextLabelOptions = struct {
+ pub const Mode = enum {
+ horizontal,
+ vertical,
+ };
+
+ font: dvui.Font,
+ label: []const u8,
+ rect: dvui.Rect.Physical,
+ color: dvui.Color,
+ mode: Mode = .horizontal,
+ /// Widest row index string (e.g. `"99"`); layout cell size uses this, text may be a shorter index.
+ largest_label: ?[]const u8 = null,
+ /// When set, layout size for that widest string (already × `natural_scale`); skips `textSize(largest_label)` per cell.
+ ref_size_physical: ?dvui.Size.Physical = null,
+};
+
+pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void {
+ const font = options.font;
+ const label = options.label;
+ const rect = options.rect;
+ const color = options.color;
+ const natural = dvui.currentWindow().natural_scale;
+
+ const ref_for_layout = options.largest_label orelse label;
+ const label_size = options.ref_size_physical orelse font.textSize(ref_for_layout).scale(natural, dvui.Size.Physical);
+ const actual_label_size = if (std.mem.eql(u8, ref_for_layout, label))
+ label_size
+ else
+ font.textSize(label).scale(natural, dvui.Size.Physical);
+
+ const padding = fizzy.editor.settings.ruler_padding * natural;
+
+ var label_rect = rect;
+
+ if (label_size.w + padding <= label_rect.w and options.mode == .horizontal) {
+ label_rect.h = label_size.h + padding;
+ label_rect.x += (label_rect.w - actual_label_size.w) / 2.0;
+ label_rect.y += (label_rect.h - actual_label_size.h) / 2.0;
+
+ dvui.renderText(.{
+ .text = label,
+ .font = font,
+ .color = color,
+ .rs = .{
+ .r = label_rect,
+ .s = natural,
+ },
+ }) catch {
+ dvui.log.err("Failed to render text", .{});
+ };
+ } else if (label_size.h + padding <= label_rect.h and options.mode == .vertical) {
+ label_rect.w = label_size.h + padding;
+ label_rect.x += (label_rect.w - actual_label_size.w) / 2.0;
+ label_rect.y += (label_rect.h - actual_label_size.h) / 2.0;
+
+ dvui.renderText(.{
+ .text = label,
+ .font = font,
+ .color = color,
+ .rs = .{
+ .r = label_rect,
+ .s = natural,
+ },
+ }) catch {
+ dvui.log.err("Failed to render text", .{});
+ };
+ }
+}
+
+pub fn processColumnReorder(self: *CanvasData, file: *File) void {
+ if (self.columns_removed_index) |columns_removed_index| {
+ if (self.columns_insert_before_index) |columns_insert_before_index| {
+ defer self.columns_removed_index = null;
+ defer self.columns_insert_before_index = null;
+
+ if (columns_removed_index == columns_insert_before_index or columns_removed_index + 1 == columns_insert_before_index) return;
+
+ file.reorderColumns(columns_removed_index, columns_insert_before_index) catch {
+ dvui.log.err("Failed to reorder columns", .{});
+ return;
+ };
+
+ // We'll store the previous indices for clarity.
+ const prev_removed_index = columns_removed_index;
+ const prev_insert_before_index = columns_insert_before_index;
+
+ if (prev_removed_index < prev_insert_before_index) {
+ file.history.append(.{
+ .reorder_col_row = .{
+ .mode = .columns,
+ .removed_index = prev_insert_before_index - 1,
+ .insert_before_index = prev_removed_index,
+ },
+ }) catch {
+ dvui.log.err("Failed to append history", .{});
+ };
+ } else {
+ file.history.append(.{
+ .reorder_col_row = .{
+ .mode = .columns,
+ .removed_index = prev_insert_before_index,
+ .insert_before_index = prev_removed_index + 1,
+ },
+ }) catch {
+ dvui.log.err("Failed to append history", .{});
+ };
+ }
+ }
+ }
+}
+
+pub fn processRowReorder(self: *CanvasData, file: *File) void {
+ if (self.rows_removed_index) |rows_removed_index| {
+ if (self.rows_insert_before_index) |rows_insert_before_index| {
+ defer self.rows_removed_index = null;
+ defer self.rows_insert_before_index = null;
+ if (rows_removed_index == rows_insert_before_index or rows_removed_index + 1 == rows_insert_before_index) return;
+
+ file.reorderRows(rows_removed_index, rows_insert_before_index) catch {
+ dvui.log.err("Failed to reorder rows", .{});
+ return;
+ };
+
+ // We'll store the previous indices for clarity.
+ const prev_removed_index = rows_removed_index;
+ const prev_insert_before_index = rows_insert_before_index;
+
+ if (prev_removed_index < prev_insert_before_index) {
+ file.history.append(.{
+ .reorder_col_row = .{
+ .mode = .rows,
+ .removed_index = prev_insert_before_index - 1,
+ .insert_before_index = prev_removed_index,
+ },
+ }) catch {
+ dvui.log.err("Failed to append history", .{});
+ };
+ } else {
+ file.history.append(.{
+ .reorder_col_row = .{
+ .mode = .rows,
+ .removed_index = prev_insert_before_index,
+ .insert_before_index = prev_removed_index + 1,
+ },
+ }) catch {
+ dvui.log.err("Failed to append history", .{});
+ };
+ }
+ }
+ }
+}
+
+pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetData) void {
+ if (file.editor.transform) |*transform| {
+ var rect = container.rect;
+ rect.w = 0;
+ rect.h = 0;
+
+ var fw: dvui.FloatingWidget = undefined;
+ fw.init(@src(), .{}, .{
+ .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 },
+ .expand = .none,
+ .background = true,
+ .color_fill = dvui.themeGet().color(.control, .fill),
+ .corner_radius = dvui.Rect.all(8),
+ .box_shadow = .{
+ .color = .black,
+ .alpha = 0.2,
+ .fade = 8,
+ .corner_radius = dvui.Rect.all(8),
+ },
+ });
+ defer fw.deinit();
+
+ var anim = dvui.animate(@src(), .{ .kind = .vertical, .duration = 450_000, .easing = dvui.easing.outBack }, .{});
+ defer anim.deinit();
+
+ var anim_box = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .both,
+ .background = false,
+ });
+ defer anim_box.deinit();
+
+ dvui.labelNoFmt(@src(), "TRANSFORM", .{ .align_x = 0.5 }, .{
+ .padding = dvui.Rect.all(4),
+ .expand = .horizontal,
+ .font = dvui.Font.theme(.heading).withWeight(.bold),
+ });
+ _ = dvui.separator(@src(), .{ .expand = .horizontal });
+
+ _ = dvui.spacer(@src(), .{ .expand = .horizontal });
+
+ var degrees: f32 = std.math.radiansToDegrees(transform.rotation);
+
+ var slider_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .background = false,
+ });
+
+ if (dvui.sliderEntry(@src(), "{d:0.0}°", .{
+ .value = °rees,
+ .min = 0,
+ .max = 360,
+ .interval = 1,
+ }, .{ .expand = .horizontal, .color_fill = dvui.themeGet().color(.window, .fill) })) {
+ transform.rotation = std.math.degreesToRadians(degrees);
+ }
+ slider_box.deinit();
+
+ if (transform.ortho) {
+ var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{
+ .expand = .horizontal,
+ .background = false,
+ });
+ defer box.deinit();
+ dvui.label(@src(), "Width: {d:0.0}", .{transform.point(.bottom_left).diff(transform.point(.bottom_right).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) });
+ dvui.label(@src(), "Height: {d:0.0}", .{transform.point(.top_left).diff(transform.point(.bottom_left).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) });
+ }
+
+ {
+ var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{
+ .expand = .horizontal,
+ .background = false,
+ });
+ defer box.deinit();
+ if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) {
+ fizzy.editor.cancel() catch {
+ dvui.log.err("Failed to cancel transform", .{});
+ };
+ }
+ if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) {
+ fizzy.editor.accept() catch {
+ dvui.log.err("Failed to accept transform", .{});
+ };
+ }
+ }
+ }
+}
+
+/// Floating rounded-pill quick-access bar anchored to the top-right of the workspace
+/// canvas. Mirrors the Edit menu (Undo / Redo / Copy / Paste / Transform / Grid Layout)
+/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a
+/// single hamburger circle; tapping toggles the row of action buttons in/out with a
+/// width animation.
+pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
+ const file = fizzy.editor.activeFile() orelse return;
+
+ const button_size: f32 = 36;
+ const button_gap: f32 = 6;
+ const pill_padding: f32 = 6;
+ const margin: f32 = 10;
+ // Canvas scroll area uses a non-overlay vertical bar on the right edge; keep the
+ // pill clear of it (see `CanvasWidget.install` + dvui `ScrollBarWidget` width).
+ const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w;
+ // Icons render at ~60% of their previous size — previous padding was 0.22 (icon
+ // ≈ 56% of button); new padding is 0.33 so the icon ends up ≈ 34% of the button,
+ // which is roughly 60% of the prior icon footprint.
+ const icon_padding: f32 = button_size * 0.33;
+
+ const Action = enum { save, exportd, undo, redo, copy, paste, transform, grid_layout };
+ const Entry = struct {
+ action: Action,
+ tvg: []const u8,
+ tooltip: []const u8,
+ };
+
+ const entries = [_]Entry{
+ .{ .action = .save, .tvg = icons.tvg.lucide.save, .tooltip = "Save" },
+ .{ .action = .exportd, .tvg = icons.tvg.lucide.@"file-output", .tooltip = "Export" },
+ .{ .action = .undo, .tvg = icons.tvg.lucide.undo, .tooltip = "Undo" },
+ .{ .action = .redo, .tvg = icons.tvg.lucide.redo, .tooltip = "Redo" },
+ .{ .action = .copy, .tvg = icons.tvg.lucide.copy, .tooltip = "Copy" },
+ .{ .action = .paste, .tvg = icons.tvg.lucide.@"clipboard-paste", .tooltip = "Paste" },
+ .{ .action = .transform, .tvg = icons.tvg.lucide.scaling, .tooltip = "Transform" },
+ .{ .action = .grid_layout, .tvg = icons.tvg.lucide.@"layout-grid", .tooltip = "Grid Layout" },
+ };
+
+ // Vertical pill: width is fixed (one button + padding), height animates between a
+ // single-button "collapsed" state and the full-stack "expanded" state. Most screens
+ // have more vertical real estate than horizontal, so growing the pill downward keeps
+ // it from eating into the canvas's working width.
+ const pill_w: f32 = button_size + 2 * pill_padding;
+ const collapsed_h: f32 = button_size + 2 * pill_padding;
+ const expanded_h: f32 = @as(f32, @floatFromInt(entries.len + 1)) * button_size +
+ @as(f32, @floatFromInt(entries.len)) * button_gap + 2 * pill_padding;
+ const pill_radius: f32 = pill_w / 2;
+ const btn_radius: f32 = button_size / 2;
+
+ // Drive the expand/collapse with a dvui animation. Look up the current value, and on
+ // a toggle click kick off a new animation between the current value and the target.
+ const anim_id = dvui.Id.update(container.id, "edit_pill_expand");
+ var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0;
+ if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0);
+
+ const pill_h: f32 = collapsed_h + (expanded_h - collapsed_h) * anim_value;
+
+ // Compute the scroll-area rect — the canvas region inside the rulers. We pull this
+ // off the live `canvas_vbox` (so the values are this frame's, not a stale latch) and
+ // subtract the ruler thickness from the top/left. Anchoring against this rect means
+ // the pill follows the workspace exactly: as a split is dragged shut the canvas area
+ // shrinks, and once it's narrower than the pill we bail and draw nothing this frame —
+ // so closing splits cleanly hides the menu.
+ const wb = container.rectScale().r.toNatural();
+ const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
+ const canvas_nat = dvui.Rect{
+ .x = wb.x + ruler_left,
+ .y = wb.y + ruler_top,
+ .w = wb.w - ruler_left,
+ .h = wb.h - ruler_top,
+ };
+
+ if (canvas_nat.w < pill_w + margin + right_margin or canvas_nat.h < collapsed_h + 2 * margin) return;
+
+ const pill_x: f32 = canvas_nat.x + canvas_nat.w - right_margin - pill_w;
+ const pill_y: f32 = canvas_nat.y + margin;
+
+ // Clamp the bottom edge so the expanded pill never spills past the canvas area —
+ // FloatingWidget bypasses parent clipping, so we cap the height explicitly.
+ const max_pill_h: f32 = canvas_nat.h - 2 * margin;
+ const effective_pill_h: f32 = @min(pill_h, max_pill_h);
+
+ var fw: dvui.FloatingWidget = undefined;
+ fw.init(@src(), .{}, .{
+ .rect = .{
+ .x = pill_x,
+ .y = pill_y,
+ .w = pill_w,
+ .h = effective_pill_h,
+ },
+ .expand = .none,
+ .background = self.edit_pill_expanded,
+ .color_fill = dvui.themeGet().color(.window, .fill),
+ .corner_radius = dvui.Rect.all(pill_radius),
+ .box_shadow = if (self.edit_pill_expanded) .{
+ .color = .black,
+ .alpha = 0.25,
+ .fade = 10,
+ .offset = .{ .x = 0, .y = 3 },
+ .corner_radius = dvui.Rect.all(pill_radius),
+ } else null,
+ });
+ defer fw.deinit();
+
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .both,
+ .background = false,
+ .padding = dvui.Rect.all(pill_padding),
+ });
+ defer vbox.deinit();
+
+ // Hamburger toggle is always present at the top of the pill; the stack of action
+ // buttons grows downward beneath it as the pill expands.
+ {
+ var btn: dvui.ButtonWidget = undefined;
+ btn.init(@src(), .{}, .{
+ .id_extra = entries.len, // distinct from action button ids below
+ .min_size_content = .{ .w = button_size, .h = button_size },
+ .expand = .none,
+ .gravity_x = 0.5,
+ .gravity_y = 0.0,
+ .background = true,
+ .corner_radius = dvui.Rect.all(btn_radius),
+ .color_fill = dvui.themeGet().color(.content, .fill),
+ .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
+ .color_border = .transparent,
+ .padding = .all(0),
+ .margin = .{},
+ .box_shadow = .{
+ .color = .black,
+ .alpha = 0.2,
+ .fade = 4,
+ .offset = .{ .x = 0, .y = 2 },
+ .corner_radius = dvui.Rect.all(btn_radius),
+ },
+ });
+ defer btn.deinit();
+ btn.processEvents();
+ btn.drawBackground();
+
+ const icon_color = dvui.themeGet().color(.content, .text);
+ dvui.icon(
+ @src(),
+ "edit_pill_toggle",
+ icons.tvg.lucide.menu,
+ .{ .stroke_color = icon_color, .fill_color = icon_color },
+ .{
+ .expand = .ratio,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .min_size_content = .{ .w = 1.0, .h = 1.0 },
+ .padding = dvui.Rect.all(icon_padding),
+ },
+ );
+
+ if (btn.clicked()) {
+ self.edit_pill_expanded = !self.edit_pill_expanded;
+ const target: f32 = if (self.edit_pill_expanded) 1.0 else 0.0;
+ dvui.animation(anim_id, "_t", .{
+ .start_val = anim_value,
+ .end_val = target,
+ .end_time = 250_000,
+ .easing = dvui.easing.outBack,
+ });
+ }
+ }
+
+ // Action buttons live inside a scroll area so the pill stays the right width and
+ // never visually "squishes" when there isn't enough vertical room — instead the
+ // overflow buttons become reachable via vertical scroll inside the pill. Bars are
+ // hidden to preserve the rounded-pill look; touch / wheel still drives the scroll.
+ var actions_scroll = dvui.scrollArea(@src(), .{
+ .vertical_bar = .hide,
+ .horizontal_bar = .hide,
+ }, .{
+ .expand = .both,
+ .background = false,
+ .padding = .{},
+ .margin = .{},
+ .border = dvui.Rect.all(0),
+ .color_fill = .transparent,
+ });
+ defer actions_scroll.deinit();
+
+ // Action buttons stacked below the hamburger. We draw them all and let the
+ // scrollArea handle any overflow when the pill is clamped to the canvas height.
+ for (entries, 0..) |entry, i| {
+ const enabled: bool = switch (entry.action) {
+ .save => file.dirty(),
+ .undo => file.history.undo_stack.items.len > 0,
+ .redo => file.history.redo_stack.items.len > 0,
+ else => true,
+ };
+
+ var btn: dvui.ButtonWidget = undefined;
+ btn.init(@src(), .{}, .{
+ .id_extra = i,
+ .min_size_content = .{ .w = button_size, .h = button_size },
+ .expand = .none,
+ .gravity_x = 0.5,
+ .background = true,
+ .corner_radius = dvui.Rect.all(btn_radius),
+ .color_fill = dvui.themeGet().color(.content, .fill),
+ .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
+ .color_border = .transparent,
+ .padding = .all(0),
+ .margin = .{ .y = button_gap },
+ .box_shadow = .{
+ .color = .black,
+ .alpha = 0.2,
+ .fade = 4,
+ .offset = .{ .x = 0, .y = 2 },
+ .corner_radius = dvui.Rect.all(btn_radius),
+ },
+ });
+ defer btn.deinit();
+ btn.processEvents();
+ btn.drawBackground();
+
+ const icon_color = if (enabled) dvui.themeGet().color(.content, .text) else dvui.themeGet().color(.content, .text).opacity(0.35);
+
+ dvui.icon(
+ @src(),
+ entry.tooltip,
+ entry.tvg,
+ .{ .stroke_color = icon_color, .fill_color = icon_color },
+ .{
+ .expand = .ratio,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .min_size_content = .{ .w = 1.0, .h = 1.0 },
+ .padding = dvui.Rect.all(icon_padding),
+ },
+ );
+
+ // Suppress activation while collapsed (or mid-animation) so a stray tap on a
+ // partially-visible button doesn't fire an Edit action behind the hamburger.
+ const fully_expanded = anim_value >= 0.999;
+ if (btn.clicked() and enabled and fully_expanded) {
+ switch (entry.action) {
+ .save => fizzy.editor.save() catch {
+ dvui.log.err("Failed to save", .{});
+ },
+ .exportd => {
+ // Open the Export dialog (same configuration the `export` keybind uses).
+ var mutex = fizzy.dvui.dialog(@src(), .{
+ .displayFn = fizzy.Editor.Dialogs.Export.dialog,
+ .callafterFn = fizzy.Editor.Dialogs.Export.callAfter,
+ .title = "Export...",
+ .ok_label = "Export",
+ .cancel_label = "Cancel",
+ .resizeable = false,
+ .modal = false,
+ .header_kind = .info,
+ .default = .ok,
+ });
+ mutex.mutex.unlock(dvui.io);
+ },
+ .undo => file.history.undoRedo(file, .undo) catch {
+ dvui.log.err("Failed to undo", .{});
+ },
+ .redo => file.history.undoRedo(file, .redo) catch {
+ dvui.log.err("Failed to redo", .{});
+ },
+ .copy => fizzy.editor.copy() catch {
+ dvui.log.err("Failed to copy", .{});
+ },
+ .paste => fizzy.editor.paste() catch {
+ dvui.log.err("Failed to paste", .{});
+ },
+ .transform => fizzy.editor.transform() catch {
+ dvui.log.err("Failed to start transform", .{});
+ },
+ .grid_layout => fizzy.editor.requestGridLayoutDialog(),
+ }
+ }
+ }
+}
+
+/// Floating round button anchored just to the left of the Edit pill at the top-right of
+/// the canvas. Tapping it shows a tooltip explaining the gesture; the primary action is
+/// to drag from the button toward whatever pixel you want to sample. The button itself
+/// stays put — instead, while the drag is in progress, we route the touch position
+/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders
+/// the existing color-dropper magnifier at the touch location. On release we read the
+/// color underneath the sample point and apply it to the primary color slot.
+pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void {
+ const file = fizzy.editor.activeFile() orelse return;
+
+ const pill_button_size: f32 = 36;
+ const pill_padding: f32 = 6;
+ const pill_outer_w: f32 = pill_button_size + 2 * pill_padding;
+ const button_size: f32 = 36;
+ const btn_radius: f32 = button_size / 2;
+ const icon_padding: f32 = button_size * 0.33;
+ const margin: f32 = 10;
+ const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w;
+ const gap: f32 = 6;
+
+ // Anchor against the same canvas-scroll-area rect the pill uses.
+ const wb = container.rectScale().r.toNatural();
+ const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
+ const canvas_nat = dvui.Rect{
+ .x = wb.x + ruler_left,
+ .y = wb.y + ruler_top,
+ .w = wb.w - ruler_left,
+ .h = wb.h - ruler_top,
+ };
+
+ // Only draw when the canvas area can fit pill + gap + sample button + margins.
+ if (canvas_nat.w < pill_outer_w + gap + button_size + margin + right_margin) return;
+ if (canvas_nat.h < button_size + 2 * margin) return;
+
+ const btn_x = canvas_nat.x + canvas_nat.w - right_margin - pill_outer_w - gap - button_size;
+ // Match the hamburger row inside the pill (pill top + inner vbox padding).
+ const btn_y = canvas_nat.y + margin + pill_padding;
+
+ var fw: dvui.FloatingWidget = undefined;
+ fw.init(@src(), .{}, .{
+ .rect = .{ .x = btn_x, .y = btn_y, .w = button_size, .h = button_size },
+ .expand = .none,
+ .background = false,
+ });
+ defer fw.deinit();
+
+ var btn: dvui.ButtonWidget = undefined;
+ // `touch_drag = true` keeps `ButtonWidget`'s own capture alive while the touch is
+ // dragging away from the button — without it, dvui's default `clickedEx` releases
+ // capture as soon as the drag crosses the threshold (treating the gesture as a
+ // canceled scroll), which would also cancel our custom drag-to-sample handler.
+ btn.init(@src(), .{ .touch_drag = true }, .{
+ .expand = .both,
+ .background = true,
+ .min_size_content = .{ .w = button_size, .h = button_size },
+ .corner_radius = dvui.Rect.all(btn_radius),
+ .color_fill = dvui.themeGet().color(.content, .fill),
+ .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
+ .color_border = .transparent,
+ .padding = .all(0),
+ .margin = .{},
+ .box_shadow = .{
+ .color = .black,
+ .alpha = 0.2,
+ .fade = 4,
+ .offset = .{ .x = 0, .y = 2 },
+ .corner_radius = dvui.Rect.all(btn_radius),
+ },
+ });
+ defer btn.deinit();
+
+ // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag
+ // threshold). Stored via dataSet because the button widget is recreated each frame.
+ const drag_state_id = dvui.Id.update(container.id, "sample_button_drag");
+ var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false;
+ var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false;
+
+ // The button's screen rect is the "press home base"; events that happen here belong
+ // to us regardless of whether motion has carried the pointer away.
+ const btn_rs = btn.data().rectScale();
+
+ // Custom event handling runs *before* `btn.processEvents()` so we can claim the
+ // press / motion / release events first. `ButtonWidget.clickedEx` ALWAYS releases
+ // mouse capture and ends the drag on a release event (regardless of touch_drag) —
+ // if we ran after it, our release branch would see `dvui.captured(...)` already
+ // false and the magnifier would stay stuck on screen. Calling `e.handle(...)` here
+ // makes `clickedEx`'s match-event check skip these events entirely, so the button
+ // leaves our gesture alone.
+ for (dvui.events()) |*e| {
+ if (e.evt != .mouse) continue;
+ const me = e.evt.mouse;
+
+ switch (me.action) {
+ .press => {
+ if (!me.button.pointer()) continue;
+ if (!btn_rs.r.contains(me.p)) continue;
+ e.handle(@src(), btn.data());
+ dvui.captureMouse(btn.data(), e.num);
+ dvui.dragPreStart(me.p, .{ .name = "sample_button_drag" });
+ is_drag_sampling = false;
+ did_sample = false;
+ },
+ .motion => {
+ if (!dvui.captured(btn.data().id)) continue;
+ if (dvui.dragging(me.p, "sample_button_drag")) |_| {
+ is_drag_sampling = true;
+ if (file.editor.canvas.samplePointerInViewport(me.p)) {
+ const data_pt = file.editor.canvas.dataFromScreenPoint(me.p);
+ dvui.dataSet(null, file.editor.canvas.id, "sample_data_point", data_pt);
+ did_sample = true;
+ } else {
+ dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point");
+ }
+ dvui.refresh(null, @src(), file.editor.canvas.id);
+ e.handle(@src(), btn.data());
+ }
+ },
+ .release => {
+ if (!me.button.pointer()) continue;
+ if (!dvui.captured(btn.data().id)) continue;
+ e.handle(@src(), btn.data());
+ dvui.captureMouse(null, e.num);
+ dvui.dragEnd();
+
+ if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) {
+ const data_pt = file.editor.canvas.dataFromScreenPoint(me.p);
+ fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true);
+ }
+
+ // Clear sample state so the magnifier disappears on the next frame.
+ dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point");
+ is_drag_sampling = false;
+ did_sample = false;
+ dvui.refresh(null, @src(), file.editor.canvas.id);
+ },
+ else => {},
+ }
+ }
+
+ // Persist the drag state for the next frame's widget recreate.
+ dvui.dataSet(null, drag_state_id, "active", is_drag_sampling);
+ dvui.dataSet(null, drag_state_id, "did_sample", did_sample);
+
+ // Now let the button run its own pass to handle hover styling against any remaining
+ // (non-claimed) events — i.e. plain mouse hover when we're not in a drag.
+ btn.processEvents();
+ btn.drawBackground();
+
+ const icon_color = dvui.themeGet().color(.content, .text);
+ dvui.icon(
+ @src(),
+ "sample_dropper",
+ icons.tvg.lucide.pipette,
+ .{ .stroke_color = icon_color, .fill_color = icon_color },
+ .{
+ .expand = .ratio,
+ .gravity_x = 0.5,
+ .gravity_y = 0.5,
+ .min_size_content = .{ .w = 1.0, .h = 1.0 },
+ .padding = dvui.Rect.all(icon_padding),
+ },
+ );
+
+ // While the drag is in progress, hide the OS cursor entirely so only the canvas
+ // magnifier (drawn at the touch point via `FileWidget.drawSample`) communicates
+ // where the sample is happening. Set after `btn.processEvents()` so it overrides
+ // the `.hand` hover cursor `clickedEx` would otherwise leave in place.
+ if (is_drag_sampling) {
+ dvui.cursorSet(.hidden);
+ }
+
+ // Tooltip prompting the gesture. We hide it during an active sample drag so it
+ // doesn't compete with the magnifier on screen.
+ if (!is_drag_sampling) {
+ var tooltip: dvui.FloatingTooltipWidget = undefined;
+ tooltip.init(@src(), .{
+ .active_rect = btn.data().rectScale().r,
+ .delay = 350_000,
+ }, .{
+ .color_fill = dvui.themeGet().color(.window, .fill),
+ .border = dvui.Rect.all(0),
+ .box_shadow = .{
+ .color = .black,
+ .shrink = 0,
+ .corner_radius = dvui.Rect.all(8),
+ .offset = .{ .x = 0, .y = 2 },
+ .fade = 4,
+ .alpha = 0.2,
+ },
+ });
+ defer tooltip.deinit();
+
+ if (tooltip.shown()) {
+ var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both });
+ defer anim.deinit();
+
+ var tl = dvui.textLayout(@src(), .{}, .{
+ .background = false,
+ .padding = dvui.Rect.all(6),
+ });
+ tl.format("Drag to sample color", .{}, .{ .font = dvui.Font.theme(.body) });
+ tl.deinit();
+ }
+ }
+}
diff --git a/src/pixelart/plugin.zig b/src/pixelart/plugin.zig
index 098520be..0db81045 100644
--- a/src/pixelart/plugin.zig
+++ b/src/pixelart/plugin.zig
@@ -7,6 +7,7 @@ const builtin = @import("builtin");
const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
+const CanvasData = @import("CanvasData.zig");
const DocHandle = sdk.DocHandle;
const Internal = fizzy.Internal;
@@ -90,18 +91,28 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void {
/// current dvui parent). The workbench owns only the container + tab/split frame and sets
/// `canvas.id` / `workspace_handle` / `center` before routing here; pixel art owns the
/// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing
-/// widget, and the sample magnifier. The per-workspace ruler/overlay state + draw helpers
-/// still live on `Workspace` for now (recovered via `ofFile`); they relocate here in 3C/2b.
+/// widget, and the sample magnifier. The per-pane ruler/overlay/reorder state + draw helpers
+/// live on the pixel-art-owned `CanvasData` (stashed in the pane's `plugin_view_state`).
fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
const ws = fizzy.Editor.Workspace.ofFile(file) orelse return;
+ const chrome = CanvasData.ensure(ws);
const container = dvui.parentGet().data();
+ // Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit
+ // the pending reorder and clear the per-frame drag indices after the whole document (incl.
+ // the file widget) has drawn. Registered first so they run last, matching the order the
+ // workbench `Workspace.draw` used before this view was relocated here.
+ defer chrome.columns_drag_index = null;
+ defer chrome.rows_drag_index = null;
+ defer chrome.processColumnReorder(file);
+ defer chrome.processRowReorder(file);
+
fizzy.perf.canvasPaneDrawn();
if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) {
defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{});
- ws.drawRuler(.horizontal);
+ chrome.drawRuler(file, .horizontal);
}
var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
@@ -109,13 +120,13 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) {
defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{});
- ws.drawRuler(.vertical);
+ chrome.drawRuler(file, .vertical);
}
- ws.drawTransformDialog(container);
- ws.drawEditPill(container);
+ chrome.drawTransformDialog(file, container);
+ chrome.drawEditPill(container);
// Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom).
- ws.drawSampleButton(container);
+ chrome.drawSampleButton(container);
if (ws.grouping != file.editor.grouping) return;
diff --git a/src/workbench/Workspace.zig b/src/workbench/Workspace.zig
index dcea40e9..75a0cb74 100644
--- a/src/workbench/Workspace.zig
+++ b/src/workbench/Workspace.zig
@@ -23,29 +23,14 @@ tabs_drag_index: ?usize = null,
tabs_removed_index: ?usize = null,
tabs_insert_before_index: ?usize = null,
-columns_drag_name: []const u8 = undefined,
-columns_drag_index: ?usize = null,
-columns_target_id: ?dvui.Id = null,
-columns_target_index: ?usize = null,
-columns_removed_index: ?usize = null,
-columns_insert_before_index: ?usize = null,
-
-rows_drag_name: []const u8 = undefined,
-rows_drag_index: ?usize = null,
-rows_target_id: ?dvui.Id = null,
-rows_target_index: ?usize = null,
-rows_removed_index: ?usize = null,
-rows_insert_before_index: ?usize = null,
-
-horizontal_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given },
-vertical_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given },
-
-horizontal_ruler_height: f32 = 0.0,
-vertical_ruler_width: f32 = 0.0,
-
-/// Floating Edit-pill quick-access bar collapse state. Starts collapsed (single
-/// hamburger button); the user toggles to expand the full action row.
-edit_pill_expanded: bool = false,
+/// Opaque per-pane state owned by the plugin that renders documents into this pane (today
+/// only pixel art, via `CanvasData`: rulers, edit pill, grid-reorder drag, etc.). The
+/// workbench never dereferences it — it just frees it through `plugin_view_destroy` when the
+/// pane is torn down (`deinit`). Lazily created by the owning plugin on first document draw.
+plugin_view_state: ?*anyopaque = null,
+/// Teardown for `plugin_view_state`, set by the owner alongside the state. Null when no
+/// plugin view has been attached.
+plugin_view_destroy: ?*const fn (state: *anyopaque) void = null,
/// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during
/// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view).
@@ -55,11 +40,17 @@ edit_pill_expanded: bool = false,
canvas_rect_physical: ?dvui.Rect.Physical = null,
pub fn init(grouping: u64) Workspace {
- return .{
- .grouping = grouping,
- .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag",
- .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag",
- };
+ return .{ .grouping = grouping };
+}
+
+/// Release any plugin-owned per-pane view state. Called when a pane is removed
+/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown.
+pub fn deinit(self: *Workspace) void {
+ if (self.plugin_view_state) |state| {
+ if (self.plugin_view_destroy) |destroy| destroy(state);
+ self.plugin_view_state = null;
+ self.plugin_view_destroy = null;
+ }
}
/// Recover the typed workspace currently drawing `file` from its opaque slot
@@ -92,13 +83,6 @@ const logo_colors: [12]fizzy.math.Color = [_]fizzy.math.Color{
var dragging: bool = false;
pub fn draw(self: *Workspace) !dvui.App.Result {
- defer self.columns_drag_index = null;
- defer self.rows_drag_index = null;
-
- // Process the column reorder, when both fields are set and we can take action
- defer self.processColumnReorder();
- defer self.processRowReorder();
-
// Canvas Area
var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .both,
@@ -840,1215 +824,6 @@ pub fn drawCanvas(self: *Workspace) !void {
}
}
-pub const RulerOrientation = enum {
- horizontal,
- vertical,
-};
-
-pub fn drawRuler(self: *Workspace, orientation: RulerOrientation) void {
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
- const font = dvui.Font.theme(.body).larger(-1);
-
- const largest_label = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{file.rows - 1}) catch {
- dvui.log.err("Failed to allocate largest label", .{});
- return;
- };
- const largest_label_size = font.textSize(largest_label);
- const natural_scale = dvui.currentWindow().natural_scale;
- const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical);
- const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding;
-
- const ruler_thickness: f32 = switch (orientation) {
- .horizontal => blk: {
- self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding;
- break :blk self.horizontal_ruler_height;
- },
- .vertical => blk: {
- self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding);
- break :blk self.vertical_ruler_width;
- },
- };
-
- switch (orientation) {
- .horizontal => {
- var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- });
- defer canvas_hbox.deinit();
-
- var corner_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .min_size_content = .{ .h = self.vertical_ruler_width, .w = self.vertical_ruler_width },
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- });
- corner_box.deinit();
-
- var top_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- .min_size_content = .{ .h = ruler_thickness, .w = ruler_thickness },
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- });
- defer top_box.deinit();
-
- self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, null);
- },
- .vertical => {
- var ruler_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .vertical,
- .min_size_content = .{ .w = ruler_thickness, .h = 1.0 },
- .background = true,
- .color_fill = dvui.themeGet().color(.window, .fill),
- });
- defer ruler_box.deinit();
-
- self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, largest_label_phys);
- },
- }
-}
-
-/// `largest_row_index_*` come from `drawRuler` (widest row index string and its measured size in physical pixels).
-fn drawRulerContent(
- self: *Workspace,
- file: *fizzy.Internal.File,
- font: dvui.Font,
- orientation: RulerOrientation,
- ruler_size: f32,
- largest_row_index_label: []const u8,
- largest_row_index_size_phys: ?dvui.Size.Physical,
-) void {
- const scale = file.editor.canvas.scale;
- const canvas = file.editor.canvas;
-
- switch (orientation) {
- .horizontal => {
- self.horizontal_scroll_info.virtual_size.w = canvas.scroll_info.virtual_size.w;
- self.horizontal_scroll_info.virtual_size.h = ruler_size;
- self.horizontal_scroll_info.viewport.w = canvas.scroll_info.viewport.w;
- self.horizontal_scroll_info.viewport.x = canvas.scroll_info.viewport.x;
- },
- .vertical => {
- self.vertical_scroll_info.virtual_size.h = canvas.scroll_info.virtual_size.h;
- self.vertical_scroll_info.virtual_size.w = ruler_size;
- self.vertical_scroll_info.viewport.h = canvas.scroll_info.viewport.h;
- self.vertical_scroll_info.viewport.y = canvas.scroll_info.viewport.y;
- },
- }
-
- const scroll_info = switch (orientation) {
- .horizontal => &self.horizontal_scroll_info,
- .vertical => &self.vertical_scroll_info,
- };
-
- var scroll_area = dvui.scrollArea(@src(), .{
- .scroll_info = scroll_info,
- .container = true,
- .process_events_after = true,
- .horizontal_bar = .hide,
- .vertical_bar = .hide,
- }, .{ .expand = .both });
- defer scroll_area.deinit();
-
- const scale_rect = switch (orientation) {
- .horizontal => dvui.Rect{ .x = -canvas.origin.x, .y = 0, .w = 0, .h = 0 },
- .vertical => dvui.Rect{ .x = 0, .y = -canvas.origin.y, .w = 0, .h = 0 },
- };
- var scaler = dvui.scale(@src(), .{ .scale = &file.editor.canvas.scale }, .{ .rect = scale_rect });
- defer scaler.deinit();
-
- const outer_rect: dvui.Rect = switch (orientation) {
- .horizontal => .{
- .x = 0,
- .y = 0,
- .w = @as(f32, @floatFromInt(file.width())),
- .h = ruler_size / scale,
- },
- .vertical => .{
- .x = 0,
- .y = 0,
- .w = ruler_size / scale,
- .h = @as(f32, @floatFromInt(file.height())),
- },
- };
- var outer_box = dvui.box(@src(), .{ .dir = switch (orientation) {
- .horizontal => .horizontal,
- .vertical => .horizontal,
- } }, .{
- .expand = .none,
- .rect = outer_rect,
- });
- defer outer_box.deinit();
-
- const drag_name = switch (orientation) {
- .horizontal => self.columns_drag_name,
- .vertical => self.rows_drag_name,
- };
-
- var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{
- .expand = .both,
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- .background = false,
- .corner_radius = dvui.Rect.all(0),
- });
- defer reorder.deinit();
-
- const reorder_box_dir: dvui.enums.Direction = switch (orientation) {
- .horizontal => .horizontal,
- .vertical => .vertical,
- };
- var reorder_box = dvui.box(@src(), .{ .dir = reorder_box_dir }, .{
- .expand = .both,
- .background = false,
- .corner_radius = dvui.Rect.all(0),
- .margin = dvui.Rect.all(0),
- .padding = dvui.Rect.all(0),
- });
- defer reorder_box.deinit();
-
- const ruler_stroke_color = dvui.themeGet().color(.control, .fill_hover).lighten(switch (orientation) {
- .horizontal => 2.0,
- .vertical => 0.0,
- });
-
- const edge_stroke_points = switch (orientation) {
- .horizontal => .{
- reorder_box.data().rectScale().r.topRight(),
- reorder_box.data().rectScale().r.bottomRight(),
- },
- .vertical => .{
- reorder_box.data().rectScale().r.bottomRight(),
- reorder_box.data().rectScale().r.bottomLeft(),
- },
- };
- defer dvui.Path.stroke(.{ .points = &edge_stroke_points }, .{
- .color = ruler_stroke_color,
- .thickness = 1.0,
- });
-
- const count = switch (orientation) {
- .horizontal => file.columns,
- .vertical => file.rows,
- };
- const cell_min_size: dvui.Size = switch (orientation) {
- .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 },
- .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) },
- };
- const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) {
- .horizontal => .any_y,
- .vertical => .any_x,
- };
- const reorder_expand: dvui.Options.Expand = switch (orientation) {
- .horizontal => .vertical,
- .vertical => .horizontal,
- };
-
- // Shared layout width for every row tick (widest index string); actual glyph size may differ per cell.
- const vertical_row_layout_size_phys: ?dvui.Size.Physical = switch (orientation) {
- .vertical => largest_row_index_size_phys,
- .horizontal => null,
- };
-
- // Captured during iteration: the highlighted target slot (drop location) screen rect.
- var target_rs_screen: ?dvui.RectScale = null;
-
- var index: usize = 0;
- while (index < count) : (index += 1) {
- var reorderable = reorder.reorderable(@src(), .{
- .mode = reorder_mode,
- .clamp_to_edges = true,
- }, .{
- .expand = reorder_expand,
- .id_extra = index,
- .padding = dvui.Rect.all(0),
- .margin = dvui.Rect.all(0),
- .min_size_content = cell_min_size,
- });
- defer reorderable.deinit();
-
- if (reorderable.targetRectScale()) |trs| {
- target_rs_screen = trs;
- }
-
- var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill);
-
- if (fizzy.dvui.hovered(reorderable.data())) {
- button_color = dvui.themeGet().color(.control, .fill_hover);
- dvui.cursorSet(.hand);
- }
-
- var cell_box: dvui.BoxWidget = undefined;
- cell_box.init(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = true,
- .color_fill = button_color,
- .id_extra = index,
- });
-
- switch (orientation) {
- .horizontal => {
- if (reorderable.floating()) {
- self.columns_drag_index = index;
- reorder.reorderable_size.h = 0.0;
- dvui.cursorSet(.hand);
- }
- if (reorderable.removed()) self.columns_removed_index = index;
- if (reorderable.insertBefore()) self.columns_insert_before_index = index;
- if (reorderable.targetID()) |target_id| self.columns_target_id = target_id;
- if (self.columns_drag_index) |_| {
- var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- mouse_pt.y = 0.0;
- mouse_pt.x = std.math.clamp(mouse_pt.x, 0.0, @as(f32, @floatFromInt(file.width() - 1)));
- self.columns_target_index = file.columnIndex(mouse_pt);
- }
- },
- .vertical => {
- if (reorderable.floating()) {
- self.rows_drag_index = index;
- reorder.reorderable_size.w = 0.0;
- dvui.cursorSet(.hand);
- }
- if (reorderable.removed()) self.rows_removed_index = index;
- if (reorderable.insertBefore()) self.rows_insert_before_index = index;
- if (reorderable.targetID()) |target_id| self.rows_target_id = target_id;
- if (self.rows_drag_index) |_| {
- var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt);
- mouse_pt.x = 0.0;
- mouse_pt.y = std.math.clamp(mouse_pt.y, 0.0, @as(f32, @floatFromInt(file.height() - 1)));
- self.rows_target_index = file.rowIndex(mouse_pt);
- }
- },
- }
-
- {
- defer cell_box.deinit();
-
- // The dragged item's cell_box is parented to the reorderable's floating widget
- // (rendered at the mouse position). We collapse that floating widget to h/w = 0
- // above, but `dvui.renderText` is not clipped by that, so the label would still
- // appear at the cursor. Skip the visible cell rendering entirely while floating;
- // the dragged label is drawn over the highlighted target slot below instead.
- if (!reorderable.floating()) {
- cell_box.drawBackground();
-
- const label = switch (orientation) {
- .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(index)) catch {
- dvui.log.err("Failed to allocate label", .{});
- return;
- },
- .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{index}) catch {
- dvui.log.err("Failed to allocate label", .{});
- return;
- },
- };
-
- self.drawRulerLabel(.{
- .font = font,
- .label = label,
- .rect = cell_box.data().rectScale().r,
- .color = dvui.themeGet().color(.control, .text).opacity(0.5),
- .mode = switch (orientation) {
- .horizontal => .horizontal,
- .vertical => .vertical,
- },
- .largest_label = if (orientation == .vertical) largest_row_index_label else null,
- .ref_size_physical = vertical_row_layout_size_phys,
- });
-
- const cell_rect = cell_box.data().rectScale().r;
- const cell_stroke_points = switch (orientation) {
- .horizontal => .{ cell_rect.topLeft(), cell_rect.bottomLeft() },
- .vertical => .{ cell_rect.topLeft(), cell_rect.topRight() },
- };
- dvui.Path.stroke(.{ .points = &cell_stroke_points }, .{ .color = ruler_stroke_color, .thickness = 2.0 });
- }
-
- loop: for (dvui.events()) |*e| {
- if (!cell_box.matchEvent(e)) continue;
-
- switch (e.evt) {
- .mouse => |me| {
- if (me.action == .press and me.button.pointer()) {
- e.handle(@src(), cell_box.data());
- dvui.captureMouse(cell_box.data(), e.num);
- dvui.dragPreStart(me.p, .{
- .size = reorderable.data().rectScale().r.size(),
- .offset = reorderable.data().rectScale().r.topLeft().diff(me.p),
- });
- } else if (me.action == .release and me.button.pointer()) {
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
- switch (orientation) {
- .horizontal => self.columns_drag_index = null,
- .vertical => self.rows_drag_index = null,
- }
- } else if (me.action == .motion) {
- if (dvui.captured(cell_box.data().id)) {
- e.handle(@src(), cell_box.data());
- if (dvui.dragging(me.p, null)) |_| {
- reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0);
- break :loop;
- }
- }
- }
- },
- else => {},
- }
- }
- }
- }
-
- const final_slot_id = switch (orientation) {
- .horizontal => file.columns,
- .vertical => file.rows,
- };
- if (reorder.needFinalSlot()) {
- var reorderable = reorder.reorderable(@src(), .{
- .mode = reorder_mode,
- .last_slot = true,
- .clamp_to_edges = true,
- }, .{
- .expand = reorder_expand,
- .id_extra = final_slot_id,
- .padding = dvui.Rect.all(0),
- .margin = dvui.Rect.all(0),
- .min_size_content = cell_min_size,
- });
- defer reorderable.deinit();
-
- if (reorderable.targetRectScale()) |trs| {
- target_rs_screen = trs;
- }
-
- if (reorderable.insertBefore()) {
- switch (orientation) {
- .horizontal => self.columns_insert_before_index = final_slot_id,
- .vertical => self.rows_insert_before_index = final_slot_id,
- }
- }
- }
-
- // Drag overlay: draw the dragged column/row label on the highlighted target slot in
- // highlight-text color (no extra fill, the reorderable's own focus fill is the
- // background) and a thick err-colored marker line at the dragged-from position in the
- // ruler that lines up with the equivalent indicator in the file canvas.
- const drag_idx_for_overlay = switch (orientation) {
- .horizontal => self.columns_drag_index,
- .vertical => self.rows_drag_index,
- };
- if (drag_idx_for_overlay) |di| {
- const target_idx_opt = switch (orientation) {
- .horizontal => self.columns_target_index,
- .vertical => self.rows_target_index,
- };
- const same_slot = target_idx_opt == di;
-
- if (target_rs_screen) |trs| {
- const drag_label_opt: ?[]const u8 = switch (orientation) {
- .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(di)) catch null,
- .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{di}) catch null,
- };
- if (drag_label_opt) |drag_label| {
- if (same_slot) {
- // Reorderable still draws theme focus fill for the drop target; paint control
- // hover on top so "no move" matches ruler button hover styling.
- trs.r.fill(.all(0), .{ .color = dvui.themeGet().color(.control, .fill_hover), .fade = 1.0 });
- }
- self.drawRulerLabel(.{
- .font = font,
- .label = drag_label,
- .rect = trs.r,
- .color = if (same_slot)
- dvui.themeGet().color(.control, .text).opacity(0.5)
- else
- dvui.themeGet().color(.highlight, .text),
- .mode = switch (orientation) {
- .horizontal => .horizontal,
- .vertical => .vertical,
- },
- .largest_label = if (orientation == .vertical) largest_row_index_label else null,
- .ref_size_physical = vertical_row_layout_size_phys,
- });
- }
- }
-
- // Use the canvas data->screen mapping for the cross-axis position so the marker
- // line aligns exactly with the err indicator drawn over the file canvas grid.
- // The other axis uses the ruler's own screen extents so the line fills the ruler.
- const target_idx_for_line = switch (orientation) {
- .horizontal => self.columns_target_index,
- .vertical => self.rows_target_index,
- };
- if (target_idx_for_line) |ti| {
- if (di != ti) {
- const removed_data_rect = switch (orientation) {
- .horizontal => file.columnRect(di),
- .vertical => file.rowRect(di),
- };
- const removed_canvas_screen = file.editor.canvas.screenFromDataRect(removed_data_rect);
- const ruler_screen = outer_box.data().contentRectScale().r;
- const err_color = dvui.themeGet().color(.err, .fill);
- const thickness = 3.0 * dvui.currentWindow().natural_scale;
- switch (orientation) {
- .horizontal => {
- const edge_x = if (di < ti)
- removed_canvas_screen.x
- else
- removed_canvas_screen.x + removed_canvas_screen.w;
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = edge_x, .y = ruler_screen.y },
- .{ .x = edge_x, .y = ruler_screen.y + ruler_screen.h },
- } }, .{ .thickness = thickness, .color = err_color });
- },
- .vertical => {
- const edge_y = if (di < ti)
- removed_canvas_screen.y
- else
- removed_canvas_screen.y + removed_canvas_screen.h;
- dvui.Path.stroke(.{ .points = &.{
- .{ .x = ruler_screen.x, .y = edge_y },
- .{ .x = ruler_screen.x + ruler_screen.w, .y = edge_y },
- } }, .{ .thickness = thickness, .color = err_color });
- },
- }
- }
- }
- }
-}
-
-pub const TextLabelOptions = struct {
- pub const Mode = enum {
- horizontal,
- vertical,
- };
-
- font: dvui.Font,
- label: []const u8,
- rect: dvui.Rect.Physical,
- color: dvui.Color,
- mode: Mode = .horizontal,
- /// Widest row index string (e.g. `"99"`); layout cell size uses this, text may be a shorter index.
- largest_label: ?[]const u8 = null,
- /// When set, layout size for that widest string (already × `natural_scale`); skips `textSize(largest_label)` per cell.
- ref_size_physical: ?dvui.Size.Physical = null,
-};
-
-pub fn drawRulerLabel(_: *Workspace, options: TextLabelOptions) void {
- const font = options.font;
- const label = options.label;
- const rect = options.rect;
- const color = options.color;
- const natural = dvui.currentWindow().natural_scale;
-
- const ref_for_layout = options.largest_label orelse label;
- const label_size = options.ref_size_physical orelse font.textSize(ref_for_layout).scale(natural, dvui.Size.Physical);
- const actual_label_size = if (std.mem.eql(u8, ref_for_layout, label))
- label_size
- else
- font.textSize(label).scale(natural, dvui.Size.Physical);
-
- const padding = fizzy.editor.settings.ruler_padding * natural;
-
- var label_rect = rect;
-
- if (label_size.w + padding <= label_rect.w and options.mode == .horizontal) {
- label_rect.h = label_size.h + padding;
- label_rect.x += (label_rect.w - actual_label_size.w) / 2.0;
- label_rect.y += (label_rect.h - actual_label_size.h) / 2.0;
-
- dvui.renderText(.{
- .text = label,
- .font = font,
- .color = color,
- .rs = .{
- .r = label_rect,
- .s = natural,
- },
- }) catch {
- dvui.log.err("Failed to render text", .{});
- };
- } else if (label_size.h + padding <= label_rect.h and options.mode == .vertical) {
- label_rect.w = label_size.h + padding;
- label_rect.x += (label_rect.w - actual_label_size.w) / 2.0;
- label_rect.y += (label_rect.h - actual_label_size.h) / 2.0;
-
- dvui.renderText(.{
- .text = label,
- .font = font,
- .color = color,
- .rs = .{
- .r = label_rect,
- .s = natural,
- },
- }) catch {
- dvui.log.err("Failed to render text", .{});
- };
- }
-}
-
-pub fn processColumnReorder(self: *Workspace) void {
- if (self.columns_removed_index) |columns_removed_index| {
- if (self.columns_insert_before_index) |columns_insert_before_index| {
- defer self.columns_removed_index = null;
- defer self.columns_insert_before_index = null;
-
- if (columns_removed_index == columns_insert_before_index or columns_removed_index + 1 == columns_insert_before_index) return;
-
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
-
- file.reorderColumns(columns_removed_index, columns_insert_before_index) catch {
- dvui.log.err("Failed to reorder columns", .{});
- return;
- };
-
- // We'll store the previous indices for clarity.
- const prev_removed_index = columns_removed_index;
- const prev_insert_before_index = columns_insert_before_index;
-
- if (prev_removed_index < prev_insert_before_index) {
- file.history.append(.{
- .reorder_col_row = .{
- .mode = .columns,
- .removed_index = prev_insert_before_index - 1,
- .insert_before_index = prev_removed_index,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- } else {
- file.history.append(.{
- .reorder_col_row = .{
- .mode = .columns,
- .removed_index = prev_insert_before_index,
- .insert_before_index = prev_removed_index + 1,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- }
- }
- }
-}
-
-pub fn processRowReorder(self: *Workspace) void {
- if (self.rows_removed_index) |rows_removed_index| {
- if (self.rows_insert_before_index) |rows_insert_before_index| {
- defer self.rows_removed_index = null;
- defer self.rows_insert_before_index = null;
- if (rows_removed_index == rows_insert_before_index or rows_removed_index + 1 == rows_insert_before_index) return;
-
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
-
- file.reorderRows(rows_removed_index, rows_insert_before_index) catch {
- dvui.log.err("Failed to reorder rows", .{});
- return;
- };
-
- // We'll store the previous indices for clarity.
- const prev_removed_index = rows_removed_index;
- const prev_insert_before_index = rows_insert_before_index;
-
- if (prev_removed_index < prev_insert_before_index) {
- file.history.append(.{
- .reorder_col_row = .{
- .mode = .rows,
- .removed_index = prev_insert_before_index - 1,
- .insert_before_index = prev_removed_index,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- } else {
- file.history.append(.{
- .reorder_col_row = .{
- .mode = .rows,
- .removed_index = prev_insert_before_index,
- .insert_before_index = prev_removed_index + 1,
- },
- }) catch {
- dvui.log.err("Failed to append history", .{});
- };
- }
- }
- }
-}
-
-pub fn drawTransformDialog(self: *Workspace, container: *dvui.WidgetData) void {
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
- if (file.editor.transform) |*transform| {
- var rect = container.rect;
- rect.w = 0;
- rect.h = 0;
-
- var fw: dvui.FloatingWidget = undefined;
- fw.init(@src(), .{}, .{
- .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 },
- .expand = .none,
- .background = true,
- .color_fill = dvui.themeGet().color(.control, .fill),
- .corner_radius = dvui.Rect.all(8),
- .box_shadow = .{
- .color = .black,
- .alpha = 0.2,
- .fade = 8,
- .corner_radius = dvui.Rect.all(8),
- },
- });
- defer fw.deinit();
-
- var anim = dvui.animate(@src(), .{ .kind = .vertical, .duration = 450_000, .easing = dvui.easing.outBack }, .{});
- defer anim.deinit();
-
- var anim_box = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = false,
- });
- defer anim_box.deinit();
-
- dvui.labelNoFmt(@src(), "TRANSFORM", .{ .align_x = 0.5 }, .{
- .padding = dvui.Rect.all(4),
- .expand = .horizontal,
- .font = dvui.Font.theme(.heading).withWeight(.bold),
- });
- _ = dvui.separator(@src(), .{ .expand = .horizontal });
-
- _ = dvui.spacer(@src(), .{ .expand = .horizontal });
-
- var degrees: f32 = std.math.radiansToDegrees(transform.rotation);
-
- var slider_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .horizontal,
- .background = false,
- });
-
- if (dvui.sliderEntry(@src(), "{d:0.0}°", .{
- .value = °rees,
- .min = 0,
- .max = 360,
- .interval = 1,
- }, .{ .expand = .horizontal, .color_fill = dvui.themeGet().color(.window, .fill) })) {
- transform.rotation = std.math.degreesToRadians(degrees);
- }
- slider_box.deinit();
-
- if (transform.ortho) {
- var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer box.deinit();
- dvui.label(@src(), "Width: {d:0.0}", .{transform.point(.bottom_left).diff(transform.point(.bottom_right).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) });
- dvui.label(@src(), "Height: {d:0.0}", .{transform.point(.top_left).diff(transform.point(.bottom_left).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) });
- }
-
- {
- var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{
- .expand = .horizontal,
- .background = false,
- });
- defer box.deinit();
- if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) {
- fizzy.editor.cancel() catch {
- dvui.log.err("Failed to cancel transform", .{});
- };
- }
- if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) {
- fizzy.editor.accept() catch {
- dvui.log.err("Failed to accept transform", .{});
- };
- }
- }
- }
-}
-
-/// Floating rounded-pill quick-access bar anchored to the top-right of the workspace
-/// canvas. Mirrors the Edit menu (Undo / Redo / Copy / Paste / Transform / Grid Layout)
-/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a
-/// single hamburger circle; tapping toggles the row of action buttons in/out with a
-/// width animation.
-pub fn drawEditPill(self: *Workspace, container: *dvui.WidgetData) void {
- const file = fizzy.editor.activeFile() orelse return;
-
- const button_size: f32 = 36;
- const button_gap: f32 = 6;
- const pill_padding: f32 = 6;
- const margin: f32 = 10;
- // Canvas scroll area uses a non-overlay vertical bar on the right edge; keep the
- // pill clear of it (see `CanvasWidget.install` + dvui `ScrollBarWidget` width).
- const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w;
- // Icons render at ~60% of their previous size — previous padding was 0.22 (icon
- // ≈ 56% of button); new padding is 0.33 so the icon ends up ≈ 34% of the button,
- // which is roughly 60% of the prior icon footprint.
- const icon_padding: f32 = button_size * 0.33;
-
- const Action = enum { save, exportd, undo, redo, copy, paste, transform, grid_layout };
- const Entry = struct {
- action: Action,
- tvg: []const u8,
- tooltip: []const u8,
- };
-
- const entries = [_]Entry{
- .{ .action = .save, .tvg = icons.tvg.lucide.save, .tooltip = "Save" },
- .{ .action = .exportd, .tvg = icons.tvg.lucide.@"file-output", .tooltip = "Export" },
- .{ .action = .undo, .tvg = icons.tvg.lucide.undo, .tooltip = "Undo" },
- .{ .action = .redo, .tvg = icons.tvg.lucide.redo, .tooltip = "Redo" },
- .{ .action = .copy, .tvg = icons.tvg.lucide.copy, .tooltip = "Copy" },
- .{ .action = .paste, .tvg = icons.tvg.lucide.@"clipboard-paste", .tooltip = "Paste" },
- .{ .action = .transform, .tvg = icons.tvg.lucide.scaling, .tooltip = "Transform" },
- .{ .action = .grid_layout, .tvg = icons.tvg.lucide.@"layout-grid", .tooltip = "Grid Layout" },
- };
-
- // Vertical pill: width is fixed (one button + padding), height animates between a
- // single-button "collapsed" state and the full-stack "expanded" state. Most screens
- // have more vertical real estate than horizontal, so growing the pill downward keeps
- // it from eating into the canvas's working width.
- const pill_w: f32 = button_size + 2 * pill_padding;
- const collapsed_h: f32 = button_size + 2 * pill_padding;
- const expanded_h: f32 = @as(f32, @floatFromInt(entries.len + 1)) * button_size +
- @as(f32, @floatFromInt(entries.len)) * button_gap + 2 * pill_padding;
- const pill_radius: f32 = pill_w / 2;
- const btn_radius: f32 = button_size / 2;
-
- // Drive the expand/collapse with a dvui animation. Look up the current value, and on
- // a toggle click kick off a new animation between the current value and the target.
- const anim_id = dvui.Id.update(container.id, "edit_pill_expand");
- var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0;
- if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0);
-
- const pill_h: f32 = collapsed_h + (expanded_h - collapsed_h) * anim_value;
-
- // Compute the scroll-area rect — the canvas region inside the rulers. We pull this
- // off the live `canvas_vbox` (so the values are this frame's, not a stale latch) and
- // subtract the ruler thickness from the top/left. Anchoring against this rect means
- // the pill follows the workspace exactly: as a split is dragged shut the canvas area
- // shrinks, and once it's narrower than the pill we bail and draw nothing this frame —
- // so closing splits cleanly hides the menu.
- const wb = container.rectScale().r.toNatural();
- const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
- const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
- const canvas_nat = dvui.Rect{
- .x = wb.x + ruler_left,
- .y = wb.y + ruler_top,
- .w = wb.w - ruler_left,
- .h = wb.h - ruler_top,
- };
-
- if (canvas_nat.w < pill_w + margin + right_margin or canvas_nat.h < collapsed_h + 2 * margin) return;
-
- const pill_x: f32 = canvas_nat.x + canvas_nat.w - right_margin - pill_w;
- const pill_y: f32 = canvas_nat.y + margin;
-
- // Clamp the bottom edge so the expanded pill never spills past the canvas area —
- // FloatingWidget bypasses parent clipping, so we cap the height explicitly.
- const max_pill_h: f32 = canvas_nat.h - 2 * margin;
- const effective_pill_h: f32 = @min(pill_h, max_pill_h);
-
- var fw: dvui.FloatingWidget = undefined;
- fw.init(@src(), .{}, .{
- .rect = .{
- .x = pill_x,
- .y = pill_y,
- .w = pill_w,
- .h = effective_pill_h,
- },
- .expand = .none,
- .background = self.edit_pill_expanded,
- .color_fill = dvui.themeGet().color(.window, .fill),
- .corner_radius = dvui.Rect.all(pill_radius),
- .box_shadow = if (self.edit_pill_expanded) .{
- .color = .black,
- .alpha = 0.25,
- .fade = 10,
- .offset = .{ .x = 0, .y = 3 },
- .corner_radius = dvui.Rect.all(pill_radius),
- } else null,
- });
- defer fw.deinit();
-
- var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = false,
- .padding = dvui.Rect.all(pill_padding),
- });
- defer vbox.deinit();
-
- // Hamburger toggle is always present at the top of the pill; the stack of action
- // buttons grows downward beneath it as the pill expands.
- {
- var btn: dvui.ButtonWidget = undefined;
- btn.init(@src(), .{}, .{
- .id_extra = entries.len, // distinct from action button ids below
- .min_size_content = .{ .w = button_size, .h = button_size },
- .expand = .none,
- .gravity_x = 0.5,
- .gravity_y = 0.0,
- .background = true,
- .corner_radius = dvui.Rect.all(btn_radius),
- .color_fill = dvui.themeGet().color(.content, .fill),
- .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- .color_border = .transparent,
- .padding = .all(0),
- .margin = .{},
- .box_shadow = .{
- .color = .black,
- .alpha = 0.2,
- .fade = 4,
- .offset = .{ .x = 0, .y = 2 },
- .corner_radius = dvui.Rect.all(btn_radius),
- },
- });
- defer btn.deinit();
- btn.processEvents();
- btn.drawBackground();
-
- const icon_color = dvui.themeGet().color(.content, .text);
- dvui.icon(
- @src(),
- "edit_pill_toggle",
- icons.tvg.lucide.menu,
- .{ .stroke_color = icon_color, .fill_color = icon_color },
- .{
- .expand = .ratio,
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 1.0, .h = 1.0 },
- .padding = dvui.Rect.all(icon_padding),
- },
- );
-
- if (btn.clicked()) {
- self.edit_pill_expanded = !self.edit_pill_expanded;
- const target: f32 = if (self.edit_pill_expanded) 1.0 else 0.0;
- dvui.animation(anim_id, "_t", .{
- .start_val = anim_value,
- .end_val = target,
- .end_time = 250_000,
- .easing = dvui.easing.outBack,
- });
- }
- }
-
- // Action buttons live inside a scroll area so the pill stays the right width and
- // never visually "squishes" when there isn't enough vertical room — instead the
- // overflow buttons become reachable via vertical scroll inside the pill. Bars are
- // hidden to preserve the rounded-pill look; touch / wheel still drives the scroll.
- var actions_scroll = dvui.scrollArea(@src(), .{
- .vertical_bar = .hide,
- .horizontal_bar = .hide,
- }, .{
- .expand = .both,
- .background = false,
- .padding = .{},
- .margin = .{},
- .border = dvui.Rect.all(0),
- .color_fill = .transparent,
- });
- defer actions_scroll.deinit();
-
- // Action buttons stacked below the hamburger. We draw them all and let the
- // scrollArea handle any overflow when the pill is clamped to the canvas height.
- for (entries, 0..) |entry, i| {
- const enabled: bool = switch (entry.action) {
- .save => file.dirty(),
- .undo => file.history.undo_stack.items.len > 0,
- .redo => file.history.redo_stack.items.len > 0,
- else => true,
- };
-
- var btn: dvui.ButtonWidget = undefined;
- btn.init(@src(), .{}, .{
- .id_extra = i,
- .min_size_content = .{ .w = button_size, .h = button_size },
- .expand = .none,
- .gravity_x = 0.5,
- .background = true,
- .corner_radius = dvui.Rect.all(btn_radius),
- .color_fill = dvui.themeGet().color(.content, .fill),
- .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- .color_border = .transparent,
- .padding = .all(0),
- .margin = .{ .y = button_gap },
- .box_shadow = .{
- .color = .black,
- .alpha = 0.2,
- .fade = 4,
- .offset = .{ .x = 0, .y = 2 },
- .corner_radius = dvui.Rect.all(btn_radius),
- },
- });
- defer btn.deinit();
- btn.processEvents();
- btn.drawBackground();
-
- const icon_color = if (enabled) dvui.themeGet().color(.content, .text) else dvui.themeGet().color(.content, .text).opacity(0.35);
-
- dvui.icon(
- @src(),
- entry.tooltip,
- entry.tvg,
- .{ .stroke_color = icon_color, .fill_color = icon_color },
- .{
- .expand = .ratio,
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 1.0, .h = 1.0 },
- .padding = dvui.Rect.all(icon_padding),
- },
- );
-
- // Suppress activation while collapsed (or mid-animation) so a stray tap on a
- // partially-visible button doesn't fire an Edit action behind the hamburger.
- const fully_expanded = anim_value >= 0.999;
- if (btn.clicked() and enabled and fully_expanded) {
- switch (entry.action) {
- .save => fizzy.editor.save() catch {
- dvui.log.err("Failed to save", .{});
- },
- .exportd => {
- // Open the Export dialog (same configuration the `export` keybind uses).
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = fizzy.Editor.Dialogs.Export.dialog,
- .callafterFn = fizzy.Editor.Dialogs.Export.callAfter,
- .title = "Export...",
- .ok_label = "Export",
- .cancel_label = "Cancel",
- .resizeable = false,
- .modal = false,
- .header_kind = .info,
- .default = .ok,
- });
- mutex.mutex.unlock(dvui.io);
- },
- .undo => file.history.undoRedo(file, .undo) catch {
- dvui.log.err("Failed to undo", .{});
- },
- .redo => file.history.undoRedo(file, .redo) catch {
- dvui.log.err("Failed to redo", .{});
- },
- .copy => fizzy.editor.copy() catch {
- dvui.log.err("Failed to copy", .{});
- },
- .paste => fizzy.editor.paste() catch {
- dvui.log.err("Failed to paste", .{});
- },
- .transform => fizzy.editor.transform() catch {
- dvui.log.err("Failed to start transform", .{});
- },
- .grid_layout => fizzy.editor.requestGridLayoutDialog(),
- }
- }
- }
-}
-
-/// Floating round button anchored just to the left of the Edit pill at the top-right of
-/// the canvas. Tapping it shows a tooltip explaining the gesture; the primary action is
-/// to drag from the button toward whatever pixel you want to sample. The button itself
-/// stays put — instead, while the drag is in progress, we route the touch position
-/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders
-/// the existing color-dropper magnifier at the touch location. On release we read the
-/// color underneath the sample point and apply it to the primary color slot.
-pub fn drawSampleButton(self: *Workspace, container: *dvui.WidgetData) void {
- const file = fizzy.editor.activeFile() orelse return;
-
- const pill_button_size: f32 = 36;
- const pill_padding: f32 = 6;
- const pill_outer_w: f32 = pill_button_size + 2 * pill_padding;
- const button_size: f32 = 36;
- const btn_radius: f32 = button_size / 2;
- const icon_padding: f32 = button_size * 0.33;
- const margin: f32 = 10;
- const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w;
- const gap: f32 = 6;
-
- // Anchor against the same canvas-scroll-area rect the pill uses.
- const wb = container.rectScale().r.toNatural();
- const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
- const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
- const canvas_nat = dvui.Rect{
- .x = wb.x + ruler_left,
- .y = wb.y + ruler_top,
- .w = wb.w - ruler_left,
- .h = wb.h - ruler_top,
- };
-
- // Only draw when the canvas area can fit pill + gap + sample button + margins.
- if (canvas_nat.w < pill_outer_w + gap + button_size + margin + right_margin) return;
- if (canvas_nat.h < button_size + 2 * margin) return;
-
- const btn_x = canvas_nat.x + canvas_nat.w - right_margin - pill_outer_w - gap - button_size;
- // Match the hamburger row inside the pill (pill top + inner vbox padding).
- const btn_y = canvas_nat.y + margin + pill_padding;
-
- var fw: dvui.FloatingWidget = undefined;
- fw.init(@src(), .{}, .{
- .rect = .{ .x = btn_x, .y = btn_y, .w = button_size, .h = button_size },
- .expand = .none,
- .background = false,
- });
- defer fw.deinit();
-
- var btn: dvui.ButtonWidget = undefined;
- // `touch_drag = true` keeps `ButtonWidget`'s own capture alive while the touch is
- // dragging away from the button — without it, dvui's default `clickedEx` releases
- // capture as soon as the drag crosses the threshold (treating the gesture as a
- // canceled scroll), which would also cancel our custom drag-to-sample handler.
- btn.init(@src(), .{ .touch_drag = true }, .{
- .expand = .both,
- .background = true,
- .min_size_content = .{ .w = button_size, .h = button_size },
- .corner_radius = dvui.Rect.all(btn_radius),
- .color_fill = dvui.themeGet().color(.content, .fill),
- .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0),
- .color_border = .transparent,
- .padding = .all(0),
- .margin = .{},
- .box_shadow = .{
- .color = .black,
- .alpha = 0.2,
- .fade = 4,
- .offset = .{ .x = 0, .y = 2 },
- .corner_radius = dvui.Rect.all(btn_radius),
- },
- });
- defer btn.deinit();
-
- // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag
- // threshold). Stored via dataSet because the button widget is recreated each frame.
- const drag_state_id = dvui.Id.update(container.id, "sample_button_drag");
- var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false;
- var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false;
-
- // The button's screen rect is the "press home base"; events that happen here belong
- // to us regardless of whether motion has carried the pointer away.
- const btn_rs = btn.data().rectScale();
-
- // Custom event handling runs *before* `btn.processEvents()` so we can claim the
- // press / motion / release events first. `ButtonWidget.clickedEx` ALWAYS releases
- // mouse capture and ends the drag on a release event (regardless of touch_drag) —
- // if we ran after it, our release branch would see `dvui.captured(...)` already
- // false and the magnifier would stay stuck on screen. Calling `e.handle(...)` here
- // makes `clickedEx`'s match-event check skip these events entirely, so the button
- // leaves our gesture alone.
- for (dvui.events()) |*e| {
- if (e.evt != .mouse) continue;
- const me = e.evt.mouse;
-
- switch (me.action) {
- .press => {
- if (!me.button.pointer()) continue;
- if (!btn_rs.r.contains(me.p)) continue;
- e.handle(@src(), btn.data());
- dvui.captureMouse(btn.data(), e.num);
- dvui.dragPreStart(me.p, .{ .name = "sample_button_drag" });
- is_drag_sampling = false;
- did_sample = false;
- },
- .motion => {
- if (!dvui.captured(btn.data().id)) continue;
- if (dvui.dragging(me.p, "sample_button_drag")) |_| {
- is_drag_sampling = true;
- if (file.editor.canvas.samplePointerInViewport(me.p)) {
- const data_pt = file.editor.canvas.dataFromScreenPoint(me.p);
- dvui.dataSet(null, file.editor.canvas.id, "sample_data_point", data_pt);
- did_sample = true;
- } else {
- dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point");
- }
- dvui.refresh(null, @src(), file.editor.canvas.id);
- e.handle(@src(), btn.data());
- }
- },
- .release => {
- if (!me.button.pointer()) continue;
- if (!dvui.captured(btn.data().id)) continue;
- e.handle(@src(), btn.data());
- dvui.captureMouse(null, e.num);
- dvui.dragEnd();
-
- if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) {
- const data_pt = file.editor.canvas.dataFromScreenPoint(me.p);
- fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true);
- }
-
- // Clear sample state so the magnifier disappears on the next frame.
- dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point");
- is_drag_sampling = false;
- did_sample = false;
- dvui.refresh(null, @src(), file.editor.canvas.id);
- },
- else => {},
- }
- }
-
- // Persist the drag state for the next frame's widget recreate.
- dvui.dataSet(null, drag_state_id, "active", is_drag_sampling);
- dvui.dataSet(null, drag_state_id, "did_sample", did_sample);
-
- // Now let the button run its own pass to handle hover styling against any remaining
- // (non-claimed) events — i.e. plain mouse hover when we're not in a drag.
- btn.processEvents();
- btn.drawBackground();
-
- const icon_color = dvui.themeGet().color(.content, .text);
- dvui.icon(
- @src(),
- "sample_dropper",
- icons.tvg.lucide.pipette,
- .{ .stroke_color = icon_color, .fill_color = icon_color },
- .{
- .expand = .ratio,
- .gravity_x = 0.5,
- .gravity_y = 0.5,
- .min_size_content = .{ .w = 1.0, .h = 1.0 },
- .padding = dvui.Rect.all(icon_padding),
- },
- );
-
- // While the drag is in progress, hide the OS cursor entirely so only the canvas
- // magnifier (drawn at the touch point via `FileWidget.drawSample`) communicates
- // where the sample is happening. Set after `btn.processEvents()` so it overrides
- // the `.hand` hover cursor `clickedEx` would otherwise leave in place.
- if (is_drag_sampling) {
- dvui.cursorSet(.hidden);
- }
-
- // Tooltip prompting the gesture. We hide it during an active sample drag so it
- // doesn't compete with the magnifier on screen.
- if (!is_drag_sampling) {
- var tooltip: dvui.FloatingTooltipWidget = undefined;
- tooltip.init(@src(), .{
- .active_rect = btn.data().rectScale().r,
- .delay = 350_000,
- }, .{
- .color_fill = dvui.themeGet().color(.window, .fill),
- .border = dvui.Rect.all(0),
- .box_shadow = .{
- .color = .black,
- .shrink = 0,
- .corner_radius = dvui.Rect.all(8),
- .offset = .{ .x = 0, .y = 2 },
- .fade = 4,
- .alpha = 0.2,
- },
- });
- defer tooltip.deinit();
-
- if (tooltip.shown()) {
- var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both });
- defer anim.deinit();
-
- var tl = dvui.textLayout(@src(), .{}, .{
- .background = false,
- .padding = dvui.Rect.all(6),
- });
- tl.format("Drag to sample color", .{}, .{ .font = dvui.Font.theme(.body) });
- tl.deinit();
- }
- }
-}
-
pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
const logo_pixel_size = 32;
const logo_width = 3;
From a9edc39019501644fcd6bca05187f725b9a21ee9 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 09:53:26 -0500
Subject: [PATCH 13/49] Structural changes
---
build.zig | 34 +++++++++----------
contributor.md | 6 ++--
src/backend_native.zig | 2 +-
src/editor/Brushes.zig | 24 -------------
src/editor/Editor.zig | 24 ++++++-------
src/editor/Menu.zig | 1 -
src/editor/dialogs/Dialogs.zig | 8 ++---
src/editor/dialogs/UnsavedClose.zig | 2 +-
src/editor/explorer/Explorer.zig | 10 +++---
src/editor/panel/Panel.zig | 2 +-
src/editor/widgets/Widgets.zig | 4 +--
src/fizzy.zig | 30 ++++++++--------
src/gfx/image.zig | 23 +------------
src/platform.zig | 2 +-
src/{ => plugins/pixelart}/Animation.zig | 0
src/{ => plugins/pixelart}/Atlas.zig | 4 +--
src/{ => plugins}/pixelart/CanvasData.zig | 2 +-
src/{editor => plugins/pixelart}/Colors.zig | 2 +-
src/{ => plugins/pixelart}/File.zig | 2 +-
.../pixelart}/LDTKTileset.zig | 2 +-
src/{ => plugins/pixelart}/Layer.zig | 0
src/{editor => plugins/pixelart}/PackJob.zig | 6 ++--
src/{tools => plugins/pixelart}/Packer.zig | 2 +-
src/{editor => plugins/pixelart}/Project.zig | 2 +-
src/{ => plugins/pixelart}/Sprite.zig | 0
src/{editor => plugins/pixelart}/Tools.zig | 2 +-
.../pixelart}/Transform.zig | 2 +-
.../pixelart}/algorithms/algorithms.zig | 0
.../pixelart}/algorithms/brezenham.zig | 2 +-
.../pixelart}/algorithms/reduce.zig | 0
.../deps/msf_gif/fizzy_msf_gif_wasm.c | 0
.../pixelart}/deps/msf_gif/msf_gif.c | 0
.../pixelart}/deps/msf_gif/msf_gif.h | 0
.../pixelart}/deps/msf_gif/msf_gif.zig | 0
.../pixelart}/deps/msf_gif/wasm_shim/string.h | 0
.../pixelart}/deps/stbi/fizzy_stbi_libc.c | 0
.../pixelart}/deps/stbi/stb_image_resize2.h | 0
.../pixelart}/deps/stbi/stb_rect_pack.h | 0
src/{ => plugins/pixelart}/deps/stbi/zstbi.c | 0
.../pixelart}/deps/stbi/zstbi.zig | 0
src/{ => plugins/pixelart}/deps/zip/build.zig | 0
.../pixelart}/deps/zip/fizzy_zip_libc.c | 0
.../pixelart}/deps/zip/fizzy_zip_strings.c | 0
.../pixelart}/deps/zip/fizzy_zip_wasm.h | 0
.../pixelart}/deps/zip/src/miniz.h | 0
src/{ => plugins/pixelart}/deps/zip/src/zip.c | 0
src/{ => plugins/pixelart}/deps/zip/src/zip.h | 0
src/{ => plugins/pixelart}/deps/zip/zip.zig | 0
.../pixelart}/dialogs/Export.zig | 6 ++--
.../dialogs/FlatRasterSaveWarning.zig | 2 +-
.../pixelart}/dialogs/GridLayout.zig | 6 ++--
.../pixelart}/dialogs/NewFile.zig | 4 +--
.../pixelart}/explorer/project.zig | 2 +-
.../pixelart}/explorer/sprites.zig | 2 +-
.../pixelart}/explorer/tools.zig | 2 +-
.../pixelart}/internal/Animation.zig | 0
src/{ => plugins/pixelart}/internal/Atlas.zig | 6 ++--
.../pixelart}/internal/Buffers.zig | 2 +-
src/{ => plugins/pixelart}/internal/File.zig | 12 +++----
.../pixelart}/internal/History.zig | 4 +--
src/{ => plugins/pixelart}/internal/Layer.zig | 17 ++++++++--
.../pixelart}/internal/Palette.zig | 2 +-
.../pixelart}/internal/Sprite.zig | 0
.../internal/grid_layout_validate.zig | 0
.../pixelart}/internal/layer_order.zig | 0
.../pixelart}/internal/palette_parse.zig | 0
.../pixelart}/panel/sprites.zig | 2 +-
src/{ => plugins}/pixelart/plugin.zig | 2 +-
.../pixelart}/widgets/CanvasBridge.zig | 4 +--
.../pixelart}/widgets/FileWidget.zig | 10 +++---
.../pixelart}/widgets/ImageWidget.zig | 4 +--
src/{ => plugins}/workbench/FileLoadJob.zig | 4 +--
src/{ => plugins}/workbench/Workbench.zig | 4 +--
src/{ => plugins}/workbench/Workspace.zig | 2 +-
src/{ => plugins}/workbench/files.zig | 3 +-
src/{ => plugins}/workbench/plugin.zig | 2 +-
src/tools/process_assets.zig | 2 +-
src/{internal => }/window_layout.zig | 3 +-
tests/README.md | 4 +--
79 files changed, 140 insertions(+), 173 deletions(-)
delete mode 100644 src/editor/Brushes.zig
rename src/{ => plugins/pixelart}/Animation.zig (100%)
rename src/{ => plugins/pixelart}/Atlas.zig (97%)
rename src/{ => plugins}/pixelart/CanvasData.zig (99%)
rename src/{editor => plugins/pixelart}/Colors.zig (85%)
rename src/{ => plugins/pixelart}/File.zig (98%)
rename src/{tools => plugins/pixelart}/LDTKTileset.zig (87%)
rename src/{ => plugins/pixelart}/Layer.zig (100%)
rename src/{editor => plugins/pixelart}/PackJob.zig (99%)
rename src/{tools => plugins/pixelart}/Packer.zig (99%)
rename src/{editor => plugins/pixelart}/Project.zig (99%)
rename src/{ => plugins/pixelart}/Sprite.zig (100%)
rename src/{editor => plugins/pixelart}/Tools.zig (99%)
rename src/{editor => plugins/pixelart}/Transform.zig (99%)
rename src/{ => plugins/pixelart}/algorithms/algorithms.zig (100%)
rename src/{ => plugins/pixelart}/algorithms/brezenham.zig (96%)
rename src/{ => plugins/pixelart}/algorithms/reduce.zig (100%)
rename src/{ => plugins/pixelart}/deps/msf_gif/fizzy_msf_gif_wasm.c (100%)
rename src/{ => plugins/pixelart}/deps/msf_gif/msf_gif.c (100%)
rename src/{ => plugins/pixelart}/deps/msf_gif/msf_gif.h (100%)
rename src/{ => plugins/pixelart}/deps/msf_gif/msf_gif.zig (100%)
rename src/{ => plugins/pixelart}/deps/msf_gif/wasm_shim/string.h (100%)
rename src/{ => plugins/pixelart}/deps/stbi/fizzy_stbi_libc.c (100%)
rename src/{ => plugins/pixelart}/deps/stbi/stb_image_resize2.h (100%)
rename src/{ => plugins/pixelart}/deps/stbi/stb_rect_pack.h (100%)
rename src/{ => plugins/pixelart}/deps/stbi/zstbi.c (100%)
rename src/{ => plugins/pixelart}/deps/stbi/zstbi.zig (100%)
rename src/{ => plugins/pixelart}/deps/zip/build.zig (100%)
rename src/{ => plugins/pixelart}/deps/zip/fizzy_zip_libc.c (100%)
rename src/{ => plugins/pixelart}/deps/zip/fizzy_zip_strings.c (100%)
rename src/{ => plugins/pixelart}/deps/zip/fizzy_zip_wasm.h (100%)
rename src/{ => plugins/pixelart}/deps/zip/src/miniz.h (100%)
rename src/{ => plugins/pixelart}/deps/zip/src/zip.c (100%)
rename src/{ => plugins/pixelart}/deps/zip/src/zip.h (100%)
rename src/{ => plugins/pixelart}/deps/zip/zip.zig (100%)
rename src/{editor => plugins/pixelart}/dialogs/Export.zig (99%)
rename src/{editor => plugins/pixelart}/dialogs/FlatRasterSaveWarning.zig (99%)
rename src/{editor => plugins/pixelart}/dialogs/GridLayout.zig (99%)
rename src/{editor => plugins/pixelart}/dialogs/NewFile.zig (98%)
rename src/{editor => plugins/pixelart}/explorer/project.zig (99%)
rename src/{editor => plugins/pixelart}/explorer/sprites.zig (99%)
rename src/{editor => plugins/pixelart}/explorer/tools.zig (99%)
rename src/{ => plugins/pixelart}/internal/Animation.zig (100%)
rename src/{ => plugins/pixelart}/internal/Atlas.zig (94%)
rename src/{ => plugins/pixelart}/internal/Buffers.zig (98%)
rename src/{ => plugins/pixelart}/internal/File.zig (99%)
rename src/{ => plugins/pixelart}/internal/History.zig (99%)
rename src/{ => plugins/pixelart}/internal/Layer.zig (96%)
rename src/{ => plugins/pixelart}/internal/Palette.zig (97%)
rename src/{ => plugins/pixelart}/internal/Sprite.zig (100%)
rename src/{ => plugins/pixelart}/internal/grid_layout_validate.zig (100%)
rename src/{ => plugins/pixelart}/internal/layer_order.zig (100%)
rename src/{ => plugins/pixelart}/internal/palette_parse.zig (100%)
rename src/{editor => plugins/pixelart}/panel/sprites.zig (99%)
rename src/{ => plugins}/pixelart/plugin.zig (99%)
rename src/{editor => plugins/pixelart}/widgets/CanvasBridge.zig (88%)
rename src/{editor => plugins/pixelart}/widgets/FileWidget.zig (99%)
rename src/{editor => plugins/pixelart}/widgets/ImageWidget.zig (99%)
rename src/{ => plugins}/workbench/FileLoadJob.zig (98%)
rename src/{ => plugins}/workbench/Workbench.zig (99%)
rename src/{ => plugins}/workbench/Workspace.zig (99%)
rename src/{ => plugins}/workbench/files.zig (99%)
rename src/{ => plugins}/workbench/plugin.zig (98%)
rename src/{internal => }/window_layout.zig (98%)
diff --git a/build.zig b/build.zig
index 48476dd6..48a3ecde 100644
--- a/build.zig
+++ b/build.zig
@@ -1,6 +1,6 @@
const std = @import("std");
-const zip = @import("src/deps/zip/build.zig");
+const zip = @import("src/plugins/pixelart/deps/zip/build.zig");
const dvui = @import("dvui");
const velopack = @import("velopack_zig");
@@ -360,7 +360,7 @@ pub fn build(b: *std.Build) !void {
.root_module = b.addModule("zstbi_web", .{
.target = web_target,
.optimize = optimize,
- .root_source_file = b.path("src/deps/stbi/zstbi.zig"),
+ .root_source_file = b.path("src/plugins/pixelart/deps/stbi/zstbi.zig"),
.link_libc = false,
.single_threaded = true,
}),
@@ -370,11 +370,11 @@ pub fn build(b: *std.Build) !void {
"-DSTBI_NO_SIMD=1",
};
zstbi_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "src/deps/stbi/zstbi.c"),
+ .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c"),
.flags = &zstbi_web_cflags,
});
zstbi_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "src/deps/stbi/fizzy_stbi_libc.c"),
+ .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c"),
.flags = &zstbi_web_cflags,
});
web_exe.root_module.addImport("zstbi", zstbi_web_lib.root_module);
@@ -384,14 +384,14 @@ pub fn build(b: *std.Build) !void {
.root_module = b.addModule("msf_gif_web", .{
.target = web_target,
.optimize = optimize,
- .root_source_file = b.path("src/deps/msf_gif/msf_gif.zig"),
+ .root_source_file = b.path("src/plugins/pixelart/deps/msf_gif/msf_gif.zig"),
.link_libc = false,
.single_threaded = true,
}),
});
- const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/deps/msf_gif/wasm_shim"};
+ const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/plugins/pixelart/deps/msf_gif/wasm_shim"};
msf_gif_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "src/deps/msf_gif/fizzy_msf_gif_wasm.c"),
+ .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c"),
.flags = &msf_gif_wasm_cflags,
});
web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module);
@@ -739,13 +739,13 @@ pub fn build(b: *std.Build) !void {
inline for (.{
.{ "fizzy-direction", "src/math/direction.zig" },
.{ "fizzy-easing", "src/math/easing.zig" },
- .{ "fizzy-layer-order", "src/internal/layer_order.zig" },
- .{ "fizzy-palette-parse", "src/internal/palette_parse.zig" },
+ .{ "fizzy-layer-order", "src/plugins/pixelart/internal/layer_order.zig" },
+ .{ "fizzy-palette-parse", "src/plugins/pixelart/internal/palette_parse.zig" },
.{ "fizzy-layout-anchor", "src/math/layout_anchor.zig" },
- .{ "fizzy-reduce", "src/algorithms/reduce.zig" },
- .{ "fizzy-grid-validate", "src/internal/grid_layout_validate.zig" },
- .{ "fizzy-animation", "src/Animation.zig" },
- .{ "fizzy-window-layout", "src/internal/window_layout.zig" },
+ .{ "fizzy-reduce", "src/plugins/pixelart/algorithms/reduce.zig" },
+ .{ "fizzy-grid-validate", "src/plugins/pixelart/internal/grid_layout_validate.zig" },
+ .{ "fizzy-animation", "src/plugins/pixelart/Animation.zig" },
+ .{ "fizzy-window-layout", "src/window_layout.zig" },
}) |entry| {
tests_module.addAnonymousImport(entry[0], .{
.root_source_file = b.path(entry[1]),
@@ -1075,22 +1075,22 @@ fn addFizzyExecutableForTarget(
.root_module = b.addModule("zstbi", .{
.target = resolved_target,
.optimize = optimize,
- .root_source_file = .{ .cwd_relative = "src/deps/stbi/zstbi.zig" },
+ .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/stbi/zstbi.zig" },
}),
});
const zstbi_module = zstbi_lib.root_module;
- zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/deps/stbi/zstbi.c") });
+ zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c") });
const msf_gif_lib = b.addLibrary(.{
.name = "msf_gif",
.root_module = b.addModule("msf_gif", .{
.target = resolved_target,
.optimize = optimize,
- .root_source_file = .{ .cwd_relative = "src/deps/msf_gif/msf_gif.zig" },
+ .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/msf_gif/msf_gif.zig" },
}),
});
const msf_gif_module = msf_gif_lib.root_module;
- msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/deps/msf_gif/msf_gif.c") });
+ msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/msf_gif.c") });
const exe = b.addExecutable(.{
.name = "fizzy",
diff --git a/contributor.md b/contributor.md
index 9bde4d85..3be379a5 100644
--- a/contributor.md
+++ b/contributor.md
@@ -15,9 +15,9 @@ to have a conversation about Fizzy, please reach out to me on discord or add an
Fizzy is built using several game development libraries by others in the Zig community, as well as a C library for handling zipped files. The dependencies are as follows:
- ***mach-core***: Handles windowing and input, and uses the new zig package manager. This library and dependencies will be downloaded to the cache on build.
- ***nfd_zig***: Native file dialogs wrapper, copied into the src/deps folder.
- - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/deps/zig-gamedev folder.
- - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/deps/zig-gamedev folder.
- - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/deps/zig-gamedev folder.
+ - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/plugins/pixelart/deps/zig-gamedev folder.
+ - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder.
+ - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder.
- ***zip***: Wrapper for the zip library, copied into the src/deps folder.
Outside of the `src` folder, we have `assets` which contain all assets that we would like to be copied over next to the executable and used by Fizzy at runtime.
diff --git a/src/backend_native.zig b/src/backend_native.zig
index 93d7c6ba..e177739f 100644
--- a/src/backend_native.zig
+++ b/src/backend_native.zig
@@ -7,7 +7,7 @@ const sdl3 = @import("backend").c;
const objc = @import("objc");
const win32 = @import("win32");
const singleton = @import("singleton.zig");
-const window_layout = @import("internal/window_layout.zig");
+const window_layout = @import("window_layout.zig");
// AppKit geometry types for NSView frame/bounds (same layout as Foundation).
const NSPoint = extern struct { x: f64, y: f64 };
diff --git a/src/editor/Brushes.zig b/src/editor/Brushes.zig
deleted file mode 100644
index fbc7566c..00000000
--- a/src/editor/Brushes.zig
+++ /dev/null
@@ -1,24 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const dvui = @import("dvui");
-
-pub const Brushes = @This();
-
-pub const Brush = struct {
- name: []const u8,
- source: dvui.ImageSource,
- origin: dvui.Point,
-};
-
-brushes: std.ArrayList(Brush) = undefined,
-selected_brush_index: usize = 0,
-
-pub fn init() !Brushes {
- return .{
- .brushes = std.ArrayList(Brush).init(fizzy.app.allocator),
- };
-}
-
-pub fn deinit(self: *Brushes) void {
- self.brushes.deinit();
-}
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index f76e6b9e..f310d7e9 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -20,32 +20,32 @@ const update_notify = @import("../update_notify.zig");
const App = fizzy.App;
const Editor = @This();
-pub const Colors = @import("Colors.zig");
-pub const Project = @import("Project.zig");
+pub const Colors = @import("../plugins/pixelart/Colors.zig");
+pub const Project = @import("../plugins/pixelart/Project.zig");
pub const Recents = @import("Recents.zig");
pub const Settings = @import("Settings.zig");
-pub const Tools = @import("Tools.zig");
+pub const Tools = @import("../plugins/pixelart/Tools.zig");
pub const Dialogs = @import("dialogs/Dialogs.zig");
-pub const Transform = @import("Transform.zig");
+pub const Transform = @import("../plugins/pixelart/Transform.zig");
pub const Keybinds = @import("Keybinds.zig");
-pub const Workspace = @import("../workbench/Workspace.zig");
+pub const Workspace = @import("../plugins/workbench/Workspace.zig");
pub const Explorer = @import("explorer/Explorer.zig");
pub const IgnoreRules = @import("explorer/IgnoreRules.zig");
pub const Panel = @import("panel/Panel.zig");
pub const Sidebar = @import("Sidebar.zig");
pub const Infobar = @import("Infobar.zig");
pub const Menu = @import("Menu.zig");
-pub const FileLoadJob = @import("../workbench/FileLoadJob.zig");
-pub const PackJob = @import("PackJob.zig");
+pub const FileLoadJob = @import("../plugins/workbench/FileLoadJob.zig");
+pub const PackJob = @import("../plugins/pixelart/PackJob.zig");
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
/// Workbench (Phase 1): file-management home — currently the per-branch
/// decoration registry for the explorer; grows to own files + tabs/splits.
-pub const Workbench = @import("../workbench/Workbench.zig");
+pub const Workbench = @import("../plugins/workbench/Workbench.zig");
/// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels.
/// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame
@@ -476,8 +476,8 @@ pub fn postInit(editor: *Editor) !void {
// near-empty shell's content: it iterates the Host registries rather than
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
// editor tick already runs on wasm. Order = sidebar order.
- try @import("../workbench/plugin.zig").register(&editor.host);
- try @import("../pixelart/plugin.zig").register(&editor.host);
+ try @import("../plugins/workbench/plugin.zig").register(&editor.host);
+ try @import("../plugins/pixelart/plugin.zig").register(&editor.host);
// Shell built-in: Settings (owner = null; not a plugin).
try editor.host.registerSidebarView(.{
@@ -1984,7 +1984,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
- editor.host.setActiveSidebarView(@import("../workbench/plugin.zig").view_files);
+ editor.host.setActiveSidebarView(@import("../plugins/workbench/plugin.zig").view_files);
editor.project = Project.load(fizzy.app.allocator) catch null;
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
@@ -2384,7 +2384,7 @@ pub fn processPackJob(editor: *Editor) void {
}
fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
job.result_consumed = true;
- editor.host.setActiveSidebarView(@import("../pixelart/plugin.zig").view_project);
+ editor.host.setActiveSidebarView(@import("../plugins/pixelart/plugin.zig").view_project);
const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null;
showPackToast("Project packed", toast_canvas);
} else blk: {
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index 2445be5a..09c51b02 100644
--- a/src/editor/Menu.zig
+++ b/src/editor/Menu.zig
@@ -3,7 +3,6 @@ const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const settings = fizzy.settings;
-const zstbi = @import("zstbi");
const builtin = @import("builtin");
pub var mouse_distance: f32 = std.math.floatMax(f32);
diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig
index cdcf0ab2..37f75577 100644
--- a/src/editor/dialogs/Dialogs.zig
+++ b/src/editor/dialogs/Dialogs.zig
@@ -4,12 +4,12 @@ const dvui = @import("dvui");
const Dialogs = @This();
-pub const NewFile = @import("NewFile.zig");
-pub const Export = @import("Export.zig");
+pub const NewFile = @import("../../plugins/pixelart/dialogs/NewFile.zig");
+pub const Export = @import("../../plugins/pixelart/dialogs/Export.zig");
pub const UnsavedClose = @import("UnsavedClose.zig");
pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig");
-pub const GridLayout = @import("GridLayout.zig");
-pub const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig");
+pub const GridLayout = @import("../../plugins/pixelart/dialogs/GridLayout.zig");
+pub const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig");
pub const AboutFizzy = @import("AboutFizzy.zig");
pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32)
@import("WebFolderUnavailable.zig")
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index b8aa3466..35210347 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -1,7 +1,7 @@
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig");
+const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig");
pub fn request(file_id: u64) void {
var mutex = fizzy.dvui.dialog(@src(), .{
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index eeeacf9c..7b85dade 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -13,12 +13,12 @@ const nfd = @import("nfd");
pub const Explorer = @This();
-pub const files = @import("../../workbench/files.zig");
-pub const Tools = @import("tools.zig");
-pub const Sprites = @import("sprites.zig");
+pub const files = @import("../../plugins/workbench/files.zig");
+pub const Tools = @import("../../plugins/pixelart/explorer/tools.zig");
+pub const Sprites = @import("../../plugins/pixelart/explorer/sprites.zig");
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
-pub const project = @import("project.zig");
+pub const project = @import("../../plugins/pixelart/explorer/project.zig");
pub const settings = @import("settings.zig");
sprites: Sprites = .{},
@@ -113,7 +113,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
.background = false,
});
- if (!fizzy.editor.host.isActiveSidebarView(@import("../../workbench/plugin.zig").view_files)) {
+ if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/plugin.zig").view_files)) {
fizzy.editor.file_tree_data_id = null;
if (fizzy.editor.tab_drag_from_tree_path) |p| {
fizzy.app.allocator.free(p);
diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig
index e4f5496c..0e669db4 100644
--- a/src/editor/panel/Panel.zig
+++ b/src/editor/panel/Panel.zig
@@ -11,7 +11,7 @@ const Packer = fizzy.Packer;
pub const Panel = @This();
-pub const Sprites = @import("sprites.zig");
+pub const Sprites = @import("../../plugins/pixelart/panel/sprites.zig");
sprites: Sprites = .{},
paned: *fizzy.dvui.PanedWidget = undefined,
diff --git a/src/editor/widgets/Widgets.zig b/src/editor/widgets/Widgets.zig
index 5ef58bf5..b0bc8d97 100644
--- a/src/editor/widgets/Widgets.zig
+++ b/src/editor/widgets/Widgets.zig
@@ -5,8 +5,8 @@ const dvui = @import("dvui");
pub const Widgets = @This();
-pub const FileWidget = @import("FileWidget.zig");
-pub const ImageWidget = @import("ImageWidget.zig");
+pub const FileWidget = @import("../../plugins/pixelart/widgets/FileWidget.zig");
+pub const ImageWidget = @import("../../plugins/pixelart/widgets/ImageWidget.zig");
pub const CanvasWidget = @import("CanvasWidget.zig");
pub const ReorderWidget = @import("ReorderWidget.zig");
pub const PanedWidget = @import("PanedWidget.zig");
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 62c47a6f..341faff0 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -13,7 +13,7 @@ pub const version: std.SemanticVersion = .{
pub const atlas = @import("generated/atlas.zig");
// Other helpers and namespaces
-pub const algorithms = @import("algorithms/algorithms.zig");
+pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig");
pub const fa = @import("tools/font_awesome.zig");
pub const fs = @import("tools/fs.zig");
pub const image = @import("gfx/image.zig");
@@ -26,7 +26,7 @@ pub const App = @import("App.zig");
pub const Editor = @import("editor/Editor.zig");
pub const Explorer = @import("editor/explorer/Explorer.zig");
pub const Fling = @import("editor/Fling.zig");
-pub const Packer = @import("tools/Packer.zig");
+pub const Packer = @import("plugins/pixelart/Packer.zig");
//pub const Popups = @import("editor/popups/Popups.zig");
pub const Sidebar = @import("editor/Sidebar.zig");
@@ -40,30 +40,30 @@ pub var packer: *Packer = undefined;
/// An example of this is File. fizzy.File matches the file type to read from JSON,
/// while the fizzy.Internal.File contains cameras, timers, file-specific editor fields.
pub const Internal = struct {
- pub const Animation = @import("internal/Animation.zig");
- pub const Atlas = @import("internal/Atlas.zig");
- pub const Buffers = @import("internal/Buffers.zig");
- pub const File = @import("internal/File.zig");
- pub const History = @import("internal/History.zig");
- pub const Layer = @import("internal/Layer.zig");
- pub const Palette = @import("internal/Palette.zig");
- pub const Sprite = @import("internal/Sprite.zig");
+ pub const Animation = @import("plugins/pixelart/internal/Animation.zig");
+ pub const Atlas = @import("plugins/pixelart/internal/Atlas.zig");
+ pub const Buffers = @import("plugins/pixelart/internal/Buffers.zig");
+ pub const File = @import("plugins/pixelart/internal/File.zig");
+ pub const History = @import("plugins/pixelart/internal/History.zig");
+ pub const Layer = @import("plugins/pixelart/internal/Layer.zig");
+ pub const Palette = @import("plugins/pixelart/internal/Palette.zig");
+ pub const Sprite = @import("plugins/pixelart/internal/Sprite.zig");
};
/// Frame-by-frame sprite animation
-pub const Animation = @import("Animation.zig");
+pub const Animation = @import("plugins/pixelart/Animation.zig");
/// Contains lists of sprites and animations
-pub const Atlas = @import("Atlas.zig");
+pub const Atlas = @import("plugins/pixelart/Atlas.zig");
/// The data that gets written to disk in a .pixi file and read back into this type
-pub const File = @import("File.zig");
+pub const File = @import("plugins/pixelart/File.zig");
/// Contains information such as the name, visibility and collapse settings of a texture layer
-pub const Layer = @import("Layer.zig");
+pub const Layer = @import("plugins/pixelart/Layer.zig");
/// Source location within the atlas texture and origin location
-pub const Sprite = @import("Sprite.zig");
+pub const Sprite = @import("plugins/pixelart/Sprite.zig");
/// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web
/// builds, where `builtin.os.tag` is always `.freestanding`.
diff --git a/src/gfx/image.zig b/src/gfx/image.zig
index b39c0110..f38682d9 100644
--- a/src/gfx/image.zig
+++ b/src/gfx/image.zig
@@ -1,7 +1,6 @@
const std = @import("std");
const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
-const zip = @import("zip");
pub fn init(width: u32, height: u32, default_color: dvui.Color.PMA, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource {
const num_pixels = width * height;
@@ -329,32 +328,12 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi
}
}
-fn ensurePngWriterBuffer(writer: *std.Io.Writer) !void {
+pub fn ensurePngWriterBuffer(writer: *std.Io.Writer) !void {
if (writer.buffer.len < dvui.PNGEncoder.min_buffer_size) {
try writer.rebase(0, dvui.PNGEncoder.min_buffer_size);
}
}
-pub fn writeToZip(
- source: dvui.ImageSource,
- zip_file: ?*anyopaque,
- resolution: u32,
-) !void {
- const s: dvui.Size = dvui.imageSize(source) catch .{ .w = 0, .h = 0 };
-
- const w = @as(c_int, @intFromFloat(s.w));
- const h = @as(c_int, @intFromFloat(s.h));
-
- var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator());
-
- try ensurePngWriterBuffer(&writer.writer);
- try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution);
-
- if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| {
- _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len));
- }
-}
-
pub fn writePngToWriter(source: dvui.ImageSource, writer: *std.Io.Writer, resolution: u32) !void {
const flat = try flatRgbaForEncode(source);
try ensurePngWriterBuffer(writer);
diff --git a/src/platform.zig b/src/platform.zig
index 575b8fa6..0c809af7 100644
--- a/src/platform.zig
+++ b/src/platform.zig
@@ -33,7 +33,7 @@ pub fn cacheFromWindow(win: *dvui.Window) void {
cached_is_macos = kb.command orelse false;
}
-/// True iff the running platform is macOS. Use this anywhere fizzy previously
+/// True if the running platform is macOS. Use this anywhere fizzy previously
/// had `builtin.os.tag == .macos` and the check needs to be right on web.
pub inline fn isMacOS() bool {
return cached_is_macos;
diff --git a/src/Animation.zig b/src/plugins/pixelart/Animation.zig
similarity index 100%
rename from src/Animation.zig
rename to src/plugins/pixelart/Animation.zig
diff --git a/src/Atlas.zig b/src/plugins/pixelart/Atlas.zig
similarity index 97%
rename from src/Atlas.zig
rename to src/plugins/pixelart/Atlas.zig
index ff1a5346..6e7749d6 100644
--- a/src/Atlas.zig
+++ b/src/plugins/pixelart/Atlas.zig
@@ -1,6 +1,6 @@
const std = @import("std");
-const fs = @import("tools/fs.zig");
-const fizzy = @import("fizzy.zig");
+const fs = @import("../../tools/fs.zig");
+const fizzy = @import("../../fizzy.zig");
const Atlas = @This();
diff --git a/src/pixelart/CanvasData.zig b/src/plugins/pixelart/CanvasData.zig
similarity index 99%
rename from src/pixelart/CanvasData.zig
rename to src/plugins/pixelart/CanvasData.zig
index 5367f544..d674b525 100644
--- a/src/pixelart/CanvasData.zig
+++ b/src/plugins/pixelart/CanvasData.zig
@@ -11,7 +11,7 @@
//! toasts) intentionally stays on `Workspace`.
const std = @import("std");
const dvui = @import("dvui");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const icons = @import("icons");
const Workspace = fizzy.Editor.Workspace;
diff --git a/src/editor/Colors.zig b/src/plugins/pixelart/Colors.zig
similarity index 85%
rename from src/editor/Colors.zig
rename to src/plugins/pixelart/Colors.zig
index 531f1cb4..5c987ee9 100644
--- a/src/editor/Colors.zig
+++ b/src/plugins/pixelart/Colors.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const Self = @This();
diff --git a/src/File.zig b/src/plugins/pixelart/File.zig
similarity index 98%
rename from src/File.zig
rename to src/plugins/pixelart/File.zig
index bb4cd017..6f0f786e 100644
--- a/src/File.zig
+++ b/src/plugins/pixelart/File.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const File = @This();
diff --git a/src/tools/LDTKTileset.zig b/src/plugins/pixelart/LDTKTileset.zig
similarity index 87%
rename from src/tools/LDTKTileset.zig
rename to src/plugins/pixelart/LDTKTileset.zig
index 86c67b96..09303032 100644
--- a/src/tools/LDTKTileset.zig
+++ b/src/plugins/pixelart/LDTKTileset.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const core = @import("mach").core;
pub const LDTKCompatibility = struct {
diff --git a/src/Layer.zig b/src/plugins/pixelart/Layer.zig
similarity index 100%
rename from src/Layer.zig
rename to src/plugins/pixelart/Layer.zig
diff --git a/src/editor/PackJob.zig b/src/plugins/pixelart/PackJob.zig
similarity index 99%
rename from src/editor/PackJob.zig
rename to src/plugins/pixelart/PackJob.zig
index d5202743..7dc1baa6 100644
--- a/src/editor/PackJob.zig
+++ b/src/plugins/pixelart/PackJob.zig
@@ -17,11 +17,11 @@
//! - `phase` / `cancelled` are atomic; either side may read or write them.
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const zstbi = @import("zstbi");
-const perf = @import("../gfx/perf.zig");
-const reduce_alg = @import("../algorithms/reduce.zig");
+const perf = @import("../../gfx/perf.zig");
+const reduce_alg = @import("algorithms/reduce.zig");
const PackJob = @This();
diff --git a/src/tools/Packer.zig b/src/plugins/pixelart/Packer.zig
similarity index 99%
rename from src/tools/Packer.zig
rename to src/plugins/pixelart/Packer.zig
index b6b5ef01..00f49532 100644
--- a/src/tools/Packer.zig
+++ b/src/plugins/pixelart/Packer.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const zstbi = @import("zstbi");
const dvui = @import("dvui");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
pub const LDTKTileset = @import("LDTKTileset.zig");
diff --git a/src/editor/Project.zig b/src/plugins/pixelart/Project.zig
similarity index 99%
rename from src/editor/Project.zig
rename to src/plugins/pixelart/Project.zig
index f7c63df3..c3cff907 100644
--- a/src/editor/Project.zig
+++ b/src/plugins/pixelart/Project.zig
@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const Project = @This();
diff --git a/src/Sprite.zig b/src/plugins/pixelart/Sprite.zig
similarity index 100%
rename from src/Sprite.zig
rename to src/plugins/pixelart/Sprite.zig
diff --git a/src/editor/Tools.zig b/src/plugins/pixelart/Tools.zig
similarity index 99%
rename from src/editor/Tools.zig
rename to src/plugins/pixelart/Tools.zig
index 68555989..8efce232 100644
--- a/src/editor/Tools.zig
+++ b/src/plugins/pixelart/Tools.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const Tools = @This();
diff --git a/src/editor/Transform.zig b/src/plugins/pixelart/Transform.zig
similarity index 99%
rename from src/editor/Transform.zig
rename to src/plugins/pixelart/Transform.zig
index 38d58931..5fe1550f 100644
--- a/src/editor/Transform.zig
+++ b/src/plugins/pixelart/Transform.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
pub const Transform = @This();
diff --git a/src/algorithms/algorithms.zig b/src/plugins/pixelart/algorithms/algorithms.zig
similarity index 100%
rename from src/algorithms/algorithms.zig
rename to src/plugins/pixelart/algorithms/algorithms.zig
diff --git a/src/algorithms/brezenham.zig b/src/plugins/pixelart/algorithms/brezenham.zig
similarity index 96%
rename from src/algorithms/brezenham.zig
rename to src/plugins/pixelart/algorithms/brezenham.zig
index f61ab318..46f2061b 100644
--- a/src/algorithms/brezenham.zig
+++ b/src/plugins/pixelart/algorithms/brezenham.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point {
diff --git a/src/algorithms/reduce.zig b/src/plugins/pixelart/algorithms/reduce.zig
similarity index 100%
rename from src/algorithms/reduce.zig
rename to src/plugins/pixelart/algorithms/reduce.zig
diff --git a/src/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c
similarity index 100%
rename from src/deps/msf_gif/fizzy_msf_gif_wasm.c
rename to src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c
diff --git a/src/deps/msf_gif/msf_gif.c b/src/plugins/pixelart/deps/msf_gif/msf_gif.c
similarity index 100%
rename from src/deps/msf_gif/msf_gif.c
rename to src/plugins/pixelart/deps/msf_gif/msf_gif.c
diff --git a/src/deps/msf_gif/msf_gif.h b/src/plugins/pixelart/deps/msf_gif/msf_gif.h
similarity index 100%
rename from src/deps/msf_gif/msf_gif.h
rename to src/plugins/pixelart/deps/msf_gif/msf_gif.h
diff --git a/src/deps/msf_gif/msf_gif.zig b/src/plugins/pixelart/deps/msf_gif/msf_gif.zig
similarity index 100%
rename from src/deps/msf_gif/msf_gif.zig
rename to src/plugins/pixelart/deps/msf_gif/msf_gif.zig
diff --git a/src/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h
similarity index 100%
rename from src/deps/msf_gif/wasm_shim/string.h
rename to src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h
diff --git a/src/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c
similarity index 100%
rename from src/deps/stbi/fizzy_stbi_libc.c
rename to src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c
diff --git a/src/deps/stbi/stb_image_resize2.h b/src/plugins/pixelart/deps/stbi/stb_image_resize2.h
similarity index 100%
rename from src/deps/stbi/stb_image_resize2.h
rename to src/plugins/pixelart/deps/stbi/stb_image_resize2.h
diff --git a/src/deps/stbi/stb_rect_pack.h b/src/plugins/pixelart/deps/stbi/stb_rect_pack.h
similarity index 100%
rename from src/deps/stbi/stb_rect_pack.h
rename to src/plugins/pixelart/deps/stbi/stb_rect_pack.h
diff --git a/src/deps/stbi/zstbi.c b/src/plugins/pixelart/deps/stbi/zstbi.c
similarity index 100%
rename from src/deps/stbi/zstbi.c
rename to src/plugins/pixelart/deps/stbi/zstbi.c
diff --git a/src/deps/stbi/zstbi.zig b/src/plugins/pixelart/deps/stbi/zstbi.zig
similarity index 100%
rename from src/deps/stbi/zstbi.zig
rename to src/plugins/pixelart/deps/stbi/zstbi.zig
diff --git a/src/deps/zip/build.zig b/src/plugins/pixelart/deps/zip/build.zig
similarity index 100%
rename from src/deps/zip/build.zig
rename to src/plugins/pixelart/deps/zip/build.zig
diff --git a/src/deps/zip/fizzy_zip_libc.c b/src/plugins/pixelart/deps/zip/fizzy_zip_libc.c
similarity index 100%
rename from src/deps/zip/fizzy_zip_libc.c
rename to src/plugins/pixelart/deps/zip/fizzy_zip_libc.c
diff --git a/src/deps/zip/fizzy_zip_strings.c b/src/plugins/pixelart/deps/zip/fizzy_zip_strings.c
similarity index 100%
rename from src/deps/zip/fizzy_zip_strings.c
rename to src/plugins/pixelart/deps/zip/fizzy_zip_strings.c
diff --git a/src/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h
similarity index 100%
rename from src/deps/zip/fizzy_zip_wasm.h
rename to src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h
diff --git a/src/deps/zip/src/miniz.h b/src/plugins/pixelart/deps/zip/src/miniz.h
similarity index 100%
rename from src/deps/zip/src/miniz.h
rename to src/plugins/pixelart/deps/zip/src/miniz.h
diff --git a/src/deps/zip/src/zip.c b/src/plugins/pixelart/deps/zip/src/zip.c
similarity index 100%
rename from src/deps/zip/src/zip.c
rename to src/plugins/pixelart/deps/zip/src/zip.c
diff --git a/src/deps/zip/src/zip.h b/src/plugins/pixelart/deps/zip/src/zip.h
similarity index 100%
rename from src/deps/zip/src/zip.h
rename to src/plugins/pixelart/deps/zip/src/zip.h
diff --git a/src/deps/zip/zip.zig b/src/plugins/pixelart/deps/zip/zip.zig
similarity index 100%
rename from src/deps/zip/zip.zig
rename to src/plugins/pixelart/deps/zip/zip.zig
diff --git a/src/editor/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig
similarity index 99%
rename from src/editor/dialogs/Export.zig
rename to src/plugins/pixelart/dialogs/Export.zig
index 7f009fe4..669b4079 100644
--- a/src/editor/dialogs/Export.zig
+++ b/src/plugins/pixelart/dialogs/Export.zig
@@ -1,16 +1,16 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const zigimg = @import("zigimg");
const msf_gif = @import("msf_gif");
const zstbi = @import("zstbi");
-const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../WebFileIo.zig") else struct {};
+const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../editor/WebFileIo.zig") else struct {};
const ExportImageFormat = enum { png, jpg };
-const Dialogs = @import("Dialogs.zig");
+const Dialogs = @import("../../../editor/dialogs/Dialogs.zig");
pub var mode: enum(usize) {
single,
diff --git a/src/editor/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig
similarity index 99%
rename from src/editor/dialogs/FlatRasterSaveWarning.zig
rename to src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig
index 26de3119..fd38b7b9 100644
--- a/src/editor/dialogs/FlatRasterSaveWarning.zig
+++ b/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save.
diff --git a/src/editor/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig
similarity index 99%
rename from src/editor/dialogs/GridLayout.zig
rename to src/plugins/pixelart/dialogs/GridLayout.zig
index 94e09510..66b3301f 100644
--- a/src/editor/dialogs/GridLayout.zig
+++ b/src/plugins/pixelart/dialogs/GridLayout.zig
@@ -6,14 +6,14 @@
//! preview on the right that expands with the window. The preview uses `CanvasWidget` so
//! panning / zooming honour `Settings.resolvedPanZoomScheme` (`auto` follows DVUI scroll heuristics).
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const std = @import("std");
const NewFile = @import("NewFile.zig");
-const CanvasWidget = @import("../widgets/CanvasWidget.zig");
+const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
const CanvasBridge = @import("../widgets/CanvasBridge.zig");
-const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig");
+const FloatingWindowWidget = @import("../../../editor/widgets/FloatingWindowWidget.zig");
const builtin = @import("builtin");
/// Editable grid fields for one mode (Slice vs Resize each keep their own backing).
diff --git a/src/editor/dialogs/NewFile.zig b/src/plugins/pixelart/dialogs/NewFile.zig
similarity index 98%
rename from src/editor/dialogs/NewFile.zig
rename to src/plugins/pixelart/dialogs/NewFile.zig
index a4a0a462..f6a591c9 100644
--- a/src/editor/dialogs/NewFile.zig
+++ b/src/plugins/pixelart/dialogs/NewFile.zig
@@ -1,8 +1,8 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
-const Dialogs = @import("Dialogs.zig");
+const Dialogs = @import("../../../editor/dialogs/Dialogs.zig");
pub var mode: enum(usize) {
single,
diff --git a/src/editor/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig
similarity index 99%
rename from src/editor/explorer/project.zig
rename to src/plugins/pixelart/explorer/project.zig
index ccc1bfe5..e6affcba 100644
--- a/src/editor/explorer/project.zig
+++ b/src/plugins/pixelart/explorer/project.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const builtin = @import("builtin");
const icons = @import("icons");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
pub fn draw() !void {
diff --git a/src/editor/explorer/sprites.zig b/src/plugins/pixelart/explorer/sprites.zig
similarity index 99%
rename from src/editor/explorer/sprites.zig
rename to src/plugins/pixelart/explorer/sprites.zig
index 8e0caea8..eaa90f5d 100644
--- a/src/editor/explorer/sprites.zig
+++ b/src/plugins/pixelart/explorer/sprites.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const Editor = fizzy.Editor;
const Sprites = @This();
diff --git a/src/editor/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig
similarity index 99%
rename from src/editor/explorer/tools.zig
rename to src/plugins/pixelart/explorer/tools.zig
index 2ec7f3b8..a6ed38d3 100644
--- a/src/editor/explorer/tools.zig
+++ b/src/plugins/pixelart/explorer/tools.zig
@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const icons = @import("icons");
const assets = @import("assets");
diff --git a/src/internal/Animation.zig b/src/plugins/pixelart/internal/Animation.zig
similarity index 100%
rename from src/internal/Animation.zig
rename to src/plugins/pixelart/internal/Animation.zig
diff --git a/src/internal/Atlas.zig b/src/plugins/pixelart/internal/Atlas.zig
similarity index 94%
rename from src/internal/Atlas.zig
rename to src/plugins/pixelart/internal/Atlas.zig
index 676e9f1f..d262c0ef 100644
--- a/src/internal/Atlas.zig
+++ b/src/plugins/pixelart/internal/Atlas.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const Atlas = @This();
@@ -64,7 +64,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
}
const bytes = try out.toOwnedSlice();
defer allocator.free(bytes);
- try @import("../editor/WebFileIo.zig").downloadBytes(path, bytes);
+ try @import("../../../editor/WebFileIo.zig").downloadBytes(path, bytes);
},
.data => {
if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) {
@@ -74,7 +74,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
const options: std.json.Stringify.Options = .{};
const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options);
defer allocator.free(output);
- try @import("../editor/WebFileIo.zig").downloadBytes(path, output);
+ try @import("../../../editor/WebFileIo.zig").downloadBytes(path, output);
},
}
return;
diff --git a/src/internal/Buffers.zig b/src/plugins/pixelart/internal/Buffers.zig
similarity index 98%
rename from src/internal/Buffers.zig
rename to src/plugins/pixelart/internal/Buffers.zig
index 88bb3c4f..968c63ca 100644
--- a/src/internal/Buffers.zig
+++ b/src/plugins/pixelart/internal/Buffers.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const History = @import("History.zig");
const Buffers = @This();
diff --git a/src/internal/File.zig b/src/plugins/pixelart/internal/File.zig
similarity index 99%
rename from src/internal/File.zig
rename to src/plugins/pixelart/internal/File.zig
index 4d5a66d0..5443e17d 100644
--- a/src/internal/File.zig
+++ b/src/plugins/pixelart/internal/File.zig
@@ -1,6 +1,6 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const pixelart = @import("../pixelart/plugin.zig");
+const fizzy = @import("../../../fizzy.zig");
+const pixelart = @import("../plugin.zig");
const zip = @import("zip");
const dvui = @import("dvui");
@@ -3151,15 +3151,15 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void {
defer snap.deinit(fizzy.app.allocator);
const bytes = try writeSnapshotToZipBytes(&snap, fizzy.app.allocator);
defer fizzy.app.allocator.free(bytes);
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
+ try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
} else if (std.mem.eql(u8, ext, ".png")) {
const bytes = try flattenedImageBytes(self, window, .png);
defer fizzy.app.allocator.free(bytes);
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes);
+ try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
const bytes = try flattenedImageBytes(self, window, .jpg);
defer fizzy.app.allocator.free(bytes);
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
+ try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
} else {
return;
}
@@ -3341,7 +3341,7 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
};
defer fizzy.app.allocator.free(bytes);
const dl_ext = if (is_png) ".png" else ".jpg";
- try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
+ try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
} else if (is_png) {
const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254));
try fizzy.image.writeToPngResolution(single_layer.source, output_path, r);
diff --git a/src/internal/History.zig b/src/plugins/pixelart/internal/History.zig
similarity index 99%
rename from src/internal/History.zig
rename to src/plugins/pixelart/internal/History.zig
index 2e2cf362..240c515f 100644
--- a/src/internal/History.zig
+++ b/src/plugins/pixelart/internal/History.zig
@@ -1,6 +1,6 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
-const pixelart = @import("../pixelart/plugin.zig");
+const fizzy = @import("../../../fizzy.zig");
+const pixelart = @import("../plugin.zig");
const zgui = @import("zgui");
const History = @This();
const Editor = fizzy.Editor;
diff --git a/src/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig
similarity index 96%
rename from src/internal/Layer.zig
rename to src/plugins/pixelart/internal/Layer.zig
index 73816b93..52a7275d 100644
--- a/src/internal/Layer.zig
+++ b/src/plugins/pixelart/internal/Layer.zig
@@ -1,6 +1,6 @@
const std = @import("std");
const dvui = @import("dvui");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const zip = @import("zip");
const Layer = @This();
@@ -416,7 +416,20 @@ pub fn writeSourceToZip(
zip_file: ?*anyopaque,
resolution: u32,
) !void {
- return fizzy.image.writeToZip(layer.source, zip_file, resolution);
+ const source = layer.source;
+ const s: dvui.Size = dvui.imageSize(source) catch .{ .w = 0, .h = 0 };
+
+ const w = @as(c_int, @intFromFloat(s.w));
+ const h = @as(c_int, @intFromFloat(s.h));
+
+ var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator());
+
+ try fizzy.image.ensurePngWriterBuffer(&writer.writer);
+ try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution);
+
+ if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| {
+ _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len));
+ }
}
pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void {
diff --git a/src/internal/Palette.zig b/src/plugins/pixelart/internal/Palette.zig
similarity index 97%
rename from src/internal/Palette.zig
rename to src/plugins/pixelart/internal/Palette.zig
index cefe2c2c..63e1b0f1 100644
--- a/src/internal/Palette.zig
+++ b/src/plugins/pixelart/internal/Palette.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const palette_parse = @import("palette_parse.zig");
diff --git a/src/internal/Sprite.zig b/src/plugins/pixelart/internal/Sprite.zig
similarity index 100%
rename from src/internal/Sprite.zig
rename to src/plugins/pixelart/internal/Sprite.zig
diff --git a/src/internal/grid_layout_validate.zig b/src/plugins/pixelart/internal/grid_layout_validate.zig
similarity index 100%
rename from src/internal/grid_layout_validate.zig
rename to src/plugins/pixelart/internal/grid_layout_validate.zig
diff --git a/src/internal/layer_order.zig b/src/plugins/pixelart/internal/layer_order.zig
similarity index 100%
rename from src/internal/layer_order.zig
rename to src/plugins/pixelart/internal/layer_order.zig
diff --git a/src/internal/palette_parse.zig b/src/plugins/pixelart/internal/palette_parse.zig
similarity index 100%
rename from src/internal/palette_parse.zig
rename to src/plugins/pixelart/internal/palette_parse.zig
diff --git a/src/editor/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig
similarity index 99%
rename from src/editor/panel/sprites.zig
rename to src/plugins/pixelart/panel/sprites.zig
index e685370b..3ffcded9 100644
--- a/src/editor/panel/sprites.zig
+++ b/src/plugins/pixelart/panel/sprites.zig
@@ -1,7 +1,7 @@
const std = @import("std");
const icons = @import("icons");
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const Editor = fizzy.Editor;
const ReflectionLagSample = fizzy.dvui.ReflectionLagSample;
const reflection_surface_cols = fizzy.dvui.reflection_surface_cols;
diff --git a/src/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig
similarity index 99%
rename from src/pixelart/plugin.zig
rename to src/plugins/pixelart/plugin.zig
index 0db81045..130b386f 100644
--- a/src/pixelart/plugin.zig
+++ b/src/plugins/pixelart/plugin.zig
@@ -4,7 +4,7 @@
//! through the `fizzy.*` globals. Registered from `Editor.postInit`.
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
const CanvasData = @import("CanvasData.zig");
diff --git a/src/editor/widgets/CanvasBridge.zig b/src/plugins/pixelart/widgets/CanvasBridge.zig
similarity index 88%
rename from src/editor/widgets/CanvasBridge.zig
rename to src/plugins/pixelart/widgets/CanvasBridge.zig
index 4b1cf339..08f7aaa8 100644
--- a/src/editor/widgets/CanvasBridge.zig
+++ b/src/plugins/pixelart/widgets/CanvasBridge.zig
@@ -1,8 +1,8 @@
//! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the
//! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable
//! viewport; these helpers supply the pixel-art editor's wiring at the install sites.
-const fizzy = @import("../../fizzy.zig");
-const CanvasWidget = @import("CanvasWidget.zig");
+const fizzy = @import("../../../fizzy.zig");
+const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum.
pub fn scheme() CanvasWidget.PanZoomScheme {
diff --git a/src/editor/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig
similarity index 99%
rename from src/editor/widgets/FileWidget.zig
rename to src/plugins/pixelart/widgets/FileWidget.zig
index cd4c8590..a6ac74c3 100644
--- a/src/editor/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/widgets/FileWidget.zig
@@ -1,7 +1,7 @@
const std = @import("std");
const math = std.math;
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const builtin = @import("builtin");
const sdl3 = @import("backend").c;
@@ -16,10 +16,10 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget;
const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
-const CanvasWidget = @import("CanvasWidget.zig");
+const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
const CanvasBridge = @import("CanvasBridge.zig");
const Workspace = fizzy.Editor.Workspace;
-const CanvasData = @import("../../pixelart/CanvasData.zig");
+const CanvasData = @import("../CanvasData.zig");
const icons = @import("icons");
// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is
@@ -1643,7 +1643,7 @@ pub fn drawSpriteBubble(
self.init_options.file.collapseAnimationSelectionToPrimary();
self.init_options.file.editor.animations_scroll_to_index = anim_index;
fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
- fizzy.editor.host.setActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites);
+ fizzy.editor.host.setActiveSidebarView(@import("../plugin.zig").view_sprites);
var anim = self.init_options.file.animations.get(anim_index);
if (anim.frames.len == 0) {
@@ -4626,7 +4626,7 @@ pub fn drawLayers(self: *FileWidget) void {
const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect);
// Draw the origins when in the sprites pane
- if (fizzy.editor.host.isActiveSidebarView(@import("../../pixelart/plugin.zig").view_sprites)) {
+ if (fizzy.editor.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) {
const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] };
const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y };
diff --git a/src/editor/widgets/ImageWidget.zig b/src/plugins/pixelart/widgets/ImageWidget.zig
similarity index 99%
rename from src/editor/widgets/ImageWidget.zig
rename to src/plugins/pixelart/widgets/ImageWidget.zig
index cf7ec299..68373922 100644
--- a/src/editor/widgets/ImageWidget.zig
+++ b/src/plugins/pixelart/widgets/ImageWidget.zig
@@ -1,5 +1,5 @@
pub const ImageWidget = @This();
-const CanvasWidget = @import("CanvasWidget.zig");
+const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
const CanvasBridge = @import("CanvasBridge.zig");
init_options: InitOptions,
@@ -469,7 +469,7 @@ const ScaleWidget = dvui.ScaleWidget;
const std = @import("std");
const math = std.math;
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const builtin = @import("builtin");
test {
diff --git a/src/workbench/FileLoadJob.zig b/src/plugins/workbench/FileLoadJob.zig
similarity index 98%
rename from src/workbench/FileLoadJob.zig
rename to src/plugins/workbench/FileLoadJob.zig
index ef7119cd..c8305d7e 100644
--- a/src/workbench/FileLoadJob.zig
+++ b/src/plugins/workbench/FileLoadJob.zig
@@ -15,9 +15,9 @@
//! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields.
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const perf = @import("../gfx/perf.zig");
+const perf = @import("../../gfx/perf.zig");
const FileLoadJob = @This();
diff --git a/src/workbench/Workbench.zig b/src/plugins/workbench/Workbench.zig
similarity index 99%
rename from src/workbench/Workbench.zig
rename to src/plugins/workbench/Workbench.zig
index 16dcae66..d799a8ef 100644
--- a/src/workbench/Workbench.zig
+++ b/src/plugins/workbench/Workbench.zig
@@ -11,7 +11,7 @@
const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const files = @import("files.zig");
pub const Workbench = @This();
@@ -88,7 +88,7 @@ fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void {
/// explorer rows.
///
/// Cross-boundary types are normal Zig (host + plugins share one pinned SDK build),
-/// so this is a plain vtable struct; only the dlopen entry symbols (Phase 4) need
+/// so this is a plain vtable struct; only the dlopen entry symbols need
/// `callconv(.c)`. The implementation lives below; `ctx` is the host's `*Editor`.
pub const Api = struct {
/// Service-locator key for `host.registerService` / `host.getService`.
diff --git a/src/workbench/Workspace.zig b/src/plugins/workbench/Workspace.zig
similarity index 99%
rename from src/workbench/Workspace.zig
rename to src/plugins/workbench/Workspace.zig
index 75a0cb74..38222eb5 100644
--- a/src/workbench/Workspace.zig
+++ b/src/plugins/workbench/Workspace.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const icons = @import("icons");
const App = fizzy.App;
diff --git a/src/workbench/files.zig b/src/plugins/workbench/files.zig
similarity index 99%
rename from src/workbench/files.zig
rename to src/plugins/workbench/files.zig
index ba988b14..9b2f03ea 100644
--- a/src/workbench/files.zig
+++ b/src/plugins/workbench/files.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const builtin = @import("builtin");
@@ -7,7 +7,6 @@ const builtin = @import("builtin");
const icons = @import("icons");
const nfd = @import("nfd");
-const zstbi = @import("zstbi");
pub var tree_removed_path: ?[]const u8 = null;
pub var selected_id: ?usize = null;
diff --git a/src/workbench/plugin.zig b/src/plugins/workbench/plugin.zig
similarity index 98%
rename from src/workbench/plugin.zig
rename to src/plugins/workbench/plugin.zig
index 8fb6afdd..8e6ed926 100644
--- a/src/workbench/plugin.zig
+++ b/src/plugins/workbench/plugin.zig
@@ -3,7 +3,7 @@
//! than owning new code. Later phases move more behind it until it becomes a
//! runtime-loaded dylib. Registered from `Editor.postInit`.
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
const files = @import("files.zig");
diff --git a/src/tools/process_assets.zig b/src/tools/process_assets.zig
index 3597b0eb..d596bfdb 100644
--- a/src/tools/process_assets.zig
+++ b/src/tools/process_assets.zig
@@ -3,7 +3,7 @@ const path = std.fs.path;
const Step = std.Build.Step;
const Io = std.Io;
-const Atlas = @import("../Atlas.zig");
+const Atlas = @import("../plugins/pixelart/Atlas.zig");
const ProcessAssetsStep = @This();
step: Step,
diff --git a/src/internal/window_layout.zig b/src/window_layout.zig
similarity index 98%
rename from src/internal/window_layout.zig
rename to src/window_layout.zig
index dd15f2f3..4e8cccfe 100644
--- a/src/internal/window_layout.zig
+++ b/src/window_layout.zig
@@ -2,7 +2,8 @@
//! (`backend_native.zig` + `objc/FizzyWindowMonitor.m`), so the "+/- titlebar
//! height" math is testable without a window. std-only — pulled in by
//! `tests/root.zig` and called from `backend_native.zig` (which keeps the
-//! AppKit/SDL plumbing). See `src/internal/window_layout` notes in the plan.
+//! AppKit/SDL plumbing). Shell/native-windowing infra (not pixel-art), so it lives at
+//! `src/window_layout.zig` beside `backend_native.zig` rather than under `internal/`.
const std = @import("std");
diff --git a/tests/README.md b/tests/README.md
index 39241bac..7b226459 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -67,9 +67,9 @@ covered:
direction encoding, `fromRadians`, rotation inverses.
- `[src/math/easing.zig](../src/math/easing.zig)` — `lerp`, `ease`,
endpoint pinning, midpoint bias.
-- `[src/internal/layer_order.zig](../src/internal/layer_order.zig)` —
+- `[src/plugins/pixelart/internal/layer_order.zig](../src/plugins/pixelart/internal/layer_order.zig)` —
the layer-reorder algorithm used by the layers tree drag-and-drop.
-- `[src/internal/palette_parse.zig](../src/internal/palette_parse.zig)`
+- `[src/plugins/pixelart/internal/palette_parse.zig](../src/plugins/pixelart/internal/palette_parse.zig)`
— `.hex` palette file parser (valid hex, comments/blanks, malformed
input, CRLF).
From 879a3657f532dbc9a00c6c3c3e408503a4459205 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 10:39:52 -0500
Subject: [PATCH 14/49] Begin Phase 4
---
src/dvui.zig | 697 +----------------------
src/fizzy.zig | 6 +-
src/gfx/image.zig | 2 +-
src/math/color.zig | 15 +
src/math/math.zig | 1 +
src/plugins/pixelart/internal/Layer.zig | 16 +-
src/plugins/pixelart/panel/sprites.zig | 6 +-
src/{gfx => plugins/pixelart}/render.zig | 4 +-
src/plugins/pixelart/sprite_render.zig | 694 ++++++++++++++++++++++
src/plugins/workbench/Workspace.zig | 2 +-
src/plugins/workbench/files.zig | 2 +-
11 files changed, 729 insertions(+), 716 deletions(-)
rename src/{gfx => plugins/pixelart}/render.zig (99%)
create mode 100644 src/plugins/pixelart/sprite_render.zig
diff --git a/src/dvui.zig b/src/dvui.zig
index 9966490b..87bd287c 100644
--- a/src/dvui.zig
+++ b/src/dvui.zig
@@ -101,17 +101,10 @@ pub const DialogOptions = struct {
};
pub fn defaultDialogDisplay(id: dvui.Id) anyerror!bool {
- const valid: bool = true;
-
+ // Placeholder body; every real dialog supplies its own `displayFn`. Kept free
+ // of plugin (atlas/sprite) draws so the core dialog code stays plugin-agnostic.
_ = id;
-
- _ = fizzy.dvui.sprite(@src(), .{
- .source = fizzy.editor.atlas.source,
- .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.fox_default],
- .scale = 2.0,
- }, .{ .gravity_y = 0.5, .gravity_x = 0.5, .background = false });
-
- return valid;
+ return true;
}
pub fn defaultDialogCallAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void {
@@ -949,690 +942,6 @@ pub fn saveCompleteToastDisplay(id: dvui.Id) !void {
}
}
-pub const SpriteInitOptions = struct {
- source: dvui.ImageSource,
- file: ?*fizzy.Internal.File = null,
- alpha_source: ?dvui.ImageSource = null,
- sprite: fizzy.Atlas.Sprite,
- scale: f32 = 1.0,
- depth: f32 = 0.0, // -1.0 is front, 1.0 is back
- reflection: bool = false,
- overlap: f32 = 0.0,
- /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out
- /// toward the background the further they sit from the focus.
- opacity: f32 = 1.0,
- /// Vertical shift (logical px, positive = down) applied to the reflection
- /// only. Lets the reflection slide away from the card — e.g. as a card flies
- /// up out of view, its reflection sinks down, like peeling off a waterline.
- reflection_offset: f32 = 0.0,
- /// Depth-lagged reflection grid (logical px); rows shear while scrolling and ripple on settle.
- reflection_lag: ?ReflectionLagSample = null,
- /// Reflection mesh density multiplier in (0, 1]. 1.0 = full per-zoom density;
- /// lower values coarsen the (O(n²)) mesh. Callers pass <1 for distant/skewed
- /// cards so only the head-on focus cards pay for a fine, high-res reflection.
- reflection_detail: f32 = 1.0,
-};
-
-/// Columns the reflection mesh samples across a card's width (waterline strip).
-/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card.
-pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols;
-
-/// Reflection-only waterline sample across the card width (logical px). `cols_dx`
-/// is horizontal refraction from surface slope; `cols_dy` is vertical height at
-/// the seam (positive = down). The card itself stays flat — only the reflection
-/// mesh pins its top edge and propagates ripples downward.
-pub const ReflectionLagSample = struct {
- cols_dx: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols,
- cols_dy: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols,
-};
-
-pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData {
- const source_size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 };
-
- const overlap: f32 = 1.0 - init_opts.overlap;
-
- const uv = dvui.Rect{
- .x = @as(f32, @floatFromInt(init_opts.sprite.source[0])) / source_size.w,
- .y = @as(f32, @floatFromInt(init_opts.sprite.source[1])) / source_size.h,
- .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) / source_size.w,
- .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) / source_size.h,
- };
-
- const options = (dvui.Options{ .name = "sprite" }).override(opts);
-
- var size = dvui.Size{};
- if (options.min_size_content) |msc| {
- // user gave us a min size, use it
- size = msc;
- } else {
- // user didn't give us one, use natural size
- size = .{ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) * init_opts.scale * overlap, .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) * init_opts.scale * overlap };
- }
-
- var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size }));
- wd.register();
-
- const cr = wd.contentRect();
- const ms = wd.options.min_size_contentGet();
-
- var too_big = false;
- if (ms.w > cr.w or ms.h > cr.h) {
- too_big = true;
- }
-
- var e = wd.options.expandGet();
- const g = wd.options.gravityGet();
- var rect = dvui.placeIn(cr, ms, e, g);
-
- if (too_big and e != .ratio) {
- if (ms.w > cr.w and !e.isHorizontal()) {
- rect.w = ms.w;
- rect.x -= g.x * (ms.w - cr.w);
- }
-
- if (ms.h > cr.h and !e.isVertical()) {
- rect.h = ms.h;
- rect.y -= g.y * (ms.h - cr.h);
- }
- }
-
- // rect is the content rect, so expand to the whole rect
- wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet());
-
- var renderBackground: ?dvui.Color = if (wd.options.backgroundGet()) wd.options.color(.fill) else null;
-
- if (wd.options.rotationGet() == 0.0) {
- wd.borderAndBackground(.{});
- renderBackground = null;
- } else {
- if (wd.options.borderGet().nonZero()) {
- dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id});
- }
- }
-
- var path: dvui.Path.Builder = .init(dvui.currentWindow().arena());
- defer path.deinit();
-
- var top_left = wd.contentRectScale().r.topLeft();
- var top_right = wd.contentRectScale().r.topRight();
- var bottom_right = wd.contentRectScale().r.bottomRight();
- var bottom_left = wd.contentRectScale().r.bottomLeft();
-
- if (init_opts.depth > 0) {
- top_left = top_left.plus(bottom_right.diff(top_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical));
- bottom_left = bottom_left.plus(top_right.diff(bottom_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical));
- } else {
- top_right = top_right.plus(bottom_right.diff(top_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical));
- bottom_right = bottom_right.plus(top_right.diff(bottom_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical));
- }
-
- const lag_active = init_opts.reflection_lag != null;
- const reflection_lag_phys: ?ReflectionLagSample = if (lag_active) reflectionLagSamplePhysical(
- init_opts.reflection_lag.?,
- wd.contentRectScale().s,
- ) else null;
-
- path.addPoint(top_left);
- path.addPoint(top_right);
- path.addPoint(bottom_right);
- path.addPoint(bottom_left);
-
- // Distance fade toward transparent: `fade_white` tints textured draws by the
- // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1.
- const op = std.math.clamp(init_opts.opacity, 0.0, 1.0);
- const fade_white = dvui.Color.white.opacity(op);
-
- // Cover-flow fast path: when a file's layer stack is fully flattenable, the
- // checker + layers + selection + temp are baked into one texture once per
- // frame, so each card (front and reflection) is a single textured pass
- // instead of several overlapping alpha-blended fills. Null → multi-pass path.
- const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null;
-
- if (init_opts.reflection) {
- var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena());
- defer path2.deinit();
-
- // Direct vertical mirror: reflect each (already skewed) top corner straight
- // down through its bottom corner, so the reflection is a true flip of the
- // card — same width and skew at every height, sharing the bottom edge —
- // rather than a trapezoid that flares outward. pathToSubdividedQuad reads
- // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top
- // and the near edge (br, bl) the sprite bottom, giving the mirrored uv.
- // `refl_off` slides the whole reflection down independently of the card.
- const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s };
- path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off));
- path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off));
- path2.addPoint(bottom_right.plus(refl_off));
- path2.addPoint(bottom_left.plus(refl_off));
-
- const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h);
- // Subdivide in proportion to on-screen size so the *physical* ripple density
- // stays constant across zoom — a big (zoomed-in) card gets many more verts,
- // rendering the fine field detail instead of undersampling it into coarse
- // waves. (The field already carries dense ripples at `cols_per_slot`.)
- const base_subdivisions_f = std.math.clamp(preview_extent / 13.0, 14.0, 44.0);
- // The mesh is O(subdivisions²) and is rebuilt + rendered per layer for every
- // card. Only the head-on focus cards need the fine, high-res ripple; skewed
- // shelf cards pass a low `reflection_detail` so they fall to the coarse floor
- // and stay cheap, which is what keeps the shelf affordable on slower GPUs.
- const detail = std.math.clamp(init_opts.reflection_detail, 0.0, 1.0);
- const subdivisions_f = @max(6.0, base_subdivisions_f * detail);
- const subdivisions: usize = @intFromFloat(subdivisions_f);
-
- if (init_opts.alpha_source) |alpha_source| preview: {
- const reflection_path = path2.build();
-
- const reflection_lag = reflection_lag_phys orelse ReflectionLagSample{};
- const displacement_max = wd.contentRectScale().r.h * 0.52;
- const refl_lag = if (lag_active) reflection_lag else null;
-
- if (preview_tex) |ptex| {
- // Single textured pass: checker + layers + selection + temp are
- // pre-flattened into the preview composite, so the reflection is one
- // draw instead of replaying the whole stack per card.
- var refl = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{
- .subdivisions = subdivisions,
- .uv = uv,
- .vertical_fade = true,
- .color_mod = fade_white,
- .reflection_lag = refl_lag,
- .waterline_propagate = true,
- .displacement_max = displacement_max,
- }) catch unreachable;
- defer refl.deinit(dvui.currentWindow().arena());
- dvui.renderTriangles(refl, ptex) catch {
- dvui.log.err("Failed to render reflection preview composite", .{});
- };
- break :preview;
- }
-
- // Build two meshes from the same path so vertex positions match (shared
- // ripple) but UVs differ: bg uses the full quad for checkerboard alpha,
- // layers use the sprite atlas rect.
- var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{
- .subdivisions = subdivisions,
- .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op),
- .vertical_fade = true,
- .reflection_lag = refl_lag,
- .waterline_propagate = true,
- .displacement_max = displacement_max,
- }) catch unreachable;
- defer reflection_triangles_bg.deinit(dvui.currentWindow().arena());
-
- var reflection_triangles_layers = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{
- .subdivisions = subdivisions,
- .uv = uv,
- .vertical_fade = true,
- .color_mod = fade_white,
- .reflection_lag = refl_lag,
- .waterline_propagate = true,
- .displacement_max = displacement_max,
- }) catch unreachable;
- defer reflection_triangles_layers.deinit(dvui.currentWindow().arena());
-
- var reflection_triangles_layers_dimmed = reflection_triangles_layers.dupe(dvui.currentWindow().arena()) catch unreachable;
- defer reflection_triangles_layers_dimmed.deinit(dvui.currentWindow().arena());
- reflection_triangles_layers_dimmed.color(.gray);
-
- dvui.renderTriangles(reflection_triangles_bg, alpha_source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
-
- if (init_opts.file) |file| {
- const preview_opts = fizzy.render.RenderFileOptions{
- .file = file,
- .rs = .{
- .r = wd.contentRectScale().r,
- .s = wd.contentRectScale().s,
- },
- .uv = uv,
- .corner_radius = .all(0),
- };
- fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| {
- dvui.log.err("Failed to render reflection layer stack: {any}", .{err});
- };
-
- dvui.renderTriangles(reflection_triangles_layers, file.editor.selection_layer.source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
-
- // Match renderLayers: use cached GPU texture when the canvas has already uploaded this frame.
- // Avoids getTexture() on .pixelsPMA sources (would upload when invalidation is .always).
- if (file.editor.temp_layer_has_content or file.editor.temp_gpu_dirty_rect != null) {
- const temp_src = file.editor.temporary_layer.source;
- const temp_key = temp_src.hash();
- if (dvui.textureGetCached(temp_key)) |tex| {
- dvui.renderTriangles(reflection_triangles_layers, tex) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- } else {
- dvui.renderTriangles(reflection_triangles_layers, temp_src.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- }
- }
- } else {
- dvui.renderTriangles(reflection_triangles_layers, init_opts.source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- }
- }
- }
-
- // The preview composite already bakes the content-fill base + checkerboard,
- // so skip the separate base/checker passes when it's in use.
- if (preview_tex == null) {
- if (init_opts.alpha_source) |alpha_source| {
- if (init_opts.depth != 0.0) {
- // Skew the opaque base along with the art so no axis-aligned sliver
- // of fill colour pokes out past the receding edge.
- var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{
- .subdivisions = 8,
- .color_mod = dvui.themeGet().color(.content, .fill).opacity(op),
- }) catch unreachable;
- defer base_triangles.deinit(dvui.currentWindow().arena());
- dvui.renderTriangles(base_triangles, null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- } else {
- wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 });
- }
-
- const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{
- .subdivisions = 8,
- .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op),
- }) catch unreachable;
- dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- }
- }
-
- if (preview_tex) |ptex| {
- // Front card: one textured pass from the baked preview composite. Skewed
- // cards build a subdivided quad so the art tilts like a record on a shelf;
- // head-on cards use the plain quad.
- const front_path = if (init_opts.depth != 0.0) blk: {
- var q: dvui.Path.Builder = .init(dvui.currentWindow().arena());
- q.addPoint(top_left);
- q.addPoint(top_right);
- q.addPoint(bottom_right);
- q.addPoint(bottom_left);
- break :blk q.build();
- } else path.build();
- var tris = pathToSubdividedQuad(front_path, dvui.currentWindow().arena(), .{
- .subdivisions = 8,
- .uv = uv,
- .color_mod = fade_white,
- }) catch unreachable;
- defer tris.deinit(dvui.currentWindow().arena());
- dvui.renderTriangles(tris, ptex) catch {
- dvui.log.err("Failed to render sprite preview composite", .{});
- };
- } else if (init_opts.file) |file| {
- fizzy.render.renderLayers(.{
- .file = file,
- .rs = .{
- .r = wd.contentRectScale().r,
- .s = wd.contentRectScale().s,
- },
- .uv = uv,
- .corner_radius = .all(0),
- .color_mod = fade_white,
- // When skewed, render the layer stack into the same quad as the
- // background so the art tilts like a record on a shelf.
- .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null,
- }) catch {
- dvui.log.err("Failed to render layers", .{});
- };
- } else {
- const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{
- .subdivisions = 8,
- .uv = uv,
- .color_mod = fade_white,
- }) catch unreachable;
-
- dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch {
- dvui.log.err("Failed to render triangles", .{});
- };
- }
-
- path.build().stroke(.{ .color = opts.color_border orelse .transparent, .thickness = 1.0, .closed = true });
-
- wd.minSizeSetAndRefresh();
- wd.minSizeReportToParent();
-
- return wd;
-}
-
-pub const PathToSubdividedQuadOptions = struct {
- subdivisions: usize = 4,
- uv: ?dvui.Rect = null,
- vertical_fade: bool = false,
- color_mod: dvui.Color = .white,
- reflection_lag: ?ReflectionLagSample = null,
- /// When true, reflection meshes refract ripples deeper below the seam.
- waterline_propagate: bool = true,
- /// Cap vertex offset (physical px) so ripples stay inside the reflection.
- displacement_max: f32 = 0.0,
-};
-
-fn reflectionLagSamplePhysical(sample: ReflectionLagSample, scale: f32) ReflectionLagSample {
- var out = sample;
- for (&out.cols_dx) |*c| c.* *= scale;
- for (&out.cols_dy) |*c| c.* *= scale;
- return out;
-}
-
-/// Linear interpolation across the column strip by horizontal fraction `t_x`.
-/// Per-row reflection factors, hoisted out of the per-vertex loop. The two `pow`
-/// calls (depth lag + seam pin) depend only on the row (`t_y`), so computing them
-/// once per row instead of per vertex removes thousands of `pow` calls per frame.
-const ReflectionRow = struct {
- low_submerge: bool,
- lag: f32,
- lag_mix: f32, // already × 0.55
- submerge_scale: f32, // lerp(1, 1.25, submerge)
- dx_pin: f32,
-};
-
-fn reflectionRowFactors(t_y: f32) ReflectionRow {
- const submerge = 1.0 - std.math.clamp(t_y, 0, 1);
- const seam_t = std.math.clamp(t_y, 0, 1);
- return .{
- .low_submerge = submerge <= 0.001,
- .lag = std.math.pow(f32, submerge, 1.55) * 0.74,
- .lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1) * 0.55,
- .submerge_scale = std.math.lerp(1.0, 1.25, submerge),
- .dx_pin = 1.0 - std.math.pow(f32, seam_t, 4.5),
- };
-}
-
-/// Horizontal refraction for one vertex using precomputed row factors. Equivalent
-/// to `reflectionMeshDisplacement(.x)`, just with the row-constant work hoisted.
-fn reflectionRowDx(t_x: f32, dx_seam: f32, row: ReflectionRow, sample: ReflectionLagSample) f32 {
- // `dx_seam` (the column's refraction at the seam) is supplied precomputed — it
- // depends only on t_x, so the caller resolves it once per column. Only the
- // depth-lagged sample, which shifts t_x by the row's phase lag, needs an interp.
- const t_lag = if (row.low_submerge)
- t_x
- else
- std.math.clamp(t_x - (if (dx_seam >= 0) row.lag else -row.lag), 0, 1);
- const dx_lag = if (row.low_submerge) dx_seam else interpolateReflectionCols(&sample.cols_dx, t_lag);
- return std.math.lerp(dx_seam, dx_lag, row.lag_mix) * row.submerge_scale * row.dx_pin;
-}
-
-fn interpolateReflectionCols(cols: []const f32, t_x: f32) f32 {
- if (cols.len == 0) return 0;
- if (cols.len == 1) return cols[0];
- const f = std.math.clamp(t_x, 0, 1) * @as(f32, @floatFromInt(cols.len - 1));
- const idx0: usize = @intFromFloat(@floor(f));
- const idx1 = @min(idx0 + 1, cols.len - 1);
- const t = f - @as(f32, @floatFromInt(idx0));
- return std.math.lerp(cols[idx0], cols[idx1], t);
-}
-
-fn clampDisplacement(d: dvui.Point.Physical, max_mag: f32) dvui.Point.Physical {
- if (max_mag <= 0.0001) return d;
- const mag = @sqrt(d.x * d.x + d.y * d.y);
- if (mag <= max_mag) return d;
- const s = max_mag / mag;
- return .{ .x = d.x * s, .y = d.y * s };
-}
-
-/// Depth into the reflection body (0 at the waterline seam, 1 at the far edge).
-fn reflectionSubmergeDepth(t_y: f32) f32 {
- return 1.0 - std.math.clamp(t_y, 0, 1);
-}
-
-/// Expanding ripple: larger displacement toward the reflection bottom. Rises
-/// quickly just below the seam (so the effect is still strong in the upper region
-/// that stays on-screen when zoomed in and the reflection's bottom is clipped),
-/// then keeps growing toward the far edge for the full zoomed-out slosh.
-fn reflectionDepthAmplitude(submerge: f32) f32 {
- const d = std.math.clamp(submerge, 0, 1);
- return 1.0 + d * (1.8 + 1.4 * d);
-}
-
-/// Phase lag vs depth — deeper rows follow the same wave, slower and larger.
-fn reflectionDepthLag(submerge: f32) f32 {
- const d = std.math.clamp(submerge, 0, 1);
- return std.math.pow(f32, d, 1.55) * 0.74;
-}
-
-/// Sample the surface field with increasing horizontal phase lag at depth.
-fn reflectionLaggedTx(t_x: f32, cols_dx: []const f32, submerge: f32) f32 {
- if (submerge <= 0.001) return t_x;
- const lag = reflectionDepthLag(submerge);
- const slope = interpolateReflectionCols(cols_dx, t_x);
- const dir: f32 = if (slope >= 0) 1 else -1;
- return std.math.clamp(t_x - dir * lag, 0, 1);
-}
-
-/// Reflection mesh: seam pinned at the waterline; the body carries horizontal
-/// refraction ripples that phase-lag with depth. cols_dy is not applied.
-fn reflectionMeshDisplacement(t_x: f32, t_y: f32, sample: ReflectionLagSample) dvui.Point.Physical {
- const submerge = reflectionSubmergeDepth(t_y);
- const t_lag = reflectionLaggedTx(t_x, &sample.cols_dx, submerge);
- const lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1);
-
- const seam_t = std.math.clamp(t_y, 0, 1);
- // Peak refraction just under the card base (not mid-body / far edge); seam
- // corners stay pinned so the base width still matches the card.
- const dx_pin = std.math.pow(f32, seam_t, 1.4) * (1.0 - std.math.pow(f32, seam_t, 12.0));
- const dx_seam = interpolateReflectionCols(&sample.cols_dx, t_x);
- const dx_lag = interpolateReflectionCols(&sample.cols_dx, t_lag);
- const dx = std.math.lerp(dx_seam, dx_lag, lag_mix * 0.55) * std.math.lerp(1.0, 1.25, submerge) * dx_pin;
-
- return .{ .x = dx, .y = 0 };
-}
-
-fn waterlineMeshDisplacement(
- t_x: f32,
- t_y: f32,
- sample: ReflectionLagSample,
- propagate: bool,
-) dvui.Point.Physical {
- if (propagate) return reflectionMeshDisplacement(t_x, t_y, sample);
- const s = std.math.clamp(t_y, 0, 1);
- const strength = s * (0.1 + 0.9 * s);
- return .{
- .x = interpolateReflectionCols(&sample.cols_dx, t_x) * strength,
- .y = 0,
- };
-}
-
-fn reflectionCombinedDisplacement(t_x: f32, t_y: f32, options: PathToSubdividedQuadOptions) dvui.Point.Physical {
- var d: dvui.Point.Physical = .{ .x = 0, .y = 0 };
- if (options.reflection_lag) |sample| {
- d = d.plus(waterlineMeshDisplacement(t_x, t_y, sample, options.waterline_propagate));
- }
- return clampDisplacement(d, options.displacement_max);
-}
-
-pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, options: PathToSubdividedQuadOptions) std.mem.Allocator.Error!dvui.Triangles {
- if (path.points.len != 4) {
- return .empty;
- }
-
- const subdivs = options.subdivisions;
- const vtx_count = (subdivs + 1) * (subdivs + 1);
- const idx_count = 2 * subdivs * subdivs * 3;
-
- var builder = try dvui.Triangles.Builder.init(allocator, vtx_count, idx_count);
- errdefer comptime unreachable;
-
- // Four quad corners in order: tl, tr, br, bl
- const tl = path.points[0];
- const tr = path.points[1];
- const br = path.points[2];
- const bl = path.points[3];
-
- // Use given UV or default to (0,0,1,1)
- const base_uv = options.uv orelse dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 };
-
- {
- // The seam refraction for a reflection mesh depends only on the column
- // (t_x), so precompute it once per column and reuse it down every row
- // instead of re-interpolating cols_dx per vertex. Guarded by the buffer
- // size; non-reflection meshes and any unusually fine mesh fall back to the
- // inline interp below (`seam_cache` stays false).
- var dx_seam_col: [64]f32 = undefined;
- const seam_cache = options.reflection_lag != null and options.waterline_propagate and subdivs + 1 <= dx_seam_col.len;
- if (seam_cache) {
- const sample = options.reflection_lag.?;
- var x: usize = 0;
- while (x <= subdivs) : (x += 1) {
- const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs));
- dx_seam_col[x] = interpolateReflectionCols(&sample.cols_dx, t_x);
- }
- }
-
- var y: usize = 0;
- while (y <= subdivs) : (y += 1) { // vertical
- const t_y = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(subdivs));
- // Interpolate between tl/bl for left and tr/br for right
- const left = dvui.Point.Physical{
- .x = tl.x + (bl.x - tl.x) * t_y,
- .y = tl.y + (bl.y - tl.y) * t_y,
- };
- const right = dvui.Point.Physical{
- .x = tr.x + (br.x - tr.x) * t_y,
- .y = tr.y + (br.y - tr.y) * t_y,
- };
- // Keep each row monotonic in x so a steep ripple pinches instead of
- // folding back over itself. Overlapping triangles double-blend the
- // semi-transparent reflection, which reads as a too-bright seam where
- // the verts cross (most visible on the fly-in splash).
- const row_increasing = right.x >= left.x;
- // Hoist the per-row (pow-heavy) refraction factors out of the x-loop.
- const refl_row: ?ReflectionRow = if (options.reflection_lag != null and options.waterline_propagate)
- reflectionRowFactors(t_y)
- else
- null;
- // Vertex tint only depends on the row (vertical fade), so resolve the
- // colour and its PMA conversion once per row, not per vertex.
- var row_col: dvui.Color = options.color_mod;
- if (options.vertical_fade) row_col = row_col.opacity(0.5 * t_y);
- const row_col_pma = dvui.Color.PMA.fromColor(row_col);
- var prev_x: f32 = 0;
- var x: usize = 0;
- while (x <= subdivs) : (x += 1) { // horizontal
- const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs));
- var pos = dvui.Point.Physical{
- .x = left.x + (right.x - left.x) * t_x,
- .y = left.y + (right.y - left.y) * t_x,
- };
- if (options.reflection_lag) |sample| {
- if (refl_row) |row| {
- const dx_seam = if (seam_cache) dx_seam_col[x] else interpolateReflectionCols(&sample.cols_dx, t_x);
- var dx = reflectionRowDx(t_x, dx_seam, row, sample);
- // The reflection offset is purely horizontal (dy = 0), so the
- // magnitude clamp is just |dx| — no Point/sqrt needed.
- const dmax = options.displacement_max;
- if (dmax > 0.0001 and @abs(dx) > dmax) dx = std.math.sign(dx) * dmax;
- pos.x += dx;
- } else {
- pos = pos.plus(reflectionCombinedDisplacement(t_x, t_y, options));
- }
- if (x > 0) {
- if (row_increasing) {
- pos.x = @max(pos.x, prev_x);
- } else {
- pos.x = @min(pos.x, prev_x);
- }
- }
- prev_x = pos.x;
- }
-
- const uv = .{
- base_uv.x + base_uv.w * t_x,
- base_uv.y + base_uv.h * t_y,
- };
-
- builder.appendVertex(.{
- .pos = pos,
- .col = row_col_pma,
- .uv = uv,
- });
- }
- }
- }
-
- // Generate indices for quads in row-major order
- for (0..subdivs) |j| {
- for (0..subdivs) |i| {
- const row_stride = subdivs + 1;
- const idx0 = j * row_stride + i;
- const idx1 = idx0 + 1;
- const idx2 = idx0 + row_stride;
- const idx3 = idx2 + 1;
- // 0---1
- // | / |
- // 2---3
- // first triangle (idx0, idx2, idx1)
- builder.appendTriangles(&.{
- @intCast(idx0),
- @intCast(idx2),
- @intCast(idx1),
- });
- // second triangle (idx1, idx2, idx3)
- builder.appendTriangles(&.{
- @intCast(idx1),
- @intCast(idx2),
- @intCast(idx3),
- });
- }
- }
-
- return builder.build();
-}
-
-pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
- const atlas_size = dvui.imageSize(source) catch {
- std.log.err("Failed to get atlas size", .{});
- return;
- };
-
- var opt = opts;
-
- const uv = dvui.Rect{
- .x = (@as(f32, @floatFromInt(s.source[0])) / atlas_size.w),
- .y = (@as(f32, @floatFromInt(s.source[1])) / atlas_size.h),
- .w = (@as(f32, @floatFromInt(s.source[2])) / atlas_size.w),
- .h = (@as(f32, @floatFromInt(s.source[3])) / atlas_size.h),
- };
-
- opt.uv = uv;
-
- const origin = dvui.Point{
- .x = @as(f32, @floatFromInt(s.origin[0])) * 1 / scale,
- .y = @as(f32, @floatFromInt(s.origin[1])) * 1 / scale,
- };
-
- const position = data_point.diff(origin);
-
- const box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .none,
- .rect = .{
- .x = position.x,
- .y = position.y,
- .w = @as(f32, @floatFromInt(s.source[2])) * scale,
- .h = @as(f32, @floatFromInt(s.source[3])) * scale,
- },
- .border = dvui.Rect.all(0),
- .corner_radius = .{ .x = 0, .y = 0 },
- .padding = .{ .x = 0, .y = 0 },
- .margin = .{ .x = 0, .y = 0 },
- .background = false,
- .color_fill = dvui.themeGet().color(.err, .fill),
- });
- defer box.deinit();
-
- const rs = box.data().rectScale();
-
- try dvui.renderImage(source, rs, opt);
-}
pub fn labelWithKeybind(label_str: []const u8, hotkey: dvui.enums.Keybind, enabled: bool, label_opts: dvui.Options, opts: dvui.Options) void {
const box = dvui.box(@src(), .{ .dir = .horizontal }, opts);
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 341faff0..21e96e9f 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -17,7 +17,11 @@ pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig");
pub const fa = @import("tools/font_awesome.zig");
pub const fs = @import("tools/fs.zig");
pub const image = @import("gfx/image.zig");
-pub const render = @import("gfx/render.zig");
+pub const render = @import("plugins/pixelart/render.zig");
+
+/// Atlas-consumer sprite rendering library (lives in the pixel-art plugin,
+/// consumed by the shell/workbench to draw sprites from a packed atlas).
+pub const sprite_render = @import("plugins/pixelart/sprite_render.zig");
pub const perf = @import("gfx/perf.zig");
pub const water_surface = @import("gfx/water_surface.zig");
pub const math = @import("math/math.zig");
diff --git a/src/gfx/image.zig b/src/gfx/image.zig
index f38682d9..366d288c 100644
--- a/src/gfx/image.zig
+++ b/src/gfx/image.zig
@@ -311,7 +311,7 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi
const bot_c = dvui.Color{ .r = bot_px[0], .g = bot_px[1], .b = bot_px[2], .a = bot_px[3] };
const tpm = dvui.Color.PMA.fromColor(top_c);
const bpm = dvui.Color.PMA.fromColor(bot_c);
- const out_pma = fizzy.Internal.Layer.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm));
+ const out_pma = fizzy.math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm));
top_px.* = @as(dvui.Color.PMA, @bitCast(out_pma)).toColor().toRGBA();
}
}
diff --git a/src/math/color.zig b/src/math/color.zig
index 76f2a011..6e6f8888 100644
--- a/src/math/color.zig
+++ b/src/math/color.zig
@@ -1,6 +1,21 @@
//const zm = @import("zmath");
const imgui = @import("zig-imgui");
+/// Porter-Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout).
+/// `top` is composited over `bottom`. Generic byte math, no pixel-art types.
+pub fn blendPmaSrcOver(top: [4]u8, bottom: [4]u8) [4]u8 {
+ const sa: u32 = @intCast(top[3]);
+ const inv: u32 = 255 - sa;
+ var out: [4]u8 = undefined;
+ inline for (0..3) |c| {
+ const v: u32 = @as(u32, @intCast(top[c])) + @as(u32, @intCast(bottom[c])) * inv / 255;
+ out[c] = @intCast(@min(255, v));
+ }
+ const a: u32 = sa + @as(u32, @intCast(bottom[3])) * inv / 255;
+ out[3] = @intCast(@min(255, a));
+ return out;
+}
+
pub const Color = struct {
value: [4]f32,
diff --git a/src/math/math.zig b/src/math/math.zig
index 82589dcc..bc64c5b7 100644
--- a/src/math/math.zig
+++ b/src/math/math.zig
@@ -39,6 +39,7 @@ pub const Direction = @import("direction.zig").Direction;
const color = @import("color.zig");
pub const Color = color.Color;
pub const Colors = color.Colors;
+pub const blendPmaSrcOver = color.blendPmaSrcOver;
pub const Point = struct { x: i32, y: i32 };
diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig
index 52a7275d..b8562ff5 100644
--- a/src/plugins/pixelart/internal/Layer.zig
+++ b/src/plugins/pixelart/internal/Layer.zig
@@ -320,19 +320,9 @@ pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usiz
}
/// Porter–Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout).
-/// `top` is composited over `bottom`.
-pub fn blendPmaSrcOver(top: [4]u8, bottom: [4]u8) [4]u8 {
- const sa: u32 = @intCast(top[3]);
- const inv: u32 = 255 - sa;
- var out: [4]u8 = undefined;
- inline for (0..3) |c| {
- const v: u32 = @as(u32, @intCast(top[c])) + @as(u32, @intCast(bottom[c])) * inv / 255;
- out[c] = @intCast(@min(255, v));
- }
- const a: u32 = sa + @as(u32, @intCast(bottom[3])) * inv / 255;
- out[3] = @intCast(@min(255, a));
- return out;
-}
+/// `top` is composited over `bottom`. The implementation is generic byte math and
+/// lives in `core` math; re-exported here for the pixel-art call sites.
+pub const blendPmaSrcOver = fizzy.math.blendPmaSrcOver;
pub fn clearRect(self: *Layer, rect: dvui.Rect) void {
fizzy.image.clearRect(self.source, rect);
diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig
index 3ffcded9..9aaa51e3 100644
--- a/src/plugins/pixelart/panel/sprites.zig
+++ b/src/plugins/pixelart/panel/sprites.zig
@@ -3,8 +3,8 @@ const icons = @import("icons");
const dvui = @import("dvui");
const fizzy = @import("../../../fizzy.zig");
const Editor = fizzy.Editor;
-const ReflectionLagSample = fizzy.dvui.ReflectionLagSample;
-const reflection_surface_cols = fizzy.dvui.reflection_surface_cols;
+const ReflectionLagSample = fizzy.sprite_render.ReflectionLagSample;
+const reflection_surface_cols = fizzy.sprite_render.reflection_surface_cols;
const wsurf = fizzy.water_surface;
const Sprites = @This();
@@ -749,7 +749,7 @@ pub fn draw(self: *Sprites) !void {
const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0;
const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness);
- _ = fizzy.dvui.sprite(SpriteSlot.src(), .{
+ _ = fizzy.sprite_render.sprite(SpriteSlot.src(), .{
.source = file.layers.items(.source)[file.selected_layer_index],
.file = file,
.alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null,
diff --git a/src/gfx/render.zig b/src/plugins/pixelart/render.zig
similarity index 99%
rename from src/gfx/render.zig
rename to src/plugins/pixelart/render.zig
index 3631ee62..14a46745 100644
--- a/src/gfx/render.zig
+++ b/src/plugins/pixelart/render.zig
@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../fizzy.zig");
+const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const perf = fizzy.perf;
@@ -772,7 +772,7 @@ pub fn renderLayers(init_opts: RenderFileOptions) !void {
qpath.addPoint(q[1]);
qpath.addPoint(q[2]);
qpath.addPoint(q[3]);
- break :blk try fizzy.dvui.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{
+ break :blk try fizzy.sprite_render.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{
.subdivisions = init_opts.quad_subdivisions,
.uv = init_opts.uv,
.color_mod = init_opts.color_mod,
diff --git a/src/plugins/pixelart/sprite_render.zig b/src/plugins/pixelart/sprite_render.zig
new file mode 100644
index 00000000..58d12c47
--- /dev/null
+++ b/src/plugins/pixelart/sprite_render.zig
@@ -0,0 +1,694 @@
+//! Sprite/atlas rendering library for the pixel-art plugin.
+//!
+//! Consumes packed-atlas output (Atlas/Sprite types) and renders sprites
+//! (including the cover-flow water reflection mesh). Lives in the pixel-art
+//! plugin but is consumed by the shell/workbench to draw sprites from a
+//! packed atlas (cursors, icons, document previews).
+const std = @import("std");
+const fizzy = @import("../../fizzy.zig");
+const dvui = @import("dvui");
+
+pub const SpriteInitOptions = struct {
+ source: dvui.ImageSource,
+ file: ?*fizzy.Internal.File = null,
+ alpha_source: ?dvui.ImageSource = null,
+ sprite: fizzy.Atlas.Sprite,
+ scale: f32 = 1.0,
+ depth: f32 = 0.0, // -1.0 is front, 1.0 is back
+ reflection: bool = false,
+ overlap: f32 = 0.0,
+ /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out
+ /// toward the background the further they sit from the focus.
+ opacity: f32 = 1.0,
+ /// Vertical shift (logical px, positive = down) applied to the reflection
+ /// only. Lets the reflection slide away from the card — e.g. as a card flies
+ /// up out of view, its reflection sinks down, like peeling off a waterline.
+ reflection_offset: f32 = 0.0,
+ /// Depth-lagged reflection grid (logical px); rows shear while scrolling and ripple on settle.
+ reflection_lag: ?ReflectionLagSample = null,
+ /// Reflection mesh density multiplier in (0, 1]. 1.0 = full per-zoom density;
+ /// lower values coarsen the (O(n²)) mesh. Callers pass <1 for distant/skewed
+ /// cards so only the head-on focus cards pay for a fine, high-res reflection.
+ reflection_detail: f32 = 1.0,
+};
+
+/// Columns the reflection mesh samples across a card's width (waterline strip).
+/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card.
+pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols;
+
+/// Reflection-only waterline sample across the card width (logical px). `cols_dx`
+/// is horizontal refraction from surface slope; `cols_dy` is vertical height at
+/// the seam (positive = down). The card itself stays flat — only the reflection
+/// mesh pins its top edge and propagates ripples downward.
+pub const ReflectionLagSample = struct {
+ cols_dx: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols,
+ cols_dy: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols,
+};
+
+pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData {
+ const source_size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 };
+
+ const overlap: f32 = 1.0 - init_opts.overlap;
+
+ const uv = dvui.Rect{
+ .x = @as(f32, @floatFromInt(init_opts.sprite.source[0])) / source_size.w,
+ .y = @as(f32, @floatFromInt(init_opts.sprite.source[1])) / source_size.h,
+ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) / source_size.w,
+ .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) / source_size.h,
+ };
+
+ const options = (dvui.Options{ .name = "sprite" }).override(opts);
+
+ var size = dvui.Size{};
+ if (options.min_size_content) |msc| {
+ // user gave us a min size, use it
+ size = msc;
+ } else {
+ // user didn't give us one, use natural size
+ size = .{ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) * init_opts.scale * overlap, .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) * init_opts.scale * overlap };
+ }
+
+ var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size }));
+ wd.register();
+
+ const cr = wd.contentRect();
+ const ms = wd.options.min_size_contentGet();
+
+ var too_big = false;
+ if (ms.w > cr.w or ms.h > cr.h) {
+ too_big = true;
+ }
+
+ var e = wd.options.expandGet();
+ const g = wd.options.gravityGet();
+ var rect = dvui.placeIn(cr, ms, e, g);
+
+ if (too_big and e != .ratio) {
+ if (ms.w > cr.w and !e.isHorizontal()) {
+ rect.w = ms.w;
+ rect.x -= g.x * (ms.w - cr.w);
+ }
+
+ if (ms.h > cr.h and !e.isVertical()) {
+ rect.h = ms.h;
+ rect.y -= g.y * (ms.h - cr.h);
+ }
+ }
+
+ // rect is the content rect, so expand to the whole rect
+ wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet());
+
+ var renderBackground: ?dvui.Color = if (wd.options.backgroundGet()) wd.options.color(.fill) else null;
+
+ if (wd.options.rotationGet() == 0.0) {
+ wd.borderAndBackground(.{});
+ renderBackground = null;
+ } else {
+ if (wd.options.borderGet().nonZero()) {
+ dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id});
+ }
+ }
+
+ var path: dvui.Path.Builder = .init(dvui.currentWindow().arena());
+ defer path.deinit();
+
+ var top_left = wd.contentRectScale().r.topLeft();
+ var top_right = wd.contentRectScale().r.topRight();
+ var bottom_right = wd.contentRectScale().r.bottomRight();
+ var bottom_left = wd.contentRectScale().r.bottomLeft();
+
+ if (init_opts.depth > 0) {
+ top_left = top_left.plus(bottom_right.diff(top_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical));
+ bottom_left = bottom_left.plus(top_right.diff(bottom_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical));
+ } else {
+ top_right = top_right.plus(bottom_right.diff(top_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical));
+ bottom_right = bottom_right.plus(top_right.diff(bottom_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical));
+ }
+
+ const lag_active = init_opts.reflection_lag != null;
+ const reflection_lag_phys: ?ReflectionLagSample = if (lag_active) reflectionLagSamplePhysical(
+ init_opts.reflection_lag.?,
+ wd.contentRectScale().s,
+ ) else null;
+
+ path.addPoint(top_left);
+ path.addPoint(top_right);
+ path.addPoint(bottom_right);
+ path.addPoint(bottom_left);
+
+ // Distance fade toward transparent: `fade_white` tints textured draws by the
+ // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1.
+ const op = std.math.clamp(init_opts.opacity, 0.0, 1.0);
+ const fade_white = dvui.Color.white.opacity(op);
+
+ // Cover-flow fast path: when a file's layer stack is fully flattenable, the
+ // checker + layers + selection + temp are baked into one texture once per
+ // frame, so each card (front and reflection) is a single textured pass
+ // instead of several overlapping alpha-blended fills. Null → multi-pass path.
+ const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null;
+
+ if (init_opts.reflection) {
+ var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena());
+ defer path2.deinit();
+
+ // Direct vertical mirror: reflect each (already skewed) top corner straight
+ // down through its bottom corner, so the reflection is a true flip of the
+ // card — same width and skew at every height, sharing the bottom edge —
+ // rather than a trapezoid that flares outward. pathToSubdividedQuad reads
+ // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top
+ // and the near edge (br, bl) the sprite bottom, giving the mirrored uv.
+ // `refl_off` slides the whole reflection down independently of the card.
+ const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s };
+ path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off));
+ path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off));
+ path2.addPoint(bottom_right.plus(refl_off));
+ path2.addPoint(bottom_left.plus(refl_off));
+
+ const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h);
+ // Subdivide in proportion to on-screen size so the *physical* ripple density
+ // stays constant across zoom — a big (zoomed-in) card gets many more verts,
+ // rendering the fine field detail instead of undersampling it into coarse
+ // waves. (The field already carries dense ripples at `cols_per_slot`.)
+ const base_subdivisions_f = std.math.clamp(preview_extent / 13.0, 14.0, 44.0);
+ // The mesh is O(subdivisions²) and is rebuilt + rendered per layer for every
+ // card. Only the head-on focus cards need the fine, high-res ripple; skewed
+ // shelf cards pass a low `reflection_detail` so they fall to the coarse floor
+ // and stay cheap, which is what keeps the shelf affordable on slower GPUs.
+ const detail = std.math.clamp(init_opts.reflection_detail, 0.0, 1.0);
+ const subdivisions_f = @max(6.0, base_subdivisions_f * detail);
+ const subdivisions: usize = @intFromFloat(subdivisions_f);
+
+ if (init_opts.alpha_source) |alpha_source| preview: {
+ const reflection_path = path2.build();
+
+ const reflection_lag = reflection_lag_phys orelse ReflectionLagSample{};
+ const displacement_max = wd.contentRectScale().r.h * 0.52;
+ const refl_lag = if (lag_active) reflection_lag else null;
+
+ if (preview_tex) |ptex| {
+ // Single textured pass: checker + layers + selection + temp are
+ // pre-flattened into the preview composite, so the reflection is one
+ // draw instead of replaying the whole stack per card.
+ var refl = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{
+ .subdivisions = subdivisions,
+ .uv = uv,
+ .vertical_fade = true,
+ .color_mod = fade_white,
+ .reflection_lag = refl_lag,
+ .waterline_propagate = true,
+ .displacement_max = displacement_max,
+ }) catch unreachable;
+ defer refl.deinit(dvui.currentWindow().arena());
+ dvui.renderTriangles(refl, ptex) catch {
+ dvui.log.err("Failed to render reflection preview composite", .{});
+ };
+ break :preview;
+ }
+
+ // Build two meshes from the same path so vertex positions match (shared
+ // ripple) but UVs differ: bg uses the full quad for checkerboard alpha,
+ // layers use the sprite atlas rect.
+ var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{
+ .subdivisions = subdivisions,
+ .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op),
+ .vertical_fade = true,
+ .reflection_lag = refl_lag,
+ .waterline_propagate = true,
+ .displacement_max = displacement_max,
+ }) catch unreachable;
+ defer reflection_triangles_bg.deinit(dvui.currentWindow().arena());
+
+ var reflection_triangles_layers = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{
+ .subdivisions = subdivisions,
+ .uv = uv,
+ .vertical_fade = true,
+ .color_mod = fade_white,
+ .reflection_lag = refl_lag,
+ .waterline_propagate = true,
+ .displacement_max = displacement_max,
+ }) catch unreachable;
+ defer reflection_triangles_layers.deinit(dvui.currentWindow().arena());
+
+ var reflection_triangles_layers_dimmed = reflection_triangles_layers.dupe(dvui.currentWindow().arena()) catch unreachable;
+ defer reflection_triangles_layers_dimmed.deinit(dvui.currentWindow().arena());
+ reflection_triangles_layers_dimmed.color(.gray);
+
+ dvui.renderTriangles(reflection_triangles_bg, alpha_source.getTexture() catch null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+
+ if (init_opts.file) |file| {
+ const preview_opts = fizzy.render.RenderFileOptions{
+ .file = file,
+ .rs = .{
+ .r = wd.contentRectScale().r,
+ .s = wd.contentRectScale().s,
+ },
+ .uv = uv,
+ .corner_radius = .all(0),
+ };
+ fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| {
+ dvui.log.err("Failed to render reflection layer stack: {any}", .{err});
+ };
+
+ dvui.renderTriangles(reflection_triangles_layers, file.editor.selection_layer.source.getTexture() catch null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+
+ // Match renderLayers: use cached GPU texture when the canvas has already uploaded this frame.
+ // Avoids getTexture() on .pixelsPMA sources (would upload when invalidation is .always).
+ if (file.editor.temp_layer_has_content or file.editor.temp_gpu_dirty_rect != null) {
+ const temp_src = file.editor.temporary_layer.source;
+ const temp_key = temp_src.hash();
+ if (dvui.textureGetCached(temp_key)) |tex| {
+ dvui.renderTriangles(reflection_triangles_layers, tex) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+ } else {
+ dvui.renderTriangles(reflection_triangles_layers, temp_src.getTexture() catch null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+ }
+ }
+ } else {
+ dvui.renderTriangles(reflection_triangles_layers, init_opts.source.getTexture() catch null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+ }
+ }
+ }
+
+ // The preview composite already bakes the content-fill base + checkerboard,
+ // so skip the separate base/checker passes when it's in use.
+ if (preview_tex == null) {
+ if (init_opts.alpha_source) |alpha_source| {
+ if (init_opts.depth != 0.0) {
+ // Skew the opaque base along with the art so no axis-aligned sliver
+ // of fill colour pokes out past the receding edge.
+ var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{
+ .subdivisions = 8,
+ .color_mod = dvui.themeGet().color(.content, .fill).opacity(op),
+ }) catch unreachable;
+ defer base_triangles.deinit(dvui.currentWindow().arena());
+ dvui.renderTriangles(base_triangles, null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+ } else {
+ wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 });
+ }
+
+ const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{
+ .subdivisions = 8,
+ .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op),
+ }) catch unreachable;
+ dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+ }
+ }
+
+ if (preview_tex) |ptex| {
+ // Front card: one textured pass from the baked preview composite. Skewed
+ // cards build a subdivided quad so the art tilts like a record on a shelf;
+ // head-on cards use the plain quad.
+ const front_path = if (init_opts.depth != 0.0) blk: {
+ var q: dvui.Path.Builder = .init(dvui.currentWindow().arena());
+ q.addPoint(top_left);
+ q.addPoint(top_right);
+ q.addPoint(bottom_right);
+ q.addPoint(bottom_left);
+ break :blk q.build();
+ } else path.build();
+ var tris = pathToSubdividedQuad(front_path, dvui.currentWindow().arena(), .{
+ .subdivisions = 8,
+ .uv = uv,
+ .color_mod = fade_white,
+ }) catch unreachable;
+ defer tris.deinit(dvui.currentWindow().arena());
+ dvui.renderTriangles(tris, ptex) catch {
+ dvui.log.err("Failed to render sprite preview composite", .{});
+ };
+ } else if (init_opts.file) |file| {
+ fizzy.render.renderLayers(.{
+ .file = file,
+ .rs = .{
+ .r = wd.contentRectScale().r,
+ .s = wd.contentRectScale().s,
+ },
+ .uv = uv,
+ .corner_radius = .all(0),
+ .color_mod = fade_white,
+ // When skewed, render the layer stack into the same quad as the
+ // background so the art tilts like a record on a shelf.
+ .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null,
+ }) catch {
+ dvui.log.err("Failed to render layers", .{});
+ };
+ } else {
+ const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{
+ .subdivisions = 8,
+ .uv = uv,
+ .color_mod = fade_white,
+ }) catch unreachable;
+
+ dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch {
+ dvui.log.err("Failed to render triangles", .{});
+ };
+ }
+
+ path.build().stroke(.{ .color = opts.color_border orelse .transparent, .thickness = 1.0, .closed = true });
+
+ wd.minSizeSetAndRefresh();
+ wd.minSizeReportToParent();
+
+ return wd;
+}
+
+pub const PathToSubdividedQuadOptions = struct {
+ subdivisions: usize = 4,
+ uv: ?dvui.Rect = null,
+ vertical_fade: bool = false,
+ color_mod: dvui.Color = .white,
+ reflection_lag: ?ReflectionLagSample = null,
+ /// When true, reflection meshes refract ripples deeper below the seam.
+ waterline_propagate: bool = true,
+ /// Cap vertex offset (physical px) so ripples stay inside the reflection.
+ displacement_max: f32 = 0.0,
+};
+
+fn reflectionLagSamplePhysical(sample: ReflectionLagSample, scale: f32) ReflectionLagSample {
+ var out = sample;
+ for (&out.cols_dx) |*c| c.* *= scale;
+ for (&out.cols_dy) |*c| c.* *= scale;
+ return out;
+}
+
+/// Linear interpolation across the column strip by horizontal fraction `t_x`.
+/// Per-row reflection factors, hoisted out of the per-vertex loop. The two `pow`
+/// calls (depth lag + seam pin) depend only on the row (`t_y`), so computing them
+/// once per row instead of per vertex removes thousands of `pow` calls per frame.
+const ReflectionRow = struct {
+ low_submerge: bool,
+ lag: f32,
+ lag_mix: f32, // already × 0.55
+ submerge_scale: f32, // lerp(1, 1.25, submerge)
+ dx_pin: f32,
+};
+
+fn reflectionRowFactors(t_y: f32) ReflectionRow {
+ const submerge = 1.0 - std.math.clamp(t_y, 0, 1);
+ const seam_t = std.math.clamp(t_y, 0, 1);
+ return .{
+ .low_submerge = submerge <= 0.001,
+ .lag = std.math.pow(f32, submerge, 1.55) * 0.74,
+ .lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1) * 0.55,
+ .submerge_scale = std.math.lerp(1.0, 1.25, submerge),
+ .dx_pin = 1.0 - std.math.pow(f32, seam_t, 4.5),
+ };
+}
+
+/// Horizontal refraction for one vertex using precomputed row factors. Equivalent
+/// to `reflectionMeshDisplacement(.x)`, just with the row-constant work hoisted.
+fn reflectionRowDx(t_x: f32, dx_seam: f32, row: ReflectionRow, sample: ReflectionLagSample) f32 {
+ // `dx_seam` (the column's refraction at the seam) is supplied precomputed — it
+ // depends only on t_x, so the caller resolves it once per column. Only the
+ // depth-lagged sample, which shifts t_x by the row's phase lag, needs an interp.
+ const t_lag = if (row.low_submerge)
+ t_x
+ else
+ std.math.clamp(t_x - (if (dx_seam >= 0) row.lag else -row.lag), 0, 1);
+ const dx_lag = if (row.low_submerge) dx_seam else interpolateReflectionCols(&sample.cols_dx, t_lag);
+ return std.math.lerp(dx_seam, dx_lag, row.lag_mix) * row.submerge_scale * row.dx_pin;
+}
+
+fn interpolateReflectionCols(cols: []const f32, t_x: f32) f32 {
+ if (cols.len == 0) return 0;
+ if (cols.len == 1) return cols[0];
+ const f = std.math.clamp(t_x, 0, 1) * @as(f32, @floatFromInt(cols.len - 1));
+ const idx0: usize = @intFromFloat(@floor(f));
+ const idx1 = @min(idx0 + 1, cols.len - 1);
+ const t = f - @as(f32, @floatFromInt(idx0));
+ return std.math.lerp(cols[idx0], cols[idx1], t);
+}
+
+fn clampDisplacement(d: dvui.Point.Physical, max_mag: f32) dvui.Point.Physical {
+ if (max_mag <= 0.0001) return d;
+ const mag = @sqrt(d.x * d.x + d.y * d.y);
+ if (mag <= max_mag) return d;
+ const s = max_mag / mag;
+ return .{ .x = d.x * s, .y = d.y * s };
+}
+
+/// Depth into the reflection body (0 at the waterline seam, 1 at the far edge).
+fn reflectionSubmergeDepth(t_y: f32) f32 {
+ return 1.0 - std.math.clamp(t_y, 0, 1);
+}
+
+/// Expanding ripple: larger displacement toward the reflection bottom. Rises
+/// quickly just below the seam (so the effect is still strong in the upper region
+/// that stays on-screen when zoomed in and the reflection's bottom is clipped),
+/// then keeps growing toward the far edge for the full zoomed-out slosh.
+fn reflectionDepthAmplitude(submerge: f32) f32 {
+ const d = std.math.clamp(submerge, 0, 1);
+ return 1.0 + d * (1.8 + 1.4 * d);
+}
+
+/// Phase lag vs depth — deeper rows follow the same wave, slower and larger.
+fn reflectionDepthLag(submerge: f32) f32 {
+ const d = std.math.clamp(submerge, 0, 1);
+ return std.math.pow(f32, d, 1.55) * 0.74;
+}
+
+/// Sample the surface field with increasing horizontal phase lag at depth.
+fn reflectionLaggedTx(t_x: f32, cols_dx: []const f32, submerge: f32) f32 {
+ if (submerge <= 0.001) return t_x;
+ const lag = reflectionDepthLag(submerge);
+ const slope = interpolateReflectionCols(cols_dx, t_x);
+ const dir: f32 = if (slope >= 0) 1 else -1;
+ return std.math.clamp(t_x - dir * lag, 0, 1);
+}
+
+/// Reflection mesh: seam pinned at the waterline; the body carries horizontal
+/// refraction ripples that phase-lag with depth. cols_dy is not applied.
+fn reflectionMeshDisplacement(t_x: f32, t_y: f32, sample: ReflectionLagSample) dvui.Point.Physical {
+ const submerge = reflectionSubmergeDepth(t_y);
+ const t_lag = reflectionLaggedTx(t_x, &sample.cols_dx, submerge);
+ const lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1);
+
+ const seam_t = std.math.clamp(t_y, 0, 1);
+ // Peak refraction just under the card base (not mid-body / far edge); seam
+ // corners stay pinned so the base width still matches the card.
+ const dx_pin = std.math.pow(f32, seam_t, 1.4) * (1.0 - std.math.pow(f32, seam_t, 12.0));
+ const dx_seam = interpolateReflectionCols(&sample.cols_dx, t_x);
+ const dx_lag = interpolateReflectionCols(&sample.cols_dx, t_lag);
+ const dx = std.math.lerp(dx_seam, dx_lag, lag_mix * 0.55) * std.math.lerp(1.0, 1.25, submerge) * dx_pin;
+
+ return .{ .x = dx, .y = 0 };
+}
+
+fn waterlineMeshDisplacement(
+ t_x: f32,
+ t_y: f32,
+ sample: ReflectionLagSample,
+ propagate: bool,
+) dvui.Point.Physical {
+ if (propagate) return reflectionMeshDisplacement(t_x, t_y, sample);
+ const s = std.math.clamp(t_y, 0, 1);
+ const strength = s * (0.1 + 0.9 * s);
+ return .{
+ .x = interpolateReflectionCols(&sample.cols_dx, t_x) * strength,
+ .y = 0,
+ };
+}
+
+fn reflectionCombinedDisplacement(t_x: f32, t_y: f32, options: PathToSubdividedQuadOptions) dvui.Point.Physical {
+ var d: dvui.Point.Physical = .{ .x = 0, .y = 0 };
+ if (options.reflection_lag) |sample| {
+ d = d.plus(waterlineMeshDisplacement(t_x, t_y, sample, options.waterline_propagate));
+ }
+ return clampDisplacement(d, options.displacement_max);
+}
+
+pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, options: PathToSubdividedQuadOptions) std.mem.Allocator.Error!dvui.Triangles {
+ if (path.points.len != 4) {
+ return .empty;
+ }
+
+ const subdivs = options.subdivisions;
+ const vtx_count = (subdivs + 1) * (subdivs + 1);
+ const idx_count = 2 * subdivs * subdivs * 3;
+
+ var builder = try dvui.Triangles.Builder.init(allocator, vtx_count, idx_count);
+ errdefer comptime unreachable;
+
+ // Four quad corners in order: tl, tr, br, bl
+ const tl = path.points[0];
+ const tr = path.points[1];
+ const br = path.points[2];
+ const bl = path.points[3];
+
+ // Use given UV or default to (0,0,1,1)
+ const base_uv = options.uv orelse dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 };
+
+ {
+ // The seam refraction for a reflection mesh depends only on the column
+ // (t_x), so precompute it once per column and reuse it down every row
+ // instead of re-interpolating cols_dx per vertex. Guarded by the buffer
+ // size; non-reflection meshes and any unusually fine mesh fall back to the
+ // inline interp below (`seam_cache` stays false).
+ var dx_seam_col: [64]f32 = undefined;
+ const seam_cache = options.reflection_lag != null and options.waterline_propagate and subdivs + 1 <= dx_seam_col.len;
+ if (seam_cache) {
+ const sample = options.reflection_lag.?;
+ var x: usize = 0;
+ while (x <= subdivs) : (x += 1) {
+ const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs));
+ dx_seam_col[x] = interpolateReflectionCols(&sample.cols_dx, t_x);
+ }
+ }
+
+ var y: usize = 0;
+ while (y <= subdivs) : (y += 1) { // vertical
+ const t_y = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(subdivs));
+ // Interpolate between tl/bl for left and tr/br for right
+ const left = dvui.Point.Physical{
+ .x = tl.x + (bl.x - tl.x) * t_y,
+ .y = tl.y + (bl.y - tl.y) * t_y,
+ };
+ const right = dvui.Point.Physical{
+ .x = tr.x + (br.x - tr.x) * t_y,
+ .y = tr.y + (br.y - tr.y) * t_y,
+ };
+ // Keep each row monotonic in x so a steep ripple pinches instead of
+ // folding back over itself. Overlapping triangles double-blend the
+ // semi-transparent reflection, which reads as a too-bright seam where
+ // the verts cross (most visible on the fly-in splash).
+ const row_increasing = right.x >= left.x;
+ // Hoist the per-row (pow-heavy) refraction factors out of the x-loop.
+ const refl_row: ?ReflectionRow = if (options.reflection_lag != null and options.waterline_propagate)
+ reflectionRowFactors(t_y)
+ else
+ null;
+ // Vertex tint only depends on the row (vertical fade), so resolve the
+ // colour and its PMA conversion once per row, not per vertex.
+ var row_col: dvui.Color = options.color_mod;
+ if (options.vertical_fade) row_col = row_col.opacity(0.5 * t_y);
+ const row_col_pma = dvui.Color.PMA.fromColor(row_col);
+ var prev_x: f32 = 0;
+ var x: usize = 0;
+ while (x <= subdivs) : (x += 1) { // horizontal
+ const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs));
+ var pos = dvui.Point.Physical{
+ .x = left.x + (right.x - left.x) * t_x,
+ .y = left.y + (right.y - left.y) * t_x,
+ };
+ if (options.reflection_lag) |sample| {
+ if (refl_row) |row| {
+ const dx_seam = if (seam_cache) dx_seam_col[x] else interpolateReflectionCols(&sample.cols_dx, t_x);
+ var dx = reflectionRowDx(t_x, dx_seam, row, sample);
+ // The reflection offset is purely horizontal (dy = 0), so the
+ // magnitude clamp is just |dx| — no Point/sqrt needed.
+ const dmax = options.displacement_max;
+ if (dmax > 0.0001 and @abs(dx) > dmax) dx = std.math.sign(dx) * dmax;
+ pos.x += dx;
+ } else {
+ pos = pos.plus(reflectionCombinedDisplacement(t_x, t_y, options));
+ }
+ if (x > 0) {
+ if (row_increasing) {
+ pos.x = @max(pos.x, prev_x);
+ } else {
+ pos.x = @min(pos.x, prev_x);
+ }
+ }
+ prev_x = pos.x;
+ }
+
+ const uv = .{
+ base_uv.x + base_uv.w * t_x,
+ base_uv.y + base_uv.h * t_y,
+ };
+
+ builder.appendVertex(.{
+ .pos = pos,
+ .col = row_col_pma,
+ .uv = uv,
+ });
+ }
+ }
+ }
+
+ // Generate indices for quads in row-major order
+ for (0..subdivs) |j| {
+ for (0..subdivs) |i| {
+ const row_stride = subdivs + 1;
+ const idx0 = j * row_stride + i;
+ const idx1 = idx0 + 1;
+ const idx2 = idx0 + row_stride;
+ const idx3 = idx2 + 1;
+ // 0---1
+ // | / |
+ // 2---3
+ // first triangle (idx0, idx2, idx1)
+ builder.appendTriangles(&.{
+ @intCast(idx0),
+ @intCast(idx2),
+ @intCast(idx1),
+ });
+ // second triangle (idx1, idx2, idx3)
+ builder.appendTriangles(&.{
+ @intCast(idx1),
+ @intCast(idx2),
+ @intCast(idx3),
+ });
+ }
+ }
+
+ return builder.build();
+}
+
+pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
+ const atlas_size = dvui.imageSize(source) catch {
+ std.log.err("Failed to get atlas size", .{});
+ return;
+ };
+
+ var opt = opts;
+
+ const uv = dvui.Rect{
+ .x = (@as(f32, @floatFromInt(s.source[0])) / atlas_size.w),
+ .y = (@as(f32, @floatFromInt(s.source[1])) / atlas_size.h),
+ .w = (@as(f32, @floatFromInt(s.source[2])) / atlas_size.w),
+ .h = (@as(f32, @floatFromInt(s.source[3])) / atlas_size.h),
+ };
+
+ opt.uv = uv;
+
+ const origin = dvui.Point{
+ .x = @as(f32, @floatFromInt(s.origin[0])) * 1 / scale,
+ .y = @as(f32, @floatFromInt(s.origin[1])) * 1 / scale,
+ };
+
+ const position = data_point.diff(origin);
+
+ const box = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .none,
+ .rect = .{
+ .x = position.x,
+ .y = position.y,
+ .w = @as(f32, @floatFromInt(s.source[2])) * scale,
+ .h = @as(f32, @floatFromInt(s.source[3])) * scale,
+ },
+ .border = dvui.Rect.all(0),
+ .corner_radius = .{ .x = 0, .y = 0 },
+ .padding = .{ .x = 0, .y = 0 },
+ .margin = .{ .x = 0, .y = 0 },
+ .background = false,
+ .color_fill = dvui.themeGet().color(.err, .fill),
+ });
+ defer box.deinit();
+
+ const rs = box.data().rectScale();
+
+ try dvui.renderImage(source, rs, opt);
+}
diff --git a/src/plugins/workbench/Workspace.zig b/src/plugins/workbench/Workspace.zig
index 38222eb5..ada28cdf 100644
--- a/src/plugins/workbench/Workspace.zig
+++ b/src/plugins/workbench/Workspace.zig
@@ -297,7 +297,7 @@ fn drawTabs(self: *Workspace) void {
}
if (is_fizzy_file) {
- _ = fizzy.dvui.sprite(@src(), .{
+ _ = fizzy.sprite_render.sprite(@src(), .{
.source = fizzy.editor.atlas.source,
.sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default],
.scale = 2.0,
diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig
index 9b2f03ea..901bbdb3 100644
--- a/src/plugins/workbench/files.zig
+++ b/src/plugins/workbench/files.zig
@@ -773,7 +773,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color;
if (ext == .fizzy) {
- _ = fizzy.dvui.sprite(
+ _ = fizzy.sprite_render.sprite(
@src(),
.{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0 },
.{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false },
From e41167ab7c330dddcecb838d48cc531ef2d83fea Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 11:18:57 -0500
Subject: [PATCH 15/49] Phase 4 stage A3
---
build.zig | 49 +-
src/App.zig | 7 +-
src/{editor => core}/Fling.zig | 0
src/core/core.zig | 39 +
src/{ => core}/dvui.zig | 43 +-
src/{tools => core}/fs.zig | 0
src/{ => core}/generated/atlas.zig | 0
src/{ => core}/gfx/image.zig | 22 +-
src/{ => core}/gfx/perf.zig | 0
src/{ => core}/gfx/water_surface.zig | 0
src/{ => core}/math/color.zig | 0
src/{ => core}/math/direction.zig | 0
src/{ => core}/math/easing.zig | 0
src/{ => core}/math/layout_anchor.zig | 0
src/{ => core}/math/math.zig | 0
src/{ => core}/paths.zig | 0
src/{ => core}/platform.zig | 0
src/{editor => core}/widgets/CanvasWidget.zig | 11 +-
.../widgets/FloatingWindowWidget.zig | 0
src/{editor => core}/widgets/PanedWidget.zig | 0
.../widgets/ReorderWidget.zig | 0
.../widgets/TreeSelection.zig | 0
src/{editor => core}/widgets/TreeWidget.zig | 0
src/editor/Editor.zig | 7 +-
src/editor/widgets/Widgets.zig | 15 -
src/fizzy.zig | 25 +-
src/gfx/gfx.zig | 2 -
src/plugins/pixelart/Atlas.zig | 10 +-
src/plugins/pixelart/CanvasData.zig | 3 +-
src/plugins/pixelart/PackJob.zig | 2 +-
src/plugins/pixelart/dialogs/GridLayout.zig | 12 +-
src/plugins/pixelart/plugin.zig | 10 +-
src/plugins/pixelart/widgets/CanvasBridge.zig | 2 +-
src/plugins/pixelart/widgets/FileWidget.zig | 2 +-
src/plugins/pixelart/widgets/ImageWidget.zig | 2 +-
src/plugins/workbench/FileLoadJob.zig | 2 +-
src/plugins/workbench/files.zig | 6 +-
src/tools/font_awesome.zig | 1005 -----------------
src/tools/timer.zig | 23 -
src/web_main.zig | 3 +-
40 files changed, 177 insertions(+), 1125 deletions(-)
rename src/{editor => core}/Fling.zig (100%)
create mode 100644 src/core/core.zig
rename src/{ => core}/dvui.zig (97%)
rename src/{tools => core}/fs.zig (100%)
rename src/{ => core}/generated/atlas.zig (100%)
rename src/{ => core}/gfx/image.zig (94%)
rename src/{ => core}/gfx/perf.zig (100%)
rename src/{ => core}/gfx/water_surface.zig (100%)
rename src/{ => core}/math/color.zig (100%)
rename src/{ => core}/math/direction.zig (100%)
rename src/{ => core}/math/easing.zig (100%)
rename src/{ => core}/math/layout_anchor.zig (100%)
rename src/{ => core}/math/math.zig (100%)
rename src/{ => core}/paths.zig (100%)
rename src/{ => core}/platform.zig (100%)
rename src/{editor => core}/widgets/CanvasWidget.zig (99%)
rename src/{editor => core}/widgets/FloatingWindowWidget.zig (100%)
rename src/{editor => core}/widgets/PanedWidget.zig (100%)
rename src/{editor => core}/widgets/ReorderWidget.zig (100%)
rename src/{editor => core}/widgets/TreeSelection.zig (100%)
rename src/{editor => core}/widgets/TreeWidget.zig (100%)
delete mode 100644 src/editor/widgets/Widgets.zig
delete mode 100644 src/gfx/gfx.zig
delete mode 100644 src/tools/font_awesome.zig
delete mode 100644 src/tools/timer.zig
diff --git a/build.zig b/build.zig
index 48a3ecde..b7fa39af 100644
--- a/build.zig
+++ b/build.zig
@@ -258,7 +258,7 @@ pub fn build(b: *std.Build) !void {
// unconditionally by `fizzy.zig`, so the process-assets step has to
// run before any target that touches fizzy.zig — exe, integration
// tests, etc.
- const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/generated/");
+ const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/core/generated/");
const process_assets_step = b.step("process-assets", "generates struct for all assets");
process_assets_step.dependOn(&assets_processing.step);
@@ -344,6 +344,21 @@ pub fn build(b: *std.Build) !void {
}).module("known-folders");
web_exe.root_module.addImport("known-folders", known_folders_web);
+ // Shared `core` module for the wasm build (dvui web backend variant).
+ const core_module_web = b.createModule(.{
+ .target = web_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ .link_libc = false,
+ .single_threaded = true,
+ });
+ core_module_web.addImport("dvui", dvui_web_dep.module("dvui_web"));
+ core_module_web.addImport("known-folders", known_folders_web);
+ if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| {
+ core_module_web.addImport("icons", dep.module("icons"));
+ }
+ web_exe.root_module.addImport("core", core_module_web);
+
// Three editor files have `const sdl3 = @import("backend").c;` at file
// scope. After refactoring all `sdl3.SDL_DialogFileFilter` references
// to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's
@@ -737,11 +752,11 @@ pub fn build(b: *std.Build) !void {
// name. Each of these files imports only `std`, so they remain free
// of dvui / SDL / globals.
inline for (.{
- .{ "fizzy-direction", "src/math/direction.zig" },
- .{ "fizzy-easing", "src/math/easing.zig" },
+ .{ "fizzy-direction", "src/core/math/direction.zig" },
+ .{ "fizzy-easing", "src/core/math/easing.zig" },
.{ "fizzy-layer-order", "src/plugins/pixelart/internal/layer_order.zig" },
.{ "fizzy-palette-parse", "src/plugins/pixelart/internal/palette_parse.zig" },
- .{ "fizzy-layout-anchor", "src/math/layout_anchor.zig" },
+ .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" },
.{ "fizzy-reduce", "src/plugins/pixelart/algorithms/reduce.zig" },
.{ "fizzy-grid-validate", "src/plugins/pixelart/internal/grid_layout_validate.zig" },
.{ "fizzy-animation", "src/plugins/pixelart/Animation.zig" },
@@ -818,6 +833,20 @@ pub fn build(b: *std.Build) !void {
if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
fizzy_test_module.addImport("icons", dep.module("icons"));
}
+
+ // Shared `core` module for the test build (dvui testing backend variant).
+ const core_module_test = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_module_test.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
+ core_module_test.addImport("known-folders", known_folders);
+ if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| {
+ core_module_test.addImport("icons", dep.module("icons"));
+ }
+ fizzy_test_module.addImport("core", core_module_test);
+
if (target.result.os.tag == .macos) {
if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
fizzy_test_module.addImport("objc", dep.module("objc"));
@@ -1131,6 +1160,17 @@ fn addFizzyExecutableForTarget(
exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
exe.root_module.addImport("backend", dvui_dep.module("sdl3"));
+ // Shared `core` module (gfx/math/fs/generated atlas/platform/paths/dvui hub +
+ // generic widgets). Imports only `dvui`, `icons`, and `known-folders`.
+ const core_module = b.createModule(.{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
+ core_module.addImport("known-folders", known_folders);
+ exe.root_module.addImport("core", core_module);
+
const singleton_app_dep = b.dependency("dvui_singleton_app", .{
.target = resolved_target,
.optimize = optimize,
@@ -1139,6 +1179,7 @@ fn addFizzyExecutableForTarget(
if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| {
exe.root_module.addImport("icons", dep.module("icons"));
+ core_module.addImport("icons", dep.module("icons"));
}
if (resolved_target.result.os.tag == .macos) {
diff --git a/src/App.zig b/src/App.zig
index 118adb5f..b7275477 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -11,7 +11,7 @@ const fizzy = @import("fizzy.zig");
const auto_update = @import("auto_update.zig");
const update_notify = @import("update_notify.zig");
const singleton = @import("singleton.zig");
-const paths = @import("paths.zig");
+const paths = fizzy.paths;
const App = @This();
const Editor = fizzy.Editor;
@@ -129,6 +129,11 @@ pub fn AppInit(win: *dvui.Window) !void {
const allocator = appAllocator();
+ // Inject shared infrastructure context into `core` so it stays decoupled from
+ // the App hub (allocator for gfx, trackpad input for the canvas widget).
+ fizzy.core.gpa = allocator;
+ fizzy.core.takeTrackpadPinchRatio = fizzy.backend.takeTrackpadPinchRatio;
+
const resolved_argv = singleton.consumeStartupArgv();
defer singleton.freeResolvedArgv(allocator, resolved_argv);
diff --git a/src/editor/Fling.zig b/src/core/Fling.zig
similarity index 100%
rename from src/editor/Fling.zig
rename to src/core/Fling.zig
diff --git a/src/core/core.zig b/src/core/core.zig
new file mode 100644
index 00000000..7eb7b3f3
--- /dev/null
+++ b/src/core/core.zig
@@ -0,0 +1,39 @@
+//! Core module root: shared infrastructure (gfx, math, fs, generated atlas,
+//! platform, paths, the generic dvui hub + generic widgets) that both the shell
+//! and the plugins depend on. Core never imports the `fizzy` app hub.
+//!
+//! Cross-cutting app resources (the allocator, platform input) are injected at
+//! startup via the context fields below so core stays decoupled from the App.
+const std = @import("std");
+
+/// Process allocator, set once at startup by the shell (`App`/`web_main`).
+/// Core infrastructure (e.g. `gfx.image`) allocates through this instead of
+/// reaching into the App hub.
+pub var gpa: std.mem.Allocator = undefined;
+
+/// Trackpad pinch-zoom accessor, wired at startup by the platform backend
+/// (native/web). Defaults to a no-op so headless/test builds work without it.
+pub var takeTrackpadPinchRatio: *const fn () f32 = defaultTrackpadPinchRatio;
+
+fn defaultTrackpadPinchRatio() f32 {
+ return 1.0;
+}
+
+// Shared infrastructure re-exports.
+pub const image = @import("gfx/image.zig");
+pub const perf = @import("gfx/perf.zig");
+pub const water_surface = @import("gfx/water_surface.zig");
+pub const math = @import("math/math.zig");
+pub const fs = @import("fs.zig");
+pub const platform = @import("platform.zig");
+pub const paths = @import("paths.zig");
+
+/// Generated atlas index (named sprite lookups). Written by the build's
+/// process-assets step into `src/core/generated/`.
+pub const atlas = @import("generated/atlas.zig");
+
+/// Generic dvui hub: dialog framework, helpers, and the generic widgets.
+pub const dvui = @import("dvui.zig");
+
+/// Generic momentum/fling helper (pan, scrub, cover-flow).
+pub const Fling = @import("Fling.zig");
diff --git a/src/dvui.zig b/src/core/dvui.zig
similarity index 97%
rename from src/dvui.zig
rename to src/core/dvui.zig
index 87bd287c..37242c67 100644
--- a/src/dvui.zig
+++ b/src/core/dvui.zig
@@ -1,19 +1,22 @@
const std = @import("std");
-const fizzy = @import("fizzy.zig");
const dvui = @import("dvui");
const builtin = @import("builtin");
const icons = @import("icons");
-const Widgets = @import("editor/widgets/Widgets.zig");
-
-pub const FileWidget = Widgets.FileWidget;
-pub const TabsWidget = Widgets.TabsWidget;
-pub const ImageWidget = Widgets.ImageWidget;
-pub const CanvasWidget = Widgets.CanvasWidget;
-pub const ReorderWidget = Widgets.ReorderWidget;
-pub const PanedWidget = Widgets.PanedWidget;
-pub const FloatingWindowWidget = Widgets.FloatingWindowWidget;
-pub const TreeWidget = Widgets.TreeWidget;
-pub const TreeSelection = Widgets.TreeSelection;
+const platform = @import("platform.zig");
+
+pub const CanvasWidget = @import("widgets/CanvasWidget.zig");
+pub const ReorderWidget = @import("widgets/ReorderWidget.zig");
+pub const PanedWidget = @import("widgets/PanedWidget.zig");
+pub const FloatingWindowWidget = @import("widgets/FloatingWindowWidget.zig");
+pub const TreeWidget = @import("widgets/TreeWidget.zig");
+pub const TreeSelection = @import("widgets/TreeSelection.zig");
+
+/// Core-owned dialog chrome state, set by the dialog framework and read by the
+/// shell so core stays decoupled from the editor. When a modal is open the shell
+/// dims the titlebar; the optional close-rect overrides the dialog's close
+/// animation origin (e.g. the New File flow animating from the tree row).
+pub var modal_dim_titlebar: bool = false;
+pub var dialog_close_rect_override: ?dvui.Rect.Physical = null;
/// Currently this is specialized for the layers paned widget, just includes icon and dragging flag so we know when the pane is dragging
pub fn paned(src: std.builtin.SourceLocation, init_opts: PanedWidget.InitOptions, opts: dvui.Options) *PanedWidget {
@@ -186,7 +189,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void {
};
if (modal) {
- fizzy.editor.dim_titlebar = true;
+ modal_dim_titlebar = true;
}
const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse {
@@ -214,7 +217,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void {
const maxSize = dvui.dataGet(null, id, "_max_size", dvui.Options.MaxSize);
const hide_footer = dvui.dataGet(null, id, "_hide_footer", bool) orelse false;
- var win = fizzy.dvui.floatingWindow(@src(), .{
+ var win = floatingWindow(@src(), .{
.modal = modal,
.center_on = center_on,
.window_avoid = .nudge,
@@ -238,12 +241,12 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void {
if (dvui.animationGet(win.data().id, "_close_x")) |a| {
if (a.done()) {
- fizzy.Editor.Explorer.files.new_file_close_rect = null;
+ dialog_close_rect_override = null;
dvui.dialogRemove(id);
}
- } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| {
+ } else if (dialog_close_rect_override) |close_rect| {
dvui.dataSet(null, win.data().id, "_close_rect", close_rect);
- fizzy.Editor.Explorer.files.new_file_close_rect = null;
+ dialog_close_rect_override = null;
} else {
win.autoSize();
}
@@ -261,7 +264,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void {
};
var header_openflag = true;
- win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind));
+ win.dragAreaSet(windowHeader(title, "", &header_openflag, header_kind));
if (!header_openflag) {
if (callafter) |ca| {
ca(id, .cancel) catch {
@@ -1001,7 +1004,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui.
if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip());
//if (needs_plus) dvui.labelNoFmt(@src(), "+", .{}, opts.strip()) else needs_plus = true;
//if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()) else needs_space = true;
- if (fizzy.platform.isMacOS()) {
+ if (platform.isMacOS()) {
dvui.icon(@src(), "cmd", icons.tvg.lucide.command, .{ .stroke_color = color }, .{ .gravity_y = 0.5 });
} else {
dvui.labelNoFmt(@src(), "cmd", .{}, second_opts);
@@ -1015,7 +1018,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui.
if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip());
//if (needs_plus) dvui.labelNoFmt(@src(), "+", .{}, opts.strip()) else needs_plus = true;
//if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()) else needs_space = true;
- if (fizzy.platform.isMacOS()) {
+ if (platform.isMacOS()) {
dvui.icon(@src(), "option", icons.tvg.lucide.option, .{ .stroke_color = color }, .{ .gravity_y = 0.5 });
} else {
dvui.labelNoFmt(@src(), "alt", .{}, second_opts);
diff --git a/src/tools/fs.zig b/src/core/fs.zig
similarity index 100%
rename from src/tools/fs.zig
rename to src/core/fs.zig
diff --git a/src/generated/atlas.zig b/src/core/generated/atlas.zig
similarity index 100%
rename from src/generated/atlas.zig
rename to src/core/generated/atlas.zig
diff --git a/src/gfx/image.zig b/src/core/gfx/image.zig
similarity index 94%
rename from src/gfx/image.zig
rename to src/core/gfx/image.zig
index 366d288c..124a7ee8 100644
--- a/src/gfx/image.zig
+++ b/src/core/gfx/image.zig
@@ -1,11 +1,13 @@
const std = @import("std");
-const fizzy = @import("../fizzy.zig");
+const core = @import("../core.zig");
+const fs = @import("../fs.zig");
+const math = @import("../math/math.zig");
const dvui = @import("dvui");
pub fn init(width: u32, height: u32, default_color: dvui.Color.PMA, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource {
const num_pixels = width * height;
if (num_pixels == 0) return error.InvalidImageSize;
- const p = fizzy.app.allocator.alloc(dvui.Color.PMA, num_pixels) catch return error.MemoryAllocationFailed;
+ const p = core.gpa.alloc(dvui.Color.PMA, num_pixels) catch return error.MemoryAllocationFailed;
@memset(p, default_color);
@@ -33,7 +35,7 @@ pub fn fromImageFileBytes(name: []const u8, file_bytes: []const u8, invalidation
return .{
.pixelsPMA = .{
- .rgba = dvui.Color.PMA.sliceFromRGBA(fizzy.app.allocator.dupe(u8, data[0..@intCast(w * h * @sizeOf(dvui.Color.PMA))]) catch return error.MemoryAllocationFailed),
+ .rgba = dvui.Color.PMA.sliceFromRGBA(core.gpa.dupe(u8, data[0..@intCast(w * h * @sizeOf(dvui.Color.PMA))]) catch return error.MemoryAllocationFailed),
.width = @as(u32, @intCast(w)),
.height = @as(u32, @intCast(h)),
.interpolation = .nearest,
@@ -43,15 +45,15 @@ pub fn fromImageFileBytes(name: []const u8, file_bytes: []const u8, invalidation
}
pub fn fromImageFilePath(name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource {
- const file_byes = try fizzy.fs.read(fizzy.app.allocator, dvui.io, path);
- defer fizzy.app.allocator.free(file_byes);
+ const file_byes = try fs.read(core.gpa, dvui.io, path);
+ defer core.gpa.free(file_byes);
return fromImageFileBytes(name, file_byes, invalidation);
}
pub fn fromPixelsPMA(pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource {
return .{
.pixelsPMA = .{
- .rgba = fizzy.app.allocator.dupe(dvui.Color.PMA, pixel_data) catch return error.MemoryAllocationFailed,
+ .rgba = core.gpa.dupe(dvui.Color.PMA, pixel_data) catch return error.MemoryAllocationFailed,
.interpolation = .nearest,
.invalidation = invalidation,
.width = width,
@@ -63,7 +65,7 @@ pub fn fromPixelsPMA(pixel_data: []dvui.Color.PMA, width: u32, height: u32, inva
pub fn fromPixels(pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource {
return .{
.pixels = .{
- .rgba = fizzy.app.allocator.dupe(u8, pixel_data) catch return error.MemoryAllocationFailed,
+ .rgba = core.gpa.dupe(u8, pixel_data) catch return error.MemoryAllocationFailed,
.interpolation = .nearest,
.invalidation = invalidation,
.width = width,
@@ -74,7 +76,7 @@ pub fn fromPixels(pixel_data: []u8, width: u32, height: u32, invalidation: dvui.
pub fn fromTexture(name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) dvui.ImageSource {
return .{
- .name = fizzy.app.allocator.dupe(u8, name) catch name,
+ .name = core.gpa.dupe(u8, name) catch name,
.texture = texture,
.invalidation = invalidation,
.interpolation = .nearest,
@@ -91,7 +93,7 @@ pub fn checkerboardTile(width: u32, height: u32, even: [4]u8, odd: [4]u8) ?dvui.
const size_f: dvui.Size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) };
for (buf, 0..) |*p, i| {
- const rgba = if (fizzy.math.checker(size_f, i)) even else odd;
+ const rgba = if (math.checker(size_f, i)) even else odd;
p.* = @bitCast(rgba);
}
@@ -311,7 +313,7 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi
const bot_c = dvui.Color{ .r = bot_px[0], .g = bot_px[1], .b = bot_px[2], .a = bot_px[3] };
const tpm = dvui.Color.PMA.fromColor(top_c);
const bpm = dvui.Color.PMA.fromColor(bot_c);
- const out_pma = fizzy.math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm));
+ const out_pma = math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm));
top_px.* = @as(dvui.Color.PMA, @bitCast(out_pma)).toColor().toRGBA();
}
}
diff --git a/src/gfx/perf.zig b/src/core/gfx/perf.zig
similarity index 100%
rename from src/gfx/perf.zig
rename to src/core/gfx/perf.zig
diff --git a/src/gfx/water_surface.zig b/src/core/gfx/water_surface.zig
similarity index 100%
rename from src/gfx/water_surface.zig
rename to src/core/gfx/water_surface.zig
diff --git a/src/math/color.zig b/src/core/math/color.zig
similarity index 100%
rename from src/math/color.zig
rename to src/core/math/color.zig
diff --git a/src/math/direction.zig b/src/core/math/direction.zig
similarity index 100%
rename from src/math/direction.zig
rename to src/core/math/direction.zig
diff --git a/src/math/easing.zig b/src/core/math/easing.zig
similarity index 100%
rename from src/math/easing.zig
rename to src/core/math/easing.zig
diff --git a/src/math/layout_anchor.zig b/src/core/math/layout_anchor.zig
similarity index 100%
rename from src/math/layout_anchor.zig
rename to src/core/math/layout_anchor.zig
diff --git a/src/math/math.zig b/src/core/math/math.zig
similarity index 100%
rename from src/math/math.zig
rename to src/core/math/math.zig
diff --git a/src/paths.zig b/src/core/paths.zig
similarity index 100%
rename from src/paths.zig
rename to src/core/paths.zig
diff --git a/src/platform.zig b/src/core/platform.zig
similarity index 100%
rename from src/platform.zig
rename to src/core/platform.zig
diff --git a/src/editor/widgets/CanvasWidget.zig b/src/core/widgets/CanvasWidget.zig
similarity index 99%
rename from src/editor/widgets/CanvasWidget.zig
rename to src/core/widgets/CanvasWidget.zig
index 48ae84bd..59a2a0f0 100644
--- a/src/editor/widgets/CanvasWidget.zig
+++ b/src/core/widgets/CanvasWidget.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
+const core = @import("../core.zig");
+const Fling = @import("../Fling.zig");
pub const CanvasWidget = @This();
@@ -134,8 +135,8 @@ scroll_pan_end_pending: bool = false,
// Momentum for the drag-pan (middle button, or a left/touch drag starting off the
// artboard). One coast per axis so a flick keeps gliding after release; see Fling.
-pan_fling_x: fizzy.Fling = .{},
-pan_fling_y: fizzy.Fling = .{},
+pan_fling_x: Fling = .{},
+pan_fling_y: Fling = .{},
// Pinch / two-finger pan input accumulated during this frame's `updateTouchGesture`.
// Mutating `scale` / `scroll_info.viewport` mid-frame jitters the canvas because the
@@ -186,7 +187,7 @@ const touch_eval_duration_ns: i128 = 80 * std.time.ns_per_ms;
/// units `scroll_info.viewport.x/y` move in — so the feel scales naturally with zoom.
/// Release velocity is measured over a wall-clock position/time window
/// (`releaseWindowed`)
-const pan_fling: fizzy.Fling.Tuning = .{
+const pan_fling: Fling.Tuning = .{
.decay = 4.0,
.min_start = 40.0,
.stop = 10.0,
@@ -757,7 +758,7 @@ pub fn updateTouchGesture(self: *CanvasWidget) void {
// scale-around-point math used by wheel/touch zoom. Focal point is the cursor position
// (macOS does not move the cursor during a trackpad gesture, so it represents intent).
// No-op on Windows/Linux/web (`takeTrackpadPinchRatio` returns 1.0 there).
- const trackpad_ratio = fizzy.backend.takeTrackpadPinchRatio();
+ const trackpad_ratio = core.takeTrackpadPinchRatio();
if (trackpad_ratio != 1.0) {
const cursor_phys = dvui.currentWindow().mouse_pt;
// Only honor the gesture when the cursor is over the canvas viewport — otherwise a
diff --git a/src/editor/widgets/FloatingWindowWidget.zig b/src/core/widgets/FloatingWindowWidget.zig
similarity index 100%
rename from src/editor/widgets/FloatingWindowWidget.zig
rename to src/core/widgets/FloatingWindowWidget.zig
diff --git a/src/editor/widgets/PanedWidget.zig b/src/core/widgets/PanedWidget.zig
similarity index 100%
rename from src/editor/widgets/PanedWidget.zig
rename to src/core/widgets/PanedWidget.zig
diff --git a/src/editor/widgets/ReorderWidget.zig b/src/core/widgets/ReorderWidget.zig
similarity index 100%
rename from src/editor/widgets/ReorderWidget.zig
rename to src/core/widgets/ReorderWidget.zig
diff --git a/src/editor/widgets/TreeSelection.zig b/src/core/widgets/TreeSelection.zig
similarity index 100%
rename from src/editor/widgets/TreeSelection.zig
rename to src/core/widgets/TreeSelection.zig
diff --git a/src/editor/widgets/TreeWidget.zig b/src/core/widgets/TreeWidget.zig
similarity index 100%
rename from src/editor/widgets/TreeWidget.zig
rename to src/core/widgets/TreeWidget.zig
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index f310d7e9..94710481 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -69,7 +69,6 @@ explorer: *Explorer,
panel: *Panel,
last_titlebar_color: dvui.Color,
-dim_titlebar: bool = false,
/// Workspaces stored by their grouping ID
workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty,
@@ -779,7 +778,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
editor.queueNativeMenuAction(action);
}
- defer editor.dim_titlebar = false;
+ defer fizzy.dvui.modal_dim_titlebar = false;
editor.setTitlebarColor();
editor.setWindowStyle();
@@ -1423,7 +1422,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA
}
pub fn setTitlebarColor(editor: *Editor) void {
- const color = if (editor.dim_titlebar) dvui.themeGet().color(.control, .fill).lerp(.black, if (dvui.themeGet().dark) 60.0 / 255.0 else 80.0 / 255.0) else dvui.themeGet().color(.control, .fill);
+ const color = if (fizzy.dvui.modal_dim_titlebar) dvui.themeGet().color(.control, .fill).lerp(.black, if (dvui.themeGet().dark) 60.0 / 255.0 else 80.0 / 255.0) else dvui.themeGet().color(.control, .fill);
if (!std.mem.eql(u8, &editor.last_titlebar_color.toRGBA(), &color.toRGBA())) {
editor.last_titlebar_color = color;
@@ -2514,7 +2513,7 @@ pub fn drawLoadingOverlay(editor: *Editor) void {
// unrelated input (mouse move, etc.) ticks a frame. Schedule a wakeup at the threshold
// boundary so the overlay shows on time even with the cursor parked.
if (earliest_pending_start_ns) |start_ns| {
- const elapsed_ms = @divTrunc(@import("../gfx/perf.zig").nanoTimestamp() - start_ns, std.time.ns_per_ms);
+ const elapsed_ms = @divTrunc(fizzy.perf.nanoTimestamp() - start_ns, std.time.ns_per_ms);
const remaining_ms: i64 = toast_threshold_ms - @as(i64, @intCast(elapsed_ms));
if (remaining_ms > 0) {
dvui.timer(dvui.currentWindow().data().id, @intCast(remaining_ms * std.time.us_per_ms));
diff --git a/src/editor/widgets/Widgets.zig b/src/editor/widgets/Widgets.zig
deleted file mode 100644
index b0bc8d97..00000000
--- a/src/editor/widgets/Widgets.zig
+++ /dev/null
@@ -1,15 +0,0 @@
-const std = @import("std");
-
-const fizzy = @import("../../fizzy.zig");
-const dvui = @import("dvui");
-
-pub const Widgets = @This();
-
-pub const FileWidget = @import("../../plugins/pixelart/widgets/FileWidget.zig");
-pub const ImageWidget = @import("../../plugins/pixelart/widgets/ImageWidget.zig");
-pub const CanvasWidget = @import("CanvasWidget.zig");
-pub const ReorderWidget = @import("ReorderWidget.zig");
-pub const PanedWidget = @import("PanedWidget.zig");
-pub const FloatingWindowWidget = @import("FloatingWindowWidget.zig");
-pub const TreeWidget = @import("TreeWidget.zig");
-pub const TreeSelection = @import("TreeSelection.zig");
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 21e96e9f..3feeeeb6 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -2,6 +2,10 @@ const std = @import("std");
const mach = @import("mach");
const Core = mach.Core;
+/// Shared infrastructure module (gfx, math, fs, generated atlas, platform,
+/// paths, the generic dvui hub + widgets). Consumed by the shell and plugins.
+pub const core = @import("core");
+
pub const version: std.SemanticVersion = .{
.major = 0,
.minor = 2,
@@ -10,26 +14,25 @@ pub const version: std.SemanticVersion = .{
// Generated files, these contain helpers for autocomplete
// So you can get a named index into atlas.sprites
-pub const atlas = @import("generated/atlas.zig");
+pub const atlas = core.atlas;
// Other helpers and namespaces
pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig");
-pub const fa = @import("tools/font_awesome.zig");
-pub const fs = @import("tools/fs.zig");
-pub const image = @import("gfx/image.zig");
+pub const fs = core.fs;
+pub const image = core.image;
pub const render = @import("plugins/pixelart/render.zig");
/// Atlas-consumer sprite rendering library (lives in the pixel-art plugin,
/// consumed by the shell/workbench to draw sprites from a packed atlas).
pub const sprite_render = @import("plugins/pixelart/sprite_render.zig");
-pub const perf = @import("gfx/perf.zig");
-pub const water_surface = @import("gfx/water_surface.zig");
-pub const math = @import("math/math.zig");
+pub const perf = core.perf;
+pub const water_surface = core.water_surface;
+pub const math = core.math;
pub const App = @import("App.zig");
pub const Editor = @import("editor/Editor.zig");
pub const Explorer = @import("editor/explorer/Explorer.zig");
-pub const Fling = @import("editor/Fling.zig");
+pub const Fling = core.Fling;
pub const Packer = @import("plugins/pixelart/Packer.zig");
//pub const Popups = @import("editor/popups/Popups.zig");
pub const Sidebar = @import("editor/Sidebar.zig");
@@ -71,13 +74,13 @@ pub const Sprite = @import("plugins/pixelart/Sprite.zig");
/// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web
/// builds, where `builtin.os.tag` is always `.freestanding`.
-pub const platform = @import("platform.zig");
+pub const platform = core.platform;
/// Plugin SDK surface
pub const sdk = @import("sdk/sdk.zig");
/// Custom dvui stuff
-pub const dvui = @import("dvui.zig");
+pub const dvui = core.dvui;
/// Custom backend stuff. Split per-arch: native uses SDL3 + objc + win32; web is a
/// no-op stub layer (no window chrome, no native dialogs, no native menu bar).
@@ -88,7 +91,7 @@ pub const backend = if (@import("builtin").target.cpu.arch == .wasm32)
else
@import("backend_native.zig");
-pub const paths = @import("paths.zig");
+pub const paths = core.paths;
/// Returns a `std.process.Environ` populated from the libc `environ` global.
/// Used to bridge APIs (like `known-folders.getPath`) that require an
diff --git a/src/gfx/gfx.zig b/src/gfx/gfx.zig
deleted file mode 100644
index 0673a5b8..00000000
--- a/src/gfx/gfx.zig
+++ /dev/null
@@ -1,2 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../fizzy.zig");
diff --git a/src/plugins/pixelart/Atlas.zig b/src/plugins/pixelart/Atlas.zig
index 6e7749d6..6d159e43 100644
--- a/src/plugins/pixelart/Atlas.zig
+++ b/src/plugins/pixelart/Atlas.zig
@@ -1,6 +1,4 @@
const std = @import("std");
-const fs = @import("../../tools/fs.zig");
-const fizzy = @import("../../fizzy.zig");
const Atlas = @This();
@@ -25,7 +23,13 @@ const AtlasV1 = struct {
};
pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, file: []const u8) !Atlas {
- const read = try fs.read(allocator, io, file);
+ const cwd = std.Io.Dir.cwd();
+ const handle = try cwd.openFile(io, file, .{});
+ defer handle.close(io);
+
+ var buf: [4096]u8 = undefined;
+ var rdr = handle.reader(io, &buf);
+ const read = try rdr.interface.allocRemaining(allocator, .unlimited);
defer allocator.free(read);
return loadFromBytes(allocator, read);
diff --git a/src/plugins/pixelart/CanvasData.zig b/src/plugins/pixelart/CanvasData.zig
index d674b525..027cddf4 100644
--- a/src/plugins/pixelart/CanvasData.zig
+++ b/src/plugins/pixelart/CanvasData.zig
@@ -13,6 +13,7 @@ const std = @import("std");
const dvui = @import("dvui");
const fizzy = @import("../../fizzy.zig");
const icons = @import("icons");
+const FileWidget = @import("widgets/FileWidget.zig");
const Workspace = fizzy.Editor.Workspace;
const File = fizzy.Internal.File;
@@ -1206,7 +1207,7 @@ pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void {
if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) {
const data_pt = file.editor.canvas.dataFromScreenPoint(me.p);
- fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true);
+ FileWidget.sampleColorAtPoint(file, data_pt, false, true, true);
}
// Clear sample state so the magnifier disappears on the next frame.
diff --git a/src/plugins/pixelart/PackJob.zig b/src/plugins/pixelart/PackJob.zig
index 7dc1baa6..2d3882a6 100644
--- a/src/plugins/pixelart/PackJob.zig
+++ b/src/plugins/pixelart/PackJob.zig
@@ -20,7 +20,7 @@ const std = @import("std");
const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const zstbi = @import("zstbi");
-const perf = @import("../../gfx/perf.zig");
+const perf = fizzy.perf;
const reduce_alg = @import("algorithms/reduce.zig");
const PackJob = @This();
diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig
index 66b3301f..8dd50c7d 100644
--- a/src/plugins/pixelart/dialogs/GridLayout.zig
+++ b/src/plugins/pixelart/dialogs/GridLayout.zig
@@ -11,9 +11,9 @@ const dvui = @import("dvui");
const std = @import("std");
const NewFile = @import("NewFile.zig");
-const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
+const CanvasWidget = fizzy.dvui.CanvasWidget;
const CanvasBridge = @import("../widgets/CanvasBridge.zig");
-const FloatingWindowWidget = @import("../../../editor/widgets/FloatingWindowWidget.zig");
+const FloatingWindowWidget = fizzy.dvui.FloatingWindowWidget;
const builtin = @import("builtin");
/// Editable grid fields for one mode (Slice vs Resize each keep their own backing).
@@ -1479,7 +1479,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
};
if (modal) {
- fizzy.editor.dim_titlebar = true;
+ fizzy.dvui.modal_dim_titlebar = true;
}
const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse {
@@ -1533,12 +1533,12 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
if (dvui.animationGet(win.data().id, "_close_x")) |a| {
if (a.done()) {
- fizzy.Editor.Explorer.files.new_file_close_rect = null;
+ fizzy.dvui.dialog_close_rect_override = null;
dvui.dialogRemove(id);
}
- } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| {
+ } else if (fizzy.dvui.dialog_close_rect_override) |close_rect| {
dvui.dataSet(null, win.data().id, "_close_rect", close_rect);
- fizzy.Editor.Explorer.files.new_file_close_rect = null;
+ fizzy.dvui.dialog_close_rect_override = null;
} else {
// Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the
// window keeps animating/snapping to content min size — user resize appears "locked".
diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig
index 130b386f..9f321f9e 100644
--- a/src/plugins/pixelart/plugin.zig
+++ b/src/plugins/pixelart/plugin.zig
@@ -8,6 +8,8 @@ const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
const CanvasData = @import("CanvasData.zig");
+const FileWidget = @import("widgets/FileWidget.zig");
+const ImageWidget = @import("widgets/ImageWidget.zig");
const DocHandle = sdk.DocHandle;
const Internal = fizzy.Internal;
@@ -130,7 +132,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
if (ws.grouping != file.editor.grouping) return;
- var file_widget = fizzy.dvui.FileWidget.init(@src(), .{
+ var file_widget = FileWidget.init(@src(), .{
.file = file,
.center = file.editor.center,
}, .{
@@ -143,7 +145,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| {
if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) {
- fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt);
+ FileWidget.drawSampleMagnifier(file, data_pt);
}
}
}
@@ -186,7 +188,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
if (show_packed_atlas) {
const atlas = &fizzy.packer.atlas.?;
- var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{
+ var image_widget = ImageWidget.init(@src(), .{
.source = atlas.source,
.canvas = &atlas.canvas,
.grouping = ws.grouping,
@@ -202,7 +204,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| {
if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) {
- fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt);
+ ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt);
}
}
} else {
diff --git a/src/plugins/pixelart/widgets/CanvasBridge.zig b/src/plugins/pixelart/widgets/CanvasBridge.zig
index 08f7aaa8..c2f655d8 100644
--- a/src/plugins/pixelart/widgets/CanvasBridge.zig
+++ b/src/plugins/pixelart/widgets/CanvasBridge.zig
@@ -2,7 +2,7 @@
//! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable
//! viewport; these helpers supply the pixel-art editor's wiring at the install sites.
const fizzy = @import("../../../fizzy.zig");
-const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
+const CanvasWidget = fizzy.dvui.CanvasWidget;
/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum.
pub fn scheme() CanvasWidget.PanZoomScheme {
diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig
index a6ac74c3..8c9285c6 100644
--- a/src/plugins/pixelart/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/widgets/FileWidget.zig
@@ -16,7 +16,7 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget;
const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
-const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
+const CanvasWidget = fizzy.dvui.CanvasWidget;
const CanvasBridge = @import("CanvasBridge.zig");
const Workspace = fizzy.Editor.Workspace;
const CanvasData = @import("../CanvasData.zig");
diff --git a/src/plugins/pixelart/widgets/ImageWidget.zig b/src/plugins/pixelart/widgets/ImageWidget.zig
index 68373922..a07d4dab 100644
--- a/src/plugins/pixelart/widgets/ImageWidget.zig
+++ b/src/plugins/pixelart/widgets/ImageWidget.zig
@@ -1,5 +1,5 @@
pub const ImageWidget = @This();
-const CanvasWidget = @import("../../../editor/widgets/CanvasWidget.zig");
+const CanvasWidget = fizzy.dvui.CanvasWidget;
const CanvasBridge = @import("CanvasBridge.zig");
init_options: InitOptions,
diff --git a/src/plugins/workbench/FileLoadJob.zig b/src/plugins/workbench/FileLoadJob.zig
index c8305d7e..c2150345 100644
--- a/src/plugins/workbench/FileLoadJob.zig
+++ b/src/plugins/workbench/FileLoadJob.zig
@@ -17,7 +17,7 @@
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const perf = @import("../../gfx/perf.zig");
+const perf = fizzy.perf;
const FileLoadJob = @This();
diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig
index 901bbdb3..34b61388 100644
--- a/src/plugins/workbench/files.zig
+++ b/src/plugins/workbench/files.zig
@@ -30,10 +30,8 @@ var pending_file_shift_range: ?struct {
clicked_path: []const u8,
} = null;
-/// Set from New File dialog when creating on disk; tree uses this to expand parents, focus rename, and set `new_file_close_rect`.
+/// Set from New File dialog when creating on disk; tree uses this to expand parents, focus rename, and set the dialog close-rect override.
pub var new_file_path: ?[]const u8 = null;
-/// When set, the dialog animates into this rect (explorer row) then closes.
-pub var new_file_close_rect: ?dvui.Rect.Physical = null;
const open_message = if (builtin.os.tag == .macos) "Reveal in Finder" else "Reveal in File Browser";
@@ -551,7 +549,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
selected_id = inner_id_extra.*;
var close_rect = branch.button.data().borderRectScale().r;
close_rect.h = @max(10.0, close_rect.h);
- new_file_close_rect = close_rect;
+ fizzy.dvui.dialog_close_rect_override = close_rect;
new_file_path = null;
}
}
diff --git a/src/tools/font_awesome.zig b/src/tools/font_awesome.zig
deleted file mode 100644
index 8f07f6e0..00000000
--- a/src/tools/font_awesome.zig
+++ /dev/null
@@ -1,1005 +0,0 @@
-pub const font_icon_filename_far = "fa-regular-400.ttf";
-pub const font_icon_filename_fas = "fa-solid-900.ttf";
-
-pub const icon_range_min = 0xf000;
-pub const icon_range_max = 0xf976;
-pub const ad = "\u{f641}";
-pub const address_book = "\u{f2b9}";
-pub const address_card = "\u{f2bb}";
-pub const adjust = "\u{f042}";
-pub const air_freshener = "\u{f5d0}";
-pub const align_center = "\u{f037}";
-pub const align_justify = "\u{f039}";
-pub const align_left = "\u{f036}";
-pub const align_right = "\u{f038}";
-pub const allergies = "\u{f461}";
-pub const ambulance = "\u{f0f9}";
-pub const american_sign_language_interpreting = "\u{f2a3}";
-pub const anchor = "\u{f13d}";
-pub const angle_double_down = "\u{f103}";
-pub const angle_double_left = "\u{f100}";
-pub const angle_double_right = "\u{f101}";
-pub const angle_double_up = "\u{f102}";
-pub const angle_down = "\u{f107}";
-pub const angle_left = "\u{f104}";
-pub const angle_right = "\u{f105}";
-pub const angle_up = "\u{f106}";
-pub const angry = "\u{f556}";
-pub const ankh = "\u{f644}";
-pub const apple_alt = "\u{f5d1}";
-pub const archive = "\u{f187}";
-pub const archway = "\u{f557}";
-pub const arrow_alt_circle_down = "\u{f358}";
-pub const arrow_alt_circle_left = "\u{f359}";
-pub const arrow_alt_circle_right = "\u{f35a}";
-pub const arrow_alt_circle_up = "\u{f35b}";
-pub const arrow_circle_down = "\u{f0ab}";
-pub const arrow_circle_left = "\u{f0a8}";
-pub const arrow_circle_right = "\u{f0a9}";
-pub const arrow_circle_up = "\u{f0aa}";
-pub const arrow_down = "\u{f063}";
-pub const arrow_left = "\u{f060}";
-pub const arrow_right = "\u{f061}";
-pub const arrow_up = "\u{f062}";
-pub const arrows_alt = "\u{f0b2}";
-pub const arrows_alt_h = "\u{f337}";
-pub const arrows_alt_v = "\u{f338}";
-pub const assistive_listening_systems = "\u{f2a2}";
-pub const asterisk = "\u{f069}";
-pub const at = "\u{f1fa}";
-pub const atlas = "\u{f558}";
-pub const atom = "\u{f5d2}";
-pub const audio_description = "\u{f29e}";
-pub const award = "\u{f559}";
-pub const baby = "\u{f77c}";
-pub const baby_carriage = "\u{f77d}";
-pub const backspace = "\u{f55a}";
-pub const backward = "\u{f04a}";
-pub const bacon = "\u{f7e5}";
-pub const bacteria = "\u{f959}";
-pub const bacterium = "\u{f95a}";
-pub const bahai = "\u{f666}";
-pub const balance_scale = "\u{f24e}";
-pub const balance_scale_left = "\u{f515}";
-pub const balance_scale_right = "\u{f516}";
-pub const ban = "\u{f05e}";
-pub const band_aid = "\u{f462}";
-pub const barcode = "\u{f02a}";
-pub const bars = "\u{f0c9}";
-pub const baseball_ball = "\u{f433}";
-pub const basketball_ball = "\u{f434}";
-pub const bath = "\u{f2cd}";
-pub const battery_empty = "\u{f244}";
-pub const battery_full = "\u{f240}";
-pub const battery_half = "\u{f242}";
-pub const battery_quarter = "\u{f243}";
-pub const battery_three_quarters = "\u{f241}";
-pub const bed = "\u{f236}";
-pub const beer = "\u{f0fc}";
-pub const bell = "\u{f0f3}";
-pub const bell_slash = "\u{f1f6}";
-pub const bezier_curve = "\u{f55b}";
-pub const bible = "\u{f647}";
-pub const bicycle = "\u{f206}";
-pub const biking = "\u{f84a}";
-pub const binoculars = "\u{f1e5}";
-pub const biohazard = "\u{f780}";
-pub const birthday_cake = "\u{f1fd}";
-pub const blender = "\u{f517}";
-pub const blender_phone = "\u{f6b6}";
-pub const blind = "\u{f29d}";
-pub const blog = "\u{f781}";
-pub const bold = "\u{f032}";
-pub const bolt = "\u{f0e7}";
-pub const bomb = "\u{f1e2}";
-pub const bone = "\u{f5d7}";
-pub const bong = "\u{f55c}";
-pub const book = "\u{f02d}";
-pub const book_dead = "\u{f6b7}";
-pub const book_medical = "\u{f7e6}";
-pub const book_open = "\u{f518}";
-pub const book_reader = "\u{f5da}";
-pub const bookmark = "\u{f02e}";
-pub const border_all = "\u{f84c}";
-pub const border_none = "\u{f850}";
-pub const border_style = "\u{f853}";
-pub const bowling_ball = "\u{f436}";
-pub const box = "\u{f466}";
-pub const box_open = "\u{f49e}";
-pub const box_tissue = "\u{f95b}";
-pub const boxes = "\u{f468}";
-pub const braille = "\u{f2a1}";
-pub const brain = "\u{f5dc}";
-pub const bread_slice = "\u{f7ec}";
-pub const briefcase = "\u{f0b1}";
-pub const briefcase_medical = "\u{f469}";
-pub const broadcast_tower = "\u{f519}";
-pub const broom = "\u{f51a}";
-pub const brush = "\u{f55d}";
-pub const bug = "\u{f188}";
-pub const building = "\u{f1ad}";
-pub const bullhorn = "\u{f0a1}";
-pub const bullseye = "\u{f140}";
-pub const burn = "\u{f46a}";
-pub const bus = "\u{f207}";
-pub const bus_alt = "\u{f55e}";
-pub const business_time = "\u{f64a}";
-pub const calculator = "\u{f1ec}";
-pub const calendar = "\u{f133}";
-pub const calendar_alt = "\u{f073}";
-pub const calendar_check = "\u{f274}";
-pub const calendar_day = "\u{f783}";
-pub const calendar_minus = "\u{f272}";
-pub const calendar_plus = "\u{f271}";
-pub const calendar_times = "\u{f273}";
-pub const calendar_week = "\u{f784}";
-pub const camera = "\u{f030}";
-pub const camera_retro = "\u{f083}";
-pub const campground = "\u{f6bb}";
-pub const candy_cane = "\u{f786}";
-pub const cannabis = "\u{f55f}";
-pub const capsules = "\u{f46b}";
-pub const car = "\u{f1b9}";
-pub const car_alt = "\u{f5de}";
-pub const car_battery = "\u{f5df}";
-pub const car_crash = "\u{f5e1}";
-pub const car_side = "\u{f5e4}";
-pub const caravan = "\u{f8ff}";
-pub const caret_down = "\u{f0d7}";
-pub const caret_left = "\u{f0d9}";
-pub const caret_right = "\u{f0da}";
-pub const caret_square_down = "\u{f150}";
-pub const caret_square_left = "\u{f191}";
-pub const caret_square_right = "\u{f152}";
-pub const caret_square_up = "\u{f151}";
-pub const caret_up = "\u{f0d8}";
-pub const carrot = "\u{f787}";
-pub const cart_arrow_down = "\u{f218}";
-pub const cart_plus = "\u{f217}";
-pub const cash_register = "\u{f788}";
-pub const cat = "\u{f6be}";
-pub const certificate = "\u{f0a3}";
-pub const chair = "\u{f6c0}";
-pub const chalkboard = "\u{f51b}";
-pub const chalkboard_teacher = "\u{f51c}";
-pub const charging_station = "\u{f5e7}";
-pub const chart_area = "\u{f1fe}";
-pub const chart_bar = "\u{f080}";
-pub const chart_line = "\u{f201}";
-pub const chart_pie = "\u{f200}";
-pub const check = "\u{f00c}";
-pub const check_circle = "\u{f058}";
-pub const check_double = "\u{f560}";
-pub const check_square = "\u{f14a}";
-pub const cheese = "\u{f7ef}";
-pub const chess = "\u{f439}";
-pub const chess_bishop = "\u{f43a}";
-pub const chess_board = "\u{f43c}";
-pub const chess_king = "\u{f43f}";
-pub const chess_knight = "\u{f441}";
-pub const chess_pawn = "\u{f443}";
-pub const chess_queen = "\u{f445}";
-pub const chess_rook = "\u{f447}";
-pub const chevron_circle_down = "\u{f13a}";
-pub const chevron_circle_left = "\u{f137}";
-pub const chevron_circle_right = "\u{f138}";
-pub const chevron_circle_up = "\u{f139}";
-pub const chevron_down = "\u{f078}";
-pub const chevron_left = "\u{f053}";
-pub const chevron_right = "\u{f054}";
-pub const chevron_up = "\u{f077}";
-pub const child = "\u{f1ae}";
-pub const church = "\u{f51d}";
-pub const circle = "\u{f111}";
-pub const circle_notch = "\u{f1ce}";
-pub const city = "\u{f64f}";
-pub const clinic_medical = "\u{f7f2}";
-pub const clipboard = "\u{f328}";
-pub const clipboard_check = "\u{f46c}";
-pub const clipboard_list = "\u{f46d}";
-pub const clock = "\u{f017}";
-pub const clone = "\u{f24d}";
-pub const closed_captioning = "\u{f20a}";
-pub const cloud = "\u{f0c2}";
-pub const cloud_download_alt = "\u{f381}";
-pub const cloud_meatball = "\u{f73b}";
-pub const cloud_moon = "\u{f6c3}";
-pub const cloud_moon_rain = "\u{f73c}";
-pub const cloud_rain = "\u{f73d}";
-pub const cloud_showers_heavy = "\u{f740}";
-pub const cloud_sun = "\u{f6c4}";
-pub const cloud_sun_rain = "\u{f743}";
-pub const cloud_upload_alt = "\u{f382}";
-pub const cocktail = "\u{f561}";
-pub const code = "\u{f121}";
-pub const code_branch = "\u{f126}";
-pub const coffee = "\u{f0f4}";
-pub const cog = "\u{f013}";
-pub const cogs = "\u{f085}";
-pub const coins = "\u{f51e}";
-pub const columns = "\u{f0db}";
-pub const comment = "\u{f075}";
-pub const comment_alt = "\u{f27a}";
-pub const comment_dollar = "\u{f651}";
-pub const comment_dots = "\u{f4ad}";
-pub const comment_medical = "\u{f7f5}";
-pub const comment_slash = "\u{f4b3}";
-pub const comments = "\u{f086}";
-pub const comments_dollar = "\u{f653}";
-pub const compact_disc = "\u{f51f}";
-pub const compass = "\u{f14e}";
-pub const compress = "\u{f066}";
-pub const compress_alt = "\u{f422}";
-pub const compress_arrows_alt = "\u{f78c}";
-pub const concierge_bell = "\u{f562}";
-pub const cookie = "\u{f563}";
-pub const cookie_bite = "\u{f564}";
-pub const copy = "\u{f0c5}";
-pub const copyright = "\u{f1f9}";
-pub const couch = "\u{f4b8}";
-pub const credit_card = "\u{f09d}";
-pub const crop = "\u{f125}";
-pub const crop_alt = "\u{f565}";
-pub const cross = "\u{f654}";
-pub const crosshairs = "\u{f05b}";
-pub const crow = "\u{f520}";
-pub const crown = "\u{f521}";
-pub const crutch = "\u{f7f7}";
-pub const cube = "\u{f1b2}";
-pub const cubes = "\u{f1b3}";
-pub const cut = "\u{f0c4}";
-pub const database = "\u{f1c0}";
-pub const deaf = "\u{f2a4}";
-pub const democrat = "\u{f747}";
-pub const desktop = "\u{f108}";
-pub const dharmachakra = "\u{f655}";
-pub const diagnoses = "\u{f470}";
-pub const dice = "\u{f522}";
-pub const dice_d20 = "\u{f6cf}";
-pub const dice_d6 = "\u{f6d1}";
-pub const dice_five = "\u{f523}";
-pub const dice_four = "\u{f524}";
-pub const dice_one = "\u{f525}";
-pub const dice_six = "\u{f526}";
-pub const dice_three = "\u{f527}";
-pub const dice_two = "\u{f528}";
-pub const digital_tachograph = "\u{f566}";
-pub const directions = "\u{f5eb}";
-pub const disease = "\u{f7fa}";
-pub const divide = "\u{f529}";
-pub const dizzy = "\u{f567}";
-pub const dna = "\u{f471}";
-pub const dog = "\u{f6d3}";
-pub const dollar_sign = "\u{f155}";
-pub const dolly = "\u{f472}";
-pub const dolly_flatbed = "\u{f474}";
-pub const donate = "\u{f4b9}";
-pub const door_closed = "\u{f52a}";
-pub const door_open = "\u{f52b}";
-pub const dot_circle = "\u{f192}";
-pub const dove = "\u{f4ba}";
-pub const download = "\u{f019}";
-pub const drafting_compass = "\u{f568}";
-pub const dragon = "\u{f6d5}";
-pub const draw_polygon = "\u{f5ee}";
-pub const drum = "\u{f569}";
-pub const drum_steelpan = "\u{f56a}";
-pub const drumstick_bite = "\u{f6d7}";
-pub const dumbbell = "\u{f44b}";
-pub const dumpster = "\u{f793}";
-pub const dumpster_fire = "\u{f794}";
-pub const dungeon = "\u{f6d9}";
-pub const edit = "\u{f044}";
-pub const egg = "\u{f7fb}";
-pub const eject = "\u{f052}";
-pub const ellipsis_h = "\u{f141}";
-pub const ellipsis_v = "\u{f142}";
-pub const envelope = "\u{f0e0}";
-pub const envelope_open = "\u{f2b6}";
-pub const envelope_open_text = "\u{f658}";
-pub const envelope_square = "\u{f199}";
-pub const equals = "\u{f52c}";
-pub const eraser = "\u{f12d}";
-pub const ethernet = "\u{f796}";
-pub const euro_sign = "\u{f153}";
-pub const exchange_alt = "\u{f362}";
-pub const exclamation = "\u{f12a}";
-pub const exclamation_circle = "\u{f06a}";
-pub const exclamation_triangle = "\u{f071}";
-pub const expand = "\u{f065}";
-pub const expand_alt = "\u{f424}";
-pub const expand_arrows_alt = "\u{f31e}";
-pub const external_link_alt = "\u{f35d}";
-pub const external_link_square_alt = "\u{f360}";
-pub const eye = "\u{f06e}";
-pub const eye_dropper = "\u{f1fb}";
-pub const eye_slash = "\u{f070}";
-pub const fan = "\u{f863}";
-pub const fast_backward = "\u{f049}";
-pub const fast_forward = "\u{f050}";
-pub const faucet = "\u{f905}";
-pub const fax = "\u{f1ac}";
-pub const feather = "\u{f52d}";
-pub const feather_alt = "\u{f56b}";
-pub const female = "\u{f182}";
-pub const fighter_jet = "\u{f0fb}";
-pub const file = "\u{f15b}";
-pub const file_alt = "\u{f15c}";
-pub const file_archive = "\u{f1c6}";
-pub const file_audio = "\u{f1c7}";
-pub const file_code = "\u{f1c9}";
-pub const file_contract = "\u{f56c}";
-pub const file_csv = "\u{f6dd}";
-pub const file_download = "\u{f56d}";
-pub const file_excel = "\u{f1c3}";
-pub const file_export = "\u{f56e}";
-pub const file_image = "\u{f1c5}";
-pub const file_import = "\u{f56f}";
-pub const file_invoice = "\u{f570}";
-pub const file_invoice_dollar = "\u{f571}";
-pub const file_medical = "\u{f477}";
-pub const file_medical_alt = "\u{f478}";
-pub const file_pdf = "\u{f1c1}";
-pub const file_powerpoint = "\u{f1c4}";
-pub const file_prescription = "\u{f572}";
-pub const file_signature = "\u{f573}";
-pub const file_upload = "\u{f574}";
-pub const file_video = "\u{f1c8}";
-pub const file_word = "\u{f1c2}";
-pub const fill = "\u{f575}";
-pub const fill_drip = "\u{f576}";
-pub const film = "\u{f008}";
-pub const filter = "\u{f0b0}";
-pub const fingerprint = "\u{f577}";
-pub const fire = "\u{f06d}";
-pub const fire_alt = "\u{f7e4}";
-pub const fire_extinguisher = "\u{f134}";
-pub const first_aid = "\u{f479}";
-pub const fish = "\u{f578}";
-pub const fist_raised = "\u{f6de}";
-pub const flag = "\u{f024}";
-pub const flag_checkered = "\u{f11e}";
-pub const flag_usa = "\u{f74d}";
-pub const flask = "\u{f0c3}";
-pub const flushed = "\u{f579}";
-pub const folder = "\u{f07b}";
-pub const folder_minus = "\u{f65d}";
-pub const folder_open = "\u{f07c}";
-pub const folder_plus = "\u{f65e}";
-pub const font = "\u{f031}";
-pub const font_awesome_logo_full = "\u{f4e6}";
-pub const football_ball = "\u{f44e}";
-pub const forward = "\u{f04e}";
-pub const frog = "\u{f52e}";
-pub const frown = "\u{f119}";
-pub const frown_open = "\u{f57a}";
-pub const funnel_dollar = "\u{f662}";
-pub const futbol = "\u{f1e3}";
-pub const gamepad = "\u{f11b}";
-pub const gas_pump = "\u{f52f}";
-pub const gavel = "\u{f0e3}";
-pub const gem = "\u{f3a5}";
-pub const genderless = "\u{f22d}";
-pub const ghost = "\u{f6e2}";
-pub const gift = "\u{f06b}";
-pub const gifts = "\u{f79c}";
-pub const glass_cheers = "\u{f79f}";
-pub const glass_martini = "\u{f000}";
-pub const glass_martini_alt = "\u{f57b}";
-pub const glass_whiskey = "\u{f7a0}";
-pub const glasses = "\u{f530}";
-pub const globe = "\u{f0ac}";
-pub const globe_africa = "\u{f57c}";
-pub const globe_americas = "\u{f57d}";
-pub const globe_asia = "\u{f57e}";
-pub const globe_europe = "\u{f7a2}";
-pub const golf_ball = "\u{f450}";
-pub const gopuram = "\u{f664}";
-pub const graduation_cap = "\u{f19d}";
-pub const greater_than = "\u{f531}";
-pub const greater_than_equal = "\u{f532}";
-pub const grimace = "\u{f57f}";
-pub const grin = "\u{f580}";
-pub const grin_alt = "\u{f581}";
-pub const grin_beam = "\u{f582}";
-pub const grin_beam_sweat = "\u{f583}";
-pub const grin_hearts = "\u{f584}";
-pub const grin_squint = "\u{f585}";
-pub const grin_squint_tears = "\u{f586}";
-pub const grin_stars = "\u{f587}";
-pub const grin_tears = "\u{f588}";
-pub const grin_tongue = "\u{f589}";
-pub const grin_tongue_squint = "\u{f58a}";
-pub const grin_tongue_wink = "\u{f58b}";
-pub const grin_wink = "\u{f58c}";
-pub const grip_horizontal = "\u{f58d}";
-pub const grip_lines = "\u{f7a4}";
-pub const grip_lines_vertical = "\u{f7a5}";
-pub const grip_vertical = "\u{f58e}";
-pub const guitar = "\u{f7a6}";
-pub const h_square = "\u{f0fd}";
-pub const hamburger = "\u{f805}";
-pub const hammer = "\u{f6e3}";
-pub const hamsa = "\u{f665}";
-pub const hand_holding = "\u{f4bd}";
-pub const hand_holding_heart = "\u{f4be}";
-pub const hand_holding_medical = "\u{f95c}";
-pub const hand_holding_usd = "\u{f4c0}";
-pub const hand_holding_water = "\u{f4c1}";
-pub const hand_lizard = "\u{f258}";
-pub const hand_middle_finger = "\u{f806}";
-pub const hand_paper = "\u{f256}";
-pub const hand_peace = "\u{f25b}";
-pub const hand_point_down = "\u{f0a7}";
-pub const hand_point_left = "\u{f0a5}";
-pub const hand_point_right = "\u{f0a4}";
-pub const hand_point_up = "\u{f0a6}";
-pub const hand_pointer = "\u{f25a}";
-pub const hand_rock = "\u{f255}";
-pub const hand_scissors = "\u{f257}";
-pub const hand_sparkles = "\u{f95d}";
-pub const hand_spock = "\u{f259}";
-pub const hands = "\u{f4c2}";
-pub const hands_helping = "\u{f4c4}";
-pub const hands_wash = "\u{f95e}";
-pub const handshake = "\u{f2b5}";
-pub const handshake_alt_slash = "\u{f95f}";
-pub const handshake_slash = "\u{f960}";
-pub const hanukiah = "\u{f6e6}";
-pub const hard_hat = "\u{f807}";
-pub const hashtag = "\u{f292}";
-pub const hat_cowboy = "\u{f8c0}";
-pub const hat_cowboy_side = "\u{f8c1}";
-pub const hat_wizard = "\u{f6e8}";
-pub const hdd = "\u{f0a0}";
-pub const head_side_cough = "\u{f961}";
-pub const head_side_cough_slash = "\u{f962}";
-pub const head_side_mask = "\u{f963}";
-pub const head_side_virus = "\u{f964}";
-pub const heading = "\u{f1dc}";
-pub const headphones = "\u{f025}";
-pub const headphones_alt = "\u{f58f}";
-pub const headset = "\u{f590}";
-pub const heart = "\u{f004}";
-pub const heart_broken = "\u{f7a9}";
-pub const heartbeat = "\u{f21e}";
-pub const helicopter = "\u{f533}";
-pub const highlighter = "\u{f591}";
-pub const hiking = "\u{f6ec}";
-pub const hippo = "\u{f6ed}";
-pub const history = "\u{f1da}";
-pub const hockey_puck = "\u{f453}";
-pub const holly_berry = "\u{f7aa}";
-pub const home = "\u{f015}";
-pub const horse = "\u{f6f0}";
-pub const horse_head = "\u{f7ab}";
-pub const hospital = "\u{f0f8}";
-pub const hospital_alt = "\u{f47d}";
-pub const hospital_symbol = "\u{f47e}";
-pub const hospital_user = "\u{f80d}";
-pub const hot_tub = "\u{f593}";
-pub const hotdog = "\u{f80f}";
-pub const hotel = "\u{f594}";
-pub const hourglass = "\u{f254}";
-pub const hourglass_end = "\u{f253}";
-pub const hourglass_half = "\u{f252}";
-pub const hourglass_start = "\u{f251}";
-pub const house_damage = "\u{f6f1}";
-pub const house_user = "\u{f965}";
-pub const hryvnia = "\u{f6f2}";
-pub const i_cursor = "\u{f246}";
-pub const ice_cream = "\u{f810}";
-pub const icicles = "\u{f7ad}";
-pub const icons = "\u{f86d}";
-pub const id_badge = "\u{f2c1}";
-pub const id_card = "\u{f2c2}";
-pub const id_card_alt = "\u{f47f}";
-pub const igloo = "\u{f7ae}";
-pub const image = "\u{f03e}";
-pub const images = "\u{f302}";
-pub const inbox = "\u{f01c}";
-pub const indent = "\u{f03c}";
-pub const industry = "\u{f275}";
-pub const infinity = "\u{f534}";
-pub const info = "\u{f129}";
-pub const info_circle = "\u{f05a}";
-pub const italic = "\u{f033}";
-pub const jedi = "\u{f669}";
-pub const joint = "\u{f595}";
-pub const journal_whills = "\u{f66a}";
-pub const kaaba = "\u{f66b}";
-pub const key = "\u{f084}";
-pub const keyboard = "\u{f11c}";
-pub const khanda = "\u{f66d}";
-pub const kiss = "\u{f596}";
-pub const kiss_beam = "\u{f597}";
-pub const kiss_wink_heart = "\u{f598}";
-pub const kiwi_bird = "\u{f535}";
-pub const landmark = "\u{f66f}";
-pub const language = "\u{f1ab}";
-pub const laptop = "\u{f109}";
-pub const laptop_code = "\u{f5fc}";
-pub const laptop_house = "\u{f966}";
-pub const laptop_medical = "\u{f812}";
-pub const laugh = "\u{f599}";
-pub const laugh_beam = "\u{f59a}";
-pub const laugh_squint = "\u{f59b}";
-pub const laugh_wink = "\u{f59c}";
-pub const layer_group = "\u{f5fd}";
-pub const leaf = "\u{f06c}";
-pub const lemon = "\u{f094}";
-pub const less_than = "\u{f536}";
-pub const less_than_equal = "\u{f537}";
-pub const level_down_alt = "\u{f3be}";
-pub const level_up_alt = "\u{f3bf}";
-pub const life_ring = "\u{f1cd}";
-pub const lightbulb = "\u{f0eb}";
-pub const link = "\u{f0c1}";
-pub const lira_sign = "\u{f195}";
-pub const list = "\u{f03a}";
-pub const list_alt = "\u{f022}";
-pub const list_ol = "\u{f0cb}";
-pub const list_ul = "\u{f0ca}";
-pub const location_arrow = "\u{f124}";
-pub const lock = "\u{f023}";
-pub const lock_open = "\u{f3c1}";
-pub const long_arrow_alt_down = "\u{f309}";
-pub const long_arrow_alt_left = "\u{f30a}";
-pub const long_arrow_alt_right = "\u{f30b}";
-pub const long_arrow_alt_up = "\u{f30c}";
-pub const low_vision = "\u{f2a8}";
-pub const luggage_cart = "\u{f59d}";
-pub const lungs = "\u{f604}";
-pub const lungs_virus = "\u{f967}";
-pub const magic = "\u{f0d0}";
-pub const magnet = "\u{f076}";
-pub const mail_bulk = "\u{f674}";
-pub const male = "\u{f183}";
-pub const map = "\u{f279}";
-pub const map_marked = "\u{f59f}";
-pub const map_marked_alt = "\u{f5a0}";
-pub const map_marker = "\u{f041}";
-pub const map_marker_alt = "\u{f3c5}";
-pub const map_pin = "\u{f276}";
-pub const map_signs = "\u{f277}";
-pub const marker = "\u{f5a1}";
-pub const mars = "\u{f222}";
-pub const mars_double = "\u{f227}";
-pub const mars_stroke = "\u{f229}";
-pub const mars_stroke_h = "\u{f22b}";
-pub const mars_stroke_v = "\u{f22a}";
-pub const mask = "\u{f6fa}";
-pub const medal = "\u{f5a2}";
-pub const medkit = "\u{f0fa}";
-pub const meh = "\u{f11a}";
-pub const meh_blank = "\u{f5a4}";
-pub const meh_rolling_eyes = "\u{f5a5}";
-pub const memory = "\u{f538}";
-pub const menorah = "\u{f676}";
-pub const mercury = "\u{f223}";
-pub const meteor = "\u{f753}";
-pub const microchip = "\u{f2db}";
-pub const microphone = "\u{f130}";
-pub const microphone_alt = "\u{f3c9}";
-pub const microphone_alt_slash = "\u{f539}";
-pub const microphone_slash = "\u{f131}";
-pub const microscope = "\u{f610}";
-pub const minus = "\u{f068}";
-pub const minus_circle = "\u{f056}";
-pub const minus_square = "\u{f146}";
-pub const mitten = "\u{f7b5}";
-pub const mobile = "\u{f10b}";
-pub const mobile_alt = "\u{f3cd}";
-pub const money_bill = "\u{f0d6}";
-pub const money_bill_alt = "\u{f3d1}";
-pub const money_bill_wave = "\u{f53a}";
-pub const money_bill_wave_alt = "\u{f53b}";
-pub const money_check = "\u{f53c}";
-pub const money_check_alt = "\u{f53d}";
-pub const monument = "\u{f5a6}";
-pub const moon = "\u{f186}";
-pub const mortar_pestle = "\u{f5a7}";
-pub const mosque = "\u{f678}";
-pub const motorcycle = "\u{f21c}";
-pub const mountain = "\u{f6fc}";
-pub const mouse = "\u{f8cc}";
-pub const mouse_pointer = "\u{f245}";
-pub const mug_hot = "\u{f7b6}";
-pub const music = "\u{f001}";
-pub const network_wired = "\u{f6ff}";
-pub const neuter = "\u{f22c}";
-pub const newspaper = "\u{f1ea}";
-pub const not_equal = "\u{f53e}";
-pub const notes_medical = "\u{f481}";
-pub const object_group = "\u{f247}";
-pub const object_ungroup = "\u{f248}";
-pub const oil_can = "\u{f613}";
-pub const om = "\u{f679}";
-pub const otter = "\u{f700}";
-pub const outdent = "\u{f03b}";
-pub const pager = "\u{f815}";
-pub const paint_brush = "\u{f1fc}";
-pub const paint_roller = "\u{f5aa}";
-pub const palette = "\u{f53f}";
-pub const pallet = "\u{f482}";
-pub const paper_plane = "\u{f1d8}";
-pub const paperclip = "\u{f0c6}";
-pub const parachute_box = "\u{f4cd}";
-pub const paragraph = "\u{f1dd}";
-pub const parking = "\u{f540}";
-pub const passport = "\u{f5ab}";
-pub const pastafarianism = "\u{f67b}";
-pub const paste = "\u{f0ea}";
-pub const pause = "\u{f04c}";
-pub const pause_circle = "\u{f28b}";
-pub const paw = "\u{f1b0}";
-pub const peace = "\u{f67c}";
-pub const pen = "\u{f304}";
-pub const pen_alt = "\u{f305}";
-pub const pen_fancy = "\u{f5ac}";
-pub const pen_nib = "\u{f5ad}";
-pub const pen_square = "\u{f14b}";
-pub const pencil_alt = "\u{f303}";
-pub const pencil_ruler = "\u{f5ae}";
-pub const people_arrows = "\u{f968}";
-pub const people_carry = "\u{f4ce}";
-pub const pepper_hot = "\u{f816}";
-pub const percent = "\u{f295}";
-pub const percentage = "\u{f541}";
-pub const person_booth = "\u{f756}";
-pub const phone = "\u{f095}";
-pub const phone_alt = "\u{f879}";
-pub const phone_slash = "\u{f3dd}";
-pub const phone_square = "\u{f098}";
-pub const phone_square_alt = "\u{f87b}";
-pub const phone_volume = "\u{f2a0}";
-pub const photo_video = "\u{f87c}";
-pub const piggy_bank = "\u{f4d3}";
-pub const pills = "\u{f484}";
-pub const pizza_slice = "\u{f818}";
-pub const place_of_worship = "\u{f67f}";
-pub const plane = "\u{f072}";
-pub const plane_arrival = "\u{f5af}";
-pub const plane_departure = "\u{f5b0}";
-pub const plane_slash = "\u{f969}";
-pub const play = "\u{f04b}";
-pub const play_circle = "\u{f144}";
-pub const plug = "\u{f1e6}";
-pub const plus = "\u{f067}";
-pub const plus_circle = "\u{f055}";
-pub const plus_square = "\u{f0fe}";
-pub const podcast = "\u{f2ce}";
-pub const poll = "\u{f681}";
-pub const poll_h = "\u{f682}";
-pub const poo = "\u{f2fe}";
-pub const poo_storm = "\u{f75a}";
-pub const poop = "\u{f619}";
-pub const portrait = "\u{f3e0}";
-pub const pound_sign = "\u{f154}";
-pub const power_off = "\u{f011}";
-pub const pray = "\u{f683}";
-pub const praying_hands = "\u{f684}";
-pub const prescription = "\u{f5b1}";
-pub const prescription_bottle = "\u{f485}";
-pub const prescription_bottle_alt = "\u{f486}";
-pub const print = "\u{f02f}";
-pub const procedures = "\u{f487}";
-pub const project_diagram = "\u{f542}";
-pub const pump_medical = "\u{f96a}";
-pub const pump_soap = "\u{f96b}";
-pub const puzzle_piece = "\u{f12e}";
-pub const qrcode = "\u{f029}";
-pub const question = "\u{f128}";
-pub const question_circle = "\u{f059}";
-pub const quidditch = "\u{f458}";
-pub const quote_left = "\u{f10d}";
-pub const quote_right = "\u{f10e}";
-pub const quran = "\u{f687}";
-pub const radiation = "\u{f7b9}";
-pub const radiation_alt = "\u{f7ba}";
-pub const rainbow = "\u{f75b}";
-pub const random = "\u{f074}";
-pub const receipt = "\u{f543}";
-pub const record_vinyl = "\u{f8d9}";
-pub const recycle = "\u{f1b8}";
-pub const redo = "\u{f01e}";
-pub const redo_alt = "\u{f2f9}";
-pub const registered = "\u{f25d}";
-pub const remove_format = "\u{f87d}";
-pub const reply = "\u{f3e5}";
-pub const reply_all = "\u{f122}";
-pub const republican = "\u{f75e}";
-pub const restroom = "\u{f7bd}";
-pub const retweet = "\u{f079}";
-pub const ribbon = "\u{f4d6}";
-pub const ring = "\u{f70b}";
-pub const road = "\u{f018}";
-pub const robot = "\u{f544}";
-pub const rocket = "\u{f135}";
-pub const route = "\u{f4d7}";
-pub const rss = "\u{f09e}";
-pub const rss_square = "\u{f143}";
-pub const ruble_sign = "\u{f158}";
-pub const ruler = "\u{f545}";
-pub const ruler_combined = "\u{f546}";
-pub const ruler_horizontal = "\u{f547}";
-pub const ruler_vertical = "\u{f548}";
-pub const running = "\u{f70c}";
-pub const rupee_sign = "\u{f156}";
-pub const sad_cry = "\u{f5b3}";
-pub const sad_tear = "\u{f5b4}";
-pub const satellite = "\u{f7bf}";
-pub const satellite_dish = "\u{f7c0}";
-pub const save = "\u{f0c7}";
-pub const school = "\u{f549}";
-pub const screwdriver = "\u{f54a}";
-pub const scroll = "\u{f70e}";
-pub const sd_card = "\u{f7c2}";
-pub const search = "\u{f002}";
-pub const search_dollar = "\u{f688}";
-pub const search_location = "\u{f689}";
-pub const search_minus = "\u{f010}";
-pub const search_plus = "\u{f00e}";
-pub const seedling = "\u{f4d8}";
-pub const server = "\u{f233}";
-pub const shapes = "\u{f61f}";
-pub const share = "\u{f064}";
-pub const share_alt = "\u{f1e0}";
-pub const share_alt_square = "\u{f1e1}";
-pub const share_square = "\u{f14d}";
-pub const shekel_sign = "\u{f20b}";
-pub const shield_alt = "\u{f3ed}";
-pub const shield_virus = "\u{f96c}";
-pub const ship = "\u{f21a}";
-pub const shipping_fast = "\u{f48b}";
-pub const shoe_prints = "\u{f54b}";
-pub const shopping_bag = "\u{f290}";
-pub const shopping_basket = "\u{f291}";
-pub const shopping_cart = "\u{f07a}";
-pub const shower = "\u{f2cc}";
-pub const shuttle_van = "\u{f5b6}";
-pub const sign = "\u{f4d9}";
-pub const sign_in_alt = "\u{f2f6}";
-pub const sign_language = "\u{f2a7}";
-pub const sign_out_alt = "\u{f2f5}";
-pub const signal = "\u{f012}";
-pub const signature = "\u{f5b7}";
-pub const sim_card = "\u{f7c4}";
-pub const sink = "\u{f96d}";
-pub const sitemap = "\u{f0e8}";
-pub const skating = "\u{f7c5}";
-pub const skiing = "\u{f7c9}";
-pub const skiing_nordic = "\u{f7ca}";
-pub const skull = "\u{f54c}";
-pub const skull_crossbones = "\u{f714}";
-pub const slash = "\u{f715}";
-pub const sleigh = "\u{f7cc}";
-pub const sliders_h = "\u{f1de}";
-pub const smile = "\u{f118}";
-pub const smile_beam = "\u{f5b8}";
-pub const smile_wink = "\u{f4da}";
-pub const smog = "\u{f75f}";
-pub const smoking = "\u{f48d}";
-pub const smoking_ban = "\u{f54d}";
-pub const sms = "\u{f7cd}";
-pub const snowboarding = "\u{f7ce}";
-pub const snowflake = "\u{f2dc}";
-pub const snowman = "\u{f7d0}";
-pub const snowplow = "\u{f7d2}";
-pub const soap = "\u{f96e}";
-pub const socks = "\u{f696}";
-pub const solar_panel = "\u{f5ba}";
-pub const sort = "\u{f0dc}";
-pub const sort_alpha_down = "\u{f15d}";
-pub const sort_alpha_down_alt = "\u{f881}";
-pub const sort_alpha_up = "\u{f15e}";
-pub const sort_alpha_up_alt = "\u{f882}";
-pub const sort_amount_down = "\u{f160}";
-pub const sort_amount_down_alt = "\u{f884}";
-pub const sort_amount_up = "\u{f161}";
-pub const sort_amount_up_alt = "\u{f885}";
-pub const sort_down = "\u{f0dd}";
-pub const sort_numeric_down = "\u{f162}";
-pub const sort_numeric_down_alt = "\u{f886}";
-pub const sort_numeric_up = "\u{f163}";
-pub const sort_numeric_up_alt = "\u{f887}";
-pub const sort_up = "\u{f0de}";
-pub const spa = "\u{f5bb}";
-pub const space_shuttle = "\u{f197}";
-pub const spell_check = "\u{f891}";
-pub const spider = "\u{f717}";
-pub const spinner = "\u{f110}";
-pub const splotch = "\u{f5bc}";
-pub const spray_can = "\u{f5bd}";
-pub const square = "\u{f0c8}";
-pub const square_full = "\u{f45c}";
-pub const square_root_alt = "\u{f698}";
-pub const stamp = "\u{f5bf}";
-pub const star = "\u{f005}";
-pub const star_and_crescent = "\u{f699}";
-pub const star_half = "\u{f089}";
-pub const star_half_alt = "\u{f5c0}";
-pub const star_of_david = "\u{f69a}";
-pub const star_of_life = "\u{f621}";
-pub const step_backward = "\u{f048}";
-pub const step_forward = "\u{f051}";
-pub const stethoscope = "\u{f0f1}";
-pub const sticky_note = "\u{f249}";
-pub const stop = "\u{f04d}";
-pub const stop_circle = "\u{f28d}";
-pub const stopwatch = "\u{f2f2}";
-pub const stopwatch_20 = "\u{f96f}";
-pub const store = "\u{f54e}";
-pub const store_alt = "\u{f54f}";
-pub const store_alt_slash = "\u{f970}";
-pub const store_slash = "\u{f971}";
-pub const stream = "\u{f550}";
-pub const street_view = "\u{f21d}";
-pub const strikethrough = "\u{f0cc}";
-pub const stroopwafel = "\u{f551}";
-pub const subscript = "\u{f12c}";
-pub const subway = "\u{f239}";
-pub const suitcase = "\u{f0f2}";
-pub const suitcase_rolling = "\u{f5c1}";
-pub const sun = "\u{f185}";
-pub const superscript = "\u{f12b}";
-pub const surprise = "\u{f5c2}";
-pub const swatchbook = "\u{f5c3}";
-pub const swimmer = "\u{f5c4}";
-pub const swimming_pool = "\u{f5c5}";
-pub const synagogue = "\u{f69b}";
-pub const sync = "\u{f021}";
-pub const sync_alt = "\u{f2f1}";
-pub const syringe = "\u{f48e}";
-pub const table = "\u{f0ce}";
-pub const table_tennis = "\u{f45d}";
-pub const tablet = "\u{f10a}";
-pub const tablet_alt = "\u{f3fa}";
-pub const tablets = "\u{f490}";
-pub const tachometer_alt = "\u{f3fd}";
-pub const tag = "\u{f02b}";
-pub const tags = "\u{f02c}";
-pub const tape = "\u{f4db}";
-pub const tasks = "\u{f0ae}";
-pub const taxi = "\u{f1ba}";
-pub const teeth = "\u{f62e}";
-pub const teeth_open = "\u{f62f}";
-pub const temperature_high = "\u{f769}";
-pub const temperature_low = "\u{f76b}";
-pub const tenge = "\u{f7d7}";
-pub const terminal = "\u{f120}";
-pub const text_height = "\u{f034}";
-pub const text_width = "\u{f035}";
-pub const th = "\u{f00a}";
-pub const th_large = "\u{f009}";
-pub const th_list = "\u{f00b}";
-pub const theater_masks = "\u{f630}";
-pub const thermometer = "\u{f491}";
-pub const thermometer_empty = "\u{f2cb}";
-pub const thermometer_full = "\u{f2c7}";
-pub const thermometer_half = "\u{f2c9}";
-pub const thermometer_quarter = "\u{f2ca}";
-pub const thermometer_three_quarters = "\u{f2c8}";
-pub const thumbs_down = "\u{f165}";
-pub const thumbs_up = "\u{f164}";
-pub const thumbtack = "\u{f08d}";
-pub const ticket_alt = "\u{f3ff}";
-pub const times = "\u{f00d}";
-pub const times_circle = "\u{f057}";
-pub const tint = "\u{f043}";
-pub const tint_slash = "\u{f5c7}";
-pub const tired = "\u{f5c8}";
-pub const toggle_off = "\u{f204}";
-pub const toggle_on = "\u{f205}";
-pub const toilet = "\u{f7d8}";
-pub const toilet_paper = "\u{f71e}";
-pub const toilet_paper_slash = "\u{f972}";
-pub const toolbox = "\u{f552}";
-pub const tools = "\u{f7d9}";
-pub const tooth = "\u{f5c9}";
-pub const torah = "\u{f6a0}";
-pub const torii_gate = "\u{f6a1}";
-pub const tractor = "\u{f722}";
-pub const trademark = "\u{f25c}";
-pub const traffic_light = "\u{f637}";
-pub const trailer = "\u{f941}";
-pub const train = "\u{f238}";
-pub const tram = "\u{f7da}";
-pub const transgender = "\u{f224}";
-pub const transgender_alt = "\u{f225}";
-pub const trash = "\u{f1f8}";
-pub const trash_alt = "\u{f2ed}";
-pub const trash_restore = "\u{f829}";
-pub const trash_restore_alt = "\u{f82a}";
-pub const tree = "\u{f1bb}";
-pub const trophy = "\u{f091}";
-pub const truck = "\u{f0d1}";
-pub const truck_loading = "\u{f4de}";
-pub const truck_monster = "\u{f63b}";
-pub const truck_moving = "\u{f4df}";
-pub const truck_pickup = "\u{f63c}";
-pub const tshirt = "\u{f553}";
-pub const tty = "\u{f1e4}";
-pub const tv = "\u{f26c}";
-pub const umbrella = "\u{f0e9}";
-pub const umbrella_beach = "\u{f5ca}";
-pub const underline = "\u{f0cd}";
-pub const undo = "\u{f0e2}";
-pub const undo_alt = "\u{f2ea}";
-pub const universal_access = "\u{f29a}";
-pub const university = "\u{f19c}";
-pub const unlink = "\u{f127}";
-pub const unlock = "\u{f09c}";
-pub const unlock_alt = "\u{f13e}";
-pub const upload = "\u{f093}";
-pub const user = "\u{f007}";
-pub const user_alt = "\u{f406}";
-pub const user_alt_slash = "\u{f4fa}";
-pub const user_astronaut = "\u{f4fb}";
-pub const user_check = "\u{f4fc}";
-pub const user_circle = "\u{f2bd}";
-pub const user_clock = "\u{f4fd}";
-pub const user_cog = "\u{f4fe}";
-pub const user_edit = "\u{f4ff}";
-pub const user_friends = "\u{f500}";
-pub const user_graduate = "\u{f501}";
-pub const user_injured = "\u{f728}";
-pub const user_lock = "\u{f502}";
-pub const user_md = "\u{f0f0}";
-pub const user_minus = "\u{f503}";
-pub const user_ninja = "\u{f504}";
-pub const user_nurse = "\u{f82f}";
-pub const user_plus = "\u{f234}";
-pub const user_secret = "\u{f21b}";
-pub const user_shield = "\u{f505}";
-pub const user_slash = "\u{f506}";
-pub const user_tag = "\u{f507}";
-pub const user_tie = "\u{f508}";
-pub const user_times = "\u{f235}";
-pub const users = "\u{f0c0}";
-pub const users_cog = "\u{f509}";
-pub const users_slash = "\u{f973}";
-pub const utensil_spoon = "\u{f2e5}";
-pub const utensils = "\u{f2e7}";
-pub const vector_square = "\u{f5cb}";
-pub const venus = "\u{f221}";
-pub const venus_double = "\u{f226}";
-pub const venus_mars = "\u{f228}";
-pub const vial = "\u{f492}";
-pub const vials = "\u{f493}";
-pub const video = "\u{f03d}";
-pub const video_slash = "\u{f4e2}";
-pub const vihara = "\u{f6a7}";
-pub const virus = "\u{f974}";
-pub const virus_slash = "\u{f975}";
-pub const viruses = "\u{f976}";
-pub const voicemail = "\u{f897}";
-pub const volleyball_ball = "\u{f45f}";
-pub const volume_down = "\u{f027}";
-pub const volume_mute = "\u{f6a9}";
-pub const volume_off = "\u{f026}";
-pub const volume_up = "\u{f028}";
-pub const vote_yea = "\u{f772}";
-pub const vr_cardboard = "\u{f729}";
-pub const walking = "\u{f554}";
-pub const wallet = "\u{f555}";
-pub const warehouse = "\u{f494}";
-pub const water = "\u{f773}";
-pub const wave_square = "\u{f83e}";
-pub const weight = "\u{f496}";
-pub const weight_hanging = "\u{f5cd}";
-pub const wheelchair = "\u{f193}";
-pub const wifi = "\u{f1eb}";
-pub const wind = "\u{f72e}";
-pub const window_close = "\u{f410}";
-pub const window_maximize = "\u{f2d0}";
-pub const window_minimize = "\u{f2d1}";
-pub const window_restore = "\u{f2d2}";
-pub const wine_bottle = "\u{f72f}";
-pub const wine_glass = "\u{f4e3}";
-pub const wine_glass_alt = "\u{f5ce}";
-pub const won_sign = "\u{f159}";
-pub const wrench = "\u{f0ad}";
-pub const x_ray = "\u{f497}";
-pub const yen_sign = "\u{f157}";
-pub const yin_yang = "\u{f6ad}";
diff --git a/src/tools/timer.zig b/src/tools/timer.zig
deleted file mode 100644
index f1891125..00000000
--- a/src/tools/timer.zig
+++ /dev/null
@@ -1,23 +0,0 @@
-// A simple timer utility for benchmarking.
-const std = @import("std");
-
-const Self = @This();
-start_time: i64 = -1,
-done: bool = false,
-
-pub fn start(self: *Self) void {
- self.start_time = std.time.milliTimestamp();
- self.done = false;
-}
-
-pub fn end(self: *Self) i64 {
- if (self.start_time == -1 or self.done) {
- std.debug.panic("Timer already ended", .{});
- return -1;
- }
- self.done = true;
-
- const end_time = std.time.milliTimestamp();
- const elapsed = end_time - self.start_time;
- return elapsed;
-}
diff --git a/src/web_main.zig b/src/web_main.zig
index 63524544..daafcbbf 100644
--- a/src/web_main.zig
+++ b/src/web_main.zig
@@ -23,7 +23,6 @@ const fizzy = @import("fizzy.zig");
comptime {
// Pure constants / re-exports
_ = fizzy.version;
- _ = fizzy.fa.adjust;
_ = fizzy.atlas;
// Algorithms — pure Zig + dvui
@@ -58,7 +57,7 @@ comptime {
// Custom dvui wrapper + widgets — types compile even though the widget files
// contain dead `@import("backend")` SDL3 imports at file scope.
- _ = fizzy.dvui.FileWidget;
+ _ = @import("plugins/pixelart/widgets/FileWidget.zig");
_ = fizzy.dvui.CanvasWidget;
// The big ones: Editor + App. Type-level reference only — passes because Zig
From 1e41380c37a3d7f80fe50277311ee93a8919999a Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 12:45:09 -0500
Subject: [PATCH 16/49] Phase 4 stage B
---
HANDOFF.md | 160 +++++++++++++++++++
src/App.zig | 11 ++
src/editor/Editor.zig | 123 +++++---------
src/editor/Keybinds.zig | 28 ++--
src/fizzy.zig | 5 +
src/plugins/pixelart/PixelArt.zig | 78 +++++++++
src/plugins/pixelart/Tools.zig | 10 +-
src/plugins/pixelart/Transform.zig | 2 +-
src/plugins/pixelart/dialogs/Export.zig | 4 +-
src/plugins/pixelart/explorer/project.zig | 12 +-
src/plugins/pixelart/explorer/sprites.zig | 4 +-
src/plugins/pixelart/explorer/tools.zig | 36 ++---
src/plugins/pixelart/internal/File.zig | 24 +--
src/plugins/pixelart/internal/Layer.zig | 4 +-
src/plugins/pixelart/panel/sprites.zig | 2 +-
src/plugins/pixelart/plugin.zig | 4 +
src/plugins/pixelart/widgets/FileWidget.zig | 124 +++++++-------
src/plugins/pixelart/widgets/ImageWidget.zig | 8 +-
src/plugins/workbench/files.zig | 2 +-
19 files changed, 430 insertions(+), 211 deletions(-)
create mode 100644 HANDOFF.md
create mode 100644 src/plugins/pixelart/PixelArt.zig
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 00000000..6cf1799a
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,160 @@
+# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, after Stage B)
+
+## TL;DR
+
+We are turning the monolithic editor into a **core shell + plugins** layout. Phase 4
+makes `core` a real, separately-wired Zig module with no dependency on the `fizzy`
+app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so
+it can become its own compile-time module.
+
+**Done:** Stage A1, A2, A3, B. **Next:** Stage C (then D, E).
+
+## What Stage B did
+
+Lifted the pixel-art editor state off the shell `Editor` into a plugin-owned
+`PixelArt` struct (`src/plugins/pixelart/PixelArt.zig`), reached via a new
+`fizzy.pixelart: *PixelArt` global (mirrors the existing `fizzy.packer`).
+
+- **Fields moved:** `tools`, `colors`, `project`, `sprite_clipboard`, `pack_jobs`
+ (plus the `SpriteClipboard` type). ~190 `editor.` / `fizzy.editor.`
+ call sites repointed to `fizzy.pixelart.` across `Editor.zig`, `Keybinds.zig`,
+ `workbench/files.zig`, and the pixel-art tree.
+- **`atlas` deliberately stayed on the shell** — it's the shared UI icon spritesheet
+ (cursor/pencil/logo/selection icons) the shell uses for its own logo
+ (`workbench/files.zig`, `Workspace.zig`), not pixel-art-specific. Moving it would
+ invert the dependency. (Its type is still `Internal.Atlas`; relocating that type to
+ core is a later structural question, not Stage B.)
+- **Lifecycle:** `fizzy.pixelart` is allocated + `PixelArt.init`'d in `App.AppInit`
+ *before* `editor.postInit()` so the pixel-art `plugin.register` adopts it as
+ `plugin.state`. `PixelArt.init` now owns the tools init + the two `fizzy.hex` palette
+ loads (moved out of `Editor.init`). `PixelArt.deinit` (pack-job cancel, palette free,
+ project save, tools free) runs from `App.AppDeinit` right after `editor.deinit()`;
+ the old interleaved pixel-art teardown blocks were removed from `Editor.deinit`.
+- Three Editor helpers (`processHoldOpenRadialMenu`, `isPackingActive`,
+ `runWasmPackWorkers`) now ignore their `editor` param (`_: *Editor`) since they only
+ reach `fizzy.pixelart`. The pack methods (`startPackProject`/`processPackJob`/…) and
+ the copy-paste / radial-menu draw code still live on `Editor` — they relocate later.
+- Type aliases on `Editor` (`pub const Tools/Colors/Project/Transform`) were left in
+ place; they're used as type paths (`Editor.Tools.Tool`) and move in Stage D.
+
+Verified green: `zig build`, `zig build check-web`, `zig build test`. (No live GUI
+run — pure refactor.)
+
+All three build configs are green right now:
+
+```
+zig build # native exe
+zig build check-web # wasm
+zig build test # unit/integration tests
+```
+
+Run all three after every stage. Note: `zig build` for this repo currently needs to
+run outside the sandbox (network/file access), so expect to pass elevated permissions.
+
+---
+
+## What `core` is now (Stage A3 result)
+
+`src/core/` is a standalone module (`src/core/core.zig` is its root). It holds shared
+infrastructure and **never imports `src/fizzy.zig`**:
+
+```
+src/core/
+ core.zig # module root: gpa + trackpad hook + re-exports
+ dvui.zig # generic dvui hub: dialog framework, helpers, generic widgets
+ fs.zig paths.zig platform.zig Fling.zig
+ gfx/ image.zig perf.zig water_surface.zig
+ math/ math.zig color.zig direction.zig easing.zig layout_anchor.zig
+ widgets/ CanvasWidget PanedWidget ReorderWidget FloatingWindowWidget
+ TreeWidget TreeSelection
+ generated/ atlas.zig # written by the build's process-assets step
+```
+
+### Decoupling mechanisms (important invariants)
+
+- **Allocator injection.** `core.gpa` is a `std.mem.Allocator` set once at startup in
+ `App.init` (`fizzy.core.gpa = allocator;`). Core code (e.g. `gfx/image.zig`) allocates
+ through `core.gpa` instead of reaching into `fizzy.app.allocator`.
+- **Trackpad hook.** `core.takeTrackpadPinchRatio` is a `*const fn () f32` set in
+ `App.init` to `fizzy.backend.takeTrackpadPinchRatio`. `CanvasWidget` calls the hook so
+ it doesn't depend on the heavy native backend. Defaults to a `1.0` no-op for headless/test.
+- **Dialog chrome state moved into core.** `core.dvui.modal_dim_titlebar: bool` and
+ `core.dvui.dialog_close_rect_override: ?dvui.Rect.Physical` replaced the old
+ `Editor.dim_titlebar` field and `workbench/files.zig: new_file_close_rect` var. The
+ shell reads `fizzy.dvui.modal_dim_titlebar` in `Editor.setTitlebarColor`.
+- **`fizzy.zig` re-exports core** so existing `fizzy.` call sites keep working:
+ `fizzy.image/fs/perf/water_surface/math/platform/paths/dvui/Fling/atlas` all alias
+ `core.*`, plus `pub const core = @import("core");`.
+- **Widget split.** Generic widgets live in `core/widgets/` and are exposed as
+ `core.dvui.CanvasWidget` etc. The **pixel-art** `FileWidget` and `ImageWidget` stayed
+ in `src/plugins/pixelart/widgets/` (ImageWidget is still pixel-art-coupled). Consumers
+ import them locally, not via the hub. `src/editor/widgets/Widgets.zig` was deleted.
+
+### Build wiring
+
+`core` is created three times (one per target/backend variant) in `build.zig`:
+- native exe: `core_module` (dvui_sdl3) — search `addImport("core"`
+- web exe: `core_module_web` (dvui_web)
+- test: `core_module_test` (dvui_testing)
+
+Each gets `dvui`, `known-folders`, and (lazy) `icons`. The generated atlas now writes to
+`src/core/generated/`, and the inline test modules point at `src/core/math/*`.
+
+### Gotchas discovered (don't repeat these)
+
+- **Build-script / module file-ownership trap.** `build.zig` imports
+ `src/tools/process_assets.zig`, which imports `src/plugins/pixelart/Atlas.zig` to
+ generate the atlas index *at build time*. A file may belong to only one module within a
+ single compilation. Routing `Atlas.zig`'s file read through `fizzy.fs`/`core.fs` (a)
+ dragged the whole `fizzy`+`core` graph into the build-runner compilation (no `core`
+ module there) and (b) caused "file exists in modules 'core' and 'root'". **Fix applied:**
+ `Atlas.zig` now imports nothing but `std` and inlines its file read. Keep build-time
+ tools (`process_assets.zig` and anything it imports) free of `fizzy`/`core` module imports.
+- **macOS case-insensitive FS.** `sprite.zig` vs `Sprite.zig` collide. The atlas-render
+ library is named `sprite_render.zig` for this reason.
+- **Lazy top-level imports.** An unused `const fizzy = @import(...)` is fine (never
+ analyzed). Problems only appear when build-*reachable* code forces analysis.
+
+---
+
+## Remaining stages
+
+The plan tasks are tracked as todos `b`, `c`, `d`, `e`. The pixel-art plugin still has a
+large coupling surface to the shell: ~250 `fizzy.editor.` / `fizzy.backend.` /
+`fizzy.platform.` references across `src/plugins/pixelart/**` (biggest offenders:
+`widgets/FileWidget.zig` ~80, `dialogs/Export.zig`, `internal/File.zig`,
+`explorer/tools.zig`). Stages B–D systematically remove these.
+
+### Stage B — lift pixel-art editor state off the shell `Editor`
+Move the pixel-art-specific fields (tools, colors, atlas, project, buffers, transform)
+off `src/editor/Editor.zig` (~83 refs) into a `PixelArt` plugin-state struct owned by the
+plugin. Update `Editor.zig`, `Keybinds` (~15 refs), and the `Menu`, plus the pixel-art
+references that read those fields. Build green (all 3).
+
+### Stage C — expand the SDK Host + a `workbench` service vtable
+Grow `src/sdk/sdk.zig` Host surface to cover the ~110-ref shell surface the plugin still
+needs: arena access, settings, folder access, doc/tab access, command registration. Then
+replace remaining pixel-art `fizzy.editor` / `fizzy.backend` / `fizzy.platform` calls with
+SDK calls. Build green.
+
+### Stage D — make `pixelart` its own module
+Add a `src/plugins/pixelart/pixelart.zig` module root; repoint all pixel-art imports from
+`fizzy.zig` to `core` / `sdk` / `dvui` / local files; wire `b.addModule("pixelart", ...)`
+in `build.zig` (3 configs, mirroring how `core` is wired); have `App` call
+`pixelart.register(host)`. Build native + test + web.
+
+### Stage E — strip pixel-art names from shell hubs
+Remove pixel-art names from `fizzy.zig` / Dialogs / `Editor` / Explorer / Panel; route all
+contributions through the SDK only. Final verification across the 3 configs.
+
+---
+
+## State of the tree
+
+Uncommitted. Stage A3 touched: `build.zig`, `src/App.zig`, `src/fizzy.zig`,
+`src/web_main.zig`, `src/editor/Editor.zig`, the moved `src/core/**` files, and the
+pixel-art/workbench consumers (`Atlas.zig`, `CanvasData.zig`, `PackJob.zig`,
+`FileLoadJob.zig`, `files.zig`, `plugin.zig`, `dialogs/GridLayout.zig`,
+`widgets/{CanvasBridge,FileWidget,ImageWidget}.zig`). Deleted: `editor/widgets/Widgets.zig`,
+`tools/timer.zig`, `core/gfx/gfx.zig` (empty), `core/font_awesome.zig` (unused — `fa`
+re-exports removed from `core.zig`/`fizzy.zig` and the web probe). Nothing has been committed.
diff --git a/src/App.zig b/src/App.zig
index b7275477..7ca4e3d5 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -164,6 +164,13 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.editor = try allocator.create(Editor);
fizzy.editor.* = Editor.init(fizzy.app) catch unreachable;
+
+ // Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
+ // before `postInit` so the pixel-art plugin's `register` can adopt it as its
+ // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`.
+ fizzy.pixelart = try allocator.create(fizzy.PixelArt);
+ fizzy.pixelart.* = fizzy.PixelArt.init(allocator) catch unreachable;
+
// Second-stage init that needs the editor at its final heap address (e.g.
// registering the workbench-api service whose `ctx` is this pointer).
fizzy.editor.postInit() catch unreachable;
@@ -220,6 +227,10 @@ pub fn AppDeinit() void {
// Persist the current windowed frame while the window still exists. No-op off macOS.
fizzy.backend.saveWindowGeometry(fizzy.app.window);
fizzy.editor.deinit() catch unreachable;
+ // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs).
+ // After the editor so any editor teardown that still reads pixel-art state runs first.
+ fizzy.pixelart.deinit(fizzy.app.allocator);
+ fizzy.app.allocator.destroy(fizzy.pixelart);
// Tear down the singleton listener after the editor so any callback
// currently in flight finishes before we free state it touches.
singleton.deinit();
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 94710481..d3ad7df0 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -77,7 +77,6 @@ infobar: Infobar,
/// The root folder that will be searched for files and a .fizproject file
folder: ?[]const u8 = null,
-project: ?Project = null,
/// From `.fizignore` (preferred) or `.gitignore` at the project root; used by the Files explorer.
ignore: IgnoreRules = .{},
@@ -91,12 +90,6 @@ open_files: std.AutoArrayHashMapUnmanaged(u64, fizzy.Internal.File) = .empty,
/// `path` allocation; the StringHashMap stores key slices that point into job memory.
loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty,
-/// Background project-pack jobs. Each `startPackProject` cancels any predecessors and pushes a
-/// new job; only the newest job's result is installed. Cancelled jobs are still kept here
-/// until their worker observes the flag and publishes `done`, at which point
-/// `processPackJob` reaps them. This way rapid Pack-Project clicks (or future per-save
-/// repacks) coalesce: only the most recent request produces a visible atlas update.
-pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty,
/// True iff a loading job should set its target file as the active file once it lands.
/// `setActiveFile`-on-completion respects the most recent open request — multiple in-flight
/// loads only auto-focus the most recently requested one.
@@ -111,14 +104,9 @@ tab_drag_from_tree_path: ?[]u8 = null,
/// `drawFiles` data id for `removed_path`; clear after drop on workspace canvas.
file_tree_data_id: ?dvui.Id = null,
-tools: Tools,
-colors: Colors = .{},
-
grouping_id_counter: u64 = 0,
file_id_counter: u64 = 0,
-sprite_clipboard: ?SpriteClipboard = null,
-
window_opacity: f32 = 1.0,
/// Animated window-background opacity multiplier. Eases toward the windowed
@@ -175,11 +163,6 @@ settings_save_deadline_ns: i128 = 0,
/// to open the hold-to-context menu on touch-only hardware.
last_touch_press_ns: ?i128 = null,
-pub const SpriteClipboard = struct {
- source: dvui.ImageSource,
- offset: dvui.Point,
-};
-
const embedded_fonts: []const dvui.Font.Source = &.{
.{
.family = dvui.Font.array("CozetteVector"),
@@ -270,7 +253,6 @@ pub fn init(
.data = try .loadFromBytes(app.allocator, assets.files.@"fizzy.atlas"),
.source = try fizzy.image.fromImageFileBytes("fizzy.png", assets.files.@"fizzy.png", .ptr),
},
- .tools = try .init(app.allocator),
.themes = .empty,
.host = .init(app.allocator),
.workbench = .init(app.allocator),
@@ -450,8 +432,8 @@ pub fn init(
return err;
};
- editor.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(app.allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
- editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(app.allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
+ // Pixel-art tools/colors/palettes now init in `PixelArt.init` (App owns the
+ // `fizzy.pixelart` instance, created just after this `Editor.init` returns).
try Keybinds.register();
@@ -1236,7 +1218,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
processHoldOpenRadialMenu(editor);
- if (editor.tools.radial_menu.visible) {
+ if (fizzy.pixelart.tools.radial_menu.visible) {
editor.drawRadialMenu() catch {
dvui.log.err("Failed to draw radial menu", .{});
};
@@ -1436,8 +1418,8 @@ pub fn setWindowStyle(_: *Editor) void {
/// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after
/// the opening finger lifts; close on tool button click or a non-drag click outside.
-fn processHoldOpenRadialMenu(editor: *Editor) void {
- const rm = &editor.tools.radial_menu;
+fn processHoldOpenRadialMenu(_: *Editor) void {
+ const rm = &fizzy.pixelart.tools.radial_menu;
if (!rm.visible or !rm.opened_by_press) {
rm.outside_click_press_p = null;
return;
@@ -1498,7 +1480,7 @@ pub fn drawRadialMenu(editor: *Editor) !void {
// `center` is set when the menu opens (Space down or hold on empty workspace) and stays
// fixed until close so tool buttons remain hoverable/clickable.
- const center = fw.data().rectScale().pointFromPhysical(editor.tools.radial_menu.center);
+ const center = fw.data().rectScale().pointFromPhysical(fizzy.pixelart.tools.radial_menu.center);
const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len;
@@ -1549,7 +1531,7 @@ pub fn drawRadialMenu(editor: *Editor) !void {
}
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(i);
}
@@ -1584,8 +1566,8 @@ pub fn drawRadialMenu(editor: *Editor) !void {
.rect = rect,
.id_extra = i,
.corner_radius = dvui.Rect.all(1000.0),
- .color_fill = if (tool == editor.tools.current) dvui.themeGet().color(.content, .fill) else .transparent,
- .box_shadow = if (tool == editor.tools.current) .{
+ .color_fill = if (tool == fizzy.pixelart.tools.current) dvui.themeGet().color(.content, .fill) else .transparent,
+ .box_shadow = if (tool == fizzy.pixelart.tools.current) .{
.color = .black,
.offset = .{ .x = -2.5, .y = 2.5 },
.fade = 4.0,
@@ -1597,10 +1579,10 @@ pub fn drawRadialMenu(editor: *Editor) !void {
});
{
- editor.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {};
+ fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {};
}
- const selection_sprite = switch (editor.tools.selection_mode) {
+ const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
.box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
.pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
.color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
@@ -1646,11 +1628,11 @@ pub fn drawRadialMenu(editor: *Editor) !void {
angle += step;
if (button.hovered()) {
- editor.tools.set(tool);
+ fizzy.pixelart.tools.set(tool);
}
if (button.clicked()) {
- editor.tools.set(tool);
- editor.tools.radial_menu.close();
+ fizzy.pixelart.tools.set(tool);
+ fizzy.pixelart.tools.radial_menu.close();
}
button.deinit();
@@ -1686,8 +1668,8 @@ pub fn drawRadialMenu(editor: *Editor) !void {
.rect = rect,
})) {
file.editor.playing = !file.editor.playing;
- if (editor.tools.radial_menu.opened_by_press) {
- editor.tools.radial_menu.close();
+ if (fizzy.pixelart.tools.radial_menu.opened_by_press) {
+ fizzy.pixelart.tools.radial_menu.close();
}
}
}
@@ -1974,7 +1956,7 @@ pub fn close(app: *App, editor: *Editor) void {
pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
if (editor.folder) |folder| {
editor.ignore.deinit(fizzy.app.allocator);
- if (editor.project) |*project| {
+ if (fizzy.pixelart.project) |*project| {
project.save() catch {
dvui.log.err("Failed to save project", .{});
};
@@ -1985,7 +1967,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
editor.host.setActiveSidebarView(@import("../plugins/workbench/plugin.zig").view_files);
- editor.project = Project.load(fizzy.app.allocator) catch null;
+ fizzy.pixelart.project = Project.load(fizzy.app.allocator) catch null;
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
}
@@ -2225,7 +2207,7 @@ pub fn startPackProject(editor: *Editor) !void {
// predecessor publishes `done` between append and cancel: `processPackJob` walks the list
// newest-first and would otherwise see an old non-cancelled ready job and install its
// (stale) atlas. Cancelled predecessors are skipped during install selection.
- for (editor.pack_jobs.items) |old| {
+ for (fizzy.pixelart.pack_jobs.items) |old| {
old.cancelled.store(true, .monotonic);
}
@@ -2233,8 +2215,8 @@ pub fn startPackProject(editor: *Editor) !void {
owned_inputs = null;
errdefer job.destroy();
- try editor.pack_jobs.append(fizzy.app.allocator, job);
- errdefer _ = editor.pack_jobs.pop();
+ try fizzy.pixelart.pack_jobs.append(fizzy.app.allocator, job);
+ errdefer _ = fizzy.pixelart.pack_jobs.pop();
if (comptime builtin.target.cpu.arch == .wasm32) {
// Worker runs at end of `tick` (after the explorer draws) so the Pack
@@ -2248,8 +2230,8 @@ pub fn startPackProject(editor: *Editor) !void {
/// True while a pack is queued, running, or finished but not yet installed into
/// `fizzy.packer.atlas`. Drives the explorer Pack button spinner.
-pub fn isPackingActive(editor: *const Editor) bool {
- for (editor.pack_jobs.items) |job| {
+pub fn isPackingActive(_: *const Editor) bool {
+ for (fizzy.pixelart.pack_jobs.items) |job| {
if (job.cancelled.load(.monotonic)) continue;
if (!job.done.load(.acquire)) return true;
if (!job.result_consumed) return true;
@@ -2258,8 +2240,8 @@ pub fn isPackingActive(editor: *const Editor) bool {
}
/// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback.
-fn runWasmPackWorkers(editor: *Editor) void {
- for (editor.pack_jobs.items) |job| {
+fn runWasmPackWorkers(_: *Editor) void {
+ for (fizzy.pixelart.pack_jobs.items) |job| {
if (job.cancelled.load(.monotonic)) continue;
if (job.done.load(.acquire)) continue;
PackJob.workerMain(job);
@@ -2342,17 +2324,17 @@ fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void {
/// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting
/// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases.
pub fn processPackJob(editor: *Editor) void {
- if (editor.pack_jobs.items.len == 0) return;
+ if (fizzy.pixelart.pack_jobs.items.len == 0) return;
// Identify the newest (last appended) job that finished with a `.ready` result and was
// not cancelled. Only its result is installed; older successful results are stale and
// get discarded along with cancelled / failed ones.
var install_index: ?usize = null;
{
- var i = editor.pack_jobs.items.len;
+ var i = fizzy.pixelart.pack_jobs.items.len;
while (i > 0) {
i -= 1;
- const job = editor.pack_jobs.items[i];
+ const job = fizzy.pixelart.pack_jobs.items[i];
if (!job.done.load(.acquire)) continue;
if (job.cancelled.load(.monotonic)) continue;
if (job.currentPhase() == .ready and job.result_atlas != null) {
@@ -2363,7 +2345,7 @@ pub fn processPackJob(editor: *Editor) void {
}
if (install_index) |idx| {
- const job = editor.pack_jobs.items[idx];
+ const job = fizzy.pixelart.pack_jobs.items[idx];
const new_atlas = job.result_atlas.?;
// Free the previously-installed atlas's allocations so the new one can take its
// place — matches the synchronous `packAndClear` cleanup ordering.
@@ -2389,10 +2371,10 @@ pub fn processPackJob(editor: *Editor) void {
} else blk: {
// Newest finished job had no atlas (empty inputs / no packable frames). Tell the user
// so the Pack button doesn't look like it silently did nothing.
- var i = editor.pack_jobs.items.len;
+ var i = fizzy.pixelart.pack_jobs.items.len;
while (i > 0) {
i -= 1;
- const job = editor.pack_jobs.items[i];
+ const job = fizzy.pixelart.pack_jobs.items[i];
if (!job.done.load(.acquire)) continue;
if (job.cancelled.load(.monotonic)) continue;
if (job.currentPhase() == .ready and job.result_atlas == null) {
@@ -2405,9 +2387,9 @@ pub fn processPackJob(editor: *Editor) void {
// Reap everything that has published `done`. Successful-but-superseded jobs leave their
// `result_atlas` un-consumed; `destroy()` frees those allocations for us.
var write: usize = 0;
- for (editor.pack_jobs.items) |job| {
+ for (fizzy.pixelart.pack_jobs.items) |job| {
if (!job.done.load(.acquire)) {
- editor.pack_jobs.items[write] = job;
+ fizzy.pixelart.pack_jobs.items[write] = job;
write += 1;
continue;
}
@@ -2422,7 +2404,7 @@ pub fn processPackJob(editor: *Editor) void {
}
job.destroy();
}
- editor.pack_jobs.shrinkRetainingCapacity(write);
+ fizzy.pixelart.pack_jobs.shrinkRetainingCapacity(write);
}
/// Returns the active workspace's canvas content rect (physical pixels) captured from the
@@ -2768,15 +2750,15 @@ pub fn copy(editor: *Editor) !void {
if (editor.activeFile()) |file| {
if (file.editor.transform != null) return;
- if (editor.sprite_clipboard) |*clipboard| {
+ if (fizzy.pixelart.sprite_clipboard) |*clipboard| {
fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source));
- editor.sprite_clipboard = null;
+ fizzy.pixelart.sprite_clipboard = null;
}
file.editor.transform_layer.clear();
var selected_layer = file.layers.get(file.selected_layer_index);
- switch (editor.tools.current) {
+ switch (fizzy.pixelart.tools.current) {
.selection => {
// We are in the selection tool, so we should assume that the user has painted a selection
// into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing
@@ -2841,7 +2823,7 @@ pub fn copy(editor: *Editor) !void {
if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
const sprite_tl = file.spritePoint(reduced_data_rect.topLeft());
- editor.sprite_clipboard = .{
+ fizzy.pixelart.sprite_clipboard = .{
.source = fizzy.image.fromPixelsPMA(
@ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)),
@intFromFloat(reduced_data_rect.w),
@@ -2864,7 +2846,7 @@ pub fn copy(editor: *Editor) !void {
}
pub fn paste(editor: *Editor) !void {
- if (editor.sprite_clipboard) |*clipboard| {
+ if (fizzy.pixelart.sprite_clipboard) |*clipboard| {
if (editor.activeFile()) |file| {
const active_layer = file.layers.get(file.selected_layer_index);
@@ -2992,7 +2974,7 @@ pub fn transform(editor: *Editor) !void {
var selected_layer = file.layers.get(file.selected_layer_index);
- switch (editor.tools.current) {
+ switch (fizzy.pixelart.tools.current) {
.selection => {
file.editor.transform_layer.clear();
// We are in the selection tool, so we should assume that the user has painted a selection
@@ -3425,13 +3407,6 @@ pub fn deinit(editor: *Editor) !void {
editor.loading_jobs.deinit(fizzy.app.allocator);
}
- for (editor.pack_jobs.items) |job| {
- // Detached workers still reference each job. Signal cancellation and leak the structs
- // on hard quit — better than a use-after-free if a worker hasn't yet observed it.
- job.cancelled.store(true, .monotonic);
- }
- editor.pack_jobs.deinit(fizzy.app.allocator);
-
if (editor.tab_drag_from_tree_path) |p| {
fizzy.app.allocator.free(p);
editor.tab_drag_from_tree_path = null;
@@ -3446,9 +3421,6 @@ pub fn deinit(editor: *Editor) !void {
editor.quit_saves_in_flight.deinit(fizzy.app.allocator);
editor.pending_close_after_save.deinit(fizzy.app.allocator);
- if (editor.colors.palette) |*palette| palette.deinit();
- if (editor.colors.file_tree_palette) |*palette| palette.deinit();
-
// Recents persist via Io.Dir.cwd writes — no FS on wasm; skip persist.
if (comptime builtin.target.cpu.arch != .wasm32) {
editor.recents.save(fizzy.app.allocator, try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "recents.json" })) catch {
@@ -3464,18 +3436,6 @@ pub fn deinit(editor: *Editor) !void {
}
editor.settings.deinit(fizzy.app.allocator);
- if (editor.project) |*project| {
- // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in
- // posix.AT (unavailable on freestanding). Browser tabs have no
- // persistent on-disk project anyway.
- if (comptime builtin.target.cpu.arch != .wasm32) {
- project.save() catch {
- dvui.log.err("Failed to save project file", .{});
- };
- }
- project.deinit(fizzy.app.allocator);
- }
-
editor.explorer.deinit();
for (editor.workspaces.values()) |*workspace| workspace.deinit();
@@ -3484,7 +3444,8 @@ pub fn deinit(editor: *Editor) !void {
editor.host.deinit();
editor.workbench.deinit();
- editor.tools.deinit(fizzy.app.allocator);
+ // Pixel-art state (tools/colors/project/pack jobs) is torn down by
+ // `PixelArt.deinit` in `App.AppDeinit`, after this returns.
editor.ignore.deinit(fizzy.app.allocator);
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index 0852fd4c..b6ae5cca 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -72,7 +72,7 @@ pub fn tick() !void {
}
if (ke.matchBind("quick_tools")) {
- const rm = &fizzy.editor.tools.radial_menu;
+ const rm = &fizzy.pixelart.tools.radial_menu;
switch (ke.action) {
.down => {
const mp = dvui.currentWindow().mouse_pt;
@@ -91,11 +91,11 @@ pub fn tick() !void {
}
if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) {
- if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
- fizzy.editor.tools.stroke_size += 1;
+ if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) {
+ if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
+ fizzy.pixelart.tools.stroke_size += 1;
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
+ fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
}
}
@@ -127,11 +127,11 @@ pub fn tick() !void {
}
if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) {
- if (fizzy.editor.tools.stroke_size > 1)
- fizzy.editor.tools.stroke_size -= 1;
+ if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) {
+ if (fizzy.pixelart.tools.stroke_size > 1)
+ fizzy.pixelart.tools.stroke_size -= 1;
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
+ fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
}
}
@@ -212,19 +212,19 @@ pub fn tick() !void {
}
if (ke.matchBind("pencil") and ke.action == .down) {
- fizzy.editor.tools.set(.pencil);
+ fizzy.pixelart.tools.set(.pencil);
}
if (ke.matchBind("eraser") and ke.action == .down) {
- fizzy.editor.tools.set(.eraser);
+ fizzy.pixelart.tools.set(.eraser);
}
if (ke.matchBind("bucket") and ke.action == .down) {
- fizzy.editor.tools.set(.bucket);
+ fizzy.pixelart.tools.set(.bucket);
}
if (ke.matchBind("pointer") and ke.action == .down) {
- fizzy.editor.tools.set(.pointer);
+ fizzy.pixelart.tools.set(.pointer);
}
if (ke.matchBind("selection") and ke.action == .down) {
- fizzy.editor.tools.set(.selection);
+ fizzy.pixelart.tools.set(.selection);
}
},
else => {},
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 3feeeeb6..dfb406e1 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -37,10 +37,15 @@ pub const Packer = @import("plugins/pixelart/Packer.zig");
//pub const Popups = @import("editor/popups/Popups.zig");
pub const Sidebar = @import("editor/Sidebar.zig");
+/// Pixel-art plugin state (Phase 4 Stage B): the tools/colors/project/clipboard/
+/// pack-job fields formerly hung off the shell `Editor`.
+pub const PixelArt = @import("plugins/pixelart/PixelArt.zig");
+
// Global pointers
pub var app: *App = undefined;
pub var editor: *Editor = undefined;
pub var packer: *Packer = undefined;
+pub var pixelart: *PixelArt = undefined;
/// Internal types
/// These types contain additional data to support the editor
diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/PixelArt.zig
new file mode 100644
index 00000000..6f411f11
--- /dev/null
+++ b/src/plugins/pixelart/PixelArt.zig
@@ -0,0 +1,78 @@
+//! Pixel-art plugin state, lifted off the shell `Editor` (Phase 4 Stage B).
+//!
+//! Owns the pixel-art-specific editor state that used to live as top-level fields
+//! on `src/editor/Editor.zig`: the active tools, color/palette state, the open
+//! project's pack config, the sprite clipboard, and the background pack-job queue.
+//!
+//! Accessed during Stages B–C through the `fizzy.pixelart` global (mirroring the
+//! existing `fizzy.packer`). Stage D repoints plugin code at the SDK instead, at
+//! which point this struct becomes the plugin's `state` proper rather than a
+//! shell-reachable global.
+const std = @import("std");
+const builtin = @import("builtin");
+const fizzy = @import("../../fizzy.zig");
+const dvui = @import("dvui");
+const assets = @import("assets");
+
+const Colors = @import("Colors.zig");
+const Project = @import("Project.zig");
+const Tools = @import("Tools.zig");
+const PackJob = @import("PackJob.zig");
+
+const PixelArt = @This();
+
+/// A floating sprite cut/copied from the canvas, pasted relative to `offset`.
+pub const SpriteClipboard = struct {
+ source: dvui.ImageSource,
+ offset: dvui.Point,
+};
+
+tools: Tools,
+colors: Colors = .{},
+
+/// The open project's `.fizproject` pack config, or null when no project folder is open.
+project: ?Project = null,
+
+sprite_clipboard: ?SpriteClipboard = null,
+
+/// Background project-pack jobs. Each `Editor.startPackProject` cancels any predecessors and
+/// pushes a new job; only the newest job's result is installed. Cancelled jobs are still kept
+/// here until their worker observes the flag and publishes `done`, at which point
+/// `Editor.processPackJob` reaps them. This way rapid Pack-Project clicks coalesce: only the
+/// most recent request produces a visible atlas update.
+pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty,
+
+pub fn init(allocator: std.mem.Allocator) !PixelArt {
+ var pa: PixelArt = .{
+ .tools = try .init(allocator),
+ };
+ pa.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
+ pa.colors.palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
+ return pa;
+}
+
+pub fn deinit(pa: *PixelArt, allocator: std.mem.Allocator) void {
+ for (pa.pack_jobs.items) |job| {
+ // Detached workers still reference each job. Signal cancellation and leak the structs
+ // on hard quit — better than a use-after-free if a worker hasn't yet observed it.
+ job.cancelled.store(true, .monotonic);
+ }
+ pa.pack_jobs.deinit(allocator);
+
+ if (pa.colors.palette) |*palette| palette.deinit();
+ if (pa.colors.file_tree_palette) |*palette| palette.deinit();
+
+ if (pa.project) |*project| {
+ // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in
+ // posix.AT (unavailable on freestanding). Browser tabs have no
+ // persistent on-disk project anyway.
+ if (comptime builtin.target.cpu.arch != .wasm32) {
+ project.save() catch {
+ dvui.log.err("Failed to save project file", .{});
+ };
+ }
+ project.deinit(allocator);
+ }
+
+ pa.tools.deinit(allocator);
+}
diff --git a/src/plugins/pixelart/Tools.zig b/src/plugins/pixelart/Tools.zig
index 8efce232..9f00c6a6 100644
--- a/src/plugins/pixelart/Tools.zig
+++ b/src/plugins/pixelart/Tools.zig
@@ -194,8 +194,8 @@ pub fn getIndex(_: *Tools, point: dvui.Point) ?usize {
/// Only used for handling getting the pixels surrounding the origin
/// for stroke sizes larger than 1
pub fn getIndexShapeOffset(self: *Tools, origin: dvui.Point, current_index: usize) ?usize {
- const shape = fizzy.editor.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.editor.tools.stroke_size);
+ const shape = fizzy.pixelart.tools.stroke_shape;
+ const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size);
if (s == 1) {
if (current_index != 0)
@@ -337,7 +337,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
const atlas_size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 };
var mode_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
mode_color = palette.getDVUIColor(4);
}
@@ -367,7 +367,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
2 => "COLOR",
else => unreachable,
};
- const selected = fizzy.editor.tools.selection_mode == mode;
+ const selected = fizzy.pixelart.tools.selection_mode == mode;
var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .none,
@@ -438,7 +438,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
};
if (mode_button.clicked()) {
- fizzy.editor.tools.selection_mode = mode;
+ fizzy.pixelart.tools.selection_mode = mode;
}
}
}
diff --git a/src/plugins/pixelart/Transform.zig b/src/plugins/pixelart/Transform.zig
index 5fe1550f..a4f44975 100644
--- a/src/plugins/pixelart/Transform.zig
+++ b/src/plugins/pixelart/Transform.zig
@@ -70,7 +70,7 @@ pub fn accept(self: *Transform) void {
// Paste / transform accept writes new pixels but does not go through `processSelection`; the
// overlay uses `selection_layer.mask ∩ active_layer.mask`. Keep the mask aligned with the
// committed transform so copied/pasted (and moved) pixels show the selection outline.
- if (fizzy.editor.tools.current == .selection) {
+ if (fizzy.pixelart.tools.current == .selection) {
file.editor.selection_layer.clearMask();
for (pix, 0..) |temp_pixel, pixel_index| {
if (temp_pixel.a != 0) {
diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig
index 669b4079..bce96316 100644
--- a/src/plugins/pixelart/dialogs/Export.zig
+++ b/src/plugins/pixelart/dialogs/Export.zig
@@ -49,7 +49,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
// Export stays non-modal so the user can click the canvas to adjust selections. Switch to
// the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close.
if (dvui.firstFrame(id)) {
- fizzy.editor.tools.set(.pointer);
+ fizzy.pixelart.tools.set(.pointer);
}
var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
@@ -498,7 +498,7 @@ fn exportCheckerboardVertexColor(
}
fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
var animation_index: ?usize = null;
if (file.selected_animation_index) |selected_animation_index| {
diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig
index e6affcba..0620a7b8 100644
--- a/src/plugins/pixelart/explorer/project.zig
+++ b/src/plugins/pixelart/explorer/project.zig
@@ -15,7 +15,7 @@ pub fn draw() !void {
}
if (fizzy.editor.folder) |folder| {
- if (fizzy.editor.project) |_| {
+ if (fizzy.pixelart.project) |_| {
const tl = dvui.textLayout(@src(), .{}, .{
.expand = .none,
.margin = dvui.Rect.all(0),
@@ -44,7 +44,7 @@ pub fn draw() !void {
tl.deinit();
if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) {
- fizzy.editor.project = .{};
+ fizzy.pixelart.project = .{};
}
return;
}
@@ -67,7 +67,7 @@ pub fn draw() !void {
dvui.log.err("Failed to draw path text entry", .{});
};
- if (fizzy.editor.project) |project| {
+ if (fizzy.pixelart.project) |project| {
if (fizzy.packer.atlas) |atlas| {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } });
if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{
@@ -258,7 +258,7 @@ const PathType = enum {
};
fn pathTextEntry(path_type: PathType) !void {
- if (fizzy.editor.project) |*project| {
+ if (fizzy.pixelart.project) |*project| {
const output_path = switch (path_type) {
.atlas => &project.packed_atlas_output,
.image => &project.packed_image_output,
@@ -455,7 +455,7 @@ fn packProjectButton(packing: bool) bool {
}
pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.editor.project) |*project| {
+ if (fizzy.pixelart.project) |*project| {
const output_path = &project.packed_atlas_output;
if (paths) |paths_| {
@@ -467,7 +467,7 @@ pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void {
}
pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.editor.project) |*project| {
+ if (fizzy.pixelart.project) |*project| {
const output_path = &project.packed_image_output;
if (paths) |paths_| {
diff --git a/src/plugins/pixelart/explorer/sprites.zig b/src/plugins/pixelart/explorer/sprites.zig
index eaa90f5d..9b20679d 100644
--- a/src/plugins/pixelart/explorer/sprites.zig
+++ b/src/plugins/pixelart/explorer/sprites.zig
@@ -829,7 +829,7 @@ pub fn drawAnimations(self: *Sprites) !void {
const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi);
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(anim_id));
}
@@ -1600,7 +1600,7 @@ pub fn drawFrames(self: *Sprites) !void {
for (animation.frames, 0..) |*frame, frame_index| {
var anim_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
anim_color = palette.getDVUIColor(@intCast(animation.id));
}
diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig
index a6ed38d3..a60d59ef 100644
--- a/src/plugins/pixelart/explorer/tools.zig
+++ b/src/plugins/pixelart/explorer/tools.zig
@@ -163,14 +163,14 @@ pub fn drawTools() !void {
const tool: fizzy.Editor.Tools.Tool = @enumFromInt(i);
const id_extra = i;
- const selected = fizzy.editor.tools.current == tool;
+ const selected = fizzy.pixelart.tools.current == tool;
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(i);
}
- const selection_sprite = switch (fizzy.editor.tools.selection_mode) {
+ const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
.pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
.box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
.color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
@@ -204,7 +204,7 @@ pub fn drawTools() !void {
});
defer button.deinit();
- fizzy.editor.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {};
+ fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {};
if (button.hovered()) {
button.data().options.color_border = color;
@@ -240,7 +240,7 @@ pub fn drawTools() !void {
};
if (button.clicked()) {
- fizzy.editor.tools.set(tool);
+ fizzy.pixelart.tools.set(tool);
}
}
}
@@ -539,7 +539,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
const font = if (visible) dvui.Font.theme(.body) else dvui.Font.theme(.body).withStyle(.italic);
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(layer_id));
}
@@ -945,8 +945,8 @@ pub fn drawColors() !void {
});
defer hbox.deinit();
- const primary: dvui.Color = .{ .r = fizzy.editor.colors.primary[0], .g = fizzy.editor.colors.primary[1], .b = fizzy.editor.colors.primary[2], .a = fizzy.editor.colors.primary[3] };
- const secondary: dvui.Color = .{ .r = fizzy.editor.colors.secondary[0], .g = fizzy.editor.colors.secondary[1], .b = fizzy.editor.colors.secondary[2], .a = fizzy.editor.colors.secondary[3] };
+ const primary: dvui.Color = .{ .r = fizzy.pixelart.colors.primary[0], .g = fizzy.pixelart.colors.primary[1], .b = fizzy.pixelart.colors.primary[2], .a = fizzy.pixelart.colors.primary[3] };
+ const secondary: dvui.Color = .{ .r = fizzy.pixelart.colors.secondary[0], .g = fizzy.pixelart.colors.secondary[1], .b = fizzy.pixelart.colors.secondary[2], .a = fizzy.pixelart.colors.secondary[3] };
const button_opts: dvui.Options = .{
.expand = .both,
@@ -978,7 +978,7 @@ pub fn drawColors() !void {
primary_button.init(@src(), .{}, button_opts);
defer primary_button.deinit();
- try drawColorPicker(primary_button.data().rectScale().r, &fizzy.editor.colors.primary, 0);
+ try drawColorPicker(primary_button.data().rectScale().r, &fizzy.pixelart.colors.primary, 0);
primary_button.processEvents();
primary_button.drawBackground();
@@ -991,7 +991,7 @@ pub fn drawColors() !void {
secondary_button.init(@src(), .{}, button_opts.override(secondary_overrider));
defer secondary_button.deinit();
- try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.editor.colors.secondary, 1);
+ try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.pixelart.colors.secondary, 1);
secondary_button.processEvents();
secondary_button.drawBackground();
@@ -1000,7 +1000,7 @@ pub fn drawColors() !void {
}
if (clicked) {
- std.mem.swap([4]u8, &fizzy.editor.colors.primary, &fizzy.editor.colors.secondary);
+ std.mem.swap([4]u8, &fizzy.pixelart.colors.primary, &fizzy.pixelart.colors.secondary);
}
}
@@ -1103,7 +1103,7 @@ pub fn drawPalettes() !void {
.gravity_x = 1.0,
});
- if (fizzy.editor.colors.palette) |*palette| {
+ if (fizzy.pixelart.colors.palette) |*palette| {
dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) });
} else {
dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) });
@@ -1133,7 +1133,7 @@ pub fn drawPalettes() !void {
const ext = std.fs.path.extension(entry.name);
if (std.mem.eql(u8, ext, ".hex")) {
if (dropdown.addChoiceLabel(entry.name)) {
- fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| {
+ fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| {
dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
return error.FailedToLoadPalette;
};
@@ -1157,7 +1157,7 @@ pub fn drawPalettes() !void {
}
{
- if (fizzy.editor.colors.palette) |*palette| {
+ if (fizzy.pixelart.colors.palette) |*palette| {
var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{
.expand = .horizontal,
.max_size_content = .{
@@ -1244,9 +1244,9 @@ pub fn drawPalettes() !void {
switch (evt) {
.mouse => |mouse_evt| {
if (mouse_evt.button.pointer() or mouse_evt.button.touch()) {
- @memcpy(&fizzy.editor.colors.primary, &color);
+ @memcpy(&fizzy.pixelart.colors.primary, &color);
} else if (mouse_evt.button == .right) {
- @memcpy(&fizzy.editor.colors.secondary, &color);
+ @memcpy(&fizzy.pixelart.colors.secondary, &color);
}
},
else => {},
@@ -1279,10 +1279,10 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void {
if (dropdown.addChoiceLabel(label)) {
const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ fizzy.editor.palette_folder, entry.name });
- if (fizzy.editor.colors.palette) |*palette|
+ if (fizzy.pixelart.colors.palette) |*palette|
palette.deinit();
- fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| {
+ fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| {
dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
return error.FailedToLoadPalette;
};
diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/internal/File.zig
index 5443e17d..291cd342 100644
--- a/src/plugins/pixelart/internal/File.zig
+++ b/src/plugins/pixelart/internal/File.zig
@@ -1678,9 +1678,9 @@ pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions
}
}
} else {
- var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
+ var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = fizzy.pixelart.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (select_options.constrain_to_tile) {
@@ -1727,11 +1727,11 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op
const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size);
const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) };
- var mask = fizzy.editor.tools.stroke;
+ var mask = fizzy.pixelart.tools.stroke;
if (select_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) {
for (0..(stroke_size * stroke_size)) |index| {
- if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
+ if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
mask.unset(i);
}
}
@@ -1742,11 +1742,11 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op
if (select_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) {
selectPoint(file, point, select_options);
} else {
- var stroke = if (point_i == 0) fizzy.editor.tools.stroke else mask;
+ var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask;
var iter = stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = fizzy.pixelart.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (select_options.constrain_to_tile) {
@@ -2339,9 +2339,9 @@ pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options:
}
}
} else {
- var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
+ var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = fizzy.pixelart.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (clip_rect) |cr| {
@@ -2430,11 +2430,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw
const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size);
const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) };
- var mask = fizzy.editor.tools.stroke;
+ var mask = fizzy.pixelart.tools.stroke;
if (draw_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) {
for (0..(stroke_size * stroke_size)) |index| {
- if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
+ if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
mask.unset(i);
}
}
@@ -2457,11 +2457,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw
.clip_rect = draw_options.clip_rect,
});
} else {
- var stroke = if (point_i == 0) fizzy.editor.tools.stroke else mask;
+ var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask;
var iter = stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.editor.tools.offset_table[i];
+ const offset = fizzy.pixelart.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (clip_rect) |cr| {
diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig
index b8562ff5..a4aedfc1 100644
--- a/src/plugins/pixelart/internal/Layer.zig
+++ b/src/plugins/pixelart/internal/Layer.zig
@@ -266,8 +266,8 @@ pub fn invalidate(self: *Layer) void {
/// Only used for handling getting the pixels surrounding the origin
/// for stroke sizes larger than 1
pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usize) ?ShapeOffsetResult {
- const shape = fizzy.editor.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.editor.tools.stroke_size);
+ const shape = fizzy.pixelart.tools.stroke_shape;
+ const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size);
if (s == 1) {
if (current_index != 0)
diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig
index 9aaa51e3..d844d03d 100644
--- a/src/plugins/pixelart/panel/sprites.zig
+++ b/src/plugins/pixelart/panel/sprites.zig
@@ -808,7 +808,7 @@ fn sideCardsFlown(playing: bool) bool {
/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee).
fn drawingToolActive() bool {
- return switch (fizzy.editor.tools.current) {
+ return switch (fizzy.pixelart.tools.current) {
.pointer, .selection => false,
.pencil, .eraser, .bucket => true,
};
diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig
index 9f321f9e..5fdcd2ef 100644
--- a/src/plugins/pixelart/plugin.zig
+++ b/src/plugins/pixelart/plugin.zig
@@ -247,6 +247,10 @@ fn redo(_: *anyopaque, doc: DocHandle) anyerror!void {
}
pub fn register(host: *sdk.Host) !void {
+ // Adopt the app-owned pixel-art state as this plugin's `state`. Stage B keeps
+ // it reachable through the `fizzy.pixelart` global too; Stage D drops the global
+ // and routes plugin access through `state` + the SDK.
+ plugin.state = fizzy.pixelart;
try host.registerPlugin(&plugin);
try host.registerSidebarView(.{
.id = view_tools,
diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig
index 8c9285c6..3fb35d68 100644
--- a/src/plugins/pixelart/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/widgets/FileWidget.zig
@@ -33,7 +33,7 @@ fn onEmptyTap(_: ?*anyopaque) void {
/// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press
/// point. The canvas releases its own capture afterward so the menu buttons can be hovered.
fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void {
- const rm = &fizzy.editor.tools.radial_menu;
+ const rm = &fizzy.pixelart.tools.radial_menu;
rm.mouse_position = press_p;
rm.center = press_p;
rm.visible = true;
@@ -45,7 +45,7 @@ fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void {
/// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's
/// while the pointer tool is active — yield it instead of starting a viewport pan.
fn yieldModifiedEmptyPress(_: ?*anyopaque) bool {
- return fizzy.editor.tools.current == .pointer;
+ return fizzy.pixelart.tools.current == .pointer;
}
init_options: InitOptions,
@@ -307,23 +307,23 @@ pub fn sampleColorAtPoint(
if (off_canvas) {
// Sampling the empty margin outside the artboard isn't an erase — drop back
// to the pointer tool so the click reads as "leave drawing mode".
- if (fizzy.editor.tools.current != .pointer) {
- fizzy.editor.tools.set(.pointer);
+ if (fizzy.pixelart.tools.current != .pointer) {
+ fizzy.pixelart.tools.set(.pointer);
}
} else if (color[3] == 0) {
- if (fizzy.editor.tools.current != .eraser) {
- fizzy.editor.tools.set(.eraser);
+ if (fizzy.pixelart.tools.current != .eraser) {
+ fizzy.pixelart.tools.set(.eraser);
}
} else {
- fizzy.editor.colors.primary = color;
- if (switch (fizzy.editor.tools.current) {
+ fizzy.pixelart.colors.primary = color;
+ if (switch (fizzy.pixelart.tools.current) {
.pencil, .bucket => false,
else => true,
})
- fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool);
+ fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool);
}
} else if (apply_primary and color[3] > 0) {
- fizzy.editor.colors.primary = color;
+ fizzy.pixelart.colors.primary = color;
}
}
@@ -349,7 +349,7 @@ pub fn processAnimationSelection(self: *FileWidget) void {
switch (e.evt) {
.mouse => |me| {
- if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.editor.tools.current != .pointer and self.sample_data_point == null)) {
+ if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.pixelart.tools.current != .pointer and self.sample_data_point == null)) {
if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
var found: bool = false;
for (file.animations.items(.frames), 0..) |frames, anim_index| {
@@ -378,7 +378,7 @@ pub fn processAnimationSelection(self: *FileWidget) void {
}
pub fn processCellReorder(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
+ if (fizzy.pixelart.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
if (self.drag_data_point != null) return;
@@ -529,7 +529,7 @@ pub fn processCellReorder(self: *FileWidget) void {
///
/// Supports add/remove, drag selection, etc.
pub fn processSpriteSelection(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
+ if (fizzy.pixelart.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -706,7 +706,7 @@ fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared {
const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id;
const cw = dvui.currentWindow();
- const tool_not_pointer = fizzy.editor.tools.current != .pointer;
+ const tool_not_pointer = fizzy.pixelart.tools.current != .pointer;
const mod_shift = cw.modifiers.matchBind("shift");
const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
const sample_active = self.sample_data_point != null;
@@ -878,10 +878,10 @@ pub fn drawSpriteBubbles(self: *FileWidget) void {
const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id;
const cw = dvui.currentWindow();
const drag_sprite_selection = dvui.dragName("sprite_selection_drag");
- const tool_not_pointer = fizzy.editor.tools.current != .pointer;
+ const tool_not_pointer = fizzy.pixelart.tools.current != .pointer;
const mod_shift = cw.modifiers.matchBind("shift");
const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
- const radial_visible = fizzy.editor.tools.radial_menu.visible;
+ const radial_visible = fizzy.pixelart.tools.radial_menu.visible;
const sample_active = self.sample_data_point != null;
const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or
self.init_options.file.editor.canvas.gestureActive();
@@ -1134,7 +1134,7 @@ fn drawSpriteBubbleForRow(
if (animation_index) |ai| {
const id = file.animations.get(ai).id;
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(id));
}
if (file.selected_animation_index == ai) {
@@ -1440,7 +1440,7 @@ pub fn drawSpriteBubble(
var add_rem_message: ?[]const u8 = null;
var border_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
if (self.init_options.file.selected_animation_index) |index| {
border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id));
add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch {
@@ -1781,7 +1781,7 @@ pub fn drawSpriteBubble(
/// Draw the highlight colored selection box for each selected sprite.
pub fn drawSpriteSelection(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
+ if (fizzy.pixelart.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -1911,8 +1911,8 @@ fn strokePolylineDashedPhysical(
}
fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .selection) return;
- if (fizzy.editor.tools.selection_mode != .box) return;
+ if (fizzy.pixelart.tools.current != .selection) return;
+ if (fizzy.pixelart.tools.selection_mode != .box) return;
const start = self.drag_data_point orelse return;
if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return;
@@ -2001,7 +2001,7 @@ fn applySelectionBoxPreview(
/// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove.
/// The selection uses the same logic as the stroke tool to brush the selection over existing pixels.
pub fn processSelection(self: *FileWidget) void {
- if (switch (fizzy.editor.tools.current) {
+ if (switch (fizzy.pixelart.tools.current) {
.selection,
=> false,
else => true,
@@ -2024,7 +2024,7 @@ pub fn processSelection(self: *FileWidget) void {
// Pixel mode: draw the committed selection before handling events (brush preview layers on top).
// Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing
// here would use stale data until the next frame. Box repaints from the current mask after events.
- if (fizzy.editor.tools.selection_mode == .pixel or fizzy.editor.tools.selection_mode == .color) {
+ if (fizzy.pixelart.tools.selection_mode == .pixel or fizzy.pixelart.tools.selection_mode == .color) {
@memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
file.editor.temporary_layer.clearMask();
@@ -2044,21 +2044,21 @@ pub fn processSelection(self: *FileWidget) void {
switch (e.evt) {
.key => |ke| {
var update: bool = false;
- if (fizzy.editor.tools.selection_mode == .pixel) {
+ if (fizzy.pixelart.tools.selection_mode == .pixel) {
if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
- fizzy.editor.tools.stroke_size += 1;
+ if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
+ fizzy.pixelart.tools.stroke_size += 1;
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
+ fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
update = true;
}
if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.editor.tools.stroke_size > 1)
- fizzy.editor.tools.stroke_size -= 1;
+ if (fizzy.pixelart.tools.stroke_size > 1)
+ fizzy.pixelart.tools.stroke_size -= 1;
- fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size);
+ fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
update = true;
}
@@ -2081,7 +2081,7 @@ pub fn processSelection(self: *FileWidget) void {
.temporary,
.{
.mask_only = true,
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = fizzy.pixelart.tools.stroke_size,
},
);
@@ -2099,8 +2099,8 @@ pub fn processSelection(self: *FileWidget) void {
const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
if (me.action == .position) {
- const box_mode = fizzy.editor.tools.selection_mode == .box;
- const color_mode = fizzy.editor.tools.selection_mode == .color;
+ const box_mode = fizzy.pixelart.tools.selection_mode == .box;
+ const color_mode = fizzy.pixelart.tools.selection_mode == .color;
const is_drag = dvui.dragging(me.p, "stroke_drag") != null;
const box_drag = box_mode and is_drag and self.drag_data_point != null;
@@ -2151,7 +2151,7 @@ pub fn processSelection(self: *FileWidget) void {
.temporary,
.{
.mask_only = true,
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = fizzy.pixelart.tools.stroke_size,
},
);
@@ -2182,7 +2182,7 @@ pub fn processSelection(self: *FileWidget) void {
if (!widget_active) continue;
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- if (fizzy.editor.tools.selection_mode == .color) {
+ if (fizzy.pixelart.tools.selection_mode == .color) {
// Only clear the mask if we don't have ctrl/cmd pressed
if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
file.editor.selection_layer.clearMask();
@@ -2200,14 +2200,14 @@ pub fn processSelection(self: *FileWidget) void {
if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
file.editor.selection_layer.clearMask();
- if (fizzy.editor.tools.selection_mode == .box) {
+ if (fizzy.pixelart.tools.selection_mode == .box) {
self.drag_data_point = current_point;
} else {
file.selectPoint(
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = fizzy.pixelart.tools.stroke_size,
},
);
@@ -2220,23 +2220,23 @@ pub fn processSelection(self: *FileWidget) void {
dvui.captureMouse(null, e.num);
dvui.dragEnd();
- if (fizzy.editor.tools.selection_mode == .box) {
+ if (fizzy.pixelart.tools.selection_mode == .box) {
if (self.drag_data_point) |start| {
file.selectRectBetweenPoints(
start,
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = fizzy.pixelart.tools.stroke_size,
},
);
}
- } else if (fizzy.editor.tools.selection_mode != .color) {
+ } else if (fizzy.pixelart.tools.selection_mode != .color) {
file.selectPoint(
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = fizzy.pixelart.tools.stroke_size,
},
);
}
@@ -2268,14 +2268,14 @@ pub fn processSelection(self: *FileWidget) void {
});
}
- if (fizzy.editor.tools.selection_mode == .pixel) {
+ if (fizzy.pixelart.tools.selection_mode == .pixel) {
if (self.drag_data_point) |previous_point| {
file.selectLine(
previous_point,
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.editor.tools.stroke_size,
+ .stroke_size = fizzy.pixelart.tools.stroke_size,
},
);
}
@@ -2290,7 +2290,7 @@ pub fn processSelection(self: *FileWidget) void {
}
}
- if (fizzy.editor.tools.selection_mode == .box) {
+ if (fizzy.pixelart.tools.selection_mode == .box) {
const mouse_pt = dvui.currentWindow().mouse_pt;
const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null;
if (!(is_drag and self.drag_data_point != null)) {
@@ -2388,7 +2388,7 @@ fn processStrokeDragSegment(
{
if (self.sample_data_point == null or color[3] == 0) {
clearTempPreview(&file.editor);
- const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
+ const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
file.drawPoint(
current_point,
.temporary,
@@ -2409,12 +2409,12 @@ fn processStrokeDragSegment(
/// Supports using shift to draw a line between two points, and increasing/decreasing stroke size
pub fn processStroke(self: *FileWidget) void {
const file = self.init_options.file;
- const stroke_size = fizzy.editor.tools.stroke_size;
+ const stroke_size = fizzy.pixelart.tools.stroke_size;
const widget_active = self.active();
if (self.cell_reorder_point != null) return;
- if (switch (fizzy.editor.tools.current) {
+ if (switch (fizzy.pixelart.tools.current) {
.pencil,
.eraser,
=> false,
@@ -2423,8 +2423,8 @@ pub fn processStroke(self: *FileWidget) void {
if (self.sample_key_down or self.right_mouse_down) return;
- const color: [4]u8 = switch (fizzy.editor.tools.current) {
- .pencil => fizzy.editor.colors.primary,
+ const color: [4]u8 = switch (fizzy.pixelart.tools.current) {
+ .pencil => fizzy.pixelart.colors.primary,
.eraser => [_]u8{ 0, 0, 0, 0 },
else => unreachable,
};
@@ -2569,7 +2569,7 @@ pub fn processStroke(self: *FileWidget) void {
self.sample_data_point == null)
{
clearTempPreview(&file.editor);
- const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
+ const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
file.drawPoint(
current_point,
.temporary,
@@ -2595,10 +2595,10 @@ pub fn processStroke(self: *FileWidget) void {
/// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color,
/// or without modifiers to flood fill the layer with the new color.
pub fn processFill(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .bucket) return;
+ if (fizzy.pixelart.tools.current != .bucket) return;
if (self.sample_key_down) return;
const file = self.init_options.file;
- const color = fizzy.editor.colors.primary;
+ const color = fizzy.pixelart.colors.primary;
const widget_active = self.active();
// Skip the cursor-follow temp preview on touch: the finger occludes the pixel and
@@ -2608,7 +2608,7 @@ pub fn processFill(self: *FileWidget) void {
self.sample_data_point == null)
{
clearTempPreview(&file.editor);
- const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
+ const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
file.drawPoint(
fill_preview_pt,
@@ -3864,7 +3864,7 @@ fn checkerboardVertexColor(
/// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation).
fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.editor.colors.file_tree_palette) |*palette| {
+ if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
var animation_index: ?usize = null;
if (file.selected_animation_index) |selected_animation_index| {
@@ -4073,8 +4073,8 @@ pub fn active(self: *FileWidget) bool {
pub fn drawCursor(self: *FileWidget) void {
if (fizzy.dvui.canvasPointerInputSuppressed()) return;
- if (fizzy.editor.tools.current == .pointer and self.sample_data_point == null) return;
- if (fizzy.editor.tools.radial_menu.visible) return;
+ if (fizzy.pixelart.tools.current == .pointer and self.sample_data_point == null) return;
+ if (fizzy.pixelart.tools.radial_menu.visible) return;
if (self.init_options.file.editor.transform != null) return;
if (self.init_options.file.editor.canvas.gestureActive()) return;
if (self.init_options.file.editor.canvas.trackpadPinching()) return;
@@ -4113,13 +4113,13 @@ pub fn drawCursor(self: *FileWidget) void {
const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point);
- const selection_sprite = switch (fizzy.editor.tools.selection_mode) {
+ const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
.box => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
.pixel => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
.color => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
};
- if (switch (fizzy.editor.tools.current) {
+ if (switch (fizzy.pixelart.tools.current) {
.pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default],
.eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default],
.bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default],
@@ -4619,7 +4619,7 @@ pub fn drawLayers(self: *FileWidget) void {
}
// Draw the selection box for the selected sprites
- if (fizzy.editor.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) {
+ if (fizzy.pixelart.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
const sprite_rect = file.spriteRect(i);
@@ -5483,7 +5483,7 @@ fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void {
}
pub fn processResize(self: *FileWidget) void {
- if (fizzy.editor.tools.current != .pointer) return;
+ if (fizzy.pixelart.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -5818,7 +5818,7 @@ pub fn processEvents(self: *FileWidget) void {
self.updateActiveLayerMask();
}
- if (fizzy.editor.tools.current == .selection) {
+ if (fizzy.pixelart.tools.current == .selection) {
if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) {
self.init_options.file.editor.checkerboard.toggleAll();
@@ -5828,7 +5828,7 @@ pub fn processEvents(self: *FileWidget) void {
if (self.init_options.file.editor.transform == null) {
const tool_t0 = fizzy.perf.toolProcessBegin();
- switch (fizzy.editor.tools.current) {
+ switch (fizzy.pixelart.tools.current) {
.bucket => self.processFill(),
.pencil, .eraser => self.processStroke(),
.selection => self.processSelection(),
diff --git a/src/plugins/pixelart/widgets/ImageWidget.zig b/src/plugins/pixelart/widgets/ImageWidget.zig
index a07d4dab..3ce4de64 100644
--- a/src/plugins/pixelart/widgets/ImageWidget.zig
+++ b/src/plugins/pixelart/widgets/ImageWidget.zig
@@ -151,15 +151,15 @@ fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical)
}
}
- fizzy.editor.colors.primary = color;
+ fizzy.pixelart.colors.primary = color;
self.sample_data_point = point;
if (color[3] == 0) {
- if (fizzy.editor.tools.current != .eraser) {
- fizzy.editor.tools.set(.eraser);
+ if (fizzy.pixelart.tools.current != .eraser) {
+ fizzy.pixelart.tools.set(.eraser);
}
} else {
- fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool);
+ fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool);
}
}
diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig
index 34b61388..01c87a5f 100644
--- a/src/plugins/workbench/files.zig
+++ b/src/plugins/workbench/files.zig
@@ -497,7 +497,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path });
var color = dvui.themeGet().color(.control, .fill);
- if (fizzy.editor.colors.palette) |*palette| {
+ if (fizzy.pixelart.colors.palette) |*palette| {
color = palette.getDVUIColor(color_id.*);
}
From 58368af733ce2fa43fdcd21dc8fadd1843e6cf4f Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 13:04:59 -0500
Subject: [PATCH 17/49] Phase 4 stage C
---
HANDOFF.md | 211 +++++++++++++++--
src/App.zig | 2 +-
src/editor/Editor.zig | 88 +++++++-
src/editor/Settings.zig | 161 +++++++------
src/editor/explorer/settings.zig | 118 ----------
src/plugins/pixelart/CanvasData.zig | 16 +-
src/plugins/pixelart/PixelArt.zig | 12 +-
src/plugins/pixelart/Settings.zig | 213 ++++++++++++++++++
src/plugins/pixelart/dialogs/Export.zig | 2 +-
src/plugins/pixelart/dialogs/GridLayout.zig | 4 +-
src/plugins/pixelart/internal/Atlas.zig | 4 +-
src/plugins/pixelart/internal/File.zig | 4 +-
src/plugins/pixelart/panel/sprites.zig | 8 +-
src/plugins/pixelart/plugin.zig | 15 +-
src/plugins/pixelart/widgets/CanvasBridge.zig | 2 +-
src/plugins/pixelart/widgets/FileWidget.zig | 8 +-
src/sdk/Host.zig | 84 +++++++
src/sdk/ShellApi.zig | 48 ++++
src/sdk/regions.zig | 13 ++
src/sdk/sdk.zig | 7 +-
20 files changed, 786 insertions(+), 234 deletions(-)
create mode 100644 src/plugins/pixelart/Settings.zig
create mode 100644 src/sdk/ShellApi.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 6cf1799a..6380df46 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,4 +1,4 @@
-# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, after Stage B)
+# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, mid Stage C)
## TL;DR
@@ -7,7 +7,13 @@ makes `core` a real, separately-wired Zig module with no dependency on the `fizz
app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so
it can become its own compile-time module.
-**Done:** Stage A1, A2, A3, B. **Next:** Stage C (then D, E).
+**Done:** Stage A1, A2, A3, B, and **Stage C part 1 (per-plugin settings)**.
+**Next:** Stage C remainder (doc/tab/host/arena/folder decoupling) + the sprite/atlas →
+`core` extraction. Then D, E.
+
+> **Read this first if you're a fresh agent:** the immediately actionable work is in
+> "Stage C — remaining work" and "Next big rock: sprite/atlas → core" near the bottom.
+> Several items there are now low-effort because the SDK surface they need already exists.
## What Stage B did
@@ -40,6 +46,87 @@ Lifted the pixel-art editor state off the shell `Editor` into a plugin-owned
Verified green: `zig build`, `zig build check-web`, `zig build test`. (No live GUI
run — pure refactor.)
+## What Stage C part 1 did — per-plugin settings (VSCode-style)
+
+Goal (set by the user): pixel-art-specific settings should **belong to the pixel-art
+plugin**, and the Settings tab should be a registry that each plugin contributes its own
+section to, grouped by plugin. The shell stores plugin settings opaquely but never
+interprets them.
+
+### New SDK surface (all in `src/sdk/`)
+
+- **`SettingsSection`** (`regions.zig`, exported from `sdk.zig`): `{ id, owner, title, draw }`.
+ The Settings sidebar view renders each registered section under its `title` heading.
+- **`Host` additions** (`Host.zig`):
+ - `settings_sections` registry + `registerSettingsSection`.
+ - `plugin_settings: PluginSettings` (= `StringArrayHashMapUnmanaged([]const u8)`): the
+ opaque per-plugin blob store (id → serialized JSON). `loadPluginSettings(id)` /
+ `storePluginSettings(id, json)` (the latter dupes + marks shell settings dirty). Host
+ owns + frees the key/value strings in `deinit`.
+ - `shell_api: ?ShellApi` + `installShell(api)` + thin forwarders: `arena()`, `folder()`,
+ `paletteFolder()`, `markSettingsDirty()`, `contentOpacity()`.
+- **`ShellApi`** (`ShellApi.zig`): vtable + ctx the shell installs so plugins reach shared
+ shell state without importing `Editor`. The shell's vtable impl lives in `Editor.zig`
+ (`shell_api_vtable` + `shellArena`/`shellFolder`/… ; ctx is `*Editor`), installed in
+ `Editor.postInit`.
+
+### Storage / persistence (`src/editor/Settings.zig`)
+
+- On-disk format gained a `"plugins"` object: `{ , "plugins": { id: } }`.
+- `Settings.serialize(settings, plugin_store, alloc)` serializes the struct, drops the
+ trailing `}`, and **textually splices** `,"plugins":{…}}` with each plugin's already-
+ serialized blob inline. (Robust — avoids `std.json.Value` lifetime hazards. Round-trip
+ validated with a standalone test: valid JSON, shell parses back via `ignore_unknown_fields`,
+ blobs re-extract cleanly.)
+- `Settings.save(...)` and the autosave **dedup snapshot** (`settings_last_saved_json`) and
+ the three Editor save sites all now go through `serialize` so plugin-only changes still
+ trigger a write. (Watch: `Settings.save` is called from `saveSettingsGuarded`,
+ `saveSettingsRaw`, and the init snapshot — all four-arg now.)
+- `Settings.loadPluginStore(alloc, path, store)` re-parses settings.json as a `Value`,
+ extracts the `"plugins"` object into the store. Called from `Editor.init` right after
+ `Settings.load`, before `PixelArt.init` runs (so the plugin can read its blob).
+- **One-time migration:** a legacy *flat* settings.json (no `"plugins"`) seeds the
+ `"pixelart"` blob from the **whole root** — pixel art ignores unknown keys, so its moved
+ fields (`show_rulers`, `input_scheme`, …) survive; the next save rewrites the blob clean.
+ (Self-healing, no data loss. The blob is temporarily bloated with shell keys until then.)
+
+### Pixel-art side
+
+- New **`src/plugins/pixelart/Settings.zig`** (`PixelArt.Settings`, `pub`): owns the moved
+ fields + `InputScheme`/`ResolvedPanZoomScheme`/`TransparencyEffect` enums +
+ `resolvedPanZoomScheme`. `load(host)` parses its blob (defaults if absent/garbage; no
+ heap fields so returning by value after `parsed.deinit()` is safe). `save(host)`
+ serializes + `host.storePluginSettings`. `draw(_)` renders the section (Canvas group:
+ transparency effect, show rulers, cover-flow cards; Controls group: control scheme).
+- `PixelArt` struct gained `host: *sdk.Host` and `settings: Settings`, both set in
+ `PixelArt.init(allocator, host)` (App now passes `&fizzy.editor.host`).
+- `plugin.register` registers the `"pixelart"` settings section ("Pixel Art").
+
+### Fields moved off shell `Settings` → `PixelArt.Settings`
+
+`input_scheme`, `show_rulers`, `scrolling_cards`, `ruler_padding`, `zoom_sensitivity`,
+`zoom_steps`, `max_file_size`, `checker_color_even/odd`, `transparency_effect` (+ the
+three enums + `resolvedPanZoomScheme`). All ~27 pixel-art read sites repointed to
+`fizzy.pixelart.settings.`; type refs (`fizzy.Editor.Settings.TransparencyEffect`,
+`…resolvedPanZoomScheme`) → `fizzy.PixelArt.Settings.…`.
+
+**`content_opacity` deliberately stays on the shell** — it's also read by `workbench/
+Workspace.zig` and `panel/Panel.zig`, so it's genuinely shell-level. Pixel art's 3 reads
+go through `fizzy.pixelart.host.contentOpacity()` (the ShellApi). The pixel-art settings
+*UI controls* were removed from `editor/explorer/settings.zig` (the shell "Editor" section
+now only has theme/fonts/window+content opacity/hold-timing/debugging).
+
+### Settings UI
+
+`Editor.drawSettingsPane` now iterates `host.settings_sections` and renders each under a
+heading label (registration order = display order; shell "Editor" registered first in
+`postInit`, before plugins). The shell section draw = `Explorer.settings.draw` (trimmed);
+the pixel-art section draw = `PixelArt.Settings.draw`.
+
+Verified green: `zig build`, `zig build check-web`, `zig build test`. Persistence splice
+round-trip checked with a throwaway `zig run` harness (valid JSON + clean extraction). No
+live GUI run.
+
All three build configs are green right now:
```
@@ -131,11 +218,10 @@ off `src/editor/Editor.zig` (~83 refs) into a `PixelArt` plugin-state struct own
plugin. Update `Editor.zig`, `Keybinds` (~15 refs), and the `Menu`, plus the pixel-art
references that read those fields. Build green (all 3).
-### Stage C — expand the SDK Host + a `workbench` service vtable
-Grow `src/sdk/sdk.zig` Host surface to cover the ~110-ref shell surface the plugin still
-needs: arena access, settings, folder access, doc/tab access, command registration. Then
-replace remaining pixel-art `fizzy.editor` / `fizzy.backend` / `fizzy.platform` calls with
-SDK calls. Build green.
+### Stage C — expand the SDK Host (settings done; rest below)
+Grow the SDK Host surface so the plugin reaches shell state via the SDK, not
+`fizzy.editor`. **Part 1 (per-plugin settings) is done** — see "What Stage C part 1 did"
+above. The remaining coupling and a recommended order are in "Stage C — remaining work".
### Stage D — make `pixelart` its own module
Add a `src/plugins/pixelart/pixelart.zig` module root; repoint all pixel-art imports from
@@ -149,12 +235,109 @@ contributions through the SDK only. Final verification across the 3 configs.
---
+## Stage C — remaining work (start here)
+
+Settings is fully decoupled (`grep -r 'fizzy.editor.settings' src/plugins/pixelart` → 0).
+Here is the **current** `fizzy.editor.*` / `fizzy.backend.*` / `fizzy.platform.*` surface
+still in `src/plugins/pixelart/**` (run the greps to refresh):
+
+```
+33 fizzy.editor.activeFile 11 fizzy.editor.open_files 6 fizzy.editor.newFileID
+31 fizzy.editor.atlas 11 fizzy.editor.host 6 fizzy.editor.folder
+17 fizzy.editor.explorer 10 fizzy.editor.arena 2 fizzy.editor.palette_folder
++ doc/save-flow tail: setActiveFile, getFile, getFileFromPath, newFile, open_file_index,
+ requestCompositeWarmup, startPackProject, isPackingActive, requestSaveAs,
+ requestWebSaveDialog, requestGridLayoutDialog, cancelPendingSaveDialog, abortSaveAllQuit,
+ copy/paste/accept/cancel, save, transform, buffers, panel, allocNextUntitledPath,
+ pending_*/quit_* (all 1–3 refs each)
+backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platform: isMacOS ×3
+```
+
+**Recommended order (easy → hard):**
+
+1. **`host` (11) — trivial now.** `PixelArt` already holds `host: *sdk.Host` (set in
+ `init`). Repoint `fizzy.editor.host.setActiveSidebarView/isActiveSidebarView` →
+ `fizzy.pixelart.host.…`. Pure mechanical, no SDK change.
+2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The ShellApi
+ forwarders already exist: `fizzy.pixelart.host.arena()` / `.folder()` /
+ `.paletteFolder()`. Repoint `fizzy.editor.arena.allocator()` → `fizzy.pixelart.host.arena()`,
+ etc. (mind that `arena` callers use `.allocator()`; the forwarder already returns the
+ `Allocator`).
+3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to ShellApi
+ (shell calls `fizzy.backend.isMaximized(dvui.currentWindow())`). `isMacOS` is just
+ `core.platform.isMacOS()` — pixel art can call `fizzy.platform.isMacOS()` until Stage D
+ repoints it to `core` directly; low priority.
+4. **`explorer` (17).** These read pixel-art state that *lives on the shell `Explorer`*
+ (`explorer.tools`, `.sprites`, `.pinned_palettes`, `.layers_ratio`, `.rect`,
+ `.scroll_info`). `tools`/`sprites` are pixel-art pane modules; `pinned_palettes`/
+ `layers_ratio` are pixel-art UI state. These should **move onto `PixelArt`** (like the
+ settings did), not get an SDK accessor. `rect`/`scroll_info` are shell explorer layout —
+ expose via ShellApi or pass into the draw.
+5. **Native save dialogs (`backend.showSaveFileDialog` ×5, `DialogFileFilter` ×4).** Add a
+ small SDK surface for "ask the host to run a native save dialog" (native-only; web has
+ its own path). The save-flow tail (`requestSaveAs`, `pending_*`, `quit_*`, `accept`,
+ `cancel`, `abortSaveAllQuit`, …) is the shell's save/quit orchestration the pixel-art
+ dialogs poke — needs a deliberate "document save service" vtable, the hardest part.
+6. **Docs/tabs (`activeFile` ×33, `open_files` ×11, `setActiveFile`, `getFile*`, `newFile*`,
+ `open_file_index`, `buffers`, `transform`, `copy/paste`, `requestCompositeWarmup`,
+ `startPackProject`, `isPackingActive`).** This is the **deep coupling**: the shell's
+ `open_files` is literally `AutoArrayHashMapUnmanaged(u64, Internal.File)` — a map of
+ *pixel-art* `Internal.File` values. The shell currently owns and iterates pixel-art docs
+ directly. Fully decoupling means the shell stores **opaque documents (`DocHandle`)** and
+ the pixel-art plugin owns the `Internal.File` storage. That is a large structural change
+ (touches the workspace/tab/save systems) — likely its own stage. Until then, pixel-art
+ can reach the active doc through a `host.activeDoc() ?DocHandle` + cast, but the storage
+ inversion is the real work.
+
+`atlas` (31) is handled by the sprite/atlas → core extraction below, not by an SDK accessor.
+
+## Next big rock: sprite / atlas → `core`
+
+This resolves the `editor.atlas` (Stage B) and `fizzy.editor.atlas` (×31) coupling and is
+the prerequisite for the shell not depending on the pixel-art plugin for its own UI icons.
+
+**Findings (verified in code):**
+
+- The shell (`workbench`) only calls `fizzy.sprite_render.sprite(...)` in two places —
+ `workbench/files.zig:~774` and `workbench/Workspace.zig:~300` — both drawing a **static
+ atlas sprite** (the logo / UI icons), passing `file = null`. It never uses the heavy path.
+- But `src/plugins/pixelart/sprite_render.zig` lives in the plugin and is tangled: the same
+ `sprite()` also does layer compositing, file previews, reflections, and `water_surface`
+ (all need a full pixel-art `Internal.File`). So today the shell reaches *backwards* into
+ the plugin just to draw an icon. `editor.atlas` is typed `Internal.Atlas` (pixel art's).
+
+**Plan:** split by responsibility with `core` as the shared floor.
+
+- → **`core`:** a generic atlas data type + a "draw sprite N (sub-rect of a texture)"
+ primitive (the slice the shell's logo/icons need; essentially `dvui.renderImage` + sprite
+ rect math). The shell's `editor.atlas` becomes a `core` atlas type drawn via the `core`
+ helper, depending on `core` not the plugin.
+- → **stays in pixel-art plugin:** `renderSprite` / `render.renderLayers` / composites /
+ reflections / `water_surface` — all the editing rendering on top of the primitive.
+
+End-state dependency graph: **shell → core**, **plugin → core**, neither depends on the
+other. (User has signed off on this direction; sequenced *after* settings.)
+
+---
+
## State of the tree
-Uncommitted. Stage A3 touched: `build.zig`, `src/App.zig`, `src/fizzy.zig`,
-`src/web_main.zig`, `src/editor/Editor.zig`, the moved `src/core/**` files, and the
-pixel-art/workbench consumers (`Atlas.zig`, `CanvasData.zig`, `PackJob.zig`,
-`FileLoadJob.zig`, `files.zig`, `plugin.zig`, `dialogs/GridLayout.zig`,
-`widgets/{CanvasBridge,FileWidget,ImageWidget}.zig`). Deleted: `editor/widgets/Widgets.zig`,
-`tools/timer.zig`, `core/gfx/gfx.zig` (empty), `core/font_awesome.zig` (unused — `fa`
-re-exports removed from `core.zig`/`fizzy.zig` and the web probe). Nothing has been committed.
+**Uncommitted** (nothing in this whole Phase-4 effort has been committed — commit on
+request). Beyond the Stage A3 changes, the working tree now also has:
+
+- **Stage B:** new `src/plugins/pixelart/PixelArt.zig`; `fizzy.pixelart` global in
+ `fizzy.zig`; init/deinit wiring in `App.zig`; field removals + ~190 repoints in
+ `Editor.zig`, `Keybinds.zig`, `workbench/files.zig`, and the pixel-art tree.
+- **Stage C part 1 (settings):** new `src/sdk/ShellApi.zig`,
+ `src/plugins/pixelart/Settings.zig`; `SettingsSection` in `sdk/regions.zig` + `sdk.zig`;
+ Host store/forwarders/section-registry in `sdk/Host.zig`; persistence rework in
+ `editor/Settings.zig`; ShellApi impl + section iteration in `editor/Editor.zig`; trimmed
+ `editor/explorer/settings.zig`; settings repoints across the pixel-art tree;
+ `App.zig` passes the host to `PixelArt.init`.
+
+Sanity greps for the next agent:
+- `grep -rn 'fizzy.editor.settings' src/plugins/pixelart` → **0** (settings decoupled).
+- `grep -rhoE 'fizzy\.editor\.[a-zA-Z_]+' src/plugins/pixelart | sort | uniq -c | sort -rn`
+ → the remaining Stage C surface (see "Stage C — remaining work").
+
+All three configs green: `zig build`, `zig build check-web`, `zig build test`.
diff --git a/src/App.zig b/src/App.zig
index 7ca4e3d5..40f13a89 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -169,7 +169,7 @@ pub fn AppInit(win: *dvui.Window) !void {
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
// `state`. Owned here for the app's lifetime; torn down in `AppDeinit`.
fizzy.pixelart = try allocator.create(fizzy.PixelArt);
- fizzy.pixelart.* = fizzy.PixelArt.init(allocator) catch unreachable;
+ fizzy.pixelart.* = fizzy.PixelArt.init(allocator, &fizzy.editor.host) catch unreachable;
// Second-stage init that needs the editor at its final heap address (e.g.
// registering the workbench-api service whose `ctx` is this pointer).
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index d3ad7df0..a77b4de4 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -260,7 +260,14 @@ pub fn init(
try editor.workbench.registerBuiltins();
- editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }));
+ {
+ const settings_path = try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" });
+ editor.settings = try Settings.load(app.allocator, settings_path);
+ // Load the opaque per-plugin settings blobs into the Host so plugins (created
+ // right after this `Editor.init` returns) can read their own settings. Runs a
+ // one-time migration of legacy flat settings; see `Settings.loadPluginStore`.
+ Settings.loadPluginStore(app.allocator, settings_path, &editor.host.plugin_settings);
+ }
// Start the long-lived save-queue worker. All .fiz async saves get
// serialized through this single thread (see `File.SaveQueue`); concurrent
@@ -437,8 +444,8 @@ pub fn init(
try Keybinds.register();
- // Collect the initial settings json
- editor.settings_last_saved_json = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{});
+ // Collect the initial settings json (shell fields + per-plugin blobs) for autosave dedup.
+ editor.settings_last_saved_json = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator);
return editor;
}
@@ -453,6 +460,20 @@ pub fn init(
pub const view_settings = "shell.settings";
pub fn postInit(editor: *Editor) !void {
+ // Install the shell's read/utility surface so plugins reach shared shell state
+ // (per-frame arena, project folder, content opacity, settings dirty-mark) through
+ // the Host instead of importing the concrete Editor.
+ editor.host.installShell(.{ .ctx = editor, .vtable = &shell_api_vtable });
+
+ // The shell's own settings section, registered first so "Editor" leads the list;
+ // plugins append theirs in their `register` (the Settings view renders each grouped
+ // by owner, VSCode-style).
+ try editor.host.registerSettingsSection(.{
+ .id = "shell.settings.editor",
+ .title = "Editor",
+ .draw = drawShellSettingsSection,
+ });
+
// Register plugin contributions (sidebar/bottom/center/menus). These are the
// near-empty shell's content: it iterates the Host registries rather than
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
@@ -495,10 +516,63 @@ pub fn postInit(editor: *Editor) !void {
}
}
+/// The Settings sidebar view: render every registered settings section under its title
+/// heading, grouped by owner (VSCode-style). The shell registers its own "Editor"
+/// section; plugins add theirs.
fn drawSettingsPane(_: ?*anyopaque) anyerror!void {
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
+ defer vbox.deinit();
+
+ for (fizzy.editor.host.settings_sections.items, 0..) |*section, i| {
+ var sbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = i });
+ defer sbox.deinit();
+
+ dvui.labelNoFmt(@src(), section.title, .{}, .{
+ .font = dvui.Font.theme(.heading),
+ .margin = .{ .x = 2, .y = 6, .w = 2, .h = 2 },
+ });
+ try section.draw(section.ctx);
+
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 12 } });
+ }
+}
+
+/// Shell-owned settings controls (theme, fonts, window/content opacity, input timing,
+/// debugging). Pixel-art-specific controls live in the pixel-art plugin's own section.
+fn drawShellSettingsSection(_: ?*anyopaque) anyerror!void {
try Explorer.settings.draw();
}
+// ---- ShellApi: the shell-provided read/utility surface for plugins ----------
+// Installed on the Host in `postInit`; `ctx` is this `*Editor`.
+
+const shell_api_vtable: sdk.ShellApi.VTable = .{
+ .arena = shellArena,
+ .folder = shellFolder,
+ .paletteFolder = shellPaletteFolder,
+ .markSettingsDirty = shellMarkSettingsDirty,
+ .contentOpacity = shellContentOpacity,
+};
+
+fn shellCtx(ctx: *anyopaque) *Editor {
+ return @ptrCast(@alignCast(ctx));
+}
+fn shellArena(ctx: *anyopaque) std.mem.Allocator {
+ return shellCtx(ctx).arena.allocator();
+}
+fn shellFolder(ctx: *anyopaque) ?[]const u8 {
+ return shellCtx(ctx).folder;
+}
+fn shellPaletteFolder(ctx: *anyopaque) ?[]const u8 {
+ return shellCtx(ctx).palette_folder;
+}
+fn shellMarkSettingsDirty(ctx: *anyopaque) void {
+ shellCtx(ctx).markSettingsDirty();
+}
+fn shellContentOpacity(ctx: *anyopaque) f32 {
+ return shellCtx(ctx).settings.content_opacity;
+}
+
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void {
const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" });
@@ -643,7 +717,7 @@ fn saveSettingsGuarded(editor: *Editor) !void {
if (editor.activelyDrawing())
return;
- const serialized = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{});
+ const serialized = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator);
defer fizzy.app.allocator.free(serialized);
if (editor.settings_last_saved_json) |old| {
@@ -656,7 +730,7 @@ fn saveSettingsGuarded(editor: *Editor) !void {
const settings_path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "settings.json" });
defer fizzy.app.allocator.free(settings_path);
- try Settings.save(&editor.settings, fizzy.app.allocator, settings_path);
+ try Settings.save(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator, settings_path);
if (editor.settings_last_saved_json) |blob| {
fizzy.app.allocator.free(blob);
@@ -668,7 +742,7 @@ fn saveSettingsGuarded(editor: *Editor) !void {
/// Flush to disk regardless of idle/drawing deferral — used during shutdown only.
fn saveSettingsRaw(editor: *Editor) !void {
- const serialized = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{});
+ const serialized = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator);
defer fizzy.app.allocator.free(serialized);
const need_disk = blk: {
@@ -682,7 +756,7 @@ fn saveSettingsRaw(editor: *Editor) !void {
defer fizzy.app.allocator.free(settings_path);
if (need_disk)
- try Settings.save(&editor.settings, fizzy.app.allocator, settings_path);
+ try Settings.save(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator, settings_path);
if (need_disk) {
if (editor.settings_last_saved_json) |blob| {
diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig
index 83a012df..518de372 100644
--- a/src/editor/Settings.zig
+++ b/src/editor/Settings.zig
@@ -12,26 +12,9 @@ pub const autosave_timeout_ns: i128 = 500 * 1_000_000;
pub var parsed: ?std.json.Parsed(Settings) = null;
-pub const InputScheme = enum { auto, mouse, trackpad };
-
-/// Resolved zoom/pan control style after applying `auto` (`dvui.getMouseTypeHint`).
-pub const ResolvedPanZoomScheme = enum {
- mouse,
- trackpad,
-};
pub const FlipbookView = enum { sequential, grid };
pub const Compatibility = enum { none, ldtk };
-/// How sprite-cell transparency (checkerboard) is tinted behind the canvas.
-pub const TransparencyEffect = enum {
- /// Uniform default tone only (no hue gradient).
- none,
- /// Mouse-smoothed corner gradient (current default).
- rainbow,
- /// Per-cell tone shifted toward the animation’s palette color (when the sprite belongs to an animation).
- animation,
-};
-
/// The ratio of the explorer to the artboard.
explorer_ratio: f32 = 0.35,
@@ -42,38 +25,15 @@ min_window_size: [2]f32 = .{ 640, 480 },
initial_window_size: [2]f32 = .{ 1280, 720 },
-/// Zoom/pan control scheme (`auto` picks mouse vs trackpad gestures from `dvui.getMouseTypeHint` after scroll events).
-input_scheme: InputScheme = .auto,
-
/// Touch or long-press duration (ms) before a context menu opens instead of a normal click.
hold_menu_duration_ms: u32 = 500,
-/// Whether or not to show rulers on each canvas.
-show_rulers: bool = true,
-
-/// Sprites panel: when true, show side cards in the cover-flow strip; when false,
-/// fly them away for single-card focus (snap scroll)
-scrolling_cards: bool = true,
-
/// When true, print frame/draw perf stats to the console (Debug / ReleaseSafe only for tick stats).
perf_logging: bool = false,
/// Pretend an app update is available (badge + launch toast). Restart after toggling.
debug_simulate_update_available: bool = false,
-/// Padding to include in the size of the ruler outside of the font height.
-ruler_padding: f32 = 4.0,
-
-/// Setting to control overall zoom sensitivity
-/// 0 - 1
-zoom_sensitivity: f32 = 1.0,
-
-/// Predetermined zoom steps, each is pixel perfect.
-zoom_steps: [23]f32 = [_]f32{ 0.125, 0.167, 0.2, 0.25, 0.333, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 18, 28, 38, 50, 70, 90, 128, 256, 512 },
-
-/// Maximum file size
-max_file_size: [2]i32 = .{ 4096, 4096 },
-
/// Maximum number of recents before removing oldest
max_recents: usize = 10,
@@ -86,39 +46,19 @@ font_title_size: f32 = 9,
font_heading_size: f32 = 8,
font_mono_size: f32 = 10,
-/// Color for the even squares of the checkerboard pattern
-checker_color_even: [4]u8 = .{ 255, 255, 255, 255 },
-/// Color for the odd squares of the checkerboard pattern
-checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 },
-
/// Opacity of the background window
/// CURRENTLY ONLY SUPPORTED ON MACOS and Windows
window_opacity_dark: f32 = 0.7,
window_opacity_light: f32 = 0.3,
-content_opacity: f32 = 0.7,
-/// Checkerboard / transparency tint behind sprites (grid cells).
-transparency_effect: TransparencyEffect = .none,
+/// Opacity of the content area (also drives plugin panes that match the shell chrome).
+content_opacity: f32 = 0.7,
titlebar_height: f32 = 26.0, // This is the height of the titlebar in pixels
/// Empty strip below the top window edge (non-macOS), above the main title row (in-window menu, etc.).
titlebar_top_buffer: f32 = 10.0,
-pub fn resolvedPanZoomScheme(settings: *const Settings) ResolvedPanZoomScheme {
- return switch (settings.input_scheme) {
- .auto => switch (dvui.mouseType()) {
- // Use runtime platform detection so macOS web users get the trackpad
- // default. `builtin.os.tag == .macos` is false on wasm32-freestanding.
- .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse,
- .mouse => .mouse,
- .trackpad => .trackpad,
- },
- .mouse => .mouse,
- .trackpad => .trackpad,
- };
-}
-
fn default(allocator: std.mem.Allocator) !Settings {
return .{
.theme = try allocator.dupe(u8, default_theme),
@@ -133,6 +73,7 @@ pub fn setThemeName(settings: *Settings, allocator: std.mem.Allocator, name: []c
}
/// Loads settings (`theme` is always heap-owned after successful return — see `setThemeName` / `deinit`).
+/// Unknown keys (e.g. the "plugins" object, parsed separately by `loadPluginStore`) are ignored.
pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings {
// Wasm: no on-disk config; `fizzy.fs.read` uses `Io.Dir.cwd()` (posix.AT).
if (comptime builtin.target.cpu.arch == .wasm32) return default(allocator);
@@ -157,13 +98,105 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings {
return result;
}
-pub fn save(settings: *Settings, allocator: std.mem.Allocator, path: []const u8) !void {
- const str = try std.json.Stringify.valueAlloc(allocator, settings, .{});
+/// Serialize the shell settings plus the opaque per-plugin store into a single
+/// settings.json document: `{ , "plugins": { : , … } }`. The
+/// plugin blobs are already-serialized JSON objects, spliced in verbatim — the shell
+/// never interprets them.
+pub fn serialize(
+ settings: *const Settings,
+ plugin_settings: *const std.StringArrayHashMapUnmanaged([]const u8),
+ allocator: std.mem.Allocator,
+) ![]u8 {
+ const fields = try std.json.Stringify.valueAlloc(allocator, settings, .{});
+ defer allocator.free(fields);
+ // `fields` is a `{…}` object with at least one member, so dropping the trailing
+ // brace and appending `,"plugins":{…}}` always yields valid JSON.
+ var out: std.ArrayListUnmanaged(u8) = .empty;
+ errdefer out.deinit(allocator);
+ try out.appendSlice(allocator, fields[0 .. fields.len - 1]);
+ try out.appendSlice(allocator, ",\"plugins\":{");
+ var first = true;
+ var it = plugin_settings.iterator();
+ while (it.next()) |e| {
+ if (!first) try out.append(allocator, ',');
+ first = false;
+ const key = try std.json.Stringify.valueAlloc(allocator, e.key_ptr.*, .{});
+ defer allocator.free(key);
+ try out.appendSlice(allocator, key);
+ try out.append(allocator, ':');
+ try out.appendSlice(allocator, e.value_ptr.*);
+ }
+ try out.appendSlice(allocator, "}}");
+ return out.toOwnedSlice(allocator);
+}
+
+pub fn save(
+ settings: *Settings,
+ plugin_settings: *const std.StringArrayHashMapUnmanaged([]const u8),
+ allocator: std.mem.Allocator,
+ path: []const u8,
+) !void {
+ const str = try serialize(settings, plugin_settings, allocator);
defer allocator.free(str);
try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = str });
}
+/// Populate `store` (id -> owned JSON blob) from the "plugins" object in settings.json.
+/// One-time migration: a legacy flat settings.json (no "plugins" object) seeds the
+/// pixel-art blob from the whole root so its moved fields (show_rulers, input_scheme, …)
+/// survive the format change — pixel art ignores unknown keys, and the next save rewrites
+/// the blob cleanly.
+pub fn loadPluginStore(
+ allocator: std.mem.Allocator,
+ path: []const u8,
+ store: *std.StringArrayHashMapUnmanaged([]const u8),
+) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ const data = fizzy.fs.read(allocator, dvui.io, path) catch return;
+ defer allocator.free(data);
+
+ var parsed_v = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return;
+ defer parsed_v.deinit();
+
+ const root = switch (parsed_v.value) {
+ .object => |o| o,
+ else => return,
+ };
+
+ if (root.get("plugins")) |plugins_val| {
+ switch (plugins_val) {
+ .object => |plugins| {
+ var it = plugins.iterator();
+ while (it.next()) |e| {
+ const blob = std.json.Stringify.valueAlloc(allocator, e.value_ptr.*, .{}) catch continue;
+ const key = allocator.dupe(u8, e.key_ptr.*) catch {
+ allocator.free(blob);
+ continue;
+ };
+ store.put(allocator, key, blob) catch {
+ allocator.free(key);
+ allocator.free(blob);
+ };
+ }
+ return;
+ },
+ else => {},
+ }
+ }
+
+ // Legacy flat settings.json: seed the pixel-art blob from the whole root.
+ const legacy_blob = std.json.Stringify.valueAlloc(allocator, parsed_v.value, .{}) catch return;
+ const key = allocator.dupe(u8, "pixelart") catch {
+ allocator.free(legacy_blob);
+ return;
+ };
+ store.put(allocator, key, legacy_blob) catch {
+ allocator.free(key);
+ allocator.free(legacy_blob);
+ };
+}
+
pub fn deinit(settings: *Settings, allocator: std.mem.Allocator) void {
allocator.free(settings.theme);
defer parsed = null;
diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig
index 8b7aba09..5141acf5 100644
--- a/src/editor/explorer/settings.zig
+++ b/src/editor/explorer/settings.zig
@@ -148,68 +148,6 @@ pub fn draw() !void {
dvui.refresh(null, @src(), vbox.data().id);
}
- {
- var dropdown: dvui.DropdownWidget = undefined;
- dropdown.init(@src(), .{ .label = "Transparency effect" }, .{
- .expand = .horizontal,
- .corner_radius = dvui.Rect.all(1000),
- });
- defer dropdown.deinit();
-
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .vertical,
- .gravity_x = 1.0,
- });
-
- const label_text = switch (fizzy.editor.settings.transparency_effect) {
- .none => "None",
- .rainbow => "Rainbow",
- .animation => "Animation",
- };
- dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) });
-
- dvui.icon(
- @src(),
- "dropdown_triangle",
- dvui.entypo.triangle_down,
- .{},
- .{ .gravity_y = 0.5 },
- );
-
- hbox.deinit();
-
- if (dropdown.dropped()) {
- if (dropdown.addChoiceLabel("None")) {
- fizzy.editor.settings.transparency_effect = .none;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), vbox.data().id);
- }
- if (dropdown.addChoiceLabel("Rainbow")) {
- fizzy.editor.settings.transparency_effect = .rainbow;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), vbox.data().id);
- }
- if (dropdown.addChoiceLabel("Animation")) {
- fizzy.editor.settings.transparency_effect = .animation;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), vbox.data().id);
- }
- }
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
- }
-
- if (dvui.checkbox(@src(), &fizzy.editor.settings.show_rulers, "Show Rulers", .{
- .expand = .none,
- })) {
- fizzy.editor.markSettingsDirty();
- }
-
- if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Show sprite cover-flow cards", .{
- .expand = .none,
- })) {
- fizzy.editor.markSettingsDirty();
- }
}
{
@@ -218,62 +156,6 @@ pub fn draw() !void {
});
defer box.deinit();
- {
- var dropdown: dvui.DropdownWidget = undefined;
- dropdown.init(@src(), .{ .label = "Control scheme" }, .{
- .expand = .horizontal,
- .corner_radius = dvui.Rect.all(1000),
- });
- defer dropdown.deinit();
-
- var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .vertical,
- .gravity_x = 1.0,
- });
-
- const label_text: []const u8 = switch (fizzy.editor.settings.input_scheme) {
- .auto => switch (dvui.mouseType()) {
- // Pre-classification (no scroll events seen yet) — drop the parenthetical
- // entirely rather than showing "Auto (unknown)".
- .unknown => "Auto",
- .mouse, .trackpad => |hint| try std.fmt.allocPrint(dvui.currentWindow().lifo(), "Auto ({s})", .{@tagName(hint)}),
- },
- .mouse => "Mouse",
- .trackpad => "Trackpad",
- };
- dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) });
-
- dvui.icon(
- @src(),
- "dropdown_triangle",
- dvui.entypo.triangle_down,
- .{},
- .{ .gravity_y = 0.5 },
- );
-
- hbox.deinit();
-
- if (dropdown.dropped()) {
- if (dropdown.addChoiceLabel("Auto")) {
- fizzy.editor.settings.input_scheme = .auto;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), vbox.data().id);
- }
- if (dropdown.addChoiceLabel("Mouse")) {
- fizzy.editor.settings.input_scheme = .mouse;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), vbox.data().id);
- }
- if (dropdown.addChoiceLabel("Trackpad")) {
- fizzy.editor.settings.input_scheme = .trackpad;
- fizzy.editor.markSettingsDirty();
- dvui.refresh(null, @src(), vbox.data().id);
- }
- }
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
- }
-
var hold_menu_ms: f32 = @floatFromInt(fizzy.editor.settings.hold_menu_duration_ms);
if (dvui.sliderEntry(@src(), "Context menu hold: {d:0.0} ms", .{
.value = &hold_menu_ms,
diff --git a/src/plugins/pixelart/CanvasData.zig b/src/plugins/pixelart/CanvasData.zig
index 027cddf4..868bb422 100644
--- a/src/plugins/pixelart/CanvasData.zig
+++ b/src/plugins/pixelart/CanvasData.zig
@@ -99,15 +99,15 @@ pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation)
const largest_label_size = font.textSize(largest_label);
const natural_scale = dvui.currentWindow().natural_scale;
const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical);
- const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding;
+ const base_ruler_size = largest_label_size.w + fizzy.pixelart.settings.ruler_padding;
const ruler_thickness: f32 = switch (orientation) {
.horizontal => blk: {
- self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding;
+ self.horizontal_ruler_height = font.textSize("M").h + fizzy.pixelart.settings.ruler_padding;
break :blk self.horizontal_ruler_height;
},
.vertical => blk: {
- self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding);
+ self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.pixelart.settings.ruler_padding);
break :blk self.vertical_ruler_width;
},
};
@@ -591,7 +591,7 @@ pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void {
else
font.textSize(label).scale(natural, dvui.Size.Physical);
- const padding = fizzy.editor.settings.ruler_padding * natural;
+ const padding = fizzy.pixelart.settings.ruler_padding * natural;
var label_rect = rect;
@@ -864,8 +864,8 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
// shrinks, and once it's narrower than the pill we bail and draw nothing this frame —
// so closing splits cleanly hides the menu.
const wb = container.rectScale().r.toNatural();
- const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
- const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
+ const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0;
const canvas_nat = dvui.Rect{
.x = wb.x + ruler_left,
.y = wb.y + ruler_top,
@@ -1102,8 +1102,8 @@ pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void {
// Anchor against the same canvas-scroll-area rect the pill uses.
const wb = container.rectScale().r.toNatural();
- const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0;
- const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0;
+ const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0;
const canvas_nat = dvui.Rect{
.x = wb.x + ruler_left,
.y = wb.y + ruler_top,
diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/PixelArt.zig
index 6f411f11..d106037d 100644
--- a/src/plugins/pixelart/PixelArt.zig
+++ b/src/plugins/pixelart/PixelArt.zig
@@ -14,10 +14,12 @@ const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const assets = @import("assets");
+const sdk = fizzy.sdk;
const Colors = @import("Colors.zig");
const Project = @import("Project.zig");
const Tools = @import("Tools.zig");
const PackJob = @import("PackJob.zig");
+pub const Settings = @import("Settings.zig");
const PixelArt = @This();
@@ -27,6 +29,12 @@ pub const SpriteClipboard = struct {
offset: dvui.Point,
};
+/// The shell host (service locator + per-plugin settings store). Set in `init`.
+host: *sdk.Host,
+
+/// Pixel-art editing preferences, loaded from the host's per-plugin settings store.
+settings: Settings = .{},
+
tools: Tools,
colors: Colors = .{},
@@ -42,8 +50,10 @@ sprite_clipboard: ?SpriteClipboard = null,
/// most recent request produces a visible atlas update.
pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty,
-pub fn init(allocator: std.mem.Allocator) !PixelArt {
+pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !PixelArt {
var pa: PixelArt = .{
+ .host = host,
+ .settings = Settings.load(host),
.tools = try .init(allocator),
};
pa.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
diff --git a/src/plugins/pixelart/Settings.zig b/src/plugins/pixelart/Settings.zig
new file mode 100644
index 00000000..260b4eb7
--- /dev/null
+++ b/src/plugins/pixelart/Settings.zig
@@ -0,0 +1,213 @@
+//! Pixel-art plugin settings: the canvas / sprite-editing preferences formerly stored
+//! as top-level fields on the shell `Settings`. Persisted via the shell's per-plugin
+//! settings store (the `Host`), keyed by the plugin id, as an opaque JSON blob the shell
+//! never interprets.
+const std = @import("std");
+const builtin = @import("builtin");
+const fizzy = @import("../../fizzy.zig");
+const dvui = @import("dvui");
+const sdk = fizzy.sdk;
+
+const PixelArtSettings = @This();
+
+/// Per-plugin settings store key (matches `plugin.id`).
+pub const plugin_id = "pixelart";
+
+pub const InputScheme = enum { auto, mouse, trackpad };
+
+/// Resolved zoom/pan control style after applying `auto` (`dvui.mouseType`).
+pub const ResolvedPanZoomScheme = enum { mouse, trackpad };
+
+/// How sprite-cell transparency (checkerboard) is tinted behind the canvas.
+pub const TransparencyEffect = enum {
+ /// Uniform default tone only (no hue gradient).
+ none,
+ /// Mouse-smoothed corner gradient.
+ rainbow,
+ /// Per-cell tone shifted toward the animation's palette color.
+ animation,
+};
+
+/// Zoom/pan control scheme (`auto` picks mouse vs trackpad from `dvui.mouseType()` after scroll events).
+input_scheme: InputScheme = .auto,
+
+/// Whether or not to show rulers on each canvas.
+show_rulers: bool = true,
+
+/// Sprites panel: when true, show side cards in the cover-flow strip; when false,
+/// fly them away for single-card focus (snap scroll).
+scrolling_cards: bool = true,
+
+/// Padding to include in the size of the ruler outside of the font height.
+ruler_padding: f32 = 4.0,
+
+/// Overall zoom sensitivity (0 - 1).
+zoom_sensitivity: f32 = 1.0,
+
+/// Predetermined zoom steps, each pixel perfect.
+zoom_steps: [23]f32 = [_]f32{ 0.125, 0.167, 0.2, 0.25, 0.333, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 18, 28, 38, 50, 70, 90, 128, 256, 512 },
+
+/// Maximum file size.
+max_file_size: [2]i32 = .{ 4096, 4096 },
+
+/// Color for the even squares of the checkerboard pattern.
+checker_color_even: [4]u8 = .{ 255, 255, 255, 255 },
+/// Color for the odd squares of the checkerboard pattern.
+checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 },
+
+/// Checkerboard / transparency tint behind sprites (grid cells).
+transparency_effect: TransparencyEffect = .none,
+
+pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings) ResolvedPanZoomScheme {
+ return switch (settings.input_scheme) {
+ .auto => switch (dvui.mouseType()) {
+ // Runtime platform detection so macOS web users get the trackpad default
+ // (`builtin.os.tag == .macos` is false on wasm32-freestanding).
+ .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse,
+ .mouse => .mouse,
+ .trackpad => .trackpad,
+ },
+ .mouse => .mouse,
+ .trackpad => .trackpad,
+ };
+}
+
+/// Load from the host's per-plugin store, or defaults if absent/unparsable. Unknown keys
+/// are ignored, so the one-time legacy-migration blob (which still carries shell fields)
+/// parses fine — only the pixel-art fields are picked up.
+pub fn load(host: *sdk.Host) PixelArtSettings {
+ const blob = host.loadPluginSettings(plugin_id) orelse return .{};
+ const parsed = std.json.parseFromSlice(PixelArtSettings, host.allocator, blob, .{
+ .ignore_unknown_fields = true,
+ }) catch return .{};
+ defer parsed.deinit();
+ // PixelArtSettings has no heap-owned fields (all values/arrays/enums), so the parsed
+ // value is safe to return after freeing the parse arena.
+ return parsed.value;
+}
+
+/// Serialize and persist to the host store (marks shell settings dirty for autosave).
+pub fn save(settings: *const PixelArtSettings, host: *sdk.Host) void {
+ const json = std.json.Stringify.valueAlloc(host.allocator, settings, .{}) catch return;
+ defer host.allocator.free(json);
+ host.storePluginSettings(plugin_id, json) catch {};
+}
+
+/// The plugin's Settings section body (registered as a `SettingsSection`). Renders the
+/// canvas / control prefs and persists on change.
+pub fn draw(_: ?*anyopaque) !void {
+ const pa = fizzy.pixelart;
+
+ var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
+ defer vbox.deinit();
+
+ {
+ var box = dvui.groupBox(@src(), "Canvas", .{ .expand = .horizontal });
+ defer box.deinit();
+
+ {
+ var dropdown: dvui.DropdownWidget = undefined;
+ dropdown.init(@src(), .{ .label = "Transparency effect" }, .{
+ .expand = .horizontal,
+ .corner_radius = dvui.Rect.all(1000),
+ });
+ defer dropdown.deinit();
+
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .vertical,
+ .gravity_x = 1.0,
+ });
+
+ const label_text = switch (pa.settings.transparency_effect) {
+ .none => "None",
+ .rainbow => "Rainbow",
+ .animation => "Animation",
+ };
+ dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) });
+
+ dvui.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 });
+
+ hbox.deinit();
+
+ if (dropdown.dropped()) {
+ if (dropdown.addChoiceLabel("None")) {
+ pa.settings.transparency_effect = .none;
+ pa.settings.save(pa.host);
+ dvui.refresh(null, @src(), vbox.data().id);
+ }
+ if (dropdown.addChoiceLabel("Rainbow")) {
+ pa.settings.transparency_effect = .rainbow;
+ pa.settings.save(pa.host);
+ dvui.refresh(null, @src(), vbox.data().id);
+ }
+ if (dropdown.addChoiceLabel("Animation")) {
+ pa.settings.transparency_effect = .animation;
+ pa.settings.save(pa.host);
+ dvui.refresh(null, @src(), vbox.data().id);
+ }
+ }
+
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
+ }
+
+ if (dvui.checkbox(@src(), &pa.settings.show_rulers, "Show Rulers", .{ .expand = .none })) {
+ pa.settings.save(pa.host);
+ }
+
+ if (dvui.checkbox(@src(), &pa.settings.scrolling_cards, "Show sprite cover-flow cards", .{ .expand = .none })) {
+ pa.settings.save(pa.host);
+ }
+ }
+
+ {
+ var box = dvui.groupBox(@src(), "Controls", .{ .expand = .horizontal });
+ defer box.deinit();
+
+ var dropdown: dvui.DropdownWidget = undefined;
+ dropdown.init(@src(), .{ .label = "Control scheme" }, .{
+ .expand = .horizontal,
+ .corner_radius = dvui.Rect.all(1000),
+ });
+ defer dropdown.deinit();
+
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .vertical,
+ .gravity_x = 1.0,
+ });
+
+ const label_text: []const u8 = switch (pa.settings.input_scheme) {
+ .auto => switch (dvui.mouseType()) {
+ // Pre-classification (no scroll events seen yet) — drop the parenthetical.
+ .unknown => "Auto",
+ .mouse, .trackpad => |hint| try std.fmt.allocPrint(dvui.currentWindow().lifo(), "Auto ({s})", .{@tagName(hint)}),
+ },
+ .mouse => "Mouse",
+ .trackpad => "Trackpad",
+ };
+ dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) });
+
+ dvui.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 });
+
+ hbox.deinit();
+
+ if (dropdown.dropped()) {
+ if (dropdown.addChoiceLabel("Auto")) {
+ pa.settings.input_scheme = .auto;
+ pa.settings.save(pa.host);
+ dvui.refresh(null, @src(), vbox.data().id);
+ }
+ if (dropdown.addChoiceLabel("Mouse")) {
+ pa.settings.input_scheme = .mouse;
+ pa.settings.save(pa.host);
+ dvui.refresh(null, @src(), vbox.data().id);
+ }
+ if (dropdown.addChoiceLabel("Trackpad")) {
+ pa.settings.input_scheme = .trackpad;
+ pa.settings.save(pa.host);
+ dvui.refresh(null, @src(), vbox.data().id);
+ }
+ }
+
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } });
+ }
+}
diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig
index bce96316..d05e66ab 100644
--- a/src/plugins/pixelart/dialogs/Export.zig
+++ b/src/plugins/pixelart/dialogs/Export.zig
@@ -536,7 +536,7 @@ fn exportCheckerboardCellCornerColor(
u: f32,
v: f32,
) dvui.Color {
- switch (fizzy.editor.settings.transparency_effect) {
+ switch (fizzy.pixelart.settings.transparency_effect) {
.none => return pal.tone,
.rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone),
.animation => {
diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig
index 8dd50c7d..c56eac5c 100644
--- a/src/plugins/pixelart/dialogs/GridLayout.zig
+++ b/src/plugins/pixelart/dialogs/GridLayout.zig
@@ -128,7 +128,7 @@ fn workspaceCanvasChromeColor() dvui.Color {
switch (builtin.os.tag) {
.macos, .windows => {
content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow()))
- content_color.opacity(fizzy.editor.settings.content_opacity)
+ content_color.opacity(fizzy.pixelart.host.contentOpacity())
else
content_color;
},
@@ -222,7 +222,7 @@ fn drawCheckerboardPreviewTiled(
if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return;
const pal = previewCheckerboardPalette();
- const te = fizzy.editor.settings.transparency_effect;
+ const te = fizzy.pixelart.settings.transparency_effect;
const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0);
const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0);
const nw = cell_w * cols_f;
diff --git a/src/plugins/pixelart/internal/Atlas.zig b/src/plugins/pixelart/internal/Atlas.zig
index d262c0ef..15eb5347 100644
--- a/src/plugins/pixelart/internal/Atlas.zig
+++ b/src/plugins/pixelart/internal/Atlas.zig
@@ -25,8 +25,8 @@ pub fn initCheckerboardTile(atlas: *Atlas) void {
atlas.checkerboard_tile = fizzy.image.checkerboardTile(
alpha_checkerboard_count,
alpha_checkerboard_count,
- fizzy.editor.settings.checker_color_even,
- fizzy.editor.settings.checker_color_odd,
+ fizzy.pixelart.settings.checker_color_even,
+ fizzy.pixelart.settings.checker_color_odd,
);
}
diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/internal/File.zig
index 291cd342..67d5914c 100644
--- a/src/plugins/pixelart/internal/File.zig
+++ b/src/plugins/pixelart/internal/File.zig
@@ -263,8 +263,8 @@ pub fn checkerboardTileTexture(file: *File) ?dvui.Texture {
file.editor.checkerboard_tile = fizzy.image.checkerboardTile(
want.w,
want.h,
- fizzy.editor.settings.checker_color_even,
- fizzy.editor.settings.checker_color_odd,
+ fizzy.pixelart.settings.checker_color_even,
+ fizzy.pixelart.settings.checker_color_odd,
);
return file.editor.checkerboard_tile;
}
diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/panel/sprites.zig
index d844d03d..76c03c15 100644
--- a/src/plugins/pixelart/panel/sprites.zig
+++ b/src/plugins/pixelart/panel/sprites.zig
@@ -275,7 +275,7 @@ pub fn draw(self: *Sprites) !void {
// ---- Animated fit-scale: aim the front sprite at a fraction of the
// pane so several neighbours are visible at once. ----
const scale = blk: {
- const steps = fizzy.editor.settings.zoom_steps;
+ const steps = fizzy.pixelart.settings.zoom_steps;
const sprite_width = src_rect.w;
const sprite_height = src_rect.h;
const target_width = parent.w * 0.34;
@@ -803,7 +803,7 @@ pub fn draw(self: *Sprites) !void {
/// Side cards lift away during playback, while a drawing tool is active, or when
/// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane).
fn sideCardsFlown(playing: bool) bool {
- return playing or drawingToolActive() or !fizzy.editor.settings.scrolling_cards;
+ return playing or drawingToolActive() or !fizzy.pixelart.settings.scrolling_cards;
}
/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee).
@@ -1237,8 +1237,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void {
!fly_forced,
flown,
) and !fly_forced) {
- fizzy.editor.settings.scrolling_cards = !fizzy.editor.settings.scrolling_cards;
- fizzy.editor.markSettingsDirty();
+ fizzy.pixelart.settings.scrolling_cards = !fizzy.pixelart.settings.scrolling_cards;
+ fizzy.pixelart.settings.save(fizzy.pixelart.host);
dvui.refresh(null, @src(), dvui.parentGet().data().id);
}
}
diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig
index 5fdcd2ef..b2745892 100644
--- a/src/plugins/pixelart/plugin.zig
+++ b/src/plugins/pixelart/plugin.zig
@@ -10,6 +10,7 @@ const sdk = fizzy.sdk;
const CanvasData = @import("CanvasData.zig");
const FileWidget = @import("widgets/FileWidget.zig");
const ImageWidget = @import("widgets/ImageWidget.zig");
+const PixelArtSettings = @import("Settings.zig");
const DocHandle = sdk.DocHandle;
const Internal = fizzy.Internal;
@@ -112,7 +113,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
fizzy.perf.canvasPaneDrawn();
- if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) {
+ if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) {
defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{});
chrome.drawRuler(file, .horizontal);
}
@@ -120,7 +121,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
defer canvas_hbox.deinit();
- if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(container.id)) {
+ if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) {
defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{});
chrome.drawRuler(file, .vertical);
}
@@ -165,10 +166,10 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
switch (builtin.os.tag) {
.macos => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
+ content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
},
.windows => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
+ content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
},
else => {},
}
@@ -280,6 +281,12 @@ pub fn register(host: *sdk.Host) !void {
.title = "Sprites",
.draw = drawSpritesPanel,
});
+ try host.registerSettingsSection(.{
+ .id = "pixelart.settings",
+ .owner = &plugin,
+ .title = "Pixel Art",
+ .draw = PixelArtSettings.draw,
+ });
}
fn drawTools(_: ?*anyopaque) anyerror!void {
diff --git a/src/plugins/pixelart/widgets/CanvasBridge.zig b/src/plugins/pixelart/widgets/CanvasBridge.zig
index c2f655d8..93d05774 100644
--- a/src/plugins/pixelart/widgets/CanvasBridge.zig
+++ b/src/plugins/pixelart/widgets/CanvasBridge.zig
@@ -6,7 +6,7 @@ const CanvasWidget = fizzy.dvui.CanvasWidget;
/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum.
pub fn scheme() CanvasWidget.PanZoomScheme {
- return switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) {
+ return switch (fizzy.PixelArt.Settings.resolvedPanZoomScheme(&fizzy.pixelart.settings)) {
.mouse => .mouse,
.trackpad => .trackpad,
};
diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig
index 3fb35d68..bbb79ccc 100644
--- a/src/plugins/pixelart/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/widgets/FileWidget.zig
@@ -3896,7 +3896,7 @@ fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize)
}
fn checkerboardCellCornerColor(
- effect: fizzy.Editor.Settings.TransparencyEffect,
+ effect: fizzy.PixelArt.Settings.TransparencyEffect,
file: *fizzy.Internal.File,
sprite_index: usize,
c_tl: dvui.Color,
@@ -3941,7 +3941,7 @@ fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr:
fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color {
const pal = checkerboardGridPalette();
const tone = pal.tone;
- switch (fizzy.editor.settings.transparency_effect) {
+ switch (fizzy.pixelart.settings.transparency_effect) {
.none => return tone,
.rainbow => {
const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
@@ -3964,7 +3964,7 @@ fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void {
const n = file.spriteCount();
if (n == 0) return;
- const te = fizzy.editor.settings.transparency_effect;
+ const te = fizzy.pixelart.settings.transparency_effect;
const pal = checkerboardGridPalette();
const tone = pal.tone;
const rs = file.editor.canvas.screen_rect_scale;
@@ -4689,7 +4689,7 @@ fn drawCheckerboardReorderFloatingStrip(
const c_tr = pal.c_tr;
const c_bl = pal.c_bl;
const c_br = pal.c_br;
- const te = fizzy.editor.settings.transparency_effect;
+ const te = fizzy.pixelart.settings.transparency_effect;
const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 00bc7bb3..8d6e78cc 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -8,6 +8,7 @@
const std = @import("std");
const Plugin = @import("Plugin.zig");
const regions = @import("regions.zig");
+const ShellApi = @import("ShellApi.zig");
pub const Host = @This();
@@ -15,6 +16,12 @@ pub const SidebarView = regions.SidebarView;
pub const BottomView = regions.BottomView;
pub const CenterProvider = regions.CenterProvider;
pub const MenuContribution = regions.MenuContribution;
+pub const SettingsSection = regions.SettingsSection;
+
+/// Per-plugin opaque settings blobs: plugin id -> serialized JSON. The Host owns the
+/// key + value strings; the shell persists them verbatim under "plugins" in
+/// settings.json and never interprets them.
+pub const PluginSettings = std.StringArrayHashMapUnmanaged([]const u8);
allocator: std.mem.Allocator,
@@ -26,6 +33,13 @@ plugins: std.ArrayListUnmanaged(*Plugin) = .empty,
/// draw per-branch explorer decorations without a compile-time dependency on it.
services: std.StringHashMapUnmanaged(*anyopaque) = .empty,
+/// The shell's read/utility surface (arena, folder, shared settings, dirty mark),
+/// installed by the shell during startup. Null until installed (headless/test).
+shell_api: ?ShellApi = null,
+
+/// Opaque per-plugin settings store (see `PluginSettings`).
+plugin_settings: PluginSettings = .empty,
+
// ---- shell region registries (Phase 2) -------------------------------------
// The shell iterates these instead of hardcoded enums/switches. Items keep their
// registration order, which is the order they appear in the UI.
@@ -38,6 +52,8 @@ bottom_views: std.ArrayListUnmanaged(BottomView) = .empty,
center_providers: std.ArrayListUnmanaged(CenterProvider) = .empty,
/// Menubar contributions (non-macOS in-app menu bar).
menus: std.ArrayListUnmanaged(MenuContribution) = .empty,
+/// Settings sections (Settings view renders each under its title, grouped by owner).
+settings_sections: std.ArrayListUnmanaged(SettingsSection) = .empty,
/// Active selection by contribution id (null = use the first registered).
active_sidebar_view: ?[]const u8 = null,
@@ -55,6 +71,70 @@ pub fn deinit(self: *Host) void {
self.bottom_views.deinit(self.allocator);
self.center_providers.deinit(self.allocator);
self.menus.deinit(self.allocator);
+ self.settings_sections.deinit(self.allocator);
+ {
+ var it = self.plugin_settings.iterator();
+ while (it.next()) |e| {
+ self.allocator.free(e.key_ptr.*);
+ self.allocator.free(e.value_ptr.*);
+ }
+ self.plugin_settings.deinit(self.allocator);
+ }
+}
+
+// ---- shell services (installed by the shell during startup) ----------------
+
+/// Install the shell's read/utility surface. Called once during startup.
+pub fn installShell(self: *Host, api: ShellApi) void {
+ self.shell_api = api;
+}
+
+/// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed.
+pub fn arena(self: *Host) std.mem.Allocator {
+ return self.shell_api.?.arena();
+}
+
+/// Open project root folder, or null when none is open.
+pub fn folder(self: *Host) ?[]const u8 {
+ return if (self.shell_api) |a| a.folder() else null;
+}
+
+/// User palettes folder (config), or null on platforms without one.
+pub fn paletteFolder(self: *Host) ?[]const u8 {
+ return if (self.shell_api) |a| a.paletteFolder() else null;
+}
+
+/// Mark shell settings dirty so the debounced autosave persists them.
+pub fn markSettingsDirty(self: *Host) void {
+ if (self.shell_api) |a| a.markSettingsDirty();
+}
+
+/// Shell-owned content-area opacity (matches the shell chrome). 1.0 if no shell installed.
+pub fn contentOpacity(self: *Host) f32 {
+ return if (self.shell_api) |a| a.contentOpacity() else 1.0;
+}
+
+// ---- per-plugin settings store ---------------------------------------------
+
+/// The stored settings blob for `id` (serialized JSON), or null if none. The returned
+/// slice is owned by the Host and valid until the next `storePluginSettings` for `id`.
+pub fn loadPluginSettings(self: *Host, id: []const u8) ?[]const u8 {
+ return self.plugin_settings.get(id);
+}
+
+/// Store `json` as `id`'s settings blob (replacing any previous), and mark the shell
+/// settings dirty so it persists. The Host copies both `id` and `json`.
+pub fn storePluginSettings(self: *Host, id: []const u8, json: []const u8) !void {
+ const dup = try self.allocator.dupe(u8, json);
+ errdefer self.allocator.free(dup);
+ if (self.plugin_settings.getPtr(id)) |slot| {
+ self.allocator.free(slot.*);
+ slot.* = dup;
+ } else {
+ const key = try self.allocator.dupe(u8, id);
+ try self.plugin_settings.put(self.allocator, key, dup);
+ }
+ self.markSettingsDirty();
}
pub fn registerPlugin(self: *Host, plugin: *Plugin) !void {
@@ -90,6 +170,10 @@ pub fn registerMenu(self: *Host, menu: MenuContribution) !void {
try self.menus.append(self.allocator, menu);
}
+pub fn registerSettingsSection(self: *Host, section: SettingsSection) !void {
+ try self.settings_sections.append(self.allocator, section);
+}
+
// ---- active selection ------------------------------------------------------
pub fn setActiveSidebarView(self: *Host, id: []const u8) void {
diff --git a/src/sdk/ShellApi.zig b/src/sdk/ShellApi.zig
new file mode 100644
index 00000000..7f352758
--- /dev/null
+++ b/src/sdk/ShellApi.zig
@@ -0,0 +1,48 @@
+//! The shell-provided read/utility surface a plugin reaches through the `Host`.
+//!
+//! The shell installs one of these on the `Host` during startup (`Host.installShell`);
+//! plugins call the convenience forwarders on `Host` (e.g. `host.arena()`), which
+//! dispatch through this vtable. It exposes only the genuinely shared shell state a
+//! plugin still needs — the per-frame arena, the open project folder, the few shell-
+//! owned settings plugins read, and the dirty-mark hook — without leaking the concrete
+//! `Editor` type across the SDK boundary.
+const std = @import("std");
+
+const ShellApi = @This();
+
+ctx: *anyopaque,
+vtable: *const VTable,
+
+pub const VTable = struct {
+ /// The shell's per-frame arena allocator (reset every frame; do not free).
+ arena: *const fn (ctx: *anyopaque) std.mem.Allocator,
+ /// The open project root folder, or null when none is open.
+ folder: *const fn (ctx: *anyopaque) ?[]const u8,
+ /// The user palettes folder (config), or null on platforms without one (web).
+ paletteFolder: *const fn (ctx: *anyopaque) ?[]const u8,
+ /// Mark shell settings dirty so the debounced autosave persists them.
+ markSettingsDirty: *const fn (ctx: *anyopaque) void,
+ /// Shell-owned content-area opacity (also drives the shell's own panes); plugins
+ /// read it to match the shell chrome.
+ contentOpacity: *const fn (ctx: *anyopaque) f32,
+};
+
+pub fn arena(self: ShellApi) std.mem.Allocator {
+ return self.vtable.arena(self.ctx);
+}
+
+pub fn folder(self: ShellApi) ?[]const u8 {
+ return self.vtable.folder(self.ctx);
+}
+
+pub fn paletteFolder(self: ShellApi) ?[]const u8 {
+ return self.vtable.paletteFolder(self.ctx);
+}
+
+pub fn markSettingsDirty(self: ShellApi) void {
+ self.vtable.markSettingsDirty(self.ctx);
+}
+
+pub fn contentOpacity(self: ShellApi) f32 {
+ return self.vtable.contentOpacity(self.ctx);
+}
diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig
index cfe89cb7..903254bb 100644
--- a/src/sdk/regions.zig
+++ b/src/sdk/regions.zig
@@ -57,3 +57,16 @@ pub const MenuContribution = struct {
ctx: ?*anyopaque = null,
draw: *const fn (ctx: ?*anyopaque) anyerror!void,
};
+
+/// A settings section. The Settings view renders each registered section under its
+/// own `title` heading, grouped by plugin (VSCode-style). The shell registers its
+/// own "Editor" section; plugins register theirs (e.g. pixel art's canvas/ruler
+/// prefs). `draw` fills the section body with that owner's controls.
+pub const SettingsSection = struct {
+ id: []const u8,
+ owner: ?*Plugin = null,
+ /// Heading shown above this section's controls.
+ title: []const u8,
+ ctx: ?*anyopaque = null,
+ draw: *const fn (ctx: ?*anyopaque) anyerror!void,
+};
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 8390a064..34c9d413 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -8,9 +8,14 @@ pub const Host = @import("Host.zig");
pub const Plugin = @import("Plugin.zig");
pub const DocHandle = @import("DocHandle.zig");
-/// Shell region contribution types (sidebar / bottom / center / menu).
+/// Shell region contribution types (sidebar / bottom / center / menu / settings).
pub const regions = @import("regions.zig");
pub const SidebarView = regions.SidebarView;
pub const BottomView = regions.BottomView;
pub const CenterProvider = regions.CenterProvider;
pub const MenuContribution = regions.MenuContribution;
+pub const SettingsSection = regions.SettingsSection;
+
+/// Shell-provided read/utility surface plugins reach through the `Host`
+/// (arena, folder, shared settings, dirty-marking).
+pub const ShellApi = @import("ShellApi.zig");
From 5913735b6f35d1f998bca6063f5dd8cab051566c Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 13:39:53 -0500
Subject: [PATCH 18/49] Phase 4 stage C ctnd
---
HANDOFF.md | 16 ++++-----
src/editor/Editor.zig | 17 ++++++++--
src/editor/explorer/Explorer.zig | 6 ----
src/plugins/pixelart/Packer.zig | 2 +-
src/plugins/pixelart/PixelArt.zig | 13 ++++++++
src/plugins/pixelart/Project.zig | 10 +++---
src/plugins/pixelart/algorithms/brezenham.zig | 2 +-
src/plugins/pixelart/dialogs/GridLayout.zig | 2 +-
src/plugins/pixelart/explorer/project.zig | 4 +--
src/plugins/pixelart/explorer/tools.zig | 25 +++++++-------
src/plugins/pixelart/internal/Atlas.zig | 6 ++--
src/plugins/pixelart/internal/File.zig | 6 ++--
src/plugins/pixelart/internal/History.zig | 16 ++++-----
src/plugins/pixelart/internal/Layer.zig | 2 +-
src/plugins/pixelart/plugin.zig | 12 +++----
src/plugins/pixelart/render.zig | 2 +-
src/plugins/pixelart/widgets/FileWidget.zig | 8 ++---
src/sdk/{ShellApi.zig => EditorAPI.zig} | 33 +++++++++++++++----
src/sdk/Host.zig | 22 +++++++++++--
src/sdk/sdk.zig | 2 +-
20 files changed, 132 insertions(+), 74 deletions(-)
rename src/sdk/{ShellApi.zig => EditorAPI.zig} (58%)
diff --git a/HANDOFF.md b/HANDOFF.md
index 6380df46..a4a4a5b9 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -63,9 +63,9 @@ interprets them.
opaque per-plugin blob store (id → serialized JSON). `loadPluginSettings(id)` /
`storePluginSettings(id, json)` (the latter dupes + marks shell settings dirty). Host
owns + frees the key/value strings in `deinit`.
- - `shell_api: ?ShellApi` + `installShell(api)` + thin forwarders: `arena()`, `folder()`,
+ - `shell_api: ?EditorAPI` + `installShell(api)` + thin forwarders: `arena()`, `folder()`,
`paletteFolder()`, `markSettingsDirty()`, `contentOpacity()`.
-- **`ShellApi`** (`ShellApi.zig`): vtable + ctx the shell installs so plugins reach shared
+- **`EditorAPI`** (`EditorAPI.zig`): vtable + ctx the shell installs so plugins reach shared
shell state without importing `Editor`. The shell's vtable impl lives in `Editor.zig`
(`shell_api_vtable` + `shellArena`/`shellFolder`/… ; ctx is `*Editor`), installed in
`Editor.postInit`.
@@ -112,7 +112,7 @@ three enums + `resolvedPanZoomScheme`). All ~27 pixel-art read sites repointed t
**`content_opacity` deliberately stays on the shell** — it's also read by `workbench/
Workspace.zig` and `panel/Panel.zig`, so it's genuinely shell-level. Pixel art's 3 reads
-go through `fizzy.pixelart.host.contentOpacity()` (the ShellApi). The pixel-art settings
+go through `fizzy.pixelart.host.contentOpacity()` (the EditorAPI). The pixel-art settings
*UI controls* were removed from `editor/explorer/settings.zig` (the shell "Editor" section
now only has theme/fonts/window+content opacity/hold-timing/debugging).
@@ -258,12 +258,12 @@ backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platfor
1. **`host` (11) — trivial now.** `PixelArt` already holds `host: *sdk.Host` (set in
`init`). Repoint `fizzy.editor.host.setActiveSidebarView/isActiveSidebarView` →
`fizzy.pixelart.host.…`. Pure mechanical, no SDK change.
-2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The ShellApi
+2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The EditorAPI
forwarders already exist: `fizzy.pixelart.host.arena()` / `.folder()` /
`.paletteFolder()`. Repoint `fizzy.editor.arena.allocator()` → `fizzy.pixelart.host.arena()`,
etc. (mind that `arena` callers use `.allocator()`; the forwarder already returns the
`Allocator`).
-3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to ShellApi
+3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to EditorAPI
(shell calls `fizzy.backend.isMaximized(dvui.currentWindow())`). `isMacOS` is just
`core.platform.isMacOS()` — pixel art can call `fizzy.platform.isMacOS()` until Stage D
repoints it to `core` directly; low priority.
@@ -272,7 +272,7 @@ backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platfor
`.scroll_info`). `tools`/`sprites` are pixel-art pane modules; `pinned_palettes`/
`layers_ratio` are pixel-art UI state. These should **move onto `PixelArt`** (like the
settings did), not get an SDK accessor. `rect`/`scroll_info` are shell explorer layout —
- expose via ShellApi or pass into the draw.
+ expose via EditorAPI or pass into the draw.
5. **Native save dialogs (`backend.showSaveFileDialog` ×5, `DialogFileFilter` ×4).** Add a
small SDK surface for "ask the host to run a native save dialog" (native-only; web has
its own path). The save-flow tail (`requestSaveAs`, `pending_*`, `quit_*`, `accept`,
@@ -328,10 +328,10 @@ request). Beyond the Stage A3 changes, the working tree now also has:
- **Stage B:** new `src/plugins/pixelart/PixelArt.zig`; `fizzy.pixelart` global in
`fizzy.zig`; init/deinit wiring in `App.zig`; field removals + ~190 repoints in
`Editor.zig`, `Keybinds.zig`, `workbench/files.zig`, and the pixel-art tree.
-- **Stage C part 1 (settings):** new `src/sdk/ShellApi.zig`,
+- **Stage C part 1 (settings):** new `src/sdk/EditorAPI.zig`,
`src/plugins/pixelart/Settings.zig`; `SettingsSection` in `sdk/regions.zig` + `sdk.zig`;
Host store/forwarders/section-registry in `sdk/Host.zig`; persistence rework in
- `editor/Settings.zig`; ShellApi impl + section iteration in `editor/Editor.zig`; trimmed
+ `editor/Settings.zig`; EditorAPI impl + section iteration in `editor/Editor.zig`; trimmed
`editor/explorer/settings.zig`; settings repoints across the pixel-art tree;
`App.zig` passes the host to `PixelArt.init`.
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index a77b4de4..f5d0ffc6 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -543,15 +543,18 @@ fn drawShellSettingsSection(_: ?*anyopaque) anyerror!void {
try Explorer.settings.draw();
}
-// ---- ShellApi: the shell-provided read/utility surface for plugins ----------
+// ---- EditorAPI: the shell-provided read/utility surface for plugins ----------
// Installed on the Host in `postInit`; `ctx` is this `*Editor`.
-const shell_api_vtable: sdk.ShellApi.VTable = .{
+const shell_api_vtable: sdk.EditorAPI.VTable = .{
.arena = shellArena,
.folder = shellFolder,
.paletteFolder = shellPaletteFolder,
.markSettingsDirty = shellMarkSettingsDirty,
.contentOpacity = shellContentOpacity,
+ .isMaximized = shellIsMaximized,
+ .explorerRect = shellExplorerRect,
+ .explorerVirtualSize = shellExplorerVirtualSize,
};
fn shellCtx(ctx: *anyopaque) *Editor {
@@ -572,6 +575,16 @@ fn shellMarkSettingsDirty(ctx: *anyopaque) void {
fn shellContentOpacity(ctx: *anyopaque) f32 {
return shellCtx(ctx).settings.content_opacity;
}
+fn shellIsMaximized(ctx: *anyopaque) bool {
+ _ = ctx;
+ return fizzy.backend.isMaximized(dvui.currentWindow());
+}
+fn shellExplorerRect(ctx: *anyopaque) dvui.Rect {
+ return shellCtx(ctx).explorer.rect;
+}
+fn shellExplorerVirtualSize(ctx: *anyopaque) dvui.Size {
+ return shellCtx(ctx).explorer.scroll_info.virtual_size;
+}
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void {
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 7b85dade..84ce657f 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -14,15 +14,11 @@ const nfd = @import("nfd");
pub const Explorer = @This();
pub const files = @import("../../plugins/workbench/files.zig");
-pub const Tools = @import("../../plugins/pixelart/explorer/tools.zig");
-pub const Sprites = @import("../../plugins/pixelart/explorer/sprites.zig");
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
pub const project = @import("../../plugins/pixelart/explorer/project.zig");
pub const settings = @import("settings.zig");
-sprites: Sprites = .{},
-tools: Tools = .{},
paned: *fizzy.dvui.PanedWidget = undefined,
scroll_info: dvui.ScrollInfo = .{
.horizontal = .auto,
@@ -30,8 +26,6 @@ scroll_info: dvui.ScrollInfo = .{
rect: dvui.Rect = .{},
rect_screen: dvui.Rect.Physical = .{},
open_branches: std.AutoHashMap(dvui.Id, void) = undefined,
-pinned_palettes: bool = false,
-layers_ratio: f32 = 0.5,
animations_ratio: f32 = 0.5,
closed: bool = false,
diff --git a/src/plugins/pixelart/Packer.zig b/src/plugins/pixelart/Packer.zig
index 00f49532..ff9688ff 100644
--- a/src/plugins/pixelart/Packer.zig
+++ b/src/plugins/pixelart/Packer.zig
@@ -249,7 +249,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
}
pub fn appendProject(packer: *Packer) !void {
- if (fizzy.editor.folder) |root_directory| {
+ if (fizzy.pixelart.host.folder()) |root_directory| {
try recurseFiles(packer, root_directory);
}
}
diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/PixelArt.zig
index d106037d..fec50fa0 100644
--- a/src/plugins/pixelart/PixelArt.zig
+++ b/src/plugins/pixelart/PixelArt.zig
@@ -19,6 +19,8 @@ const Colors = @import("Colors.zig");
const Project = @import("Project.zig");
const Tools = @import("Tools.zig");
const PackJob = @import("PackJob.zig");
+const ToolsPane = @import("explorer/tools.zig");
+const SpritesPane = @import("explorer/sprites.zig");
pub const Settings = @import("Settings.zig");
const PixelArt = @This();
@@ -38,6 +40,17 @@ settings: Settings = .{},
tools: Tools,
colors: Colors = .{},
+/// Explorer sidebar panes (lifted off the shell `Explorer` in Phase 4 Stage C). The "tools"
+/// view (layers + palette) and the "sprites" view (animations/frames) are pixel-art-specific
+/// UI state; the shell only routes the registered sidebar view's `draw` to them.
+tools_pane: ToolsPane = .{},
+sprites_pane: SpritesPane = .{},
+
+/// Whether the palette pane is pinned open in the tools sidebar (pixel-art UI state).
+pinned_palettes: bool = false,
+/// Split ratio between the layers list and the palette in the tools sidebar.
+layers_ratio: f32 = 0.5,
+
/// The open project's `.fizproject` pack config, or null when no project folder is open.
project: ?Project = null,
diff --git a/src/plugins/pixelart/Project.zig b/src/plugins/pixelart/Project.zig
index c3cff907..e9e28ce0 100644
--- a/src/plugins/pixelart/Project.zig
+++ b/src/plugins/pixelart/Project.zig
@@ -22,8 +22,8 @@ pack_on_save: bool = false,
pub fn load(allocator: std.mem.Allocator) !?Project {
if (comptime builtin.target.cpu.arch == .wasm32) return null;
- if (fizzy.editor.folder) |folder| {
- const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" });
+ if (fizzy.pixelart.host.folder()) |folder| {
+ const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" });
if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| {
read = r;
@@ -60,8 +60,8 @@ pub fn load(allocator: std.mem.Allocator) !?Project {
pub fn save(project: *Project) !void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
- if (fizzy.editor.folder) |folder| {
- const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" });
+ if (fizzy.pixelart.host.folder()) |folder| {
+ const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" });
const options = std.json.Stringify.Options{};
const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{
@@ -90,7 +90,7 @@ pub fn exportAssets(project: *Project) !void {
}
// if (project.packed_heightmap_output) |packed_heightmap_output| {
- // const path = try std.fs.path.joinZ(fizzy.editor.arena.allocator(), &.{ parent_folder, packed_heightmap_output });
+ // const path = try std.fs.path.joinZ(fizzy.pixelart.host.arena(), &.{ parent_folder, packed_heightmap_output });
// try fizzy.editor.atlas.save(path, .heightmap);
// }
}
diff --git a/src/plugins/pixelart/algorithms/brezenham.zig b/src/plugins/pixelart/algorithms/brezenham.zig
index 46f2061b..4d114cc8 100644
--- a/src/plugins/pixelart/algorithms/brezenham.zig
+++ b/src/plugins/pixelart/algorithms/brezenham.zig
@@ -4,7 +4,7 @@ const dvui = @import("dvui");
pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point {
// Bresenham's line algorithm for integer grid points
- var output = std.array_list.Managed(dvui.Point).init(fizzy.editor.arena.allocator());
+ var output = std.array_list.Managed(dvui.Point).init(fizzy.pixelart.host.arena());
// Round input points to nearest integer grid
const x0: i32 = @intFromFloat(@floor(start.x));
diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/dialogs/GridLayout.zig
index c56eac5c..d047845f 100644
--- a/src/plugins/pixelart/dialogs/GridLayout.zig
+++ b/src/plugins/pixelart/dialogs/GridLayout.zig
@@ -127,7 +127,7 @@ fn workspaceCanvasChromeColor() dvui.Color {
var content_color = dvui.themeGet().color(.window, .fill);
switch (builtin.os.tag) {
.macos, .windows => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow()))
+ content_color = if (!fizzy.pixelart.host.isMaximized())
content_color.opacity(fizzy.pixelart.host.contentOpacity())
else
content_color;
diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig
index 0620a7b8..8988377c 100644
--- a/src/plugins/pixelart/explorer/project.zig
+++ b/src/plugins/pixelart/explorer/project.zig
@@ -14,7 +14,7 @@ pub fn draw() !void {
return;
}
- if (fizzy.editor.folder) |folder| {
+ if (fizzy.pixelart.host.folder()) |folder| {
if (fizzy.pixelart.project) |_| {
const tl = dvui.textLayout(@src(), .{}, .{
.expand = .none,
@@ -34,7 +34,7 @@ pub fn draw() !void {
} else {
var box = dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .horizontal,
- .max_size_content = .{ .w = fizzy.editor.explorer.scroll_info.virtual_size.w, .h = std.math.floatMax(f32) },
+ .max_size_content = .{ .w = fizzy.pixelart.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) },
});
defer box.deinit();
diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig
index a60d59ef..d3da9a3e 100644
--- a/src/plugins/pixelart/explorer/tools.zig
+++ b/src/plugins/pixelart/explorer/tools.zig
@@ -81,7 +81,7 @@ pub fn draw(self: *Tools) !void {
if (paned.dragging) {
max_split_ratio = paned.split_ratio.*;
- fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
+ fizzy.pixelart.layers_ratio = paned.split_ratio.*;
}
if (paned.showFirst()) {
@@ -97,7 +97,7 @@ pub fn draw(self: *Tools) !void {
const autofit = !paned.dragging and !paned.collapsed_state and !paned.animating;
// Refit must be done between showFirst and showSecond
- if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.editor.explorer.pinned_palettes) {
+ if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.pixelart.pinned_palettes) {
if (dvui.firstFrame(paned.data().id) and layer_count == 0)
paned.split_ratio.* = 0.0;
@@ -108,7 +108,7 @@ pub fn draw(self: *Tools) !void {
// next frame when min sizes are valid.
if (dvui.firstFrame(paned.data().id) and layer_count > 0) {
paned.split_ratio.* = 0.01;
- //fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
+ //fizzy.pixelart.layers_ratio = paned.split_ratio.*;
} else {
const ratio = paned.getFirstFittedRatio(
.{
@@ -129,9 +129,9 @@ pub fn draw(self: *Tools) !void {
if (layer_count == 0)
paned.split_ratio.* = 0.0
else
- paned.split_ratio.* = fizzy.editor.explorer.layers_ratio;
+ paned.split_ratio.* = fizzy.pixelart.layers_ratio;
- fizzy.editor.explorer.layers_ratio = paned.split_ratio.*;
+ fizzy.pixelart.layers_ratio = paned.split_ratio.*;
}
}
@@ -589,7 +589,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
if (file.editor.isolate_layer) {
if (file.peek_layer_index) |peek_layer_index| {
min_layer_index = peek_layer_index;
- } else if (!fizzy.editor.explorer.tools.layersHovered()) {
+ } else if (!fizzy.pixelart.tools_pane.layersHovered()) {
min_layer_index = file.selected_layer_index;
}
}
@@ -1069,9 +1069,9 @@ pub fn drawPaletteControls() !void {
.corner_radius = dvui.Rect.all(1000),
},
.rotation = std.math.pi * 0.25,
- .style = if (fizzy.editor.explorer.pinned_palettes) .highlight else .control,
+ .style = if (fizzy.pixelart.pinned_palettes) .highlight else .control,
})) {
- fizzy.editor.explorer.pinned_palettes = !fizzy.editor.explorer.pinned_palettes;
+ fizzy.pixelart.pinned_palettes = !fizzy.pixelart.pinned_palettes;
}
}
@@ -1161,8 +1161,8 @@ pub fn drawPalettes() !void {
var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{
.expand = .horizontal,
.max_size_content = .{
- .w = fizzy.editor.explorer.rect.w - 20 * dvui.currentWindow().natural_scale,
- .h = fizzy.editor.explorer.rect.h - 20 * dvui.currentWindow().natural_scale,
+ .w = fizzy.pixelart.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale,
+ .h = fizzy.pixelart.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale,
},
});
@@ -1267,7 +1267,8 @@ pub fn drawPalettes() !void {
}
fn searchPalettes(dropdown: *dvui.DropdownWidget) !void {
const io = dvui.io;
- var dir_opt = std.Io.Dir.cwd().openDir(io, fizzy.editor.palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null;
+ const palette_folder = fizzy.pixelart.host.paletteFolder() orelse return;
+ var dir_opt = std.Io.Dir.cwd().openDir(io, palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null;
if (dir_opt) |*dir| {
defer dir.close(io);
var iter = dir.iterate();
@@ -1277,7 +1278,7 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void {
if (std.mem.eql(u8, ext, ".hex")) {
const label = try std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{entry.name});
if (dropdown.addChoiceLabel(label)) {
- const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ fizzy.editor.palette_folder, entry.name });
+ const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ palette_folder, entry.name });
if (fizzy.pixelart.colors.palette) |*palette|
palette.deinit();
diff --git a/src/plugins/pixelart/internal/Atlas.zig b/src/plugins/pixelart/internal/Atlas.zig
index 15eb5347..bf8a25b6 100644
--- a/src/plugins/pixelart/internal/Atlas.zig
+++ b/src/plugins/pixelart/internal/Atlas.zig
@@ -48,7 +48,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
// below writes through `std.Io.Dir.cwd()` which requires `posix.AT` (not
// available on `wasm32-freestanding`).
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- const allocator = fizzy.editor.arena.allocator();
+ const allocator = fizzy.pixelart.host.arena();
switch (selector) {
.source => {
const ext = std.fs.path.extension(path);
@@ -83,7 +83,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
switch (selector) {
.source => {
const ext = std.fs.path.extension(path);
- const write_path = std.fmt.allocPrintSentinel(fizzy.editor.arena.allocator(), "{s}", .{path}, 0) catch unreachable;
+ const write_path = std.fmt.allocPrintSentinel(fizzy.pixelart.host.arena(), "{s}", .{path}, 0) catch unreachable;
if (std.mem.eql(u8, ext, ".png")) {
try fizzy.image.writeToPng(atlas.source, write_path);
@@ -101,7 +101,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
}
const options: std.json.Stringify.Options = .{};
- const output = try std.json.Stringify.valueAlloc(fizzy.editor.arena.allocator(), atlas.data, options);
+ const output = try std.json.Stringify.valueAlloc(fizzy.pixelart.host.arena(), atlas.data, options);
std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = output }) catch return error.CouldNotWriteAtlasData;
},
diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/internal/File.zig
index 67d5914c..d72ef320 100644
--- a/src/plugins/pixelart/internal/File.zig
+++ b/src/plugins/pixelart/internal/File.zig
@@ -2682,7 +2682,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i:
.dest_pixels_before = dest_pixels_before,
.dest_mask_before = dest_mask_before,
} });
- fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
}
pub fn duplicateLayer(self: *File, index: usize) !u64 {
@@ -2798,7 +2798,7 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void {
var ext = try self.external(fizzy.app.allocator);
defer ext.deinit(fizzy.app.allocator);
- const output_path = try fizzy.editor.arena.allocator().dupeZ(u8, self.path);
+ const output_path = try fizzy.pixelart.host.arena().dupeZ(u8, self.path);
var handle = try std.fs.cwd().createFile(output_path, .{});
defer handle.close();
@@ -2826,7 +2826,7 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void {
else => return error.InvalidImageSource,
};
- try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.editor.arena.allocator(), "{s}.layer", .{layer.name}), data, .{});
+ try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.pixelart.host.arena(), "{s}.layer", .{layer.name}), data, .{});
}
}
diff --git a/src/plugins/pixelart/internal/History.zig b/src/plugins/pixelart/internal/History.zig
index 240c515f..45025e7c 100644
--- a/src/plugins/pixelart/internal/History.zig
+++ b/src/plugins/pixelart/internal/History.zig
@@ -388,7 +388,7 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
file.editor.layer_composite_dirty = true;
file.editor.split_composite_dirty = true;
file.selected_layer_index = lm.source_index;
- fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
@@ -430,7 +430,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
.up => dest_i,
.down => dest_i - 1,
};
- fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
@@ -619,7 +619,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
//try file.editor.selected_sprites.append(sprite_index);
}
- fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites);
},
.layers_order => |*layers_order| {
file.editor.layer_composite_dirty = true;
@@ -674,7 +674,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
layer_restore_delete.action = .restore;
},
}
- fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
},
.layer_name => |*layer_name| {
@@ -682,7 +682,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]);
file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name);
layer_name.name = name;
- fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
},
.layer_settings => |*layer_settings| {
const idx = layer_settings.index;
@@ -701,7 +701,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
if (visibility_changed) {
file.editor.split_composite_dirty = true;
}
- fizzy.editor.host.setActiveSidebarView(pixelart.view_tools);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
},
.animation_restore_delete => |*animation_restore_delete| {
const a = animation_restore_delete.action;
@@ -727,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
}
},
}
- fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites);
},
.animation_name => |*animation_name| {
const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]);
fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]);
file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name);
animation_name.name = name;
- fizzy.editor.host.setActiveSidebarView(pixelart.view_sprites);
+ fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites);
},
.animation_settings => {},
.animation_order => |*animation_order| {
diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/internal/Layer.zig
index a4aedfc1..21a4fe60 100644
--- a/src/plugins/pixelart/internal/Layer.zig
+++ b/src/plugins/pixelart/internal/Layer.zig
@@ -412,7 +412,7 @@ pub fn writeSourceToZip(
const w = @as(c_int, @intFromFloat(s.w));
const h = @as(c_int, @intFromFloat(s.h));
- var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator());
+ var writer = std.Io.Writer.Allocating.init(fizzy.pixelart.host.arena());
try fizzy.image.ensurePngWriterBuffer(&writer.writer);
try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution);
diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/plugin.zig
index b2745892..6a1934bb 100644
--- a/src/plugins/pixelart/plugin.zig
+++ b/src/plugins/pixelart/plugin.zig
@@ -166,10 +166,10 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
switch (builtin.os.tag) {
.macos => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
+ content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
},
.windows => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
+ content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
},
else => {},
}
@@ -177,7 +177,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32)
fizzy.packer.atlas != null
else
- fizzy.editor.folder != null and fizzy.packer.atlas != null;
+ fizzy.pixelart.host.folder() != null and fizzy.packer.atlas != null;
// Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage).
var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping);
@@ -218,7 +218,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32)
"Pack open files to see the preview."
- else if (fizzy.editor.folder == null)
+ else if (fizzy.pixelart.host.folder() == null)
"Open a project folder, then pack to see the preview."
else
"Pack the project to see the preview.";
@@ -290,10 +290,10 @@ pub fn register(host: *sdk.Host) !void {
}
fn drawTools(_: ?*anyopaque) anyerror!void {
- try fizzy.editor.explorer.tools.draw();
+ try fizzy.pixelart.tools_pane.draw();
}
fn drawSprites(_: ?*anyopaque) anyerror!void {
- try fizzy.editor.explorer.sprites.draw();
+ try fizzy.pixelart.sprites_pane.draw();
}
fn drawProject(_: ?*anyopaque) anyerror!void {
try fizzy.Editor.Explorer.project.draw();
diff --git a/src/plugins/pixelart/render.zig b/src/plugins/pixelart/render.zig
index 14a46745..a3ec3daf 100644
--- a/src/plugins/pixelart/render.zig
+++ b/src/plugins/pixelart/render.zig
@@ -112,7 +112,7 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde
if (init_opts.file.editor.isolate_layer) {
if (init_opts.file.peek_layer_index) |peek_layer_index| {
min_layer_index = peek_layer_index;
- } else if (!fizzy.editor.explorer.tools.layersHovered()) {
+ } else if (!fizzy.pixelart.tools_pane.layersHovered()) {
min_layer_index = init_opts.file.selected_layer_index;
}
}
diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig
index bbb79ccc..4f3e1143 100644
--- a/src/plugins/pixelart/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/widgets/FileWidget.zig
@@ -273,7 +273,7 @@ pub fn sampleColorAtPoint(
if (file.editor.isolate_layer) {
if (file.peek_layer_index) |peek_layer_index| {
min_layer_index = peek_layer_index;
- } else if (!fizzy.editor.explorer.tools.layersHovered()) {
+ } else if (!fizzy.pixelart.tools_pane.layersHovered()) {
min_layer_index = file.selected_layer_index;
}
}
@@ -1642,8 +1642,8 @@ pub fn drawSpriteBubble(
self.init_options.file.selected_animation_index = anim_index;
self.init_options.file.collapseAnimationSelectionToPrimary();
self.init_options.file.editor.animations_scroll_to_index = anim_index;
- fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
- fizzy.editor.host.setActiveSidebarView(@import("../plugin.zig").view_sprites);
+ fizzy.pixelart.sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
+ fizzy.pixelart.host.setActiveSidebarView(@import("../plugin.zig").view_sprites);
var anim = self.init_options.file.animations.get(anim_index);
if (anim.frames.len == 0) {
@@ -4626,7 +4626,7 @@ pub fn drawLayers(self: *FileWidget) void {
const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect);
// Draw the origins when in the sprites pane
- if (fizzy.editor.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) {
+ if (fizzy.pixelart.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) {
const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] };
const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y };
diff --git a/src/sdk/ShellApi.zig b/src/sdk/EditorAPI.zig
similarity index 58%
rename from src/sdk/ShellApi.zig
rename to src/sdk/EditorAPI.zig
index 7f352758..f94f2e2a 100644
--- a/src/sdk/ShellApi.zig
+++ b/src/sdk/EditorAPI.zig
@@ -7,8 +7,9 @@
//! owned settings plugins read, and the dirty-mark hook — without leaking the concrete
//! `Editor` type across the SDK boundary.
const std = @import("std");
+const dvui = @import("dvui");
-const ShellApi = @This();
+const EditorAPI = @This();
ctx: *anyopaque,
vtable: *const VTable,
@@ -25,24 +26,44 @@ pub const VTable = struct {
/// Shell-owned content-area opacity (also drives the shell's own panes); plugins
/// read it to match the shell chrome.
contentOpacity: *const fn (ctx: *anyopaque) f32,
+ /// Whether the OS window is currently maximized (always false on web).
+ isMaximized: *const fn (ctx: *anyopaque) bool,
+ /// The explorer pane's content rect (shell layout); plugins drawn inside the explorer
+ /// read it to size their content. Zero rect when no shell is installed.
+ explorerRect: *const fn (ctx: *anyopaque) dvui.Rect,
+ /// The explorer scroll area's virtual content size (shell layout). Zero size when no
+ /// shell is installed.
+ explorerVirtualSize: *const fn (ctx: *anyopaque) dvui.Size,
};
-pub fn arena(self: ShellApi) std.mem.Allocator {
+pub fn arena(self: EditorAPI) std.mem.Allocator {
return self.vtable.arena(self.ctx);
}
-pub fn folder(self: ShellApi) ?[]const u8 {
+pub fn folder(self: EditorAPI) ?[]const u8 {
return self.vtable.folder(self.ctx);
}
-pub fn paletteFolder(self: ShellApi) ?[]const u8 {
+pub fn paletteFolder(self: EditorAPI) ?[]const u8 {
return self.vtable.paletteFolder(self.ctx);
}
-pub fn markSettingsDirty(self: ShellApi) void {
+pub fn markSettingsDirty(self: EditorAPI) void {
self.vtable.markSettingsDirty(self.ctx);
}
-pub fn contentOpacity(self: ShellApi) f32 {
+pub fn contentOpacity(self: EditorAPI) f32 {
return self.vtable.contentOpacity(self.ctx);
}
+
+pub fn isMaximized(self: EditorAPI) bool {
+ return self.vtable.isMaximized(self.ctx);
+}
+
+pub fn explorerRect(self: EditorAPI) dvui.Rect {
+ return self.vtable.explorerRect(self.ctx);
+}
+
+pub fn explorerVirtualSize(self: EditorAPI) dvui.Size {
+ return self.vtable.explorerVirtualSize(self.ctx);
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 8d6e78cc..33bdec8e 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -6,9 +6,10 @@
//! Phase 0: holds the plugin registry + service locator. Nothing is registered
//! yet — the existing pixel-art code still uses globals directly.
const std = @import("std");
+const dvui = @import("dvui");
const Plugin = @import("Plugin.zig");
const regions = @import("regions.zig");
-const ShellApi = @import("ShellApi.zig");
+const EditorAPI = @import("EditorAPI.zig");
pub const Host = @This();
@@ -35,7 +36,7 @@ services: std.StringHashMapUnmanaged(*anyopaque) = .empty,
/// The shell's read/utility surface (arena, folder, shared settings, dirty mark),
/// installed by the shell during startup. Null until installed (headless/test).
-shell_api: ?ShellApi = null,
+shell_api: ?EditorAPI = null,
/// Opaque per-plugin settings store (see `PluginSettings`).
plugin_settings: PluginSettings = .empty,
@@ -85,7 +86,7 @@ pub fn deinit(self: *Host) void {
// ---- shell services (installed by the shell during startup) ----------------
/// Install the shell's read/utility surface. Called once during startup.
-pub fn installShell(self: *Host, api: ShellApi) void {
+pub fn installShell(self: *Host, api: EditorAPI) void {
self.shell_api = api;
}
@@ -114,6 +115,21 @@ pub fn contentOpacity(self: *Host) f32 {
return if (self.shell_api) |a| a.contentOpacity() else 1.0;
}
+/// Whether the OS window is currently maximized. False if no shell installed (headless/web).
+pub fn isMaximized(self: *Host) bool {
+ return if (self.shell_api) |a| a.isMaximized() else false;
+}
+
+/// The explorer pane's content rect (shell layout). Zero rect if no shell installed.
+pub fn explorerRect(self: *Host) dvui.Rect {
+ return if (self.shell_api) |a| a.explorerRect() else .{};
+}
+
+/// The explorer scroll area's virtual content size (shell layout). Zero size if no shell installed.
+pub fn explorerVirtualSize(self: *Host) dvui.Size {
+ return if (self.shell_api) |a| a.explorerVirtualSize() else .{};
+}
+
// ---- per-plugin settings store ---------------------------------------------
/// The stored settings blob for `id` (serialized JSON), or null if none. The returned
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 34c9d413..7e5e6992 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -18,4 +18,4 @@ pub const SettingsSection = regions.SettingsSection;
/// Shell-provided read/utility surface plugins reach through the `Host`
/// (arena, folder, shared settings, dirty-marking).
-pub const ShellApi = @import("ShellApi.zig");
+pub const EditorAPI = @import("EditorAPI.zig");
From 001dfec30a11f74a041eb5ae1aa0cca8fb3c8fc3 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 15:09:42 -0500
Subject: [PATCH 19/49] phase 4 stage c ctnd
---
src/core/Atlas.zig | 34 ++++++++
src/core/Sprite.zig | 91 +++++++++++++++++++++
src/core/core.zig | 6 ++
src/editor/Editor.zig | 41 ++++++++--
src/fizzy.zig | 4 +-
src/plugins/pixelart/Project.zig | 8 +-
src/plugins/pixelart/Tools.zig | 10 +--
src/plugins/pixelart/dialogs/Export.zig | 16 ++--
src/plugins/pixelart/explorer/project.zig | 2 +-
src/plugins/pixelart/explorer/tools.zig | 18 ++--
src/plugins/pixelart/sprite_render.zig | 11 ++-
src/plugins/pixelart/widgets/FileWidget.zig | 16 ++--
src/plugins/workbench/Workspace.zig | 6 +-
src/plugins/workbench/files.zig | 12 +--
src/sdk/EditorAPI.zig | 50 +++++++++++
src/sdk/Host.zig | 16 ++++
src/sdk/sdk.zig | 4 +
17 files changed, 284 insertions(+), 61 deletions(-)
create mode 100644 src/core/Atlas.zig
create mode 100644 src/core/Sprite.zig
diff --git a/src/core/Atlas.zig b/src/core/Atlas.zig
new file mode 100644
index 00000000..060995f9
--- /dev/null
+++ b/src/core/Atlas.zig
@@ -0,0 +1,34 @@
+//! A loaded spritesheet: GPU `source` texture + indexed sprite metadata.
+//!
+//! The shell's `editor.atlas` uses this minimal type for UI icons. The pixel-art
+//! plugin's packed output uses the richer `Internal.Atlas` instead.
+const std = @import("std");
+const dvui = @import("dvui");
+
+const Sprite = @import("Sprite.zig");
+
+const Atlas = @This();
+
+source: dvui.ImageSource,
+sprites: []Sprite,
+
+const SpritesOnly = struct {
+ sprites: []Sprite,
+};
+
+/// Parse a `.atlas` JSON blob and return a duped sprite table. Animations and
+/// other fields are ignored (`ignore_unknown_fields`).
+pub fn loadSpritesFromBytes(allocator: std.mem.Allocator, bytes: []const u8) ![]Sprite {
+ const options: std.json.ParseOptions = .{
+ .ignore_unknown_fields = true,
+ .allocate = .alloc_if_needed,
+ };
+ var parsed = try std.json.parseFromSlice(SpritesOnly, allocator, bytes, options);
+ defer parsed.deinit();
+ return try allocator.dupe(Sprite, parsed.value.sprites);
+}
+
+pub fn deinit(self: *Atlas, allocator: std.mem.Allocator) void {
+ allocator.free(self.sprites);
+ self.sprites = &.{};
+}
diff --git a/src/core/Sprite.zig b/src/core/Sprite.zig
new file mode 100644
index 00000000..e71d8c49
--- /dev/null
+++ b/src/core/Sprite.zig
@@ -0,0 +1,91 @@
+//! A sub-rect within an atlas texture: pixel `source` rect + optional `origin`.
+//!
+//! Used by the shell for UI icons and by the pixel-art renderer as the sprite-rect
+//! type. Distinct from the plugin's build-time `Atlas.zig` (JSON loader with animations).
+const std = @import("std");
+const dvui = @import("dvui");
+
+const Sprite = @This();
+
+origin: [2]f32 = .{ 0.0, 0.0 },
+source: [4]u32,
+
+/// Draw this sprite from `atlas_source` as a dvui widget (static textured quad).
+pub fn draw(
+ self: Sprite,
+ src: std.builtin.SourceLocation,
+ atlas_source: dvui.ImageSource,
+ scale: f32,
+ opts: dvui.Options,
+) dvui.WidgetData {
+ const source_size: dvui.Size = dvui.imageSize(atlas_source) catch .{ .w = 0, .h = 0 };
+
+ const uv = dvui.Rect{
+ .x = @as(f32, @floatFromInt(self.source[0])) / source_size.w,
+ .y = @as(f32, @floatFromInt(self.source[1])) / source_size.h,
+ .w = @as(f32, @floatFromInt(self.source[2])) / source_size.w,
+ .h = @as(f32, @floatFromInt(self.source[3])) / source_size.h,
+ };
+
+ const options = (dvui.Options{ .name = "sprite" }).override(opts);
+
+ const size: dvui.Size = if (options.min_size_content) |msc| msc else .{
+ .w = @as(f32, @floatFromInt(self.source[2])) * scale,
+ .h = @as(f32, @floatFromInt(self.source[3])) * scale,
+ };
+
+ var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size }));
+ wd.register();
+
+ const cr = wd.contentRect();
+ const ms = wd.options.min_size_contentGet();
+
+ var too_big = false;
+ if (ms.w > cr.w or ms.h > cr.h) too_big = true;
+
+ var e = wd.options.expandGet();
+ const g = wd.options.gravityGet();
+ var rect = dvui.placeIn(cr, ms, e, g);
+
+ if (too_big and e != .ratio) {
+ if (ms.w > cr.w and !e.isHorizontal()) {
+ rect.w = ms.w;
+ rect.x -= g.x * (ms.w - cr.w);
+ }
+ if (ms.h > cr.h and !e.isVertical()) {
+ rect.h = ms.h;
+ rect.y -= g.y * (ms.h - cr.h);
+ }
+ }
+
+ wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet());
+
+ if (wd.options.rotationGet() == 0.0) {
+ wd.borderAndBackground(.{});
+ } else if (wd.options.borderGet().nonZero()) {
+ dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id});
+ }
+
+ const rs = wd.contentRectScale();
+ dvui.renderImage(atlas_source, rs, .{
+ .uv = uv,
+ .fade = 0.0,
+ }) catch {
+ dvui.log.err("Failed to render sprite", .{});
+ };
+
+ if (opts.color_border) |border| {
+ var path: dvui.Path.Builder = .init(dvui.currentWindow().arena());
+ defer path.deinit();
+ const r = wd.contentRectScale().r;
+ path.addPoint(r.topLeft());
+ path.addPoint(r.topRight());
+ path.addPoint(r.bottomRight());
+ path.addPoint(r.bottomLeft());
+ path.build().stroke(.{ .color = border, .thickness = 1.0, .closed = true });
+ }
+
+ wd.minSizeSetAndRefresh();
+ wd.minSizeReportToParent();
+ return wd;
+}
diff --git a/src/core/core.zig b/src/core/core.zig
index 7eb7b3f3..2fd4cd4b 100644
--- a/src/core/core.zig
+++ b/src/core/core.zig
@@ -37,3 +37,9 @@ pub const dvui = @import("dvui.zig");
/// Generic momentum/fling helper (pan, scrub, cover-flow).
pub const Fling = @import("Fling.zig");
+
+/// Generic sprite sub-rect within an atlas texture.
+pub const Sprite = @import("Sprite.zig");
+
+/// Generic loaded spritesheet (`source` texture + sprite table).
+pub const Atlas = @import("Atlas.zig");
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index f5d0ffc6..a90ea23e 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -54,7 +54,7 @@ arena: std.heap.ArenaAllocator,
config_folder: []const u8,
palette_folder: []const u8,
-atlas: fizzy.Internal.Atlas,
+atlas: fizzy.core.Atlas,
/// Plugin registry + service locator exposed to plugins
host: Host,
@@ -250,7 +250,7 @@ pub fn init(
.arena = .init(std.heap.page_allocator),
.last_titlebar_color = dvui.themeGet().color(.control, .fill),
.atlas = .{
- .data = try .loadFromBytes(app.allocator, assets.files.@"fizzy.atlas"),
+ .sprites = try fizzy.core.Atlas.loadSpritesFromBytes(app.allocator, assets.files.@"fizzy.atlas"),
.source = try fizzy.image.fromImageFileBytes("fizzy.png", assets.files.@"fizzy.png", .ptr),
},
.themes = .empty,
@@ -555,6 +555,8 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.isMaximized = shellIsMaximized,
.explorerRect = shellExplorerRect,
.explorerVirtualSize = shellExplorerVirtualSize,
+ .showSaveDialog = shellShowSaveDialog,
+ .uiAtlas = shellUiAtlas,
};
fn shellCtx(ctx: *anyopaque) *Editor {
@@ -585,6 +587,25 @@ fn shellExplorerRect(ctx: *anyopaque) dvui.Rect {
fn shellExplorerVirtualSize(ctx: *anyopaque) dvui.Size {
return shellCtx(ctx).explorer.scroll_info.virtual_size;
}
+fn shellShowSaveDialog(
+ ctx: *anyopaque,
+ cb: sdk.EditorAPI.SaveDialogCallback,
+ filters: []const sdk.EditorAPI.SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+) void {
+ _ = ctx;
+ // `SaveDialogFilter` shares `DialogFileFilter`'s layout, so the slice forwards as-is.
+ const native_filters: [*]const fizzy.backend.DialogFileFilter = @ptrCast(filters.ptr);
+ fizzy.backend.showSaveFileDialog(cb, native_filters[0..filters.len], default_filename, default_folder);
+}
+fn shellUiAtlas(ctx: *anyopaque) sdk.EditorAPI.UiAtlasView {
+ const atlas = &shellCtx(ctx).atlas;
+ return .{
+ .source = atlas.source,
+ .sprites = @as([]const sdk.EditorAPI.UiSprite, @ptrCast(atlas.sprites)),
+ };
+}
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void {
@@ -1670,16 +1691,16 @@ pub fn drawRadialMenu(editor: *Editor) !void {
}
const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
- .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
- .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
- .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
+ .box => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.box_selection_default],
+ .pixel => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pixel_selection_default],
+ .color => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.color_selection_default],
};
const sprite = switch (@as(Editor.Tools.Tool, @enumFromInt(i))) {
- .pointer => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.cursor_default],
- .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default],
- .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default],
- .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default],
+ .pointer => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.cursor_default],
+ .pencil => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pencil_default],
+ .eraser => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.eraser_default],
+ .bucket => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.bucket_default],
.selection => selection_sprite,
};
const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 1, .h = 1 };
@@ -3536,6 +3557,8 @@ pub fn deinit(editor: *Editor) !void {
editor.ignore.deinit(fizzy.app.allocator);
+ editor.atlas.deinit(fizzy.app.allocator);
+
if (editor.folder) |folder| fizzy.app.allocator.free(folder);
editor.arena.deinit();
}
diff --git a/src/fizzy.zig b/src/fizzy.zig
index dfb406e1..561e3b0b 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -22,8 +22,8 @@ pub const fs = core.fs;
pub const image = core.image;
pub const render = @import("plugins/pixelart/render.zig");
-/// Atlas-consumer sprite rendering library (lives in the pixel-art plugin,
-/// consumed by the shell/workbench to draw sprites from a packed atlas).
+/// Pixel-art sprite renderer (layer compositing, reflections, cover-flow). Shell UI
+/// icons use `fizzy.core.Sprite.draw` from core instead.
pub const sprite_render = @import("plugins/pixelart/sprite_render.zig");
pub const perf = core.perf;
pub const water_surface = core.water_surface;
diff --git a/src/plugins/pixelart/Project.zig b/src/plugins/pixelart/Project.zig
index e9e28ce0..7d85d568 100644
--- a/src/plugins/pixelart/Project.zig
+++ b/src/plugins/pixelart/Project.zig
@@ -81,17 +81,19 @@ pub fn save(project: *Project) !void {
/// Project output assets will be exported to a join of parent_folder and the individual output paths for each asset
pub fn exportAssets(project: *Project) !void {
+ const atlas = fizzy.packer.atlas orelse return;
+
if (project.packed_atlas_output) |packed_atlas_output| {
- try fizzy.editor.atlas.save(packed_atlas_output, .data);
+ try atlas.save(packed_atlas_output, .data);
}
if (project.packed_image_output) |packed_image_output| {
- try fizzy.editor.atlas.save(packed_image_output, .source);
+ try atlas.save(packed_image_output, .source);
}
// if (project.packed_heightmap_output) |packed_heightmap_output| {
// const path = try std.fs.path.joinZ(fizzy.pixelart.host.arena(), &.{ parent_folder, packed_heightmap_output });
- // try fizzy.editor.atlas.save(path, .heightmap);
+ // try atlas.save(path, .heightmap);
// }
}
diff --git a/src/plugins/pixelart/Tools.zig b/src/plugins/pixelart/Tools.zig
index 9f00c6a6..2dc496b1 100644
--- a/src/plugins/pixelart/Tools.zig
+++ b/src/plugins/pixelart/Tools.zig
@@ -334,7 +334,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
});
defer mode_row.deinit();
- const atlas_size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 };
+ const atlas_size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
var mode_color = dvui.themeGet().color(.control, .fill_hover);
if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
@@ -377,9 +377,9 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
defer mode_col.deinit();
const sprite = switch (mode) {
- .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
- .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
- .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
+ .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default],
+ .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default],
+ .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default],
};
const uv = dvui.Rect{
.x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w,
@@ -430,7 +430,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
rs.r.w = width;
rs.r.h = height;
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
+ dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{
.uv = uv,
.fade = 0.0,
}) catch {
diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/dialogs/Export.zig
index d05e66ab..a6a1fd64 100644
--- a/src/plugins/pixelart/dialogs/Export.zig
+++ b/src/plugins/pixelart/dialogs/Export.zig
@@ -281,9 +281,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
break :blk default_filename;
};
- fizzy.backend.showSaveFileDialog(
+ fizzy.pixelart.host.showSaveDialog(
saveAnimationCallback,
- &[_]fizzy.backend.DialogFileFilter{.{ .name = "GIF", .pattern = "gif" }},
+ &[_]fizzy.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }},
default,
null, // Passing null here means use the last save folder location
);
@@ -304,9 +304,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
};
defer fizzy.app.allocator.free(default);
- fizzy.backend.showSaveFileDialog(
+ fizzy.pixelart.host.showSaveDialog(
exportCurrentSpriteCallback,
- &[_]fizzy.backend.DialogFileFilter{
+ &[_]fizzy.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -328,9 +328,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
};
defer fizzy.app.allocator.free(default);
- fizzy.backend.showSaveFileDialog(
+ fizzy.pixelart.host.showSaveDialog(
exportLayerCallback,
- &[_]fizzy.backend.DialogFileFilter{
+ &[_]fizzy.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -352,9 +352,9 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
};
defer fizzy.app.allocator.free(default);
- fizzy.backend.showSaveFileDialog(
+ fizzy.pixelart.host.showSaveDialog(
exportAllCallback,
- &[_]fizzy.backend.DialogFileFilter{
+ &[_]fizzy.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/explorer/project.zig
index 8988377c..63bc7528 100644
--- a/src/plugins/pixelart/explorer/project.zig
+++ b/src/plugins/pixelart/explorer/project.zig
@@ -315,7 +315,7 @@ fn pathTextEntry(path_type: PathType) !void {
break :blk true;
};
- fizzy.backend.showSaveFileDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
+ fizzy.pixelart.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" },
}, "", if (valid_path) output_path.* else null);
set_text = true;
diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/explorer/tools.zig
index d3da9a3e..d26445b1 100644
--- a/src/plugins/pixelart/explorer/tools.zig
+++ b/src/plugins/pixelart/explorer/tools.zig
@@ -171,16 +171,16 @@ pub fn drawTools() !void {
}
const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
- .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
- .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
- .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
+ .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default],
+ .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default],
+ .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default],
};
const sprite = switch (tool) {
- .pointer => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.cursor_default],
- .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default],
- .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default],
- .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default],
+ .pointer => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.cursor_default],
+ .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default],
+ .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default],
+ .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default],
.selection => selection_sprite,
};
var button: dvui.ButtonWidget = undefined;
@@ -210,7 +210,7 @@ pub fn drawTools() !void {
button.data().options.color_border = color;
}
- const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 };
+ const size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
const uv = dvui.Rect{
.x = @as(f32, @floatFromInt(sprite.source[0])) / size.w,
@@ -232,7 +232,7 @@ pub fn drawTools() !void {
rs.r.w = width;
rs.r.h = height;
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
+ dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{
.uv = uv,
.fade = 0.0,
}) catch {
diff --git a/src/plugins/pixelart/sprite_render.zig b/src/plugins/pixelart/sprite_render.zig
index 58d12c47..efd519dd 100644
--- a/src/plugins/pixelart/sprite_render.zig
+++ b/src/plugins/pixelart/sprite_render.zig
@@ -1,9 +1,8 @@
//! Sprite/atlas rendering library for the pixel-art plugin.
//!
-//! Consumes packed-atlas output (Atlas/Sprite types) and renders sprites
-//! (including the cover-flow water reflection mesh). Lives in the pixel-art
-//! plugin but is consumed by the shell/workbench to draw sprites from a
-//! packed atlas (cursors, icons, document previews).
+//! Heavy rendering on top of `core.Sprite` rects: layer compositing, file previews,
+//! reflections, and water-surface meshes. Shell/workbench UI icons use
+//! `fizzy.core.Sprite.draw` from core instead of this module.
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
@@ -12,7 +11,7 @@ pub const SpriteInitOptions = struct {
source: dvui.ImageSource,
file: ?*fizzy.Internal.File = null,
alpha_source: ?dvui.ImageSource = null,
- sprite: fizzy.Atlas.Sprite,
+ sprite: fizzy.core.Sprite,
scale: f32 = 1.0,
depth: f32 = 0.0, // -1.0 is front, 1.0 is back
reflection: bool = false,
@@ -647,7 +646,7 @@ pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, optio
return builder.build();
}
-pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
+pub fn renderSprite(source: dvui.ImageSource, s: fizzy.core.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
const atlas_size = dvui.imageSize(source) catch {
std.log.err("Failed to get atlas size", .{});
return;
diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/widgets/FileWidget.zig
index 4f3e1143..217c3209 100644
--- a/src/plugins/pixelart/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/widgets/FileWidget.zig
@@ -4114,19 +4114,19 @@ pub fn drawCursor(self: *FileWidget) void {
const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point);
const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
- .box => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default],
- .pixel => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default],
- .color => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default],
+ .box => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default],
+ .pixel => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default],
+ .color => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default],
};
if (switch (fizzy.pixelart.tools.current) {
- .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default],
- .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default],
- .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default],
+ .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default],
+ .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default],
+ .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default],
.selection => selection_sprite,
else => null,
}) |sprite| {
- const atlas_size = dvui.imageSize(fizzy.editor.atlas.source) catch {
+ const atlas_size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch {
dvui.log.err("Failed to get atlas size", .{});
return;
};
@@ -4164,7 +4164,7 @@ pub fn drawCursor(self: *FileWidget) void {
const rs = box.data().rectScale();
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
+ dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{
.uv = uv,
}) catch {
dvui.log.err("Failed to render cursor image", .{});
diff --git a/src/plugins/workbench/Workspace.zig b/src/plugins/workbench/Workspace.zig
index ada28cdf..000429a6 100644
--- a/src/plugins/workbench/Workspace.zig
+++ b/src/plugins/workbench/Workspace.zig
@@ -297,11 +297,7 @@ fn drawTabs(self: *Workspace) void {
}
if (is_fizzy_file) {
- _ = fizzy.sprite_render.sprite(@src(), .{
- .source = fizzy.editor.atlas.source,
- .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default],
- .scale = 2.0,
- }, .{
+ _ = fizzy.core.Sprite.draw(fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default], @src(), fizzy.editor.atlas.source, 2.0, .{
.gravity_y = 0.5,
.padding = dvui.Rect.all(4),
});
diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/files.zig
index 01c87a5f..1e07143a 100644
--- a/src/plugins/workbench/files.zig
+++ b/src/plugins/workbench/files.zig
@@ -771,11 +771,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color;
if (ext == .fizzy) {
- _ = fizzy.sprite_render.sprite(
- @src(),
- .{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0 },
- .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false },
- );
+ _ = fizzy.core.Sprite.draw(
+ fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default],
+ @src(),
+ fizzy.editor.atlas.source,
+ 2.0,
+ .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false },
+ );
} else {
dvui.icon(
@src(),
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index f94f2e2a..db08f64d 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -11,6 +11,30 @@ const dvui = @import("dvui");
const EditorAPI = @This();
+/// Sub-rect within the shell UI spritesheet. Layout matches `core.Sprite`.
+pub const UiSprite = struct {
+ origin: [2]f32 = .{ 0.0, 0.0 },
+ source: [4]u32,
+};
+
+/// Read-only view of the shell's UI icon atlas (source texture + sprite table).
+pub const UiAtlasView = struct {
+ source: dvui.ImageSource,
+ sprites: []const UiSprite,
+};
+
+/// A name/extension-pattern pair for a native save dialog. Layout matches the backend's
+/// `DialogFileFilter` (which mirrors `SDL_DialogFileFilter`), so the shell forwards a slice
+/// of these straight to the backend without a copy. `pattern` is a `;`-separated extension
+/// list, e.g. `"png;jpg;jpeg"`.
+pub const SaveDialogFilter = extern struct {
+ name: [*:0]const u8,
+ pattern: [*:0]const u8,
+};
+
+/// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled.
+pub const SaveDialogCallback = *const fn (?[][:0]const u8) void;
+
ctx: *anyopaque,
vtable: *const VTable,
@@ -34,6 +58,18 @@ pub const VTable = struct {
/// The explorer scroll area's virtual content size (shell layout). Zero size when no
/// shell is installed.
explorerVirtualSize: *const fn (ctx: *anyopaque) dvui.Size,
+ /// Run the platform's native "save file" dialog (native: OS dialog; web: download
+ /// picker). `cb` is invoked when it resolves. No-op when no shell is installed.
+ showSaveDialog: *const fn (
+ ctx: *anyopaque,
+ cb: SaveDialogCallback,
+ filters: []const SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+ ) void,
+ /// Shell-owned UI icon spritesheet (cursors, tool icons, logo). Stable for the
+ /// editor lifetime; plugins read `.source` / `.sprites` but never mutate it.
+ uiAtlas: *const fn (ctx: *anyopaque) UiAtlasView,
};
pub fn arena(self: EditorAPI) std.mem.Allocator {
@@ -67,3 +103,17 @@ pub fn explorerRect(self: EditorAPI) dvui.Rect {
pub fn explorerVirtualSize(self: EditorAPI) dvui.Size {
return self.vtable.explorerVirtualSize(self.ctx);
}
+
+pub fn showSaveDialog(
+ self: EditorAPI,
+ cb: SaveDialogCallback,
+ filters: []const SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+) void {
+ self.vtable.showSaveDialog(self.ctx, cb, filters, default_filename, default_folder);
+}
+
+pub fn uiAtlas(self: EditorAPI) UiAtlasView {
+ return self.vtable.uiAtlas(self.ctx);
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 33bdec8e..5b0cad6f 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -130,6 +130,22 @@ pub fn explorerVirtualSize(self: *Host) dvui.Size {
return if (self.shell_api) |a| a.explorerVirtualSize() else .{};
}
+/// Run the platform's native "save file" dialog. No-op if no shell installed (headless/test).
+pub fn showSaveDialog(
+ self: *Host,
+ cb: EditorAPI.SaveDialogCallback,
+ filters: []const EditorAPI.SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+) void {
+ if (self.shell_api) |a| a.showSaveDialog(cb, filters, default_filename, default_folder);
+}
+
+/// Shell-owned UI icon spritesheet. Asserts the shell is installed.
+pub fn uiAtlas(self: *Host) EditorAPI.UiAtlasView {
+ return self.shell_api.?.uiAtlas();
+}
+
// ---- per-plugin settings store ---------------------------------------------
/// The stored settings blob for `id` (serialized JSON), or null if none. The returned
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 7e5e6992..dbd90bb7 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -19,3 +19,7 @@ pub const SettingsSection = regions.SettingsSection;
/// Shell-provided read/utility surface plugins reach through the `Host`
/// (arena, folder, shared settings, dirty-marking).
pub const EditorAPI = @import("EditorAPI.zig");
+pub const SaveDialogFilter = EditorAPI.SaveDialogFilter;
+pub const SaveDialogCallback = EditorAPI.SaveDialogCallback;
+pub const UiSprite = EditorAPI.UiSprite;
+pub const UiAtlasView = EditorAPI.UiAtlasView;
From ceab790d219768ba2afc053736cbd4e23d6669c6 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 15:55:40 -0500
Subject: [PATCH 20/49] Phase 4 stage d
---
CLA.md | 88 ---
CONTRIBUTING.md | 36 --
HANDOFF.md | 556 ++++++++---------
build.zig | 66 +-
contributor.md | 60 --
.../process_assets.zig => process_assets.zig | 2 +-
src/App.zig | 16 +-
src/{ => backend}/auto_update.zig | 0
src/{ => backend}/backend_native.zig | 2 +-
src/{ => backend}/backend_web.zig | 4 +-
src/{ => backend}/file_assoc.zig | 0
.../msvc_translatec_shim/stdint.h | 0
src/{ => backend}/objc/FizzyMenuTarget.m | 0
src/{ => backend}/objc/FizzyTrackpadGesture.m | 0
.../objc/FizzyVisualEffectView.m | 0
src/{ => backend}/objc/FizzyWindowMonitor.m | 6 +-
src/{ => backend}/singleton.zig | 0
src/{ => backend}/singleton_native.zig | 2 +-
src/{ => backend}/singleton_web.zig | 0
src/{ => backend}/update_install.zig | 0
src/{ => backend}/update_notify.zig | 2 +-
src/{ => backend}/web_io.zig | 0
src/{ => backend}/window_layout.zig | 2 +-
src/editor/Editor.zig | 377 ++++++++---
src/editor/Infobar.zig | 2 +-
src/editor/Keybinds.zig | 2 +-
src/editor/Menu.zig | 3 +-
src/editor/WebFileIo.zig | 8 +-
src/editor/dialogs/AboutFizzy.zig | 4 +-
src/editor/dialogs/AppQuitUnsaved.zig | 7 +-
src/editor/dialogs/Dialogs.zig | 9 +-
src/editor/dialogs/UnsavedClose.zig | 6 +-
src/editor/explorer/Explorer.zig | 6 +-
src/editor/panel/Panel.zig | 3 -
src/fizzy.zig | 66 +-
src/plugins/pixelart/Colors.zig | 10 -
src/plugins/pixelart/module.zig | 51 ++
src/plugins/pixelart/pixelart.zig | 54 ++
src/plugins/pixelart/{ => src}/Animation.zig | 0
src/plugins/pixelart/{ => src}/Atlas.zig | 0
src/plugins/pixelart/{ => src}/CanvasData.zig | 56 +-
src/plugins/pixelart/src/Colors.zig | 11 +
src/plugins/pixelart/src/Docs.zig | 37 ++
src/plugins/pixelart/{ => src}/File.zig | 29 +-
src/plugins/pixelart/src/Globals.zig | 15 +
.../pixelart/{ => src}/LDTKTileset.zig | 1 -
src/plugins/pixelart/{ => src}/Layer.zig | 0
src/plugins/pixelart/{ => src}/PackJob.zig | 63 +-
src/plugins/pixelart/{ => src}/Packer.zig | 77 +--
src/plugins/pixelart/{ => src}/Project.zig | 20 +-
src/plugins/pixelart/{ => src}/Settings.zig | 15 +-
src/plugins/pixelart/{ => src}/Sprite.zig | 0
.../pixelart/{PixelArt.zig => src/State.zig} | 71 ++-
src/plugins/pixelart/{ => src}/Tools.zig | 27 +-
src/plugins/pixelart/{ => src}/Transform.zig | 47 +-
.../{ => src}/algorithms/algorithms.zig | 0
.../{ => src}/algorithms/brezenham.zig | 5 +-
.../pixelart/{ => src}/algorithms/reduce.zig | 0
.../deps/msf_gif/fizzy_msf_gif_wasm.c | 0
.../pixelart/{ => src}/deps/msf_gif/msf_gif.c | 0
.../pixelart/{ => src}/deps/msf_gif/msf_gif.h | 0
.../{ => src}/deps/msf_gif/msf_gif.zig | 0
.../{ => src}/deps/msf_gif/wasm_shim/string.h | 0
.../{ => src}/deps/stbi/fizzy_stbi_libc.c | 0
.../{ => src}/deps/stbi/stb_image_resize2.h | 0
.../{ => src}/deps/stbi/stb_rect_pack.h | 0
.../pixelart/{ => src}/deps/stbi/zstbi.c | 0
.../pixelart/{ => src}/deps/stbi/zstbi.zig | 0
.../pixelart/{ => src}/deps/zip/build.zig | 0
.../{ => src}/deps/zip/fizzy_zip_libc.c | 0
.../{ => src}/deps/zip/fizzy_zip_strings.c | 0
.../{ => src}/deps/zip/fizzy_zip_wasm.h | 0
.../pixelart/{ => src}/deps/zip/src/miniz.h | 0
.../pixelart/{ => src}/deps/zip/src/zip.c | 0
.../pixelart/{ => src}/deps/zip/src/zip.h | 0
.../pixelart/{ => src}/deps/zip/zip.zig | 0
.../pixelart/{ => src}/dialogs/Export.zig | 161 ++---
.../dialogs/FlatRasterSaveWarning.zig | 45 +-
.../pixelart/{ => src}/dialogs/GridLayout.zig | 121 ++--
.../pixelart/{ => src}/dialogs/NewFile.zig | 33 +-
.../pixelart/{ => src}/explorer/project.zig | 67 +-
.../pixelart/{ => src}/explorer/sprites.zig | 234 +++----
.../pixelart/{ => src}/explorer/tools.zig | 159 ++---
.../pixelart/{ => src}/internal/Animation.zig | 0
.../pixelart/{ => src}/internal/Atlas.zig | 29 +-
.../pixelart/{ => src}/internal/Buffers.zig | 35 +-
.../pixelart/{ => src}/internal/File.zig | 588 +++++++++---------
.../pixelart/{ => src}/internal/History.zig | 136 ++--
.../pixelart/{ => src}/internal/Layer.zig | 89 +--
.../pixelart/{ => src}/internal/Palette.zig | 11 +-
.../pixelart/{ => src}/internal/Sprite.zig | 0
.../internal/grid_layout_validate.zig | 0
.../{ => src}/internal/layer_order.zig | 0
.../{ => src}/internal/palette_parse.zig | 0
.../pixelart/{ => src}/panel/sprites.zig | 48 +-
src/plugins/pixelart/{ => src}/plugin.zig | 74 ++-
src/plugins/pixelart/{ => src}/render.zig | 83 +--
.../pixelart/{ => src}/sprite_render.zig | 21 +-
.../{ => src}/widgets/CanvasBridge.zig | 11 +-
.../pixelart/{ => src}/widgets/FileWidget.zig | 326 +++++-----
.../{ => src}/widgets/ImageWidget.zig | 35 +-
src/plugins/workbench/module.zig | 11 +
.../workbench/{ => src}/FileLoadJob.zig | 2 +-
src/plugins/workbench/{ => src}/Workbench.zig | 4 +-
src/plugins/workbench/{ => src}/Workspace.zig | 54 +-
src/plugins/workbench/{ => src}/files.zig | 5 +-
src/plugins/workbench/{ => src}/plugin.zig | 2 +-
src/plugins/workbench/workbench.zig | 9 +
src/sdk/EditorAPI.zig | 176 ++++++
src/sdk/Host.zig | 118 ++++
src/web_main.zig | 2 +-
111 files changed, 2547 insertions(+), 2066 deletions(-)
delete mode 100644 CLA.md
delete mode 100644 CONTRIBUTING.md
delete mode 100644 contributor.md
rename src/tools/process_assets.zig => process_assets.zig (98%)
rename src/{ => backend}/auto_update.zig (100%)
rename src/{ => backend}/backend_native.zig (99%)
rename src/{ => backend}/backend_web.zig (98%)
rename src/{ => backend}/file_assoc.zig (100%)
rename src/{tools => backend}/msvc_translatec_shim/stdint.h (100%)
rename src/{ => backend}/objc/FizzyMenuTarget.m (100%)
rename src/{ => backend}/objc/FizzyTrackpadGesture.m (100%)
rename src/{ => backend}/objc/FizzyVisualEffectView.m (100%)
rename src/{ => backend}/objc/FizzyWindowMonitor.m (99%)
rename src/{ => backend}/singleton.zig (100%)
rename src/{ => backend}/singleton_native.zig (99%)
rename src/{ => backend}/singleton_web.zig (100%)
rename src/{ => backend}/update_install.zig (100%)
rename src/{ => backend}/update_notify.zig (99%)
rename src/{ => backend}/web_io.zig (100%)
rename src/{ => backend}/window_layout.zig (99%)
delete mode 100644 src/plugins/pixelart/Colors.zig
create mode 100644 src/plugins/pixelart/module.zig
create mode 100644 src/plugins/pixelart/pixelart.zig
rename src/plugins/pixelart/{ => src}/Animation.zig (100%)
rename src/plugins/pixelart/{ => src}/Atlas.zig (100%)
rename src/plugins/pixelart/{ => src}/CanvasData.zig (96%)
create mode 100644 src/plugins/pixelart/src/Colors.zig
create mode 100644 src/plugins/pixelart/src/Docs.zig
rename src/plugins/pixelart/{ => src}/File.zig (84%)
create mode 100644 src/plugins/pixelart/src/Globals.zig
rename src/plugins/pixelart/{ => src}/LDTKTileset.zig (87%)
rename src/plugins/pixelart/{ => src}/Layer.zig (100%)
rename src/plugins/pixelart/{ => src}/PackJob.zig (93%)
rename src/plugins/pixelart/{ => src}/Packer.zig (81%)
rename src/plugins/pixelart/{ => src}/Project.zig (82%)
rename src/plugins/pixelart/{ => src}/Settings.zig (95%)
rename src/plugins/pixelart/{ => src}/Sprite.zig (100%)
rename src/plugins/pixelart/{PixelArt.zig => src/State.zig} (61%)
rename src/plugins/pixelart/{ => src}/Tools.zig (93%)
rename src/plugins/pixelart/{ => src}/Transform.zig (85%)
rename src/plugins/pixelart/{ => src}/algorithms/algorithms.zig (100%)
rename src/plugins/pixelart/{ => src}/algorithms/brezenham.zig (86%)
rename src/plugins/pixelart/{ => src}/algorithms/reduce.zig (100%)
rename src/plugins/pixelart/{ => src}/deps/msf_gif/fizzy_msf_gif_wasm.c (100%)
rename src/plugins/pixelart/{ => src}/deps/msf_gif/msf_gif.c (100%)
rename src/plugins/pixelart/{ => src}/deps/msf_gif/msf_gif.h (100%)
rename src/plugins/pixelart/{ => src}/deps/msf_gif/msf_gif.zig (100%)
rename src/plugins/pixelart/{ => src}/deps/msf_gif/wasm_shim/string.h (100%)
rename src/plugins/pixelart/{ => src}/deps/stbi/fizzy_stbi_libc.c (100%)
rename src/plugins/pixelart/{ => src}/deps/stbi/stb_image_resize2.h (100%)
rename src/plugins/pixelart/{ => src}/deps/stbi/stb_rect_pack.h (100%)
rename src/plugins/pixelart/{ => src}/deps/stbi/zstbi.c (100%)
rename src/plugins/pixelart/{ => src}/deps/stbi/zstbi.zig (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/build.zig (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/fizzy_zip_libc.c (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/fizzy_zip_strings.c (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/fizzy_zip_wasm.h (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/src/miniz.h (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/src/zip.c (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/src/zip.h (100%)
rename src/plugins/pixelart/{ => src}/deps/zip/zip.zig (100%)
rename src/plugins/pixelart/{ => src}/dialogs/Export.zig (85%)
rename src/plugins/pixelart/{ => src}/dialogs/FlatRasterSaveWarning.zig (79%)
rename src/plugins/pixelart/{ => src}/dialogs/GridLayout.zig (93%)
rename src/plugins/pixelart/{ => src}/dialogs/NewFile.zig (90%)
rename src/plugins/pixelart/{ => src}/explorer/project.zig (89%)
rename src/plugins/pixelart/{ => src}/explorer/sprites.zig (90%)
rename src/plugins/pixelart/{ => src}/explorer/tools.zig (90%)
rename src/plugins/pixelart/{ => src}/internal/Animation.zig (100%)
rename src/plugins/pixelart/{ => src}/internal/Atlas.zig (76%)
rename src/plugins/pixelart/{ => src}/internal/Buffers.zig (76%)
rename src/plugins/pixelart/{ => src}/internal/File.zig (86%)
rename src/plugins/pixelart/{ => src}/internal/History.zig (87%)
rename src/plugins/pixelart/{ => src}/internal/Layer.zig (82%)
rename src/plugins/pixelart/{ => src}/internal/Palette.zig (81%)
rename src/plugins/pixelart/{ => src}/internal/Sprite.zig (100%)
rename src/plugins/pixelart/{ => src}/internal/grid_layout_validate.zig (100%)
rename src/plugins/pixelart/{ => src}/internal/layer_order.zig (100%)
rename src/plugins/pixelart/{ => src}/internal/palette_parse.zig (100%)
rename src/plugins/pixelart/{ => src}/panel/sprites.zig (97%)
rename src/plugins/pixelart/{ => src}/plugin.zig (86%)
rename src/plugins/pixelart/{ => src}/render.zig (92%)
rename src/plugins/pixelart/{ => src}/sprite_render.zig (97%)
rename src/plugins/pixelart/{ => src}/widgets/CanvasBridge.zig (66%)
rename src/plugins/pixelart/{ => src}/widgets/FileWidget.zig (95%)
rename src/plugins/pixelart/{ => src}/widgets/ImageWidget.zig (93%)
create mode 100644 src/plugins/workbench/module.zig
rename src/plugins/workbench/{ => src}/FileLoadJob.zig (99%)
rename src/plugins/workbench/{ => src}/Workbench.zig (98%)
rename src/plugins/workbench/{ => src}/Workspace.zig (95%)
rename src/plugins/workbench/{ => src}/files.zig (99%)
rename src/plugins/workbench/{ => src}/plugin.zig (98%)
create mode 100644 src/plugins/workbench/workbench.zig
diff --git a/CLA.md b/CLA.md
deleted file mode 100644
index 6a8bf745..00000000
--- a/CLA.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Fizzy Contributor License Agreement (CLA)
-
-Thank you for your interest in contributing to fizzy (the "Project"), maintained
-by Colton Franklin ("Maintainer"). This Contributor License Agreement ("CLA")
-clarifies the intellectual property rights granted with each contribution.
-
-This CLA is adapted from the "inbound = outbound + relicense" pattern used by
-many dual-licensed open-source projects. You retain ownership of your
-contributions; this document only grants the Maintainer the rights needed to
-distribute and dual-license the Project as a whole.
-
-**You** ("Contributor") agree to the following terms for any contribution you
-submit (via pull request, patch, or any other means) to the Project. The
-Maintainer accepts your contribution under these terms.
-
-## 1. Definitions
-
-- **"Contribution"** means any source code, documentation, asset, or other
- work of authorship that you intentionally submit to the Project.
-- **"Submit"** means any form of communication sent to the Maintainer or
- Project, including pull requests, issues, patches, and electronic
- discussion, but excluding communication explicitly marked "Not a
- Contribution."
-
-## 2. Copyright License Grant
-
-You hereby grant to the Maintainer, and to recipients of software distributed
-by the Maintainer, a perpetual, worldwide, non-exclusive, no-charge,
-royalty-free, irrevocable copyright license to reproduce, prepare derivative
-works of, publicly display, publicly perform, sublicense, and distribute your
-Contribution and such derivative works **under any license terms, including
-proprietary and commercial license terms.** This explicitly includes the
-right to relicense your Contribution as part of the Project under different
-terms (for example, alongside the Project's GNU GPL v3.0 license, under a
-separate paid commercial license).
-
-You retain all right, title, and interest in your Contribution; this is a
-license, not an assignment.
-
-## 3. Patent License Grant
-
-You hereby grant to the Maintainer and recipients of software distributed by
-the Maintainer a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
-irrevocable (except as stated in this section) patent license to make, have
-made, use, offer to sell, sell, import, and otherwise transfer your
-Contribution, where such license applies only to those patent claims
-licensable by you that are necessarily infringed by your Contribution alone
-or by combination of your Contribution with the Project to which it was
-submitted.
-
-If any entity institutes patent litigation against you or any other entity
-(including a cross-claim or counterclaim in a lawsuit) alleging that your
-Contribution, or the Project to which you have contributed, constitutes
-direct or contributory patent infringement, then any patent licenses granted
-to that entity under this CLA for that Contribution or Project shall
-terminate as of the date such litigation is filed.
-
-## 4. Your Representations
-
-You represent that:
-
-1. Each of your Contributions is your original creation, or you have the
- right to submit it under this CLA.
-2. Your Contribution does not violate any third party's intellectual
- property rights, contracts, or other obligations (including, if
- applicable, any agreement with your employer).
-3. If your employer has rights to intellectual property you create, you have
- either (a) received permission to make Contributions on behalf of that
- employer, (b) had your employer waive such rights for your Contributions,
- or (c) had your employer also sign this CLA.
-
-You agree to notify the Maintainer if any of these representations becomes
-inaccurate.
-
-## 5. No Obligation
-
-You are not expected to provide support for your Contributions, except to the
-extent you desire to provide support. Unless required by applicable law or
-agreed to in writing, you provide your Contributions on an "AS IS" basis,
-without warranties or conditions of any kind, either express or implied.
-
-## 6. Acceptance
-
-You accept this CLA by submitting a pull request after this CLA is in place,
-or by explicitly indicating agreement in a manner the Maintainer accepts
-(for example, signing via [CLA Assistant](https://cla-assistant.io/) on a
-pull request, or replying to an issue with the exact text "I have read the
-CLA Document and I hereby sign the CLA").
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 5a48ddaa..00000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Contributing to fizzy
-
-Thanks for your interest in contributing!
-
-## License & CLA
-
-fizzy is licensed under the [GNU General Public License v3.0](LICENSE).
-
-To keep the door open for the project to offer a separate commercial license
-in addition to the GPL, **all contributors must sign the
-[Contributor License Agreement](CLA.md)** before their pull request can be
-merged.
-
-You retain copyright on your contributions — the CLA only grants the
-maintainer (Colton Franklin) the rights needed to relicense the project as a
-whole, including under future commercial terms.
-
-### How to sign
-
-A [CLA Assistant](https://cla-assistant.io/) bot is wired up to this
-repository. The first time you open a pull request, it will post a comment
-with a one-click sign-off link. After you sign once, subsequent PRs are
-auto-checked against your signature — no further action needed.
-
-## Pull requests
-
-- Keep changes focused. One concern per PR.
-- Match the style of the surrounding code. Run `zig build` locally before
- pushing.
-- Reference any related issue in the PR description.
-
-## Reporting issues
-
-Use the issue tracker for bug reports and feature requests. For bug reports,
-include OS, fizzy version (visible in the title bar / `Help > About`), and
-steps to reproduce.
diff --git a/HANDOFF.md b/HANDOFF.md
index a4a4a5b9..7a780283 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,4 +1,4 @@
-# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, mid Stage C)
+# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, Stage D in progress)
## TL;DR
@@ -7,127 +7,21 @@ makes `core` a real, separately-wired Zig module with no dependency on the `fizz
app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so
it can become its own compile-time module.
-**Done:** Stage A1, A2, A3, B, and **Stage C part 1 (per-plugin settings)**.
-**Next:** Stage C remainder (doc/tab/host/arena/folder decoupling) + the sprite/atlas →
-`core` extraction. Then D, E.
-
-> **Read this first if you're a fresh agent:** the immediately actionable work is in
-> "Stage C — remaining work" and "Next big rock: sprite/atlas → core" near the bottom.
-> Several items there are now low-effort because the SDK surface they need already exists.
-
-## What Stage B did
-
-Lifted the pixel-art editor state off the shell `Editor` into a plugin-owned
-`PixelArt` struct (`src/plugins/pixelart/PixelArt.zig`), reached via a new
-`fizzy.pixelart: *PixelArt` global (mirrors the existing `fizzy.packer`).
-
-- **Fields moved:** `tools`, `colors`, `project`, `sprite_clipboard`, `pack_jobs`
- (plus the `SpriteClipboard` type). ~190 `editor.` / `fizzy.editor.`
- call sites repointed to `fizzy.pixelart.` across `Editor.zig`, `Keybinds.zig`,
- `workbench/files.zig`, and the pixel-art tree.
-- **`atlas` deliberately stayed on the shell** — it's the shared UI icon spritesheet
- (cursor/pencil/logo/selection icons) the shell uses for its own logo
- (`workbench/files.zig`, `Workspace.zig`), not pixel-art-specific. Moving it would
- invert the dependency. (Its type is still `Internal.Atlas`; relocating that type to
- core is a later structural question, not Stage B.)
-- **Lifecycle:** `fizzy.pixelart` is allocated + `PixelArt.init`'d in `App.AppInit`
- *before* `editor.postInit()` so the pixel-art `plugin.register` adopts it as
- `plugin.state`. `PixelArt.init` now owns the tools init + the two `fizzy.hex` palette
- loads (moved out of `Editor.init`). `PixelArt.deinit` (pack-job cancel, palette free,
- project save, tools free) runs from `App.AppDeinit` right after `editor.deinit()`;
- the old interleaved pixel-art teardown blocks were removed from `Editor.deinit`.
-- Three Editor helpers (`processHoldOpenRadialMenu`, `isPackingActive`,
- `runWasmPackWorkers`) now ignore their `editor` param (`_: *Editor`) since they only
- reach `fizzy.pixelart`. The pack methods (`startPackProject`/`processPackJob`/…) and
- the copy-paste / radial-menu draw code still live on `Editor` — they relocate later.
-- Type aliases on `Editor` (`pub const Tools/Colors/Project/Transform`) were left in
- place; they're used as type paths (`Editor.Tools.Tool`) and move in Stage D.
-
-Verified green: `zig build`, `zig build check-web`, `zig build test`. (No live GUI
-run — pure refactor.)
-
-## What Stage C part 1 did — per-plugin settings (VSCode-style)
-
-Goal (set by the user): pixel-art-specific settings should **belong to the pixel-art
-plugin**, and the Settings tab should be a registry that each plugin contributes its own
-section to, grouped by plugin. The shell stores plugin settings opaquely but never
-interprets them.
-
-### New SDK surface (all in `src/sdk/`)
-
-- **`SettingsSection`** (`regions.zig`, exported from `sdk.zig`): `{ id, owner, title, draw }`.
- The Settings sidebar view renders each registered section under its `title` heading.
-- **`Host` additions** (`Host.zig`):
- - `settings_sections` registry + `registerSettingsSection`.
- - `plugin_settings: PluginSettings` (= `StringArrayHashMapUnmanaged([]const u8)`): the
- opaque per-plugin blob store (id → serialized JSON). `loadPluginSettings(id)` /
- `storePluginSettings(id, json)` (the latter dupes + marks shell settings dirty). Host
- owns + frees the key/value strings in `deinit`.
- - `shell_api: ?EditorAPI` + `installShell(api)` + thin forwarders: `arena()`, `folder()`,
- `paletteFolder()`, `markSettingsDirty()`, `contentOpacity()`.
-- **`EditorAPI`** (`EditorAPI.zig`): vtable + ctx the shell installs so plugins reach shared
- shell state without importing `Editor`. The shell's vtable impl lives in `Editor.zig`
- (`shell_api_vtable` + `shellArena`/`shellFolder`/… ; ctx is `*Editor`), installed in
- `Editor.postInit`.
-
-### Storage / persistence (`src/editor/Settings.zig`)
-
-- On-disk format gained a `"plugins"` object: `{ , "plugins": { id: } }`.
-- `Settings.serialize(settings, plugin_store, alloc)` serializes the struct, drops the
- trailing `}`, and **textually splices** `,"plugins":{…}}` with each plugin's already-
- serialized blob inline. (Robust — avoids `std.json.Value` lifetime hazards. Round-trip
- validated with a standalone test: valid JSON, shell parses back via `ignore_unknown_fields`,
- blobs re-extract cleanly.)
-- `Settings.save(...)` and the autosave **dedup snapshot** (`settings_last_saved_json`) and
- the three Editor save sites all now go through `serialize` so plugin-only changes still
- trigger a write. (Watch: `Settings.save` is called from `saveSettingsGuarded`,
- `saveSettingsRaw`, and the init snapshot — all four-arg now.)
-- `Settings.loadPluginStore(alloc, path, store)` re-parses settings.json as a `Value`,
- extracts the `"plugins"` object into the store. Called from `Editor.init` right after
- `Settings.load`, before `PixelArt.init` runs (so the plugin can read its blob).
-- **One-time migration:** a legacy *flat* settings.json (no `"plugins"`) seeds the
- `"pixelart"` blob from the **whole root** — pixel art ignores unknown keys, so its moved
- fields (`show_rulers`, `input_scheme`, …) survive; the next save rewrites the blob clean.
- (Self-healing, no data loss. The blob is temporarily bloated with shell keys until then.)
-
-### Pixel-art side
-
-- New **`src/plugins/pixelart/Settings.zig`** (`PixelArt.Settings`, `pub`): owns the moved
- fields + `InputScheme`/`ResolvedPanZoomScheme`/`TransparencyEffect` enums +
- `resolvedPanZoomScheme`. `load(host)` parses its blob (defaults if absent/garbage; no
- heap fields so returning by value after `parsed.deinit()` is safe). `save(host)`
- serializes + `host.storePluginSettings`. `draw(_)` renders the section (Canvas group:
- transparency effect, show rulers, cover-flow cards; Controls group: control scheme).
-- `PixelArt` struct gained `host: *sdk.Host` and `settings: Settings`, both set in
- `PixelArt.init(allocator, host)` (App now passes `&fizzy.editor.host`).
-- `plugin.register` registers the `"pixelart"` settings section ("Pixel Art").
-
-### Fields moved off shell `Settings` → `PixelArt.Settings`
-
-`input_scheme`, `show_rulers`, `scrolling_cards`, `ruler_padding`, `zoom_sensitivity`,
-`zoom_steps`, `max_file_size`, `checker_color_even/odd`, `transparency_effect` (+ the
-three enums + `resolvedPanZoomScheme`). All ~27 pixel-art read sites repointed to
-`fizzy.pixelart.settings.`; type refs (`fizzy.Editor.Settings.TransparencyEffect`,
-`…resolvedPanZoomScheme`) → `fizzy.PixelArt.Settings.…`.
-
-**`content_opacity` deliberately stays on the shell** — it's also read by `workbench/
-Workspace.zig` and `panel/Panel.zig`, so it's genuinely shell-level. Pixel art's 3 reads
-go through `fizzy.pixelart.host.contentOpacity()` (the EditorAPI). The pixel-art settings
-*UI controls* were removed from `editor/explorer/settings.zig` (the shell "Editor" section
-now only has theme/fonts/window+content opacity/hold-timing/debugging).
-
-### Settings UI
-
-`Editor.drawSettingsPane` now iterates `host.settings_sections` and renders each under a
-heading label (registration order = display order; shell "Editor" registered first in
-`postInit`, before plugins). The shell section draw = `Explorer.settings.draw` (trimmed);
-the pixel-art section draw = `PixelArt.Settings.draw`.
-
-Verified green: `zig build`, `zig build check-web`, `zig build test`. Persistence splice
-round-trip checked with a throwaway `zig run` harness (valid JSON + clean extraction). No
-live GUI run.
-
-All three build configs are green right now:
+**Done:** Stage A1, A2, A3, B, and **Stage C (full)** — per-plugin settings, docs/tabs
+storage inversion, save/pack/editor-action decoupling, platform detection, explorer pane
+lift, sprites bottom-panel lift.
+
+**In progress:** **Stage D** — module scaffold (`module.zig`, `State.zig`, `pixelart.zig`,
+`Globals.zig`), hub consolidation through `fizzy.pixelart_mod`, plugin import migration off
+`fizzy.zig`.
+
+**Next:** wire `b.addModule("pixelart", …)` in `build.zig`, break `plugin.zig` →
+`Editor.Workspace` dep. Then Stage E.
+
+> **Read this first if you're a fresh agent:** start at "Stage D — remaining work"
+> below. All three build configs are green right now.
+
+All three build configs are green:
```
zig build # native exe
@@ -135,209 +29,277 @@ zig build check-web # wasm
zig build test # unit/integration tests
```
-Run all three after every stage. Note: `zig build` for this repo currently needs to
-run outside the sandbox (network/file access), so expect to pass elevated permissions.
+Run all three after every stage. `zig build` for this repo currently needs to run outside
+the sandbox (network/file access).
---
-## What `core` is now (Stage A3 result)
+## Plugin directory layout (convention)
-`src/core/` is a standalone module (`src/core/core.zig` is its root). It holds shared
-infrastructure and **never imports `src/fizzy.zig`**:
+Every plugin follows the same shape:
```
-src/core/
- core.zig # module root: gpa + trackpad hook + re-exports
- dvui.zig # generic dvui hub: dialog framework, helpers, generic widgets
- fs.zig paths.zig platform.zig Fling.zig
- gfx/ image.zig perf.zig water_surface.zig
- math/ math.zig color.zig direction.zig easing.zig layout_anchor.zig
- widgets/ CanvasWidget PanedWidget ReorderWidget FloatingWindowWidget
- TreeWidget TreeSelection
- generated/ atlas.zig # written by the build's process-assets step
+src/plugins//
+ module.zig # build module root / shell import surface
+ .zig # intra-plugin hub (sdk, core, Globals, shared types)
+ src/ # all implementation code
+```
+
+**pixelart** and **workbench** both use this layout now.
+
+| File | Role |
+|------|------|
+| `module.zig` | Compile-time module root; shell reaches it via `fizzy.pixelart_mod` / future `@import("pixelart")` |
+| `pixelart.zig` / `workbench.zig` | Hub named after the plugin folder; files in `src/**` import as `../.zig` or `../../.zig` |
+| `src/State.zig` | Plugin runtime state (`pixelart` only) |
+| `src/Globals.zig` | Runtime injection: `gpa`, `state`, `packer` (`pixelart` only) |
+| `src/plugin.zig` | Plugin registration + draw entry points |
+| `src/deps/` | Third-party deps (`pixelart` only) |
+
+Shell still uses `fizzy.pixelart: *State` global during migration; plugin code uses
+`Globals.state`.
+
+### macOS case-insensitive rename protocol
+
+On APFS (default, case-insensitive), `PixelArt.zig` and `pixelart.zig` are the **same
+file**. Never create `pixelart.zig` while `PixelArt.zig` is still in git — it silently
+overwrites the state struct.
+
+**Two-step git rename (Option A):**
+
+```bash
+git mv src/plugins/pixelart/PixelArt.zig src/plugins/pixelart/__legacy_remove__.zig
+git rm -f src/plugins/pixelart/__legacy_remove__.zig
+# now safe to add src/plugins/pixelart/pixelart.zig and State.zig
```
-### Decoupling mechanisms (important invariants)
-
-- **Allocator injection.** `core.gpa` is a `std.mem.Allocator` set once at startup in
- `App.init` (`fizzy.core.gpa = allocator;`). Core code (e.g. `gfx/image.zig`) allocates
- through `core.gpa` instead of reaching into `fizzy.app.allocator`.
-- **Trackpad hook.** `core.takeTrackpadPinchRatio` is a `*const fn () f32` set in
- `App.init` to `fizzy.backend.takeTrackpadPinchRatio`. `CanvasWidget` calls the hook so
- it doesn't depend on the heavy native backend. Defaults to a `1.0` no-op for headless/test.
-- **Dialog chrome state moved into core.** `core.dvui.modal_dim_titlebar: bool` and
- `core.dvui.dialog_close_rect_override: ?dvui.Rect.Physical` replaced the old
- `Editor.dim_titlebar` field and `workbench/files.zig: new_file_close_rect` var. The
- shell reads `fizzy.dvui.modal_dim_titlebar` in `Editor.setTitlebarColor`.
-- **`fizzy.zig` re-exports core** so existing `fizzy.` call sites keep working:
- `fizzy.image/fs/perf/water_surface/math/platform/paths/dvui/Fling/atlas` all alias
- `core.*`, plus `pub const core = @import("core");`.
-- **Widget split.** Generic widgets live in `core/widgets/` and are exposed as
- `core.dvui.CanvasWidget` etc. The **pixel-art** `FileWidget` and `ImageWidget` stayed
- in `src/plugins/pixelart/widgets/` (ImageWidget is still pixel-art-coupled). Consumers
- import them locally, not via the hub. `src/editor/widgets/Widgets.zig` was deleted.
-
-### Build wiring
-
-`core` is created three times (one per target/backend variant) in `build.zig`:
-- native exe: `core_module` (dvui_sdl3) — search `addImport("core"`
-- web exe: `core_module_web` (dvui_web)
-- test: `core_module_test` (dvui_testing)
-
-Each gets `dvui`, `known-folders`, and (lazy) `icons`. The generated atlas now writes to
-`src/core/generated/`, and the inline test modules point at `src/core/math/*`.
-
-### Gotchas discovered (don't repeat these)
-
-- **Build-script / module file-ownership trap.** `build.zig` imports
- `src/tools/process_assets.zig`, which imports `src/plugins/pixelart/Atlas.zig` to
- generate the atlas index *at build time*. A file may belong to only one module within a
- single compilation. Routing `Atlas.zig`'s file read through `fizzy.fs`/`core.fs` (a)
- dragged the whole `fizzy`+`core` graph into the build-runner compilation (no `core`
- module there) and (b) caused "file exists in modules 'core' and 'root'". **Fix applied:**
- `Atlas.zig` now imports nothing but `std` and inlines its file read. Keep build-time
- tools (`process_assets.zig` and anything it imports) free of `fizzy`/`core` module imports.
-- **macOS case-insensitive FS.** `sprite.zig` vs `Sprite.zig` collide. The atlas-render
- library is named `sprite_render.zig` for this reason.
-- **Lazy top-level imports.** An unused `const fizzy = @import(...)` is fine (never
- analyzed). Problems only appear when build-*reachable* code forces analysis.
+**Import paths inside `src/`:**
+
+- `src/foo.zig` → `@import("../pixelart.zig")`
+- `src/widgets/bar.zig` → `@import("../../pixelart.zig")`
+- View ids (`view_tools`, `view_sprites`) live in `src/plugin.zig` — import as
+ `@import("../plugin.zig")` from nested dirs, not through the hub.
---
-## Remaining stages
+## What Stage C did (complete)
+
+### Part 1 — per-plugin settings (VSCode-style)
+
+Pixel-art-specific settings belong to the pixel-art plugin; the shell stores them opaquely.
+
+- **`SettingsSection`** in SDK; `Host` registry + `plugin_settings` blob store.
+- **`EditorAPI`** vtable for shell reach-through (`arena`, `folder`, `paletteFolder`, …).
+- **`Settings`** owns moved fields; `plugin.register` adds the "Pixel Art" section.
+- Shell `Settings.serialize` splices `"plugins": { id: blob }` into settings.json.
+
+### Part 2 — docs/tabs storage inversion
+
+The shell no longer owns `Internal.File` values directly.
-The plan tasks are tracked as todos `b`, `c`, `d`, `e`. The pixel-art plugin still has a
-large coupling surface to the shell: ~250 `fizzy.editor.` / `fizzy.backend.` /
-`fizzy.platform.` references across `src/plugins/pixelart/**` (biggest offenders:
-`widgets/FileWidget.zig` ~80, `dialogs/Export.zig`, `internal/File.zig`,
-`explorer/tools.zig`). Stages B–D systematically remove these.
+- **`Docs.zig`**: plugin owns `files: HashMap(u64, Internal.File)`.
+- **`Editor.open_files`**: `HashMap(u64, sdk.DocHandle)` — opaque handles with `ptr`/`id`/`owner`.
+- **EditorAPI doc surface**: `activeDoc`, `docByIndex`, `docById`, `docIndex`, `openDocCount`,
+ `setActiveDocIndex`, `allocDocId`.
+- Shell helpers: `fileFromDoc`, `docAt`, `fileAt`, `activeDoc`, `insertOpenDoc`, `closeDocumentResources`.
+- Plugin repointed: `fizzy.pixelart.docs.activeFile(host)`, `host.docIndex` / `setActiveDocIndex`,
+ `host.allocDocId()`, `docs.fileById`, etc.
+- **`State.docs`**: field + `docs.deinit` in teardown.
-### Stage B — lift pixel-art editor state off the shell `Editor`
-Move the pixel-art-specific fields (tools, colors, atlas, project, buffers, transform)
-off `src/editor/Editor.zig` (~83 refs) into a `PixelArt` plugin-state struct owned by the
-plugin. Update `Editor.zig`, `Keybinds` (~15 refs), and the `Menu`, plus the pixel-art
-references that read those fields. Build green (all 3).
+### Part 3 — save/pack/editor-action decoupling
-### Stage C — expand the SDK Host (settings done; rest below)
-Grow the SDK Host surface so the plugin reaches shell state via the SDK, not
-`fizzy.editor`. **Part 1 (per-plugin settings) is done** — see "What Stage C part 1 did"
-above. The remaining coupling and a recommended order are in "Stage C — remaining work".
+Pixel-art dialogs and actions reach the shell through `host.*` / `EditorAPI`, not `fizzy.editor.*`.
-### Stage D — make `pixelart` its own module
-Add a `src/plugins/pixelart/pixelart.zig` module root; repoint all pixel-art imports from
-`fizzy.zig` to `core` / `sdk` / `dvui` / local files; wire `b.addModule("pixelart", ...)`
-in `build.zig` (3 configs, mirroring how `core` is wired); have `App` call
-`pixelart.register(host)`. Build native + test + web.
+**EditorAPI additions** (all wired in `Editor.zig` shell vtable + `Host.zig` forwarders):
-### Stage E — strip pixel-art names from shell hubs
-Remove pixel-art names from `fizzy.zig` / Dialogs / `Editor` / Explorer / Panel; route all
-contributions through the SDK only. Final verification across the 3 configs.
+`accept`, `cancel`, `copy`, `paste`, `transform`, `save`, `requestCompositeWarmup`,
+`requestGridLayoutDialog`, `allocUntitledPath`, `createDocument`, `requestSaveAs`,
+`requestWebSave`, `cancelPendingSaveDialog`, `setPendingCloseDocId`, `queueCloseAfterSave`,
+`trackQuitSaveInFlight`, `resumeSaveAllQuit`, `abortSaveAllQuit`, `startPackProject`,
+`isPackingActive`, `showSaveDialog`, `uiAtlas`, `explorerRect`, `explorerVirtualSize`,
+`isMaximized`.
+
+### Part 4 — explorer pane + bottom-panel lift
+
+- **`tools_pane`**, **`sprites_pane`**, **`pinned_palettes`**, **`layers_ratio`** moved onto
+ `State` (were on shell `Explorer`).
+- **`sprites_panel`** moved off `editor.panel.sprites` onto `State`; drawn via
+ `Globals.state.sprites_panel.draw()` from `plugin.zig`.
+
+### Part 5 — platform detection
+
+- **EditorAPI**: `isMacOS()`, `appliesNativeWindowOpacity()`.
+- Plugin repointed: keybinds, window chrome opacity, `Settings.resolvedPanZoomScheme(settings, host)`.
+- **Zero** live `fizzy.platform` / `builtin.os.tag` in `src/plugins/pixelart/**`.
+
+### Stage C sanity greps
+
+```
+grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (4 commented-out lines in Tools.zig, Project.zig)
+grep -rn 'fizzy\.platform' src/plugins/pixelart → 0
+grep -rn 'fizzy\.backend\.' src/plugins/pixelart → check; native save dialogs go through host.showSaveDialog
+```
---
-## Stage C — remaining work (start here)
+## What Stage D has done so far
+
+### Module root — `src/plugins/pixelart/module.zig`
+
+Canonical export surface for the plugin tree. **`fizzy.zig`** re-exports through
+`fizzy.pixelart_mod = @import("plugins/pixelart/module.zig")` instead of scattering
+direct `@import("plugins/pixelart/…")` across the hub.
+
+Exports: `Globals`, `State`, `Settings`, `Docs`, `Tools`, `Transform`, `Project`,
+`Colors`, `Packer`, `PackJob`, `plugin`, `dialogs.*`, `explorer.project`, `render`,
+`sprite_render`, `algorithms`, on-disk types, `internal.*`.
+
+### Intra-plugin hub — `src/plugins/pixelart/pixelart.zig`
+
+Plugin files import this for `sdk`, `core`, `Globals`, shared types, and `internal.*`.
+**Not** the build module root — that is `module.zig`.
+
+### Plugin state — `src/plugins/pixelart/State.zig`
+
+Renamed from `PixelArt.zig` / `PixelArt` struct → `State.zig` / `State`.
+
+### Globals injection — `src/plugins/pixelart/Globals.zig`
+
+Runtime pointers set once in `App.AppInit`:
+
+```zig
+fizzy.pixelart_mod.Globals.gpa = allocator;
+fizzy.pixelart_mod.Globals.state = fizzy.pixelart;
+fizzy.pixelart_mod.Globals.packer = fizzy.packer;
+```
+
+Plugin tree now uses `Globals.allocator()` / `Globals.state` / `Globals.packer` — **zero**
+remaining `fizzy.app.allocator` refs in `src/plugins/pixelart/**`.
+
+### Hub consolidation (partial)
+
+- **`fizzy.zig`**: `State`, `Packer`, `Internal`, on-disk types, `Tools`, `Transform`,
+ `PackJob`, `algorithms`, `render`, `sprite_render` all alias `pixelart_mod.*`.
+ Global `fizzy.pixelart: *State` kept for shell during migration.
+- **`Editor.zig`**: removed public aliases `Colors`, `Project`, `Tools`, `Transform`;
+ uses `fizzy.Tools`, `fizzy.pixelart_mod.Project`, `fizzy.pixelart_mod.plugin.*`.
+- **Shell imports rerouted** (via `fizzy.pixelart_mod`):
+ - `editor/dialogs/Dialogs.zig` → `dialogs.NewFile/Export/GridLayout/FlatRasterSaveWarning`
+ - `editor/dialogs/UnsavedClose.zig` → `dialogs.FlatRasterSaveWarning`
+ - `editor/explorer/Explorer.zig` → `explorer.project`
+- **`Panel.zig`**: removed dead `Sprites` field/import.
+- **Plugin import migration**: `bridge.zig` → `pixelart.zig`; `Globals.pixelart` →
+ `Globals.state`; subdirectory files use `../pixelart.zig`.
+
+### SDK module wired in `build.zig`
-Settings is fully decoupled (`grep -r 'fizzy.editor.settings' src/plugins/pixelart` → 0).
-Here is the **current** `fizzy.editor.*` / `fizzy.backend.*` / `fizzy.platform.*` surface
-still in `src/plugins/pixelart/**` (run the greps to refresh):
+`wireSdkModule` adds `@import("sdk")` to native, web, and test roots. `fizzy.zig` imports
+sdk via `@import("sdk")` (not a duplicate file-path import).
+
+### Still direct-importing pixel-art files (shell)
```
-33 fizzy.editor.activeFile 11 fizzy.editor.open_files 6 fizzy.editor.newFileID
-31 fizzy.editor.atlas 11 fizzy.editor.host 6 fizzy.editor.folder
-17 fizzy.editor.explorer 10 fizzy.editor.arena 2 fizzy.editor.palette_folder
-+ doc/save-flow tail: setActiveFile, getFile, getFileFromPath, newFile, open_file_index,
- requestCompositeWarmup, startPackProject, isPackingActive, requestSaveAs,
- requestWebSaveDialog, requestGridLayoutDialog, cancelPendingSaveDialog, abortSaveAllQuit,
- copy/paste/accept/cancel, save, transform, buffers, panel, allocNextUntitledPath,
- pending_*/quit_* (all 1–3 refs each)
-backend: showSaveFileDialog ×5, DialogFileFilter ×4, isMaximized ×3 ; platform: isMacOS ×3
+process_assets.zig (repo root) → Atlas.zig (build-time, std-only — OK, separate compilation)
+src/web_main.zig → FileWidget.zig force-import (wasm link — migrate later)
```
-**Recommended order (easy → hard):**
-
-1. **`host` (11) — trivial now.** `PixelArt` already holds `host: *sdk.Host` (set in
- `init`). Repoint `fizzy.editor.host.setActiveSidebarView/isActiveSidebarView` →
- `fizzy.pixelart.host.…`. Pure mechanical, no SDK change.
-2. **`arena` (10), `folder` (6), `palette_folder` (2) — done-for-you.** The EditorAPI
- forwarders already exist: `fizzy.pixelart.host.arena()` / `.folder()` /
- `.paletteFolder()`. Repoint `fizzy.editor.arena.allocator()` → `fizzy.pixelart.host.arena()`,
- etc. (mind that `arena` callers use `.allocator()`; the forwarder already returns the
- `Allocator`).
-3. **`backend.isMaximized` (3), `platform.isMacOS` (3).** Add `isMaximized()` to EditorAPI
- (shell calls `fizzy.backend.isMaximized(dvui.currentWindow())`). `isMacOS` is just
- `core.platform.isMacOS()` — pixel art can call `fizzy.platform.isMacOS()` until Stage D
- repoints it to `core` directly; low priority.
-4. **`explorer` (17).** These read pixel-art state that *lives on the shell `Explorer`*
- (`explorer.tools`, `.sprites`, `.pinned_palettes`, `.layers_ratio`, `.rect`,
- `.scroll_info`). `tools`/`sprites` are pixel-art pane modules; `pinned_palettes`/
- `layers_ratio` are pixel-art UI state. These should **move onto `PixelArt`** (like the
- settings did), not get an SDK accessor. `rect`/`scroll_info` are shell explorer layout —
- expose via EditorAPI or pass into the draw.
-5. **Native save dialogs (`backend.showSaveFileDialog` ×5, `DialogFileFilter` ×4).** Add a
- small SDK surface for "ask the host to run a native save dialog" (native-only; web has
- its own path). The save-flow tail (`requestSaveAs`, `pending_*`, `quit_*`, `accept`,
- `cancel`, `abortSaveAllQuit`, …) is the shell's save/quit orchestration the pixel-art
- dialogs poke — needs a deliberate "document save service" vtable, the hardest part.
-6. **Docs/tabs (`activeFile` ×33, `open_files` ×11, `setActiveFile`, `getFile*`, `newFile*`,
- `open_file_index`, `buffers`, `transform`, `copy/paste`, `requestCompositeWarmup`,
- `startPackProject`, `isPackingActive`).** This is the **deep coupling**: the shell's
- `open_files` is literally `AutoArrayHashMapUnmanaged(u64, Internal.File)` — a map of
- *pixel-art* `Internal.File` values. The shell currently owns and iterates pixel-art docs
- directly. Fully decoupling means the shell stores **opaque documents (`DocHandle`)** and
- the pixel-art plugin owns the `Internal.File` storage. That is a large structural change
- (touches the workspace/tab/save systems) — likely its own stage. Until then, pixel-art
- can reach the active doc through a `host.activeDoc() ?DocHandle` + cast, but the storage
- inversion is the real work.
-
-`atlas` (31) is handled by the sprite/atlas → core extraction below, not by an SDK accessor.
-
-## Next big rock: sprite / atlas → `core`
-
-This resolves the `editor.atlas` (Stage B) and `fizzy.editor.atlas` (×31) coupling and is
-the prerequisite for the shell not depending on the pixel-art plugin for its own UI icons.
-
-**Findings (verified in code):**
-
-- The shell (`workbench`) only calls `fizzy.sprite_render.sprite(...)` in two places —
- `workbench/files.zig:~774` and `workbench/Workspace.zig:~300` — both drawing a **static
- atlas sprite** (the logo / UI icons), passing `file = null`. It never uses the heavy path.
-- But `src/plugins/pixelart/sprite_render.zig` lives in the plugin and is tangled: the same
- `sprite()` also does layer compositing, file previews, reflections, and `water_surface`
- (all need a full pixel-art `Internal.File`). So today the shell reaches *backwards* into
- the plugin just to draw an icon. `editor.atlas` is typed `Internal.Atlas` (pixel art's).
-
-**Plan:** split by responsibility with `core` as the shared floor.
-
-- → **`core`:** a generic atlas data type + a "draw sprite N (sub-rect of a texture)"
- primitive (the slice the shell's logo/icons need; essentially `dvui.renderImage` + sprite
- rect math). The shell's `editor.atlas` becomes a `core` atlas type drawn via the `core`
- helper, depending on `core` not the plugin.
-- → **stays in pixel-art plugin:** `renderSprite` / `render.renderLayers` / composites /
- reflections / `water_surface` — all the editing rendering on top of the primitive.
-
-End-state dependency graph: **shell → core**, **plugin → core**, neither depends on the
-other. (User has signed off on this direction; sequenced *after* settings.)
+---
+
+## Stage D — remaining work (start here)
+
+1. **Wire `b.addModule("pixelart", …)` in `build.zig`** (native, web, test) with deps:
+ `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`, etc. — mirroring how `core` is wired.
+ Point the module root at `module.zig`. Today the plugin compiles through path imports
+ in `fizzy.zig`; the build module is scaffold-only.
+
+2. **Break `plugin.zig` dependency on `fizzy.Editor.Workspace`** (project view drawing
+ still reaches into shell types).
+
+3. **Route `web_main.zig` FileWidget import** through `pixelart_mod` or the future build
+ module.
+
+4. **Optional cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively —
+ shrink as plugin vtable / EditorAPI surface grows (Stage E).
+
+Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both
+`App.zig` and `fizzy.zig` via a third path; always go through `fizzy.pixelart_mod` in
+app code until the build module is fully wired.
+
+---
+
+## Stage E — strip pixel-art names from shell hubs (later)
+
+- Remove pixel-art type names from `fizzy.zig` hub (consumers import `pixelart` module).
+- Remove `editor/dialogs/` pixel-art dialog aliases (plugins register dialogs via SDK).
+- Shell `Editor` radial-menu / copy-paste / pack code still touches `fizzy.pixelart.tools` —
+ route through plugin vtable or EditorAPI.
+- Shell still uses `fizzy.Internal.File` directly in several `Editor.zig` helpers — shrink
+ as doc ownership solidifies.
+
+---
+
+## Next big rock: sprite / atlas → `core` (parallel track)
+
+Resolves `editor.atlas` coupling and the shell reaching into the plugin for UI icons.
+
+- Shell only needs a static atlas sprite draw (logo/icons) — `workbench/files.zig`,
+ `workbench/Workspace.zig`.
+- **`core`:** generic atlas type + "draw sprite N" primitive.
+- **Plugin:** `renderSprite`, composites, reflections, `water_surface`.
+- End-state: **shell → core**, **plugin → core**, neither depends on the other.
+
+(User signed off; sequenced after settings, can proceed alongside late Stage D.)
+
+---
+
+## What `core` is (Stage A3 — unchanged)
+
+`src/core/` is a standalone module; never imports `src/fizzy.zig`. See prior handoff
+sections for allocator injection, trackpad hook, dialog chrome state, build wiring, and
+the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atlas.zig`).
+
+**macOS case-insensitive FS gotchas:**
+- `sprite.zig` vs `Sprite.zig` → use `sprite_render.zig`.
+- `pixelart.zig` vs `PixelArt.zig` / `State.zig` → use `module.zig` for the build module
+ root; use the two-step git rename when introducing `pixelart.zig` hub.
+
+---
+
+## Key paths
+
+| Path | Role |
+|------|------|
+| `HANDOFF.md` | This file |
+| `src/plugins/pixelart/module.zig` | Pixel-art build module root |
+| `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub |
+| `src/plugins/pixelart/src/` | Pixel-art implementation tree |
+| `src/plugins/workbench/module.zig` | Workbench build module root |
+| `src/plugins/workbench/workbench.zig` | Workbench intra-plugin hub |
+| `src/plugins/workbench/src/` | Workbench implementation tree |
+| `src/sdk/EditorAPI.zig`, `Host.zig` | Full shell API surface |
+| `src/editor/Editor.zig` | Shell; still uses `fizzy.pixelart.*` and `Internal.File` helpers |
+| `src/fizzy.zig` | App hub; mid-migration to `pixelart_mod` re-exports |
+| `process_assets.zig` | Build-time asset atlas generator (repo root, beside `build.zig`) |
+| `src/backend/` | Platform backend: native/web stubs, singleton, auto-update, objc, MSVC shim |
---
## State of the tree
-**Uncommitted** (nothing in this whole Phase-4 effort has been committed — commit on
-request). Beyond the Stage A3 changes, the working tree now also has:
-
-- **Stage B:** new `src/plugins/pixelart/PixelArt.zig`; `fizzy.pixelart` global in
- `fizzy.zig`; init/deinit wiring in `App.zig`; field removals + ~190 repoints in
- `Editor.zig`, `Keybinds.zig`, `workbench/files.zig`, and the pixel-art tree.
-- **Stage C part 1 (settings):** new `src/sdk/EditorAPI.zig`,
- `src/plugins/pixelart/Settings.zig`; `SettingsSection` in `sdk/regions.zig` + `sdk.zig`;
- Host store/forwarders/section-registry in `sdk/Host.zig`; persistence rework in
- `editor/Settings.zig`; EditorAPI impl + section iteration in `editor/Editor.zig`; trimmed
- `editor/explorer/settings.zig`; settings repoints across the pixel-art tree;
- `App.zig` passes the host to `PixelArt.init`.
-
-Sanity greps for the next agent:
-- `grep -rn 'fizzy.editor.settings' src/plugins/pixelart` → **0** (settings decoupled).
-- `grep -rhoE 'fizzy\.editor\.[a-zA-Z_]+' src/plugins/pixelart | sort | uniq -c | sort -rn`
- → the remaining Stage C surface (see "Stage C — remaining work").
+**Uncommitted** — nothing in this Phase-4 effort has been committed (commit on request).
+
+Beyond Stages A–C, the working tree now also has Stage D scaffold changes:
+`module.zig`, `pixelart.zig`, `State.zig`, `Globals.zig`, hub re-exports in `fizzy.zig`,
+shell import migration, `State.docs` + explorer/bottom-panel fields, `bridge.zig` removed.
+
+Sanity greps:
+
+```
+grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live
+grep -rn 'fizzy\.platform' src/plugins/pixelart → 0
+grep -rn 'fizzy\.app\.allocator' src/plugins/pixelart → 0
+grep -rn 'bridge\.' src/plugins/pixelart → 0
+grep -rn 'plugins/pixelart/' src --include='*.zig' → process_assets, fizzy module import, web_main
+```
All three configs green: `zig build`, `zig build check-web`, `zig build test`.
diff --git a/build.zig b/build.zig
index b7fa39af..85a57c51 100644
--- a/build.zig
+++ b/build.zig
@@ -1,13 +1,13 @@
const std = @import("std");
-const zip = @import("src/plugins/pixelart/deps/zip/build.zig");
+const zip = @import("src/plugins/pixelart/src/deps/zip/build.zig");
const dvui = @import("dvui");
const velopack = @import("velopack_zig");
const content_dir = "assets/";
-const ProcessAssetsStep = @import("src/tools/process_assets.zig");
+const ProcessAssetsStep = @import("process_assets.zig");
const update = @import("update.zig");
const GitDependency = update.GitDependency;
@@ -358,6 +358,7 @@ pub fn build(b: *std.Build) !void {
core_module_web.addImport("icons", dep.module("icons"));
}
web_exe.root_module.addImport("core", core_module_web);
+ wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module);
// Three editor files have `const sdl3 = @import("backend").c;` at file
// scope. After refactoring all `sdl3.SDL_DialogFileFilter` references
@@ -375,7 +376,7 @@ pub fn build(b: *std.Build) !void {
.root_module = b.addModule("zstbi_web", .{
.target = web_target,
.optimize = optimize,
- .root_source_file = b.path("src/plugins/pixelart/deps/stbi/zstbi.zig"),
+ .root_source_file = b.path("src/plugins/pixelart/src/deps/stbi/zstbi.zig"),
.link_libc = false,
.single_threaded = true,
}),
@@ -385,11 +386,11 @@ pub fn build(b: *std.Build) !void {
"-DSTBI_NO_SIMD=1",
};
zstbi_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c"),
+ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/stbi/zstbi.c"),
.flags = &zstbi_web_cflags,
});
zstbi_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c"),
+ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c"),
.flags = &zstbi_web_cflags,
});
web_exe.root_module.addImport("zstbi", zstbi_web_lib.root_module);
@@ -399,14 +400,14 @@ pub fn build(b: *std.Build) !void {
.root_module = b.addModule("msf_gif_web", .{
.target = web_target,
.optimize = optimize,
- .root_source_file = b.path("src/plugins/pixelart/deps/msf_gif/msf_gif.zig"),
+ .root_source_file = b.path("src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig"),
.link_libc = false,
.single_threaded = true,
}),
});
- const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/plugins/pixelart/deps/msf_gif/wasm_shim"};
+ const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/plugins/pixelart/src/deps/msf_gif/wasm_shim"};
msf_gif_web_lib.root_module.addCSourceFile(.{
- .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c"),
+ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c"),
.flags = &msf_gif_wasm_cflags,
});
web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module);
@@ -754,13 +755,13 @@ pub fn build(b: *std.Build) !void {
inline for (.{
.{ "fizzy-direction", "src/core/math/direction.zig" },
.{ "fizzy-easing", "src/core/math/easing.zig" },
- .{ "fizzy-layer-order", "src/plugins/pixelart/internal/layer_order.zig" },
- .{ "fizzy-palette-parse", "src/plugins/pixelart/internal/palette_parse.zig" },
+ .{ "fizzy-layer-order", "src/plugins/pixelart/src/internal/layer_order.zig" },
+ .{ "fizzy-palette-parse", "src/plugins/pixelart/src/internal/palette_parse.zig" },
.{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" },
- .{ "fizzy-reduce", "src/plugins/pixelart/algorithms/reduce.zig" },
- .{ "fizzy-grid-validate", "src/plugins/pixelart/internal/grid_layout_validate.zig" },
- .{ "fizzy-animation", "src/plugins/pixelart/Animation.zig" },
- .{ "fizzy-window-layout", "src/window_layout.zig" },
+ .{ "fizzy-reduce", "src/plugins/pixelart/src/algorithms/reduce.zig" },
+ .{ "fizzy-grid-validate", "src/plugins/pixelart/src/internal/grid_layout_validate.zig" },
+ .{ "fizzy-animation", "src/plugins/pixelart/src/Animation.zig" },
+ .{ "fizzy-window-layout", "src/backend/window_layout.zig" },
}) |entry| {
tests_module.addAnonymousImport(entry[0], .{
.root_source_file = b.path(entry[1]),
@@ -846,6 +847,7 @@ pub fn build(b: *std.Build) !void {
core_module_test.addImport("icons", dep.module("icons"));
}
fizzy_test_module.addImport("core", core_module_test);
+ wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
if (target.result.os.tag == .macos) {
if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
@@ -980,7 +982,7 @@ fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile
const rt = tc.target.result;
if (rt.os.tag != .windows or rt.abi != .msvc) continue;
// `-I` searches before `-isystem`, so this shim wins over MSVC's .
- tc.addIncludePath(b.path("src/tools/msvc_translatec_shim"));
+ tc.addIncludePath(b.path("src/backend/msvc_translatec_shim"));
// Pre-define SIZE_MAX so MSVC's stdint.h `#ifndef SIZE_MAX` block — which would
// otherwise install a `0xff…ui64` literal — skips itself. Belt-and-suspenders
// to the shim: covers the case where another header includes through
@@ -1104,22 +1106,22 @@ fn addFizzyExecutableForTarget(
.root_module = b.addModule("zstbi", .{
.target = resolved_target,
.optimize = optimize,
- .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/stbi/zstbi.zig" },
+ .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/src/deps/stbi/zstbi.zig" },
}),
});
const zstbi_module = zstbi_lib.root_module;
- zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/stbi/zstbi.c") });
+ zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/stbi/zstbi.c") });
const msf_gif_lib = b.addLibrary(.{
.name = "msf_gif",
.root_module = b.addModule("msf_gif", .{
.target = resolved_target,
.optimize = optimize,
- .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/deps/msf_gif/msf_gif.zig" },
+ .root_source_file = .{ .cwd_relative = "src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig" },
}),
});
const msf_gif_module = msf_gif_lib.root_module;
- msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/deps/msf_gif/msf_gif.c") });
+ msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/plugins/pixelart/src/deps/msf_gif/msf_gif.c") });
const exe = b.addExecutable(.{
.name = "fizzy",
@@ -1170,6 +1172,7 @@ fn addFizzyExecutableForTarget(
core_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
core_module.addImport("known-folders", known_folders);
exe.root_module.addImport("core", core_module);
+ wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module);
const singleton_app_dep = b.dependency("dvui_singleton_app", .{
.target = resolved_target,
@@ -1197,10 +1200,10 @@ fn addFizzyExecutableForTarget(
})) |dep| {
exe.root_module.addImport("objc", dep.module("objc"));
}
- exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyVisualEffectView.m") });
- exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyMenuTarget.m") });
- exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyTrackpadGesture.m") });
- exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyWindowMonitor.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyVisualEffectView.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyMenuTarget.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyTrackpadGesture.m") });
+ exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyWindowMonitor.m") });
} else if (resolved_target.result.os.tag == .windows) {
if (b.lazyDependency("zigwin32", .{})) |dep| {
exe.root_module.addImport("win32", dep.module("win32"));
@@ -1238,6 +1241,23 @@ fn addFizzyExecutableForTarget(
};
}
+/// Plugin SDK (`src/sdk/sdk.zig`). Depends only on `dvui`.
+fn wireSdkModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_module: *std.Build.Module,
+ consumer: *std.Build.Module,
+) void {
+ const sdk_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/sdk/sdk.zig"),
+ });
+ sdk_module.addImport("dvui", dvui_module);
+ consumer.addImport("sdk", sdk_module);
+}
+
inline fn thisDir() []const u8 {
return comptime std.fs.path.dirname(@src().file) orelse ".";
}
diff --git a/contributor.md b/contributor.md
deleted file mode 100644
index 3be379a5..00000000
--- a/contributor.md
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
-## Contributing
-
-Hello and thank you so much for considering contributing to Fizzy!
-
-By suggestion, this document will hopefully serve as a good starting point for understanding Fizzy's internals and where things are. However, if you ever have any questions or would like
-to have a conversation about Fizzy, please reach out to me on discord or add an issue. I'm "foxnne" on discord as well.
-
-### Overview
-
-Fizzy is built using several game development libraries by others in the Zig community, as well as a C library for handling zipped files. The dependencies are as follows:
- - ***mach-core***: Handles windowing and input, and uses the new zig package manager. This library and dependencies will be downloaded to the cache on build.
- - ***nfd_zig***: Native file dialogs wrapper, copied into the src/deps folder.
- - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/plugins/pixelart/deps/zig-gamedev folder.
- - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder.
- - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/plugins/pixelart/deps/zig-gamedev folder.
- - ***zip***: Wrapper for the zip library, copied into the src/deps folder.
-
-Outside of the `src` folder, we have `assets` which contain all assets that we would like to be copied over next to the executable and used by Fizzy at runtime.
-
-`fizzy.zig` holds all the main loop information and init, update, and deinit functions. Mach-core handles the main entry point and calls these functions for us. Mach-core is multi-threaded in the sense that there are two update loops, one which is run on the main thread, and one that runs in a separate thread. For more information about mach-core please see [the mach-core website](https://machengine.org/core/).
-
-Please note that we need to handle native file dialogs from the main thread, which is currently how Fizzy handles it. I tried to set this up as a request/response.
-
-Inside of the `src` folder we have several subfolders. I tried to organize the project based on a few categories as follows:
-
-Outside of these subfolders, please note that `assets.zig` is generated so don't edit this file.
-
-- **algorithms**: This folder holds any generalized algorithms for use in pixel art operations. As of writing this, it only currently contains the brezenham algorithm used
- by the stroke/pencil tool. This algorithm handles quick mouse movements when drawing and prevents broken lines, as each frame a line is drawn from the previous frame.
-
-- **deps**: This folder holds the previously outlined dependencies, except for those that are using the new zig package manager.
-- **editor**: This folder holds individual files generally with simple *draw()* functions that mimic the layout of the editor itself. I tried to use subfolders and similar to
- set the project up in a way that was easy to understand from looking at the editor itself.
- - i.e. `editor/artboard/canvas.zig` is the file responsible for the canvas within the main artboard, while `editor/artboard/flipbook/canvas.zig` is the canvas within the flipbook.
- - Note that `editor.zig` contains a bit more than just drawing of the editor panels, and contains many of the main *editor* related functions, like loading and opening files, setting the project folder,
- saving files, and importing png files.
-
-- **gfx**: Fizzy is set up similar to a game, with the flipbook and main artboard having a camera. Each file actually has its own Camera, which allows u
- to have individual views per file, and not a shared camera between all files. That means you can be working on two files and not have your camera move around as you switch.
- - Other things in gfx are general things related to textures, atlases, quads, etc. Some of this is unused currently and can be removed.
-
-- **input**: Input holds hotkeys and mouse information.
- - `Hotkeys.zig` is my attempt at trying to set up configurable hotkeys in the future.
-
-- **math**: General math functions I've written or picked up over time.
-- **shaders**: Currently doesn't get used, but in the future if we support using the GPU for some operations, the wgsl files would live here.
-- **storage**: This is where History, and the containers used to store information are. internal and external contain the structs used to describe a fizzy file internally, with additional information for the program to use, or externally, which should be easily exported as JSON.
-- **tools**: A few helpful things such as font-awesome mapping, an example of the build step to process assets, and the Packer struct, which is responsible for packing all sprites to an atlas.
-
-
-
-
-
-
-
diff --git a/src/tools/process_assets.zig b/process_assets.zig
similarity index 98%
rename from src/tools/process_assets.zig
rename to process_assets.zig
index d596bfdb..505042f9 100644
--- a/src/tools/process_assets.zig
+++ b/process_assets.zig
@@ -3,7 +3,7 @@ const path = std.fs.path;
const Step = std.Build.Step;
const Io = std.Io;
-const Atlas = @import("../plugins/pixelart/Atlas.zig");
+const Atlas = @import("src/plugins/pixelart/src/Atlas.zig");
const ProcessAssetsStep = @This();
step: Step,
diff --git a/src/App.zig b/src/App.zig
index 40f13a89..e93f5bb3 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -8,9 +8,9 @@ const assets = @import("assets");
const icon = assets.files.@"icon.png";
const fizzy = @import("fizzy.zig");
-const auto_update = @import("auto_update.zig");
-const update_notify = @import("update_notify.zig");
-const singleton = @import("singleton.zig");
+const auto_update = @import("backend/auto_update.zig");
+const update_notify = @import("backend/update_notify.zig");
+const singleton = @import("backend/singleton.zig");
const paths = fizzy.paths;
const App = @This();
@@ -168,8 +168,10 @@ pub fn AppInit(win: *dvui.Window) !void {
// Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
// `state`. Owned here for the app's lifetime; torn down in `AppDeinit`.
- fizzy.pixelart = try allocator.create(fizzy.PixelArt);
- fizzy.pixelart.* = fizzy.PixelArt.init(allocator, &fizzy.editor.host) catch unreachable;
+ fizzy.pixelart = try allocator.create(fizzy.State);
+ fizzy.pixelart_mod.Globals.gpa = allocator;
+ fizzy.pixelart_mod.Globals.state = fizzy.pixelart;
+ fizzy.pixelart.* = fizzy.State.init(allocator, &fizzy.editor.host) catch unreachable;
// Second-stage init that needs the editor at its final heap address (e.g.
// registering the workbench-api service whose `ctx` is this pointer).
@@ -181,6 +183,8 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.packer = try allocator.create(Packer);
fizzy.packer.* = Packer.init(allocator) catch unreachable;
+ fizzy.pixelart_mod.Globals.packer = fizzy.packer;
+
// Hand the window to the listener thread and queue our own argv so the
// first frame opens any files / project folder supplied on the command line.
singleton.registerWindow(win, resolved_argv);
@@ -226,6 +230,8 @@ pub fn AppInit(win: *dvui.Window) !void {
pub fn AppDeinit() void {
// Persist the current windowed frame while the window still exists. No-op off macOS.
fizzy.backend.saveWindowGeometry(fizzy.app.window);
+ // Persist `.fizproject` while `editor.host` and `editor.folder` are still live.
+ fizzy.State.persistProject(fizzy.pixelart);
fizzy.editor.deinit() catch unreachable;
// Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs).
// After the editor so any editor teardown that still reads pixel-art state runs first.
diff --git a/src/auto_update.zig b/src/backend/auto_update.zig
similarity index 100%
rename from src/auto_update.zig
rename to src/backend/auto_update.zig
diff --git a/src/backend_native.zig b/src/backend/backend_native.zig
similarity index 99%
rename from src/backend_native.zig
rename to src/backend/backend_native.zig
index e177739f..e785e85d 100644
--- a/src/backend_native.zig
+++ b/src/backend/backend_native.zig
@@ -1,5 +1,5 @@
// These are functions specific to the backend, which is currently SDL3
-const fizzy = @import("fizzy.zig");
+const fizzy = @import("../fizzy.zig");
const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
diff --git a/src/backend_web.zig b/src/backend/backend_web.zig
similarity index 98%
rename from src/backend_web.zig
rename to src/backend/backend_web.zig
index 0bf57f11..95299263 100644
--- a/src/backend_web.zig
+++ b/src/backend/backend_web.zig
@@ -8,7 +8,7 @@ const dvui = @import("dvui");
const builtin = @import("builtin");
const WebFileIo = if (builtin.target.cpu.arch == .wasm32)
- @import("editor/WebFileIo.zig")
+ @import("../editor/WebFileIo.zig")
else
struct {};
@@ -151,7 +151,7 @@ pub fn showOpenFolderDialog(
_: ?[]const u8,
) void {
if (comptime builtin.target.cpu.arch == .wasm32) {
- const Dialogs = @import("editor/dialogs/Dialogs.zig");
+ const Dialogs = @import("../editor/dialogs/Dialogs.zig");
Dialogs.WebFolderUnavailable.request();
}
}
diff --git a/src/file_assoc.zig b/src/backend/file_assoc.zig
similarity index 100%
rename from src/file_assoc.zig
rename to src/backend/file_assoc.zig
diff --git a/src/tools/msvc_translatec_shim/stdint.h b/src/backend/msvc_translatec_shim/stdint.h
similarity index 100%
rename from src/tools/msvc_translatec_shim/stdint.h
rename to src/backend/msvc_translatec_shim/stdint.h
diff --git a/src/objc/FizzyMenuTarget.m b/src/backend/objc/FizzyMenuTarget.m
similarity index 100%
rename from src/objc/FizzyMenuTarget.m
rename to src/backend/objc/FizzyMenuTarget.m
diff --git a/src/objc/FizzyTrackpadGesture.m b/src/backend/objc/FizzyTrackpadGesture.m
similarity index 100%
rename from src/objc/FizzyTrackpadGesture.m
rename to src/backend/objc/FizzyTrackpadGesture.m
diff --git a/src/objc/FizzyVisualEffectView.m b/src/backend/objc/FizzyVisualEffectView.m
similarity index 100%
rename from src/objc/FizzyVisualEffectView.m
rename to src/backend/objc/FizzyVisualEffectView.m
diff --git a/src/objc/FizzyWindowMonitor.m b/src/backend/objc/FizzyWindowMonitor.m
similarity index 99%
rename from src/objc/FizzyWindowMonitor.m
rename to src/backend/objc/FizzyWindowMonitor.m
index 707a841a..ca6e034b 100644
--- a/src/objc/FizzyWindowMonitor.m
+++ b/src/backend/objc/FizzyWindowMonitor.m
@@ -8,11 +8,11 @@
* Green-button maximize uses a native fullscreen Space (menu bar hidden).
* SDL3 ignores resize notifications while a Space transition animates, so a
* 60Hz NSTimer pump renders live frames during the morph. The Zig side
- * (src/backend_native.zig) pushes live contentView bounds into SDL before each
+ * (src/backend/backend_native.zig) pushes live contentView bounds into SDL before each
* frame so the Metal drawable and layout stay paired.
*
* The fizzy_macos_window_* callbacks below are exported from
- * src/backend_native.zig; everything else is self-contained. */
+ * src/backend/backend_native.zig; everything else is self-contained. */
extern void fizzy_macos_window_resize_cb(void);
extern void fizzy_macos_window_pump_frame(void);
@@ -20,7 +20,7 @@
extern void fizzy_macos_window_request_clear_frames(int frames);
extern void fizzy_macos_window_commit_steady_state(void);
/* Pure window-frame decisions live in window_layout.zig (unit-tested); see
- * backend_native.zig for the C-ABI wrappers. */
+ * backend/backend_native.zig for the C-ABI wrappers. */
extern int fizzy_macos_constrain_is_menu_bar_nudge(double rx, double ry, double rw, double rh,
double cx, double cy, double cw, double ch,
double visible_top);
diff --git a/src/singleton.zig b/src/backend/singleton.zig
similarity index 100%
rename from src/singleton.zig
rename to src/backend/singleton.zig
diff --git a/src/singleton_native.zig b/src/backend/singleton_native.zig
similarity index 99%
rename from src/singleton_native.zig
rename to src/backend/singleton_native.zig
index d52a071e..dd453999 100644
--- a/src/singleton_native.zig
+++ b/src/backend/singleton_native.zig
@@ -15,7 +15,7 @@ const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
const singleton_app = @import("singleton_app");
-const fizzy = @import("fizzy.zig");
+const fizzy = @import("../fizzy.zig");
const log = std.log.scoped(.singleton);
diff --git a/src/singleton_web.zig b/src/backend/singleton_web.zig
similarity index 100%
rename from src/singleton_web.zig
rename to src/backend/singleton_web.zig
diff --git a/src/update_install.zig b/src/backend/update_install.zig
similarity index 100%
rename from src/update_install.zig
rename to src/backend/update_install.zig
diff --git a/src/update_notify.zig b/src/backend/update_notify.zig
similarity index 99%
rename from src/update_notify.zig
rename to src/backend/update_notify.zig
index 7644f846..1c8de6a5 100644
--- a/src/update_notify.zig
+++ b/src/backend/update_notify.zig
@@ -7,7 +7,7 @@ const std = @import("std");
const dvui = @import("dvui");
const auto_update = @import("auto_update.zig");
const update_install = @import("update_install.zig");
-const fizzy = @import("fizzy.zig");
+const fizzy = @import("../fizzy.zig");
const Phase = enum(u8) {
pending,
diff --git a/src/web_io.zig b/src/backend/web_io.zig
similarity index 100%
rename from src/web_io.zig
rename to src/backend/web_io.zig
diff --git a/src/window_layout.zig b/src/backend/window_layout.zig
similarity index 99%
rename from src/window_layout.zig
rename to src/backend/window_layout.zig
index 4e8cccfe..af3ec513 100644
--- a/src/window_layout.zig
+++ b/src/backend/window_layout.zig
@@ -3,7 +3,7 @@
//! height" math is testable without a window. std-only — pulled in by
//! `tests/root.zig` and called from `backend_native.zig` (which keeps the
//! AppKit/SDL plumbing). Shell/native-windowing infra (not pixel-art), so it lives at
-//! `src/window_layout.zig` beside `backend_native.zig` rather than under `internal/`.
+//! `src/backend/window_layout.zig` beside `backend_native.zig` rather than under `internal/`.
const std = @import("std");
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index a90ea23e..206310bc 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -15,37 +15,35 @@ const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf
const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
-const update_notify = @import("../update_notify.zig");
+const update_notify = @import("../backend/update_notify.zig");
const App = fizzy.App;
const Editor = @This();
-pub const Colors = @import("../plugins/pixelart/Colors.zig");
-pub const Project = @import("../plugins/pixelart/Project.zig");
+const Project = fizzy.pixelart_mod.Project;
pub const Recents = @import("Recents.zig");
pub const Settings = @import("Settings.zig");
-pub const Tools = @import("../plugins/pixelart/Tools.zig");
+const Tools = fizzy.Tools;
pub const Dialogs = @import("dialogs/Dialogs.zig");
-pub const Transform = @import("../plugins/pixelart/Transform.zig");
pub const Keybinds = @import("Keybinds.zig");
-pub const Workspace = @import("../plugins/workbench/Workspace.zig");
+pub const Workspace = @import("../plugins/workbench/src/Workspace.zig");
pub const Explorer = @import("explorer/Explorer.zig");
pub const IgnoreRules = @import("explorer/IgnoreRules.zig");
pub const Panel = @import("panel/Panel.zig");
pub const Sidebar = @import("Sidebar.zig");
pub const Infobar = @import("Infobar.zig");
pub const Menu = @import("Menu.zig");
-pub const FileLoadJob = @import("../plugins/workbench/FileLoadJob.zig");
-pub const PackJob = @import("../plugins/pixelart/PackJob.zig");
+pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig");
+const PackJob = fizzy.PackJob;
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
/// Workbench (Phase 1): file-management home — currently the per-branch
/// decoration registry for the explorer; grows to own files + tabs/splits.
-pub const Workbench = @import("../plugins/workbench/Workbench.zig");
+pub const Workbench = @import("../plugins/workbench/src/Workbench.zig");
/// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels.
/// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame
@@ -82,7 +80,7 @@ ignore: IgnoreRules = .{},
themes: std.ArrayList(dvui.Theme) = .empty,
-open_files: std.AutoArrayHashMapUnmanaged(u64, fizzy.Internal.File) = .empty,
+open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty,
/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread runs
/// `Internal.File.fromPath` off the main thread; the main thread polls via `processLoadingJobs`
@@ -439,7 +437,7 @@ pub fn init(
return err;
};
- // Pixel-art tools/colors/palettes now init in `PixelArt.init` (App owns the
+ // Pixel-art tools/colors/palettes now init in `State.init` (App owns the
// `fizzy.pixelart` instance, created just after this `Editor.init` returns).
try Keybinds.register();
@@ -478,8 +476,8 @@ pub fn postInit(editor: *Editor) !void {
// near-empty shell's content: it iterates the Host registries rather than
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
// editor tick already runs on wasm. Order = sidebar order.
- try @import("../plugins/workbench/plugin.zig").register(&editor.host);
- try @import("../plugins/pixelart/plugin.zig").register(&editor.host);
+ try @import("../plugins/workbench/src/plugin.zig").register(&editor.host);
+ try fizzy.pixelart_mod.plugin.register(&editor.host);
// Shell built-in: Settings (owner = null; not a plugin).
try editor.host.registerSidebarView(.{
@@ -553,10 +551,39 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.markSettingsDirty = shellMarkSettingsDirty,
.contentOpacity = shellContentOpacity,
.isMaximized = shellIsMaximized,
+ .isMacOS = shellIsMacOS,
+ .appliesNativeWindowOpacity = shellAppliesNativeWindowOpacity,
.explorerRect = shellExplorerRect,
.explorerVirtualSize = shellExplorerVirtualSize,
.showSaveDialog = shellShowSaveDialog,
.uiAtlas = shellUiAtlas,
+ .activeDoc = shellActiveDoc,
+ .docByIndex = shellDocByIndex,
+ .docById = shellDocById,
+ .docIndex = shellDocIndex,
+ .openDocCount = shellOpenDocCount,
+ .setActiveDocIndex = shellSetActiveDocIndex,
+ .allocDocId = shellAllocDocId,
+ .accept = shellAccept,
+ .cancel = shellCancel,
+ .copy = shellCopy,
+ .paste = shellPaste,
+ .transform = shellTransform,
+ .save = shellSave,
+ .requestCompositeWarmup = shellRequestCompositeWarmup,
+ .requestGridLayoutDialog = shellRequestGridLayoutDialog,
+ .allocUntitledPath = shellAllocUntitledPath,
+ .createDocument = shellCreateDocument,
+ .requestSaveAs = shellRequestSaveAs,
+ .requestWebSave = shellRequestWebSave,
+ .cancelPendingSaveDialog = shellCancelPendingSaveDialog,
+ .setPendingCloseDocId = shellSetPendingCloseDocId,
+ .queueCloseAfterSave = shellQueueCloseAfterSave,
+ .trackQuitSaveInFlight = shellTrackQuitSaveInFlight,
+ .resumeSaveAllQuit = shellResumeSaveAllQuit,
+ .abortSaveAllQuit = shellAbortSaveAllQuit,
+ .startPackProject = shellStartPackProject,
+ .isPackingActive = shellIsPackingActive,
};
fn shellCtx(ctx: *anyopaque) *Editor {
@@ -581,6 +608,13 @@ fn shellIsMaximized(ctx: *anyopaque) bool {
_ = ctx;
return fizzy.backend.isMaximized(dvui.currentWindow());
}
+fn shellIsMacOS(_: *anyopaque) bool {
+ return fizzy.platform.isMacOS();
+}
+fn shellAppliesNativeWindowOpacity(_: *anyopaque) bool {
+ if (comptime builtin.target.cpu.arch == .wasm32) return false;
+ return builtin.os.tag == .macos or builtin.os.tag == .windows;
+}
fn shellExplorerRect(ctx: *anyopaque) dvui.Rect {
return shellCtx(ctx).explorer.rect;
}
@@ -606,6 +640,133 @@ fn shellUiAtlas(ctx: *anyopaque) sdk.EditorAPI.UiAtlasView {
.sprites = @as([]const sdk.EditorAPI.UiSprite, @ptrCast(atlas.sprites)),
};
}
+fn shellActiveDoc(ctx: *anyopaque) ?sdk.DocHandle {
+ return shellCtx(ctx).activeDoc();
+}
+fn shellDocByIndex(ctx: *anyopaque, index: usize) ?sdk.DocHandle {
+ return shellCtx(ctx).docAt(index);
+}
+fn shellDocById(ctx: *anyopaque, id: u64) ?sdk.DocHandle {
+ return shellCtx(ctx).docById(id);
+}
+fn shellDocIndex(ctx: *anyopaque, id: u64) ?usize {
+ return shellCtx(ctx).open_files.getIndex(id);
+}
+fn shellOpenDocCount(ctx: *anyopaque) usize {
+ return shellCtx(ctx).open_files.count();
+}
+fn shellSetActiveDocIndex(ctx: *anyopaque, index: usize) void {
+ shellCtx(ctx).setActiveFile(index);
+}
+fn shellAllocDocId(ctx: *anyopaque) u64 {
+ return shellCtx(ctx).newFileID();
+}
+fn shellAccept(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).accept();
+}
+fn shellCancel(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).cancel();
+}
+fn shellCopy(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).copy();
+}
+fn shellPaste(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).paste();
+}
+fn shellTransform(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).transform();
+}
+fn shellSave(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).save();
+}
+fn shellRequestCompositeWarmup(ctx: *anyopaque) void {
+ shellCtx(ctx).requestCompositeWarmup();
+}
+fn shellRequestGridLayoutDialog(ctx: *anyopaque) void {
+ shellCtx(ctx).requestGridLayoutDialog();
+}
+fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 {
+ return shellCtx(ctx).allocNextUntitledPath();
+}
+fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle {
+ const editor = shellCtx(ctx);
+ const file = try editor.newFile(path, .{
+ .columns = grid.columns,
+ .rows = grid.rows,
+ .column_width = grid.column_width,
+ .row_height = grid.row_height,
+ });
+ const owner = fizzy.pixelart_mod.plugin.pluginPtr();
+ return .{ .ptr = file, .owner = owner, .id = file.id };
+}
+fn shellRequestSaveAs(ctx: *anyopaque) void {
+ shellCtx(ctx).requestSaveAs();
+}
+fn shellRequestWebSave(ctx: *anyopaque, kind: sdk.EditorAPI.WebSaveKind) void {
+ const native_kind: Dialogs.WebSaveAs.Kind = switch (kind) {
+ .save => .save,
+ .save_as => .save_as,
+ };
+ shellCtx(ctx).requestWebSaveDialog(native_kind);
+}
+fn shellCancelPendingSaveDialog(ctx: *anyopaque) void {
+ shellCtx(ctx).cancelPendingSaveDialog();
+}
+fn shellSetPendingCloseDocId(ctx: *anyopaque, id: u64) void {
+ shellCtx(ctx).pending_close_file_id = id;
+}
+fn shellQueueCloseAfterSave(ctx: *anyopaque, id: u64) anyerror!void {
+ try shellCtx(ctx).pending_close_after_save.put(fizzy.app.allocator, id, {});
+}
+fn shellTrackQuitSaveInFlight(ctx: *anyopaque, id: u64) anyerror!void {
+ try shellCtx(ctx).quit_saves_in_flight.put(fizzy.app.allocator, id, {});
+}
+fn shellResumeSaveAllQuit(ctx: *anyopaque) void {
+ shellCtx(ctx).pending_quit_continue = true;
+}
+fn shellAbortSaveAllQuit(ctx: *anyopaque) void {
+ shellCtx(ctx).abortSaveAllQuit();
+}
+fn shellStartPackProject(ctx: *anyopaque) anyerror!void {
+ return shellCtx(ctx).startPackProject();
+}
+fn shellIsPackingActive(ctx: *anyopaque) bool {
+ return shellCtx(ctx).isPackingActive();
+}
+
+/// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`:
+/// `docs.files` may reallocate and invalidate pointers stored at insert time.
+pub fn fileFromDoc(_: *Editor, doc: sdk.DocHandle) *fizzy.Internal.File {
+ return fizzy.pixelart.docs.fileById(doc.id).?;
+}
+
+pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle {
+ if (index >= editor.open_files.values().len) return null;
+ return editor.open_files.values()[index];
+}
+
+pub fn docById(editor: *Editor, id: u64) ?sdk.DocHandle {
+ return editor.open_files.get(id);
+}
+
+pub fn activeDoc(editor: *Editor) ?sdk.DocHandle {
+ if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| {
+ return editor.docAt(workspace.open_file_index);
+ }
+ return null;
+}
+
+/// Store a loaded/created document in the plugin registry and register its handle.
+pub fn insertOpenDoc(editor: *Editor, file: fizzy.Internal.File, owner: *sdk.Plugin) !void {
+ try fizzy.pixelart.docs.files.put(fizzy.app.allocator, file.id, file);
+ const ptr = fizzy.pixelart.docs.files.getPtr(file.id).?;
+ try editor.open_files.put(fizzy.app.allocator, file.id, .{
+ // `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`.
+ .ptr = ptr,
+ .owner = owner,
+ .id = file.id,
+ });
+}
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void {
@@ -733,8 +894,8 @@ pub fn markSettingsDirty(editor: *Editor) void {
}
fn activelyDrawing(editor: *Editor) bool {
- for (editor.open_files.values()) |*file| {
- if (file.editor.active_drawing) return true;
+ for (editor.open_files.values()) |doc| {
+ if (editor.fileFromDoc(doc).editor.active_drawing) return true;
}
return false;
}
@@ -828,7 +989,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// Drain any "Save and Close" requests whose async save has settled.
editor.tickPendingSaveCloses();
var needs_save_status_anim_tick = false;
- for (editor.open_files.values()) |*f| {
+ for (editor.open_files.values()) |doc| {
+ const f = editor.fileFromDoc(doc);
f.tickSaveDoneFlash();
if (f.showsSaveStatusIndicator()) needs_save_status_anim_tick = true;
}
@@ -853,8 +1015,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
if (!want_quit) continue;
var dirty_n: usize = 0;
- for (editor.open_files.values()) |f| {
- if (f.dirty()) dirty_n += 1;
+ for (editor.open_files.values()) |doc| {
+ if (editor.fileFromDoc(doc).dirty()) dirty_n += 1;
}
if (dirty_n == 0) continue;
@@ -910,7 +1072,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
{
var any_drawing = false;
fizzy.perf.draw_stroke_buf_count = 0; // no active stroke → 0; else first active file's map size
- for (editor.open_files.values()) |*file| {
+ for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (file.editor.active_drawing) {
any_drawing = true;
fizzy.perf.draw_stroke_buf_count = file.buffers.stroke.pixels.count();
@@ -1126,7 +1289,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// Always reset the peek layer index back, but we need to do this outside of the file widget so
// other editor windows can use it
- defer for (editor.open_files.values()) |*file| {
+ defer for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (file.editor.isolate_layer) {
file.peek_layer_index = file.selected_layer_index;
} else {
@@ -1590,7 +1754,7 @@ pub fn drawRadialMenu(editor: *Editor) !void {
// fixed until close so tool buttons remain hoverable/clickable.
const center = fw.data().rectScale().pointFromPhysical(fizzy.pixelart.tools.radial_menu.center);
- const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len;
+ const tool_count: usize = std.meta.fields(Tools.Tool).len;
const radius: f32 = 50.0;
const width: f32 = radius * 2.0;
@@ -1667,7 +1831,7 @@ pub fn drawRadialMenu(editor: *Editor) !void {
rect.x -= rect.w / 2.0;
rect.y -= rect.h / 2.0;
- const tool = @as(Editor.Tools.Tool, @enumFromInt(i));
+ const tool = @as(Tools.Tool, @enumFromInt(i));
var button: dvui.ButtonWidget = undefined;
button.init(@src(), .{}, .{
@@ -1696,7 +1860,7 @@ pub fn drawRadialMenu(editor: *Editor) !void {
.color => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.color_selection_default],
};
- const sprite = switch (@as(Editor.Tools.Tool, @enumFromInt(i))) {
+ const sprite = switch (@as(Tools.Tool, @enumFromInt(i))) {
.pointer => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.cursor_default],
.pencil => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pencil_default],
.eraser => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.eraser_default],
@@ -1788,10 +1952,12 @@ pub fn drawRadialMenu(editor: *Editor) !void {
pub fn rebuildWorkspaces(editor: *Editor) !void {
// Create workspaces for each grouping ID
- for (editor.open_files.values()) |*file| {
+ for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (!editor.workspaces.contains(file.editor.grouping)) {
var workspace: fizzy.Editor.Workspace = .init(file.editor.grouping);
- for (editor.open_files.values()) |*f| {
+ for (editor.open_files.values()) |d| {
+ const f = editor.fileFromDoc(d);
if (f.editor.grouping == file.editor.grouping) {
workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
}
@@ -1811,7 +1977,8 @@ pub fn rebuildWorkspaces(editor: *Editor) !void {
}
var contains: bool = false;
- for (editor.open_files.values()) |*file| {
+ for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (file.editor.grouping == workspace.grouping) {
contains = true;
break;
@@ -1939,8 +2106,7 @@ fn tickPendingSaveCloses(editor: *Editor) void {
var i: usize = 0;
while (i < editor.pending_close_after_save.count()) {
const id = editor.pending_close_after_save.keys()[i];
- const file_ptr = editor.open_files.getPtr(id);
- if (file_ptr) |f| {
+ if (fizzy.pixelart.docs.fileById(id)) |f| {
if (f.isSaving()) {
i += 1;
continue;
@@ -1970,7 +2136,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
// Pass 1: kick off any queued saves we haven't started yet.
while (editor.quit_save_all_ids.items.len > 0) {
const id = editor.quit_save_all_ids.items[0];
- const file_ptr = editor.open_files.getPtr(id) orelse {
+ const file_ptr = fizzy.pixelart.docs.fileById(id) orelse {
_ = editor.quit_save_all_ids.swapRemove(0);
continue;
};
@@ -2019,8 +2185,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
var i: usize = 0;
while (i < editor.quit_saves_in_flight.count()) {
const id = editor.quit_saves_in_flight.keys()[i];
- const file_ptr = editor.open_files.getPtr(id);
- if (file_ptr) |f| {
+ if (fizzy.pixelart.docs.fileById(id)) |f| {
if (f.isSaving()) {
i += 1;
continue;
@@ -2051,8 +2216,8 @@ pub fn close(app: *App, editor: *Editor) void {
return;
}
var dirty_n: usize = 0;
- for (editor.open_files.values()) |f| {
- if (f.dirty()) dirty_n += 1;
+ for (editor.open_files.values()) |doc| {
+ if (editor.fileFromDoc(doc).dirty()) dirty_n += 1;
}
if (dirty_n > 0) {
Dialogs.AppQuitUnsaved.request();
@@ -2073,15 +2238,15 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
- editor.host.setActiveSidebarView(@import("../plugins/workbench/plugin.zig").view_files);
+ editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files);
fizzy.pixelart.project = Project.load(fizzy.app.allocator) catch null;
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
}
pub fn saving(editor: *Editor) bool {
- for (editor.open_files.values()) |file| {
- if (file.saving) return true;
+ for (editor.open_files.values()) |doc| {
+ if (editor.fileFromDoc(doc).saving) return true;
}
return false;
}
@@ -2098,7 +2263,7 @@ pub fn saving(editor: *Editor) bool {
pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u64) !?usize {
if (editor.getFileFromPath(path)) |file| {
const idx = editor.open_files.getIndex(file.id) orelse return error.Unexpected;
- editor.open_files.values()[idx].editor.grouping = grouping;
+ editor.fileAt(idx).?.editor.grouping = grouping;
editor.setActiveFile(idx);
return idx;
}
@@ -2121,7 +2286,8 @@ pub fn clearFileTreeTabDragDropState(editor: *Editor) void {
pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool {
// Already open? Just focus it.
- for (editor.open_files.values(), 0..) |*file, i| {
+ for (editor.open_files.values(), 0..) |doc, i| {
+ const file = editor.fileFromDoc(doc);
if (std.mem.eql(u8, file.path, path)) {
editor.setActiveFile(i);
return false;
@@ -2170,7 +2336,8 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool {
/// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`).
pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !fizzy.Internal.File {
- for (editor.open_files.values()) |*file| {
+ for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (std.mem.eql(u8, file.path, path)) {
if (editor.open_files.getIndex(file.id)) |idx| {
editor.setActiveFile(idx);
@@ -2224,7 +2391,15 @@ pub fn processLoadingJobs(editor: *Editor) void {
var file = result;
file.editor.grouping = job.target_grouping;
- editor.open_files.put(fizzy.app.allocator, file.id, file) catch {
+ const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse {
+ dvui.log.err("No plugin for loaded file: {s}", .{job.path});
+ var f = file;
+ f.deinit();
+ job.destroy();
+ continue;
+ };
+
+ editor.insertOpenDoc(file, owner) catch {
dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path});
// We still own `file` here — clean it up.
var f = file;
@@ -2358,7 +2533,8 @@ fn runWasmPackWorkers(_: *Editor) void {
}
fn appendOpenPackInputs(editor: *Editor, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void {
- for (editor.open_files.values()) |*open_file| {
+ for (editor.open_files.values()) |doc| {
+ const open_file = editor.fileFromDoc(doc);
const snapshot = try PackJob.PackFile.fromOpenFile(fizzy.app.allocator, open_file);
try inputs.append(fizzy.app.allocator, .{ .open = snapshot });
}
@@ -2400,7 +2576,8 @@ fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.F
if (editor.getFileFromPath(path)) |file| return file;
const basename = std.fs.path.basename(path);
- for (editor.open_files.values()) |*file| {
+ for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue;
if (std.mem.eql(u8, file.path, path)) return file;
if (editor.folder) |folder| {
@@ -2473,7 +2650,7 @@ pub fn processPackJob(editor: *Editor) void {
}
fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
job.result_consumed = true;
- editor.host.setActiveSidebarView(@import("../plugins/pixelart/plugin.zig").view_project);
+ editor.host.setActiveSidebarView(fizzy.pixelart_mod.plugin.view_project);
const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null;
showPackToast("Project packed", toast_canvas);
} else blk: {
@@ -2719,17 +2896,18 @@ pub fn newFile(editor: *Editor, path: []const u8, options: fizzy.Internal.File.I
return error.FailedToCreateFile;
};
- try editor.open_files.put(fizzy.app.allocator, file.id, file);
+ try editor.insertOpenDoc(file, fizzy.pixelart_mod.plugin.pluginPtr());
editor.setActiveFile(editor.open_files.count() - 1);
editor.pending_composite_warmup = true;
- return editor.open_files.getPtr(file.id) orelse return error.FailedToCreateFile;
+ return fizzy.pixelart.docs.fileById(file.id) orelse return error.FailedToCreateFile;
}
-/// Heap-owned path like `untitled-1`, unique among `open_files` basenames.
+/// Heap-owned path like `untitled-1`, unique among open-document basenames.
pub fn allocNextUntitledPath(editor: *Editor) ![]u8 {
var max_n: u32 = 0;
- for (editor.open_files.values()) |f| {
+ for (editor.open_files.values()) |doc| {
+ const f = editor.fileFromDoc(doc);
const base = std.fs.path.basename(f.path);
if (std.mem.startsWith(u8, base, "untitled-")) {
const suffix = base["untitled-".len..];
@@ -2786,8 +2964,7 @@ pub fn requestNewFileDialog(_: *Editor) void {
}
pub fn setActiveFile(editor: *Editor, index: usize) void {
- if (index >= editor.open_files.values().len) return;
- const file = editor.open_files.values()[index];
+ const file = editor.fileAt(index) orelse return;
const grouping = file.editor.grouping;
if (editor.workspaces.getPtr(grouping)) |workspace| {
@@ -2798,30 +2975,21 @@ pub fn setActiveFile(editor: *Editor, index: usize) void {
/// Returns the actively focused file, through workspace grouping.
pub fn activeFile(editor: *Editor) ?*fizzy.Internal.File {
- if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| {
- return editor.getFile(workspace.open_file_index);
- }
-
- return null;
+ const doc = editor.activeDoc() orelse return null;
+ return editor.fileFromDoc(doc);
}
pub fn getFile(editor: *Editor, index: usize) ?*fizzy.Internal.File {
- if (editor.open_files.values().len == 0) return null;
- if (index >= editor.open_files.values().len) return null;
-
- return &editor.open_files.values()[index];
+ return editor.fileAt(index);
}
-pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.File {
- if (editor.open_files.values().len == 0) return null;
-
- for (editor.open_files.values()) |*file| {
- if (std.mem.eql(u8, file.path, path)) {
- return file;
- }
- }
+pub fn fileAt(editor: *Editor, index: usize) ?*fizzy.Internal.File {
+ const doc = editor.docAt(index) orelse return null;
+ return editor.fileFromDoc(doc);
+}
- return null;
+pub fn getFileFromPath(_: *Editor, path: []const u8) ?*fizzy.Internal.File {
+ return fizzy.pixelart.docs.fileFromPath(path);
}
pub fn forceCloseFile(editor: *Editor, index: usize) !void {
@@ -3227,7 +3395,8 @@ pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void
/// or flat-raster confirmation are skipped — the user can save those individually.
/// Files that are already saving are also skipped (their `saveAsync` no-ops).
pub fn saveAll(editor: *Editor) !void {
- for (editor.open_files.values()) |*file| {
+ for (editor.open_files.values()) |doc| {
+ const file = editor.fileFromDoc(doc);
if (!file.dirty()) continue;
if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue;
if (file.shouldConfirmFlatRasterSave()) continue;
@@ -3275,7 +3444,7 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void {
if (file_id) |id| {
_ = editor.pending_close_after_save.swapRemove(id);
- if (editor.open_files.getPtr(id)) |f| {
+ if (fizzy.pixelart.docs.fileById(id)) |f| {
f.resetSaveUIState();
}
} else if (editor.activeFile()) |f| {
@@ -3425,38 +3594,43 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void {
}
pub fn closeFileID(editor: *Editor, id: u64) !void {
- if (editor.open_files.get(id)) |file| {
- if (file.dirty()) {
- Dialogs.UnsavedClose.request(id);
- return;
+ if (editor.open_files.contains(id)) {
+ if (fizzy.pixelart.docs.fileById(id)) |file| {
+ if (file.dirty()) {
+ Dialogs.UnsavedClose.request(id);
+ return;
+ }
}
try editor.rawCloseFileID(id);
}
}
pub fn closeFile(editor: *Editor, index: usize) !void {
- const file = editor.open_files.values()[index];
- try editor.closeFileID(file.id);
+ const doc = editor.docAt(index) orelse return;
+ try editor.closeFileID(doc.id);
}
-/// Tear down a file's resources via its owning plugin, falling back to a direct
-/// `deinit` when no plugin claims the extension. The shell still owns removing the
-/// entry from `open_files`; this only releases the document's own resources.
-fn closeDocumentResources(editor: *Editor, file: *fizzy.Internal.File) void {
- if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
- if (plugin.closeDocument(.{ .ptr = file, .owner = plugin, .id = file.id })) return;
+/// Tear down a document via its owning plugin, falling back to a direct `deinit`.
+/// Removes the entry from the plugin's document registry; the shell still removes
+/// the matching `DocHandle` from `open_files`.
+fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void {
+ if (doc.owner.closeDocument(doc)) {
+ _ = fizzy.pixelart.docs.files.swapRemove(doc.id);
+ return;
}
- file.deinit();
+ editor.fileFromDoc(doc).deinit();
+ _ = fizzy.pixelart.docs.files.swapRemove(doc.id);
}
pub fn rawCloseFile(editor: *Editor, index: usize) !void {
- //editor.open_file_index = 0;
- var file = editor.open_files.values()[index];
+ const doc = editor.docAt(index) orelse return;
+ const file = editor.fileFromDoc(doc);
if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| {
- if (workspace.open_file_index == fizzy.editor.open_files.getIndex(file.id)) {
- for (fizzy.editor.open_files.values(), 0..) |f, i| {
- if (f.grouping == workspace.grouping and f.id != file.id) {
+ if (workspace.open_file_index == index) {
+ for (editor.open_files.values(), 0..) |d, i| {
+ const f = editor.fileFromDoc(d);
+ if (f.editor.grouping == workspace.grouping and f.id != file.id) {
workspace.open_file_index = i;
break;
}
@@ -3464,27 +3638,28 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void {
}
}
- editor.closeDocumentResources(&file);
+ editor.closeDocumentResources(doc);
editor.open_files.orderedRemoveAt(index);
}
pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
- if (editor.open_files.getPtr(id)) |file| {
-
- //editor.open_file_index = 0;
- if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| {
- if (workspace.open_file_index == fizzy.editor.open_files.getIndex(file.id)) {
- for (fizzy.editor.open_files.values(), 0..) |f, i| {
- if (f.editor.grouping == workspace.grouping and f.id != file.id) {
- workspace.open_file_index = i;
- break;
- }
+ const doc = editor.open_files.get(id) orelse return;
+ const file = editor.fileFromDoc(doc);
+
+ if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| {
+ if (workspace.open_file_index == editor.open_files.getIndex(file.id)) {
+ for (editor.open_files.values(), 0..) |d, i| {
+ const f = editor.fileFromDoc(d);
+ if (f.editor.grouping == workspace.grouping and f.id != file.id) {
+ workspace.open_file_index = i;
+ break;
}
}
}
- editor.closeDocumentResources(file);
- _ = editor.open_files.orderedRemove(id);
}
+
+ editor.closeDocumentResources(doc);
+ _ = editor.open_files.orderedRemove(id);
}
pub fn closeReference(editor: *Editor, index: usize) !void {
@@ -3553,7 +3728,7 @@ pub fn deinit(editor: *Editor) !void {
editor.workbench.deinit();
// Pixel-art state (tools/colors/project/pack jobs) is torn down by
- // `PixelArt.deinit` in `App.AppDeinit`, after this returns.
+ // `State.deinit` in `App.AppDeinit`, after this returns.
editor.ignore.deinit(fizzy.app.allocator);
diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig
index 9110c24e..0d0beb1a 100644
--- a/src/editor/Infobar.zig
+++ b/src/editor/Infobar.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const fizzy = @import("../fizzy.zig");
const dvui = @import("dvui");
const icons = @import("icons");
-const update_notify = @import("../update_notify.zig");
+const update_notify = @import("../backend/update_notify.zig");
const Dialogs = fizzy.Editor.Dialogs;
pub const Infobar = @This();
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index b6ae5cca..c5714204 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -92,7 +92,7 @@ pub fn tick() !void {
if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) {
- if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
+ if (fizzy.pixelart.tools.stroke_size < fizzy.Tools.max_brush_size - 1)
fizzy.pixelart.tools.stroke_size += 1;
fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index 09c51b02..6a83cc57 100644
--- a/src/editor/Menu.zig
+++ b/src/editor/Menu.zig
@@ -157,7 +157,8 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
// Save All is enabled whenever any open file is dirty with a recognized
// extension. Worker queue handles them serially; UI stays responsive.
const any_dirty = blk: {
- for (fizzy.editor.open_files.values()) |*f| {
+ for (fizzy.editor.open_files.values()) |doc| {
+ const f = fizzy.editor.fileFromDoc(doc);
if (f.dirty() and fizzy.Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true;
}
break :blk false;
diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig
index 2582e00d..8acf190a 100644
--- a/src/editor/WebFileIo.zig
+++ b/src/editor/WebFileIo.zig
@@ -80,7 +80,13 @@ pub fn pollOpenPicker(editor: *fizzy.Editor) void {
const path_owned = fizzy.app.allocator.dupe(u8, wasm_file.name) catch continue;
if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |file| {
- editor.open_files.put(fizzy.app.allocator, file.id, file) catch {
+ const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse {
+ var f = file;
+ f.deinit();
+ fizzy.app.allocator.free(path_owned);
+ continue;
+ };
+ editor.insertOpenDoc(file, owner) catch {
var f = file;
f.deinit();
fizzy.app.allocator.free(path_owned);
diff --git a/src/editor/dialogs/AboutFizzy.zig b/src/editor/dialogs/AboutFizzy.zig
index eb0b9313..8b15a4a2 100644
--- a/src/editor/dialogs/AboutFizzy.zig
+++ b/src/editor/dialogs/AboutFizzy.zig
@@ -3,8 +3,8 @@ const builtin = @import("builtin");
const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const build_opts = @import("build_opts");
-const auto_update = @import("../../auto_update.zig");
-const update_notify = @import("../../update_notify.zig");
+const auto_update = @import("../../backend/auto_update.zig");
+const update_notify = @import("../../backend/update_notify.zig");
const assets = @import("assets");
fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool {
diff --git a/src/editor/dialogs/AppQuitUnsaved.zig b/src/editor/dialogs/AppQuitUnsaved.zig
index 4246abff..b2e05023 100644
--- a/src/editor/dialogs/AppQuitUnsaved.zig
+++ b/src/editor/dialogs/AppQuitUnsaved.zig
@@ -31,8 +31,8 @@ pub fn request() void {
fn dirtyCount() usize {
var n: usize = 0;
- for (fizzy.editor.open_files.values()) |f| {
- if (f.dirty()) n += 1;
+ for (fizzy.editor.open_files.values()) |doc| {
+ if (fizzy.editor.fileFromDoc(doc).dirty()) n += 1;
}
return n;
}
@@ -112,7 +112,8 @@ fn onSaveAllAndQuit() !void {
fizzy.dvui.closeFloatingDialogAnchored();
fizzy.editor.quit_save_all_ids.clearRetainingCapacity();
- for (fizzy.editor.open_files.values()) |f| {
+ for (fizzy.editor.open_files.values()) |doc| {
+ const f = fizzy.editor.fileFromDoc(doc);
if (f.dirty()) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, f.id);
}
if (fizzy.editor.quit_save_all_ids.items.len == 0) {
diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig
index 37f75577..43d7cbac 100644
--- a/src/editor/dialogs/Dialogs.zig
+++ b/src/editor/dialogs/Dialogs.zig
@@ -1,15 +1,16 @@
const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
+const fizzy = @import("../../fizzy.zig");
const Dialogs = @This();
-pub const NewFile = @import("../../plugins/pixelart/dialogs/NewFile.zig");
-pub const Export = @import("../../plugins/pixelart/dialogs/Export.zig");
+pub const NewFile = fizzy.pixelart_mod.dialogs.NewFile;
+pub const Export = fizzy.pixelart_mod.dialogs.Export;
pub const UnsavedClose = @import("UnsavedClose.zig");
pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig");
-pub const GridLayout = @import("../../plugins/pixelart/dialogs/GridLayout.zig");
-pub const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig");
+pub const GridLayout = fizzy.pixelart_mod.dialogs.GridLayout;
+pub const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning;
pub const AboutFizzy = @import("AboutFizzy.zig");
pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32)
@import("WebFolderUnavailable.zig")
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index 35210347..32cb0511 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -1,7 +1,7 @@
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const FlatRasterSaveWarning = @import("../../plugins/pixelart/dialogs/FlatRasterSaveWarning.zig");
+const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning;
pub fn request(file_id: u64) void {
var mutex = fizzy.dvui.dialog(@src(), .{
@@ -21,7 +21,7 @@ pub fn request(file_id: u64) void {
}
fn fileBasename(file_id: u64) []const u8 {
- const file = fizzy.editor.open_files.get(file_id) orelse return "?";
+ const file = fizzy.pixelart.docs.fileById(file_id) orelse return "?";
return std.fs.path.basename(file.path);
}
@@ -111,7 +111,7 @@ fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void {
}
fn onSaveAndClose(file_id: u64) !void {
- const file = fizzy.editor.open_files.getPtr(file_id) orelse return;
+ const file = fizzy.pixelart.docs.fileById(file_id) orelse return;
if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
fizzy.editor.setActiveFile(idx);
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 84ce657f..731678b5 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -13,10 +13,10 @@ const nfd = @import("nfd");
pub const Explorer = @This();
-pub const files = @import("../../plugins/workbench/files.zig");
+pub const files = @import("../../plugins/workbench/src/files.zig");
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
-pub const project = @import("../../plugins/pixelart/explorer/project.zig");
+pub const project = fizzy.pixelart_mod.explorer.project;
pub const settings = @import("settings.zig");
paned: *fizzy.dvui.PanedWidget = undefined,
@@ -107,7 +107,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
.background = false,
});
- if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/plugin.zig").view_files)) {
+ if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/src/plugin.zig").view_files)) {
fizzy.editor.file_tree_data_id = null;
if (fizzy.editor.tab_drag_from_tree_path) |p| {
fizzy.app.allocator.free(p);
diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig
index 0e669db4..f63c1c39 100644
--- a/src/editor/panel/Panel.zig
+++ b/src/editor/panel/Panel.zig
@@ -11,9 +11,6 @@ const Packer = fizzy.Packer;
pub const Panel = @This();
-pub const Sprites = @import("../../plugins/pixelart/panel/sprites.zig");
-
-sprites: Sprites = .{},
paned: *fizzy.dvui.PanedWidget = undefined,
scroll_info: dvui.ScrollInfo = .{
.horizontal = .auto,
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 561e3b0b..d9c46493 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -17,14 +17,15 @@ pub const version: std.SemanticVersion = .{
pub const atlas = core.atlas;
// Other helpers and namespaces
-pub const algorithms = @import("plugins/pixelart/algorithms/algorithms.zig");
+pub const pixelart_mod = @import("plugins/pixelart/module.zig");
+pub const algorithms = pixelart_mod.algorithms;
+pub const render = pixelart_mod.render;
+pub const sprite_render = pixelart_mod.sprite_render;
+pub const Tools = pixelart_mod.Tools;
+pub const Transform = pixelart_mod.Transform;
+pub const PackJob = pixelart_mod.PackJob;
pub const fs = core.fs;
pub const image = core.image;
-pub const render = @import("plugins/pixelart/render.zig");
-
-/// Pixel-art sprite renderer (layer compositing, reflections, cover-flow). Shell UI
-/// icons use `fizzy.core.Sprite.draw` from core instead.
-pub const sprite_render = @import("plugins/pixelart/sprite_render.zig");
pub const perf = core.perf;
pub const water_surface = core.water_surface;
pub const math = core.math;
@@ -33,56 +34,35 @@ pub const App = @import("App.zig");
pub const Editor = @import("editor/Editor.zig");
pub const Explorer = @import("editor/explorer/Explorer.zig");
pub const Fling = core.Fling;
-pub const Packer = @import("plugins/pixelart/Packer.zig");
+pub const Packer = pixelart_mod.Packer;
//pub const Popups = @import("editor/popups/Popups.zig");
pub const Sidebar = @import("editor/Sidebar.zig");
-/// Pixel-art plugin state (Phase 4 Stage B): the tools/colors/project/clipboard/
-/// pack-job fields formerly hung off the shell `Editor`.
-pub const PixelArt = @import("plugins/pixelart/PixelArt.zig");
+/// Pixel-art plugin state (Phase 4 Stage B/D): reached via `fizzy.pixelart` global.
+pub const State = pixelart_mod.State;
// Global pointers
pub var app: *App = undefined;
pub var editor: *Editor = undefined;
pub var packer: *Packer = undefined;
-pub var pixelart: *PixelArt = undefined;
-
-/// Internal types
-/// These types contain additional data to support the editor
-/// An example of this is File. fizzy.File matches the file type to read from JSON,
-/// while the fizzy.Internal.File contains cameras, timers, file-specific editor fields.
-pub const Internal = struct {
- pub const Animation = @import("plugins/pixelart/internal/Animation.zig");
- pub const Atlas = @import("plugins/pixelart/internal/Atlas.zig");
- pub const Buffers = @import("plugins/pixelart/internal/Buffers.zig");
- pub const File = @import("plugins/pixelart/internal/File.zig");
- pub const History = @import("plugins/pixelart/internal/History.zig");
- pub const Layer = @import("plugins/pixelart/internal/Layer.zig");
- pub const Palette = @import("plugins/pixelart/internal/Palette.zig");
- pub const Sprite = @import("plugins/pixelart/internal/Sprite.zig");
-};
-
-/// Frame-by-frame sprite animation
-pub const Animation = @import("plugins/pixelart/Animation.zig");
-
-/// Contains lists of sprites and animations
-pub const Atlas = @import("plugins/pixelart/Atlas.zig");
-
-/// The data that gets written to disk in a .pixi file and read back into this type
-pub const File = @import("plugins/pixelart/File.zig");
+pub var pixelart: *State = undefined;
-/// Contains information such as the name, visibility and collapse settings of a texture layer
-pub const Layer = @import("plugins/pixelart/Layer.zig");
+/// Internal runtime types for open documents (cameras, history, buffers, …).
+pub const Internal = pixelart_mod.internal;
-/// Source location within the atlas texture and origin location
-pub const Sprite = @import("plugins/pixelart/Sprite.zig");
+/// On-disk / JSON pixel-art types.
+pub const Animation = pixelart_mod.Animation;
+pub const Atlas = pixelart_mod.Atlas;
+pub const File = pixelart_mod.File;
+pub const Layer = pixelart_mod.Layer;
+pub const Sprite = pixelart_mod.Sprite;
/// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web
/// builds, where `builtin.os.tag` is always `.freestanding`.
pub const platform = core.platform;
/// Plugin SDK surface
-pub const sdk = @import("sdk/sdk.zig");
+pub const sdk = @import("sdk");
/// Custom dvui stuff
pub const dvui = core.dvui;
@@ -90,11 +70,11 @@ pub const dvui = core.dvui;
/// Custom backend stuff. Split per-arch: native uses SDL3 + objc + win32; web is a
/// no-op stub layer (no window chrome, no native dialogs, no native menu bar).
/// Zig only semantically analyzes the chosen branch, so the wasm build never sees
-/// the SDL3 / objc / win32 imports inside `backend_native.zig`.
+/// the SDL3 / objc / win32 imports inside `backend/backend_native.zig`.
pub const backend = if (@import("builtin").target.cpu.arch == .wasm32)
- @import("backend_web.zig")
+ @import("backend/backend_web.zig")
else
- @import("backend_native.zig");
+ @import("backend/backend_native.zig");
pub const paths = core.paths;
diff --git a/src/plugins/pixelart/Colors.zig b/src/plugins/pixelart/Colors.zig
deleted file mode 100644
index 5c987ee9..00000000
--- a/src/plugins/pixelart/Colors.zig
+++ /dev/null
@@ -1,10 +0,0 @@
-const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
-
-const Self = @This();
-
-primary: [4]u8 = .{ 255, 255, 255, 255 },
-secondary: [4]u8 = .{ 0, 0, 0, 255 },
-height: u8 = 0,
-palette: ?fizzy.Internal.Palette = null,
-file_tree_palette: ?fizzy.Internal.Palette = null,
diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixelart/module.zig
new file mode 100644
index 00000000..55dc09c6
--- /dev/null
+++ b/src/plugins/pixelart/module.zig
@@ -0,0 +1,51 @@
+//! Pixel-art plugin compile-time module root (Phase 4 Stage D).
+//!
+//! Wired in `build.zig` as `b.addModule("pixelart", .{ .root_source_file = "module.zig" })`.
+//! Shell code imports this as `@import("pixelart")`. Plugin files inside `src/` import
+//! `../pixelart.zig` for shared types and `Globals`.
+pub const pixelart = @import("pixelart.zig");
+pub const Globals = pixelart.Globals;
+pub const State = @import("src/State.zig");
+pub const Settings = @import("src/Settings.zig");
+pub const Docs = @import("src/Docs.zig");
+pub const Tools = @import("src/Tools.zig");
+pub const Transform = @import("src/Transform.zig");
+pub const Project = @import("src/Project.zig");
+pub const Colors = @import("src/Colors.zig");
+pub const Packer = @import("src/Packer.zig");
+pub const PackJob = @import("src/PackJob.zig");
+pub const plugin = @import("src/plugin.zig");
+
+pub const dialogs = struct {
+ pub const NewFile = @import("src/dialogs/NewFile.zig");
+ pub const Export = @import("src/dialogs/Export.zig");
+ pub const GridLayout = @import("src/dialogs/GridLayout.zig");
+ pub const FlatRasterSaveWarning = @import("src/dialogs/FlatRasterSaveWarning.zig");
+};
+
+pub const explorer = struct {
+ pub const project = @import("src/explorer/project.zig");
+};
+
+pub const render = @import("src/render.zig");
+pub const sprite_render = @import("src/sprite_render.zig");
+pub const algorithms = @import("src/algorithms/algorithms.zig");
+
+/// On-disk / JSON types.
+pub const File = @import("src/File.zig");
+pub const Layer = @import("src/Layer.zig");
+pub const Sprite = @import("src/Sprite.zig");
+pub const Atlas = @import("src/Atlas.zig");
+pub const Animation = @import("src/Animation.zig");
+
+/// Editor/runtime types (cameras, history, buffers, …).
+pub const internal = struct {
+ pub const Animation = @import("src/internal/Animation.zig");
+ pub const Atlas = @import("src/internal/Atlas.zig");
+ pub const Buffers = @import("src/internal/Buffers.zig");
+ pub const File = @import("src/internal/File.zig");
+ pub const History = @import("src/internal/History.zig");
+ pub const Layer = @import("src/internal/Layer.zig");
+ pub const Palette = @import("src/internal/Palette.zig");
+ pub const Sprite = @import("src/internal/Sprite.zig");
+};
diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig
new file mode 100644
index 00000000..66e29e95
--- /dev/null
+++ b/src/plugins/pixelart/pixelart.zig
@@ -0,0 +1,54 @@
+//! Intra-plugin import hub for the pixel-art plugin (Phase 4 Stage D).
+//!
+//! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or
+//! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals
+//! and shared plugin types. The compile-time module root for the build is `module.zig`;
+//! shell code reaches the plugin through `@import("pixelart")`.
+//!
+//! Files that still need shell workbench types (`Editor.Workspace`) keep a local
+//! `fizzy` import until that surface moves behind EditorAPI.
+const std = @import("std");
+
+pub const sdk = @import("sdk");
+pub const core = @import("core");
+pub const dvui = @import("dvui");
+pub const atlas = core.atlas;
+pub const math = core.math;
+pub const image = core.image;
+pub const fs = core.fs;
+pub const perf = core.perf;
+pub const Fling = core.Fling;
+pub const water_surface = core.water_surface;
+pub const core_sprite = core.Sprite;
+pub const Globals = @import("src/Globals.zig");
+
+/// On-disk file format version stamp (kept in sync with `fizzy.version`).
+pub const version: std.SemanticVersion = .{ .major = 0, .minor = 2, .patch = 0 };
+
+pub const State = @import("src/State.zig");
+pub const Settings = @import("src/Settings.zig");
+pub const Docs = @import("src/Docs.zig");
+pub const Tools = @import("src/Tools.zig");
+pub const Transform = @import("src/Transform.zig");
+pub const Animation = @import("src/Animation.zig");
+pub const Layer = @import("src/Layer.zig");
+pub const Sprite = @import("src/Sprite.zig");
+pub const Atlas = @import("src/Atlas.zig");
+pub const File = @import("src/File.zig");
+pub const render = @import("src/render.zig");
+pub const sprite_render = @import("src/sprite_render.zig");
+pub const algorithms = @import("src/algorithms/algorithms.zig");
+
+pub const internal = struct {
+ pub const File = @import("src/internal/File.zig");
+ pub const Layer = @import("src/internal/Layer.zig");
+ pub const Palette = @import("src/internal/Palette.zig");
+ pub const Atlas = @import("src/internal/Atlas.zig");
+ pub const History = @import("src/internal/History.zig");
+ pub const Buffers = @import("src/internal/Buffers.zig");
+ pub const Animation = @import("src/internal/Animation.zig");
+ pub const Sprite = @import("src/internal/Sprite.zig");
+};
+
+/// Layer rename buffer size (was `Editor.Constants.max_name_len`).
+pub const max_name_len = 256;
diff --git a/src/plugins/pixelart/Animation.zig b/src/plugins/pixelart/src/Animation.zig
similarity index 100%
rename from src/plugins/pixelart/Animation.zig
rename to src/plugins/pixelart/src/Animation.zig
diff --git a/src/plugins/pixelart/Atlas.zig b/src/plugins/pixelart/src/Atlas.zig
similarity index 100%
rename from src/plugins/pixelart/Atlas.zig
rename to src/plugins/pixelart/src/Atlas.zig
diff --git a/src/plugins/pixelart/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig
similarity index 96%
rename from src/plugins/pixelart/CanvasData.zig
rename to src/plugins/pixelart/src/CanvasData.zig
index 868bb422..3cb74427 100644
--- a/src/plugins/pixelart/CanvasData.zig
+++ b/src/plugins/pixelart/src/CanvasData.zig
@@ -11,12 +11,14 @@
//! toasts) intentionally stays on `Workspace`.
const std = @import("std");
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const icons = @import("icons");
const FileWidget = @import("widgets/FileWidget.zig");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
const Workspace = fizzy.Editor.Workspace;
-const File = fizzy.Internal.File;
+const File = pixelart.internal.File;
const CanvasData = @This();
@@ -48,8 +50,8 @@ edit_pill_expanded: bool = false,
pub fn init(grouping: u64) CanvasData {
return .{
- .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag",
- .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag",
+ .columns_drag_name = std.fmt.allocPrint(Globals.allocator(), "column_drag_{d}", .{grouping}) catch "column_drag",
+ .rows_drag_name = std.fmt.allocPrint(Globals.allocator(), "row_drag_{d}", .{grouping}) catch "row_drag",
};
}
@@ -62,7 +64,7 @@ pub fn deinit(_: *CanvasData) void {}
/// first use. Called from the plugin's `drawDocument` each frame a document pane renders.
pub fn ensure(ws: *Workspace) *CanvasData {
if (ws.plugin_view_state) |p| return @ptrCast(@alignCast(p));
- const self = fizzy.app.allocator.create(CanvasData) catch @panic("OOM allocating CanvasData");
+ const self = Globals.allocator().create(CanvasData) catch @panic("OOM allocating CanvasData");
self.* = CanvasData.init(ws.grouping);
ws.plugin_view_state = self;
ws.plugin_view_destroy = destroyOpaque;
@@ -81,7 +83,7 @@ pub fn fromWorkspace(ws: *Workspace) ?*CanvasData {
fn destroyOpaque(state: *anyopaque) void {
const self: *CanvasData = @ptrCast(@alignCast(state));
self.deinit();
- fizzy.app.allocator.destroy(self);
+ Globals.allocator().destroy(self);
}
pub const RulerOrientation = enum {
@@ -99,15 +101,15 @@ pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation)
const largest_label_size = font.textSize(largest_label);
const natural_scale = dvui.currentWindow().natural_scale;
const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical);
- const base_ruler_size = largest_label_size.w + fizzy.pixelart.settings.ruler_padding;
+ const base_ruler_size = largest_label_size.w + Globals.state.settings.ruler_padding;
const ruler_thickness: f32 = switch (orientation) {
.horizontal => blk: {
- self.horizontal_ruler_height = font.textSize("M").h + fizzy.pixelart.settings.ruler_padding;
+ self.horizontal_ruler_height = font.textSize("M").h + Globals.state.settings.ruler_padding;
break :blk self.horizontal_ruler_height;
},
.vertical => blk: {
- self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.pixelart.settings.ruler_padding);
+ self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + Globals.state.settings.ruler_padding);
break :blk self.vertical_ruler_width;
},
};
@@ -228,7 +230,7 @@ fn drawRulerContent(
.vertical => self.rows_drag_name,
};
- var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{
+ var reorder = pixelart.core.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{
.expand = .both,
.margin = dvui.Rect.all(0),
.padding = dvui.Rect.all(0),
@@ -278,7 +280,7 @@ fn drawRulerContent(
.horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 },
.vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) },
};
- const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) {
+ const reorder_mode: pixelart.core.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) {
.horizontal => .any_y,
.vertical => .any_x,
};
@@ -316,7 +318,7 @@ fn drawRulerContent(
var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill);
- if (fizzy.dvui.hovered(reorderable.data())) {
+ if (pixelart.core.dvui.hovered(reorderable.data())) {
button_color = dvui.themeGet().color(.control, .fill_hover);
dvui.cursorSet(.hand);
}
@@ -591,7 +593,7 @@ pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void {
else
font.textSize(label).scale(natural, dvui.Size.Physical);
- const padding = fizzy.pixelart.settings.ruler_padding * natural;
+ const padding = Globals.state.settings.ruler_padding * natural;
var label_rect = rect;
@@ -787,12 +789,12 @@ pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetD
});
defer box.deinit();
if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) {
- fizzy.editor.cancel() catch {
+ Globals.state.host.cancel() catch {
dvui.log.err("Failed to cancel transform", .{});
};
}
if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) {
- fizzy.editor.accept() catch {
+ Globals.state.host.accept() catch {
dvui.log.err("Failed to accept transform", .{});
};
}
@@ -806,7 +808,7 @@ pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetD
/// single hamburger circle; tapping toggles the row of action buttons in/out with a
/// width animation.
pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
- const file = fizzy.editor.activeFile() orelse return;
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse return;
const button_size: f32 = 36;
const button_gap: f32 = 6;
@@ -864,8 +866,8 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
// shrinks, and once it's narrower than the pill we bail and draw nothing this frame —
// so closing splits cleanly hides the menu.
const wb = container.rectScale().r.toNatural();
- const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0;
- const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0;
+ const ruler_top: f32 = if (Globals.state.settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (Globals.state.settings.show_rulers) self.vertical_ruler_width else 0;
const canvas_nat = dvui.Rect{
.x = wb.x + ruler_left,
.y = wb.y + ruler_top,
@@ -1041,12 +1043,12 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
const fully_expanded = anim_value >= 0.999;
if (btn.clicked() and enabled and fully_expanded) {
switch (entry.action) {
- .save => fizzy.editor.save() catch {
+ .save => Globals.state.host.save() catch {
dvui.log.err("Failed to save", .{});
},
.exportd => {
// Open the Export dialog (same configuration the `export` keybind uses).
- var mutex = fizzy.dvui.dialog(@src(), .{
+ var mutex = pixelart.core.dvui.dialog(@src(), .{
.displayFn = fizzy.Editor.Dialogs.Export.dialog,
.callafterFn = fizzy.Editor.Dialogs.Export.callAfter,
.title = "Export...",
@@ -1065,16 +1067,16 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
.redo => file.history.undoRedo(file, .redo) catch {
dvui.log.err("Failed to redo", .{});
},
- .copy => fizzy.editor.copy() catch {
+ .copy => Globals.state.host.copy() catch {
dvui.log.err("Failed to copy", .{});
},
- .paste => fizzy.editor.paste() catch {
+ .paste => Globals.state.host.paste() catch {
dvui.log.err("Failed to paste", .{});
},
- .transform => fizzy.editor.transform() catch {
+ .transform => Globals.state.host.transform() catch {
dvui.log.err("Failed to start transform", .{});
},
- .grid_layout => fizzy.editor.requestGridLayoutDialog(),
+ .grid_layout => Globals.state.host.requestGridLayoutDialog(),
}
}
}
@@ -1088,7 +1090,7 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
/// the existing color-dropper magnifier at the touch location. On release we read the
/// color underneath the sample point and apply it to the primary color slot.
pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void {
- const file = fizzy.editor.activeFile() orelse return;
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse return;
const pill_button_size: f32 = 36;
const pill_padding: f32 = 6;
@@ -1102,8 +1104,8 @@ pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void {
// Anchor against the same canvas-scroll-area rect the pill uses.
const wb = container.rectScale().r.toNatural();
- const ruler_top: f32 = if (fizzy.pixelart.settings.show_rulers) self.horizontal_ruler_height else 0;
- const ruler_left: f32 = if (fizzy.pixelart.settings.show_rulers) self.vertical_ruler_width else 0;
+ const ruler_top: f32 = if (Globals.state.settings.show_rulers) self.horizontal_ruler_height else 0;
+ const ruler_left: f32 = if (Globals.state.settings.show_rulers) self.vertical_ruler_width else 0;
const canvas_nat = dvui.Rect{
.x = wb.x + ruler_left,
.y = wb.y + ruler_top,
diff --git a/src/plugins/pixelart/src/Colors.zig b/src/plugins/pixelart/src/Colors.zig
new file mode 100644
index 00000000..6fc49554
--- /dev/null
+++ b/src/plugins/pixelart/src/Colors.zig
@@ -0,0 +1,11 @@
+const std = @import("std");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+
+const Self = @This();
+
+primary: [4]u8 = .{ 255, 255, 255, 255 },
+secondary: [4]u8 = .{ 0, 0, 0, 255 },
+height: u8 = 0,
+palette: ?pixelart.internal.Palette = null,
+file_tree_palette: ?pixelart.internal.Palette = null,
diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixelart/src/Docs.zig
new file mode 100644
index 00000000..7ce735de
--- /dev/null
+++ b/src/plugins/pixelart/src/Docs.zig
@@ -0,0 +1,37 @@
+//! Open-document registry for the pixel-art plugin (Phase 4 docs/tabs inversion).
+//!
+//! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the
+//! concrete `Internal.File` values their `ptr` fields point at.
+const std = @import("std");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const sdk = pixelart.sdk;
+const Internal = pixelart.internal;
+
+const Docs = @This();
+
+files: std.AutoArrayHashMapUnmanaged(u64, Internal.File) = .{},
+
+pub fn fileFrom(self: *Docs, doc: sdk.DocHandle) *Internal.File {
+ return self.files.getPtr(doc.id).?;
+}
+
+pub fn activeFile(self: *Docs, host: *sdk.Host) ?*Internal.File {
+ const doc = host.activeDoc() orelse return null;
+ return self.fileFrom(doc);
+}
+
+pub fn fileById(self: *Docs, id: u64) ?*Internal.File {
+ return self.files.getPtr(id);
+}
+
+pub fn fileFromPath(self: *Docs, path: []const u8) ?*Internal.File {
+ for (self.files.values()) |*file| {
+ if (std.mem.eql(u8, file.path, path)) return file;
+ }
+ return null;
+}
+
+pub fn deinit(self: *Docs, allocator: std.mem.Allocator) void {
+ self.files.deinit(allocator);
+}
diff --git a/src/plugins/pixelart/File.zig b/src/plugins/pixelart/src/File.zig
similarity index 84%
rename from src/plugins/pixelart/File.zig
rename to src/plugins/pixelart/src/File.zig
index 6f0f786e..df2157cf 100644
--- a/src/plugins/pixelart/File.zig
+++ b/src/plugins/pixelart/src/File.zig
@@ -1,5 +1,8 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+
+const Layer = @import("Layer.zig");
+const Sprite = @import("Sprite.zig");
+const Animation = @import("Animation.zig");
const File = @This();
@@ -13,11 +16,11 @@ column_width: u32,
row_height: u32,
// Layer data
-layers: []fizzy.Layer,
+layers: []Layer,
// Origins of sprites
-sprites: []fizzy.Sprite,
+sprites: []Sprite,
// Lists of sprite indexes and timings
-animations: []fizzy.Animation,
+animations: []Animation,
pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
for (self.layers) |*layer| {
@@ -39,9 +42,9 @@ pub const FileV3 = struct {
rows: u32,
column_width: u32,
row_height: u32,
- layers: []fizzy.Layer,
- sprites: []fizzy.Sprite,
- animations: []fizzy.Animation.AnimationV2,
+ layers: []Layer,
+ sprites: []Sprite,
+ animations: []Animation.AnimationV2,
pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
for (self.layers) |*layer| {
@@ -63,9 +66,9 @@ pub const FileV2 = struct {
height: u32,
tile_width: u32,
tile_height: u32,
- layers: []fizzy.Layer,
- sprites: []fizzy.Sprite,
- animations: []fizzy.Animation.AnimationV2,
+ layers: []Layer,
+ sprites: []Sprite,
+ animations: []Animation.AnimationV2,
pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
for (self.layers) |*layer| {
@@ -87,9 +90,9 @@ pub const FileV1 = struct {
height: u32,
tile_width: u32,
tile_height: u32,
- layers: []fizzy.Layer,
- sprites: []fizzy.Sprite,
- animations: []fizzy.Animation.AnimationV1,
+ layers: []Layer,
+ sprites: []Sprite,
+ animations: []Animation.AnimationV1,
pub fn deinit(self: *File, allocator: std.mem.Allocator) void {
for (self.layers) |*layer| {
diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig
new file mode 100644
index 00000000..16ce8f0d
--- /dev/null
+++ b/src/plugins/pixelart/src/Globals.zig
@@ -0,0 +1,15 @@
+//! Runtime injection points for the pixel-art plugin (Phase 4 Stage D).
+//!
+//! The shell sets these once during `App` startup so plugin code can reach the
+//! app allocator and singletons without importing `fizzy.zig`.
+const std = @import("std");
+const State = @import("State.zig");
+const Packer = @import("Packer.zig");
+
+pub var gpa: std.mem.Allocator = undefined;
+pub var state: *State = undefined;
+pub var packer: *Packer = undefined;
+
+pub fn allocator() std.mem.Allocator {
+ return gpa;
+}
diff --git a/src/plugins/pixelart/LDTKTileset.zig b/src/plugins/pixelart/src/LDTKTileset.zig
similarity index 87%
rename from src/plugins/pixelart/LDTKTileset.zig
rename to src/plugins/pixelart/src/LDTKTileset.zig
index 09303032..216a59d6 100644
--- a/src/plugins/pixelart/LDTKTileset.zig
+++ b/src/plugins/pixelart/src/LDTKTileset.zig
@@ -1,5 +1,4 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const core = @import("mach").core;
pub const LDTKCompatibility = struct {
diff --git a/src/plugins/pixelart/Layer.zig b/src/plugins/pixelart/src/Layer.zig
similarity index 100%
rename from src/plugins/pixelart/Layer.zig
rename to src/plugins/pixelart/src/Layer.zig
diff --git a/src/plugins/pixelart/PackJob.zig b/src/plugins/pixelart/src/PackJob.zig
similarity index 93%
rename from src/plugins/pixelart/PackJob.zig
rename to src/plugins/pixelart/src/PackJob.zig
index 2d3882a6..e3583213 100644
--- a/src/plugins/pixelart/PackJob.zig
+++ b/src/plugins/pixelart/src/PackJob.zig
@@ -7,7 +7,7 @@
//! worker only ever touches its own `PackFile` values plus the app allocator.
//!
//! The worker produces a finished `Internal.Atlas` (RGBA pixels + sprite/animation data). The
-//! main thread swaps it into `fizzy.packer.atlas` via `Editor.processPackJob` once `done` is
+//! main thread swaps it into `Globals.packer.atlas` via `Editor.processPackJob` once `done` is
//! published.
//!
//! Ownership / threading model:
@@ -17,11 +17,12 @@
//! - `phase` / `cancelled` are atomic; either side may read or write them.
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const zstbi = @import("zstbi");
-const perf = fizzy.perf;
+const perf = pixelart.perf;
const reduce_alg = @import("algorithms/reduce.zig");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
const PackJob = @This();
@@ -60,7 +61,7 @@ pub const PackSprite = struct {
pub const PackAnimation = struct {
name: []u8,
- frames: []fizzy.Animation.Frame,
+ frames: []pixelart.Animation.Frame,
fn deinit(self: *PackAnimation, allocator: std.mem.Allocator) void {
allocator.free(self.name);
@@ -81,7 +82,7 @@ pub const PackFile = struct {
/// Deep-copy the pack-relevant fields of an in-memory file. Caller must run on the main
/// thread (reads the file's pixel buffers, which the editor may otherwise mutate).
- pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const fizzy.Internal.File) !PackFile {
+ pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const pixelart.internal.File) !PackFile {
const src_layers = file.layers.slice();
var layers = try allocator.alloc(PackLayer, src_layers.len);
@@ -97,7 +98,7 @@ pub const PackFile = struct {
const sz = dvui.imageSize(layer.source) catch dvui.Size{ .w = 0, .h = 0 };
const layer_w: u32 = @intFromFloat(sz.w);
const layer_h: u32 = @intFromFloat(sz.h);
- const src_pixels = fizzy.image.pixels(layer.source);
+ const src_pixels = pixelart.image.pixels(layer.source);
const name_copy = try allocator.dupe(u8, layer.name);
errdefer allocator.free(name_copy);
@@ -135,7 +136,7 @@ pub const PackFile = struct {
const anim = src_anims.get(a);
const name_copy = try allocator.dupe(u8, anim.name);
errdefer allocator.free(name_copy);
- const frames_copy = try allocator.dupe(fizzy.Animation.Frame, anim.frames);
+ const frames_copy = try allocator.dupe(pixelart.Animation.Frame, anim.frames);
anims[a] = .{ .name = name_copy, .frames = frames_copy };
anims_initialized = a + 1;
}
@@ -155,7 +156,7 @@ pub const PackFile = struct {
/// Build a snapshot by loading the file from disk. Safe to call from any thread.
pub fn fromPath(allocator: std.mem.Allocator, path: []const u8) !?PackFile {
- const maybe_file = try fizzy.Internal.File.fromPath(path);
+ const maybe_file = try pixelart.internal.File.fromPath(path);
var file = maybe_file orelse return null;
defer file.deinit();
return try PackFile.fromOpenFile(allocator, &file);
@@ -213,7 +214,7 @@ done: std.atomic.Value(bool) = .init(false),
/// Worker output. Read only after `done.load(.acquire)`. The main thread takes ownership of
/// the inner allocations when it consumes the job; subsequent `destroy()` will leave the
/// fields alone.
-result_atlas: ?fizzy.Internal.Atlas = null,
+result_atlas: ?pixelart.internal.Atlas = null,
/// Set to `true` once the main thread has consumed `result_atlas` (so `destroy()` knows not
/// to free the moved-out atlas allocations).
@@ -238,11 +239,11 @@ pub fn destroy(job: *PackJob) void {
a.free(job.inputs);
// Free any unconsumed result. `result_consumed` is set by the main thread when it moves
- // the atlas into `fizzy.packer.atlas`; in that case the new owner is responsible for the
+ // the atlas into `Globals.packer.atlas`; in that case the new owner is responsible for the
// allocations and we must not double-free.
if (job.result_atlas != null and !job.result_consumed) {
const atlas = job.result_atlas.?;
- a.free(fizzy.image.bytes(atlas.source));
+ a.free(pixelart.image.bytes(atlas.source));
for (atlas.data.animations) |*anim| a.free(anim.name);
a.free(atlas.data.animations);
a.free(atlas.data.sprites);
@@ -294,10 +295,10 @@ pub fn workerMain(job: *PackJob) void {
dvui.refresh(job.window, @src(), null);
}
- // Worker-local scratch. The final atlas allocations are made through `fizzy.app.allocator`
+ // Worker-local scratch. The final atlas allocations are made through `Globals.allocator()`
// so they outlive the job; everything else (sprite refs, frames, animations, any
// `.path`-loaded `PackFile`s, collapse carry-overs) lives in `ws` and is freed below.
- const work = WorkerState.init(fizzy.app.allocator) catch |e| {
+ const work = WorkerState.init(Globals.allocator()) catch |e| {
job.err = e;
job.phase.store(@intFromEnum(Phase.failed), .release);
return;
@@ -341,7 +342,7 @@ pub fn workerMain(job: *PackJob) void {
return;
}
job.phase.store(@intFromEnum(Phase.loading), .release);
- const maybe_pf = PackFile.fromPath(fizzy.app.allocator, path) catch |e| {
+ const maybe_pf = PackFile.fromPath(Globals.allocator(), path) catch |e| {
job.err = e;
job.phase.store(@intFromEnum(Phase.failed), .release);
return;
@@ -403,10 +404,10 @@ pub fn workerMain(job: *PackJob) void {
if (job.cancelled.load(.monotonic)) {
// Free the atlas we just built since the consumer won't take it.
- fizzy.app.allocator.free(fizzy.image.bytes(atlas.source));
- for (atlas.data.animations) |*anim| fizzy.app.allocator.free(anim.name);
- fizzy.app.allocator.free(atlas.data.animations);
- fizzy.app.allocator.free(atlas.data.sprites);
+ Globals.allocator().free(pixelart.image.bytes(atlas.source));
+ for (atlas.data.animations) |*anim| Globals.allocator().free(anim.name);
+ Globals.allocator().free(atlas.data.animations);
+ Globals.allocator().free(atlas.data.sprites);
job.phase.store(@intFromEnum(Phase.cancelled), .release);
return;
}
@@ -440,7 +441,7 @@ const WorkerSprite = struct {
const WorkerAnimation = struct {
name: []u8,
- frames: []fizzy.Animation.Frame,
+ frames: []pixelart.Animation.Frame,
fn deinit(self: *WorkerAnimation, allocator: std.mem.Allocator) void {
allocator.free(self.name);
@@ -590,7 +591,7 @@ const WorkerState = struct {
if (anim.frames.len == 0) continue;
if (anim.frames[0].sprite_index != sprite_index) continue;
- const frames = try self.allocator.alloc(fizzy.Animation.Frame, anim.frames.len);
+ const frames = try self.allocator.alloc(pixelart.Animation.Frame, anim.frames.len);
for (frames, anim.frames, 0..) |*current_frame, src_frame, i| {
current_frame.* = .{
.sprite_index = new_sprite_index + i,
@@ -668,10 +669,10 @@ const WorkerState = struct {
/// and panics off the main thread. Build the atlas as a plain pixel buffer + raw
/// `pixelsPMA` ImageSource directly; first use of the source on the main thread will pick
/// up a fresh texture-cache key because `.invalidation = .ptr` keys on the pixel pointer.
- fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !fizzy.Internal.Atlas {
+ fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !pixelart.internal.Atlas {
const num_pixels: usize = @as(usize, tex_size[0]) * @as(usize, tex_size[1]);
- const pixels = try fizzy.app.allocator.alloc([4]u8, num_pixels);
- errdefer fizzy.app.allocator.free(pixels);
+ const pixels = try Globals.allocator().alloc([4]u8, num_pixels);
+ errdefer Globals.allocator().free(pixels);
@memset(pixels, .{ 0, 0, 0, 0 });
const tex_w: usize = tex_size[0];
@@ -698,23 +699,23 @@ const WorkerState = struct {
}
}
- const sprites_out = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, self.sprites.items.len);
- errdefer fizzy.app.allocator.free(sprites_out);
+ const sprites_out = try Globals.allocator().alloc(pixelart.Atlas.Sprite, self.sprites.items.len);
+ errdefer Globals.allocator().free(sprites_out);
for (sprites_out, self.sprites.items, self.frames.items) |*dst, src, src_rect| {
dst.source = .{ src_rect.x, src_rect.y, src_rect.w, src_rect.h };
dst.origin = src.origin;
}
- const animations_out = try fizzy.app.allocator.alloc(fizzy.Animation, self.animations.items.len);
+ const animations_out = try Globals.allocator().alloc(pixelart.Animation, self.animations.items.len);
var anims_initialized: usize = 0;
errdefer {
- for (animations_out[0..anims_initialized]) |*anim| fizzy.app.allocator.free(anim.name);
- fizzy.app.allocator.free(animations_out);
+ for (animations_out[0..anims_initialized]) |*anim| Globals.allocator().free(anim.name);
+ Globals.allocator().free(animations_out);
}
for (animations_out, self.animations.items) |*dst, src| {
- dst.name = try fizzy.app.allocator.dupe(u8, src.name);
- errdefer fizzy.app.allocator.free(dst.name);
- dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames);
+ dst.name = try Globals.allocator().dupe(u8, src.name);
+ errdefer Globals.allocator().free(dst.name);
+ dst.frames = try Globals.allocator().dupe(pixelart.Animation.Frame, src.frames);
anims_initialized += 1;
}
diff --git a/src/plugins/pixelart/Packer.zig b/src/plugins/pixelart/src/Packer.zig
similarity index 81%
rename from src/plugins/pixelart/Packer.zig
rename to src/plugins/pixelart/src/Packer.zig
index ff9688ff..7af26053 100644
--- a/src/plugins/pixelart/Packer.zig
+++ b/src/plugins/pixelart/src/Packer.zig
@@ -1,8 +1,9 @@
const std = @import("std");
const zstbi = @import("zstbi");
const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
-const fizzy = @import("../../fizzy.zig");
pub const LDTKTileset = @import("LDTKTileset.zig");
@@ -31,16 +32,16 @@ pub const Sprite = struct {
frames: std.array_list.Managed(zstbi.Rect),
sprites: std.array_list.Managed(Sprite),
-animations: std.array_list.Managed(fizzy.Animation),
+animations: std.array_list.Managed(pixelart.Animation),
id_counter: u32 = 0,
placeholder: Image,
contains_height: bool = false,
-open_files: std.array_list.Managed(fizzy.Internal.File),
+open_files: std.array_list.Managed(pixelart.internal.File),
target: PackTarget = .project,
//camera: fizzy.gfx.Camera = .{},
-atlas: ?fizzy.Internal.Atlas = null,
+atlas: ?pixelart.internal.Atlas = null,
-/// Monotonic time (`fizzy.perf.nanoTimestamp`) when the current in-memory atlas was last installed.
+/// Monotonic time (`pixelart.perf.nanoTimestamp`) when the current in-memory atlas was last installed.
last_packed_at_ns: ?i128 = null,
ldtk: bool = false,
@@ -61,8 +62,8 @@ pub fn init(allocator: std.mem.Allocator) !Packer {
return .{
.sprites = std.array_list.Managed(Sprite).init(allocator),
.frames = std.array_list.Managed(zstbi.Rect).init(allocator),
- .animations = std.array_list.Managed(fizzy.Animation).init(allocator),
- .open_files = std.array_list.Managed(fizzy.Internal.File).init(allocator),
+ .animations = std.array_list.Managed(pixelart.Animation).init(allocator),
+ .open_files = std.array_list.Managed(pixelart.internal.File).init(allocator),
.placeholder = .{ .width = 2, .height = 2, .pixels = pixels },
.ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator),
};
@@ -75,7 +76,7 @@ pub fn newId(self: *Packer) u32 {
}
pub fn deinit(self: *Packer) void {
- fizzy.app.allocator.free(self.placeholder.pixels);
+ Globals.allocator().free(self.placeholder.pixels);
self.clearAndFree();
self.sprites.deinit();
self.frames.deinit();
@@ -85,17 +86,17 @@ pub fn deinit(self: *Packer) void {
pub fn clearAndFree(self: *Packer) void {
for (self.sprites.items) |*sprite| {
- sprite.deinit(fizzy.app.allocator);
+ sprite.deinit(Globals.allocator());
}
for (self.animations.items) |*animation| {
- fizzy.app.allocator.free(animation.name);
+ Globals.allocator().free(animation.name);
}
for (self.ldtk_tilesets.items) |*tileset| {
for (tileset.layer_paths) |path| {
- fizzy.app.allocator.free(path);
+ Globals.allocator().free(path);
}
- fizzy.app.allocator.free(tileset.sprites);
- fizzy.app.allocator.free(tileset.layer_paths);
+ Globals.allocator().free(tileset.sprites);
+ Globals.allocator().free(tileset.layer_paths);
}
self.frames.clearAndFree();
self.sprites.clearAndFree();
@@ -109,9 +110,9 @@ pub fn clearAndFree(self: *Packer) void {
self.open_files.clearAndFree();
}
-pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
+pub fn append(self: *Packer, file: *pixelart.internal.File) !void {
std.log.info("Appending file with sprites: {d}", .{file.sprites.slice().len});
- var layer_opt: ?fizzy.Internal.Layer = null;
+ var layer_opt: ?pixelart.Layer = null;
var index: usize = 0;
while (index < file.layers.slice().len) : (index += 1) {
var layer = file.layers.get(index);
@@ -121,7 +122,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
// If this layer is collapsed, we need to record its texture to survive the next loop
if ((layer.collapse and !last_item) or ((index != 0 and file.layers.slice().get(index - 1).collapse))) {
- const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try fizzy.Internal.Layer.init(
+ const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try pixelart.Layer.init(
0,
"",
file.width(),
@@ -176,7 +177,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
var image: Image = .{
.width = reduced_src_width,
.height = reduced_src_height,
- .pixels = try fizzy.app.allocator.alloc([4]u8, reduced_src_width * reduced_src_height),
+ .pixels = try Globals.allocator().alloc([4]u8, reduced_src_width * reduced_src_height),
};
@memset(image.pixels, .{ 0, 0, 0, 0 });
@@ -204,13 +205,13 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
for (0..file.animations.len) |animation_index| {
const animation = file.animations.get(animation_index);
if (animation.frames[0].sprite_index == sprite_index) {
- const frames = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len);
+ const frames = try Globals.allocator().alloc(pixelart.Animation.Frame, animation.frames.len);
for (frames, animation.frames, 0..) |*current_frame, file_anim_frame, i| {
current_frame.sprite_index = new_sprite_index + i;
current_frame.ms = file_anim_frame.ms;
}
try self.animations.append(.{
- .name = try std.fmt.allocPrint(fizzy.app.allocator, "{s}_{s}", .{ animation.name, layer.name }),
+ .name = try std.fmt.allocPrint(Globals.allocator(), "{s}_{s}", .{ animation.name, layer.name }),
.frames = frames,
});
}
@@ -249,7 +250,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void {
}
pub fn appendProject(packer: *Packer) !void {
- if (fizzy.pixelart.host.folder()) |root_directory| {
+ if (Globals.state.host.folder()) |root_directory| {
try recurseFiles(packer, root_directory);
}
}
@@ -265,22 +266,22 @@ pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void {
while (try iter.next(io)) |entry| {
if (entry.kind == .file) {
const ext = std.fs.path.extension(entry.name);
- if (fizzy.Internal.File.isFizzyExtension(ext)) {
- const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &.{ directory, entry.name });
- defer fizzy.app.allocator.free(abs_path);
+ if (pixelart.internal.File.isFizzyExtension(ext)) {
+ const abs_path = try std.fs.path.joinZ(Globals.allocator(), &.{ directory, entry.name });
+ defer Globals.allocator().free(abs_path);
- if (fizzy.editor.getFileFromPath(abs_path)) |file| {
+ if (Globals.state.docs.fileFromPath(abs_path)) |file| {
try p.append(file);
} else {
- if (try fizzy.Internal.File.fromPath(abs_path)) |file| {
+ if (try pixelart.internal.File.fromPath(abs_path)) |file| {
try p.open_files.append(file);
try p.append(&p.open_files.items[p.open_files.items.len - 1]);
}
}
}
} else if (entry.kind == .directory) {
- const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &[_][]const u8{ directory, entry.name });
- defer fizzy.app.allocator.free(abs_path);
+ const abs_path = try std.fs.path.joinZ(Globals.allocator(), &[_][]const u8{ directory, entry.name });
+ defer Globals.allocator().free(abs_path);
try search(p, abs_path);
}
}
@@ -295,7 +296,7 @@ pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void {
pub fn packAndClear(packer: *Packer) !void {
if (try packer.packRects()) |size| {
//var atlas_texture = try fizzy.gfx.Texture.createEmpty(size[0], size[1], .{});
- var atlas_layer = try fizzy.Internal.Layer.init(
+ var atlas_layer = try pixelart.Layer.init(
0,
"",
size[0],
@@ -318,9 +319,9 @@ pub fn packAndClear(packer: *Packer) !void {
}
atlas_layer.invalidate();
- const atlas: fizzy.Atlas = .{
- .sprites = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, packer.sprites.items.len),
- .animations = try fizzy.app.allocator.alloc(fizzy.Animation, packer.animations.items.len),
+ const atlas: pixelart.Atlas = .{
+ .sprites = try Globals.allocator().alloc(pixelart.Atlas.Sprite, packer.sprites.items.len),
+ .animations = try Globals.allocator().alloc(pixelart.Animation, packer.animations.items.len),
};
for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| {
@@ -329,8 +330,8 @@ pub fn packAndClear(packer: *Packer) !void {
}
for (atlas.animations, packer.animations.items) |*dst, src| {
- dst.name = try fizzy.app.allocator.dupe(u8, src.name);
- dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames);
+ dst.name = try Globals.allocator().dupe(u8, src.name);
+ dst.frames = try Globals.allocator().dupe(pixelart.Animation.Frame, src.frames);
//dst.length = src.length;
// dst.start = src.start;
}
@@ -338,12 +339,12 @@ pub fn packAndClear(packer: *Packer) !void {
if (packer.atlas) |*current_atlas| {
current_atlas.deinitCheckerboardTile();
for (current_atlas.data.animations) |*animation| {
- fizzy.app.allocator.free(animation.name);
+ Globals.allocator().free(animation.name);
}
- fizzy.app.allocator.free(current_atlas.data.sprites);
- fizzy.app.allocator.free(current_atlas.data.animations);
+ Globals.allocator().free(current_atlas.data.sprites);
+ Globals.allocator().free(current_atlas.data.animations);
- fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source));
+ Globals.allocator().free(pixelart.image.bytes(current_atlas.source));
current_atlas.data = atlas;
current_atlas.source = atlas_layer.source;
@@ -356,7 +357,7 @@ pub fn packAndClear(packer: *Packer) !void {
packer.atlas.?.initCheckerboardTile();
}
- packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
+ packer.last_packed_at_ns = pixelart.perf.nanoTimestamp();
packer.clearAndFree();
}
}
diff --git a/src/plugins/pixelart/Project.zig b/src/plugins/pixelart/src/Project.zig
similarity index 82%
rename from src/plugins/pixelart/Project.zig
rename to src/plugins/pixelart/src/Project.zig
index 7d85d568..767dc0eb 100644
--- a/src/plugins/pixelart/Project.zig
+++ b/src/plugins/pixelart/src/Project.zig
@@ -1,7 +1,8 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
const Project = @This();
@@ -22,10 +23,10 @@ pack_on_save: bool = false,
pub fn load(allocator: std.mem.Allocator) !?Project {
if (comptime builtin.target.cpu.arch == .wasm32) return null;
- if (fizzy.pixelart.host.folder()) |folder| {
- const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" });
+ if (Globals.state.host.folder()) |folder| {
+ const file = try std.fs.path.join(Globals.state.host.arena(), &.{ folder, ".fizproject" });
- if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| {
+ if (pixelart.fs.read(allocator, dvui.io, file) catch null) |r| {
read = r;
const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true };
@@ -60,11 +61,12 @@ pub fn load(allocator: std.mem.Allocator) !?Project {
pub fn save(project: *Project) !void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
- if (fizzy.pixelart.host.folder()) |folder| {
- const file = try std.fs.path.join(fizzy.pixelart.host.arena(), &.{ folder, ".fizproject" });
+ if (Globals.state.host.folder()) |folder| {
+ const file = try std.fs.path.join(Globals.allocator(), &.{ folder, ".fizproject" });
+ defer Globals.allocator().free(file);
const options = std.json.Stringify.Options{};
- const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{
+ const str = try std.json.Stringify.valueAlloc(Globals.allocator(), Project{
.packed_atlas_output = project.packed_atlas_output,
.packed_image_output = project.packed_image_output,
//.packed_heightmap_output = project.packed_heightmap_output,
@@ -81,7 +83,7 @@ pub fn save(project: *Project) !void {
/// Project output assets will be exported to a join of parent_folder and the individual output paths for each asset
pub fn exportAssets(project: *Project) !void {
- const atlas = fizzy.packer.atlas orelse return;
+ const atlas = Globals.packer.atlas orelse return;
if (project.packed_atlas_output) |packed_atlas_output| {
try atlas.save(packed_atlas_output, .data);
@@ -92,7 +94,7 @@ pub fn exportAssets(project: *Project) !void {
}
// if (project.packed_heightmap_output) |packed_heightmap_output| {
- // const path = try std.fs.path.joinZ(fizzy.pixelart.host.arena(), &.{ parent_folder, packed_heightmap_output });
+ // const path = try std.fs.path.joinZ(Globals.state.host.arena(), &.{ parent_folder, packed_heightmap_output });
// try atlas.save(path, .heightmap);
// }
}
diff --git a/src/plugins/pixelart/Settings.zig b/src/plugins/pixelart/src/Settings.zig
similarity index 95%
rename from src/plugins/pixelart/Settings.zig
rename to src/plugins/pixelart/src/Settings.zig
index 260b4eb7..59f90919 100644
--- a/src/plugins/pixelart/Settings.zig
+++ b/src/plugins/pixelart/src/Settings.zig
@@ -3,10 +3,10 @@
//! settings store (the `Host`), keyed by the plugin id, as an opaque JSON blob the shell
//! never interprets.
const std = @import("std");
-const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const sdk = fizzy.sdk;
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const sdk = pixelart.sdk;
const PixelArtSettings = @This();
@@ -58,12 +58,11 @@ checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 },
/// Checkerboard / transparency tint behind sprites (grid cells).
transparency_effect: TransparencyEffect = .none,
-pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings) ResolvedPanZoomScheme {
+pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings, host: *sdk.Host) ResolvedPanZoomScheme {
return switch (settings.input_scheme) {
.auto => switch (dvui.mouseType()) {
- // Runtime platform detection so macOS web users get the trackpad default
- // (`builtin.os.tag == .macos` is false on wasm32-freestanding).
- .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse,
+ // Runtime platform detection so macOS web users get the trackpad default.
+ .unknown => if (host.isMacOS()) .trackpad else .mouse,
.mouse => .mouse,
.trackpad => .trackpad,
},
@@ -96,7 +95,7 @@ pub fn save(settings: *const PixelArtSettings, host: *sdk.Host) void {
/// The plugin's Settings section body (registered as a `SettingsSection`). Renders the
/// canvas / control prefs and persists on change.
pub fn draw(_: ?*anyopaque) !void {
- const pa = fizzy.pixelart;
+ const pa = Globals.state;
var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal });
defer vbox.deinit();
diff --git a/src/plugins/pixelart/Sprite.zig b/src/plugins/pixelart/src/Sprite.zig
similarity index 100%
rename from src/plugins/pixelart/Sprite.zig
rename to src/plugins/pixelart/src/Sprite.zig
diff --git a/src/plugins/pixelart/PixelArt.zig b/src/plugins/pixelart/src/State.zig
similarity index 61%
rename from src/plugins/pixelart/PixelArt.zig
rename to src/plugins/pixelart/src/State.zig
index fec50fa0..e89361c0 100644
--- a/src/plugins/pixelart/PixelArt.zig
+++ b/src/plugins/pixelart/src/State.zig
@@ -1,29 +1,28 @@
-//! Pixel-art plugin state, lifted off the shell `Editor` (Phase 4 Stage B).
+//! Pixel-art plugin runtime state (Phase 4 Stage B/D).
//!
//! Owns the pixel-art-specific editor state that used to live as top-level fields
//! on `src/editor/Editor.zig`: the active tools, color/palette state, the open
//! project's pack config, the sprite clipboard, and the background pack-job queue.
//!
-//! Accessed during Stages B–C through the `fizzy.pixelart` global (mirroring the
-//! existing `fizzy.packer`). Stage D repoints plugin code at the SDK instead, at
-//! which point this struct becomes the plugin's `state` proper rather than a
-//! shell-reachable global.
+//! Each plugin has a `State.zig` holding its live state. The shell still reaches
+//! this through `fizzy.pixelart` during migration; plugin code uses `Globals.state`.
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
const assets = @import("assets");
-
-const sdk = fizzy.sdk;
+const sdk = @import("sdk");
const Colors = @import("Colors.zig");
const Project = @import("Project.zig");
const Tools = @import("Tools.zig");
const PackJob = @import("PackJob.zig");
const ToolsPane = @import("explorer/tools.zig");
const SpritesPane = @import("explorer/sprites.zig");
+const SpritesPanel = @import("panel/sprites.zig");
+const Palette = @import("internal/Palette.zig");
pub const Settings = @import("Settings.zig");
+pub const Docs = @import("Docs.zig");
-const PixelArt = @This();
+const State = @This();
/// A floating sprite cut/copied from the canvas, pasted relative to `offset`.
pub const SpriteClipboard = struct {
@@ -34,6 +33,9 @@ pub const SpriteClipboard = struct {
/// The shell host (service locator + per-plugin settings store). Set in `init`.
host: *sdk.Host,
+/// Open pixel-art documents (shell `open_files` holds matching `DocHandle`s).
+docs: Docs = .{},
+
/// Pixel-art editing preferences, loaded from the host's per-plugin settings store.
settings: Settings = .{},
@@ -46,6 +48,9 @@ colors: Colors = .{},
tools_pane: ToolsPane = .{},
sprites_pane: SpritesPane = .{},
+/// Sprites cover-flow bottom panel (scroll/fly state; was `editor.panel.sprites`).
+sprites_panel: SpritesPanel = .{},
+
/// Whether the palette pane is pinned open in the tools sidebar (pixel-art UI state).
pinned_palettes: bool = false,
/// Split ratio between the layers list and the palette in the tools sidebar.
@@ -63,39 +68,43 @@ sprite_clipboard: ?SpriteClipboard = null,
/// most recent request produces a visible atlas update.
pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty,
-pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !PixelArt {
- var pa: PixelArt = .{
+pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !State {
+ var st: State = .{
.host = host,
.settings = Settings.load(host),
.tools = try .init(allocator),
};
- pa.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
- pa.colors.palette = fizzy.Internal.Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
- return pa;
+ st.colors.file_tree_palette = Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
+ st.colors.palette = Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null;
+ return st;
+}
+
+/// Write `.fizproject` while the shell `host` and project folder are still live.
+/// Called from `AppDeinit` before `editor.deinit`.
+pub fn persistProject(st: *State) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ if (st.project) |*project| {
+ project.save() catch {
+ dvui.log.err("Failed to save project file", .{});
+ };
+ }
}
-pub fn deinit(pa: *PixelArt, allocator: std.mem.Allocator) void {
- for (pa.pack_jobs.items) |job| {
+pub fn deinit(st: *State, allocator: std.mem.Allocator) void {
+ for (st.pack_jobs.items) |job| {
// Detached workers still reference each job. Signal cancellation and leak the structs
// on hard quit — better than a use-after-free if a worker hasn't yet observed it.
job.cancelled.store(true, .monotonic);
}
- pa.pack_jobs.deinit(allocator);
-
- if (pa.colors.palette) |*palette| palette.deinit();
- if (pa.colors.file_tree_palette) |*palette| palette.deinit();
-
- if (pa.project) |*project| {
- // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in
- // posix.AT (unavailable on freestanding). Browser tabs have no
- // persistent on-disk project anyway.
- if (comptime builtin.target.cpu.arch != .wasm32) {
- project.save() catch {
- dvui.log.err("Failed to save project file", .{});
- };
- }
+ st.pack_jobs.deinit(allocator);
+
+ if (st.colors.palette) |*palette| palette.deinit();
+ if (st.colors.file_tree_palette) |*palette| palette.deinit();
+
+ if (st.project) |*project| {
project.deinit(allocator);
}
- pa.tools.deinit(allocator);
+ st.tools.deinit(allocator);
+ st.docs.deinit(allocator);
}
diff --git a/src/plugins/pixelart/Tools.zig b/src/plugins/pixelart/src/Tools.zig
similarity index 93%
rename from src/plugins/pixelart/Tools.zig
rename to src/plugins/pixelart/src/Tools.zig
index 2dc496b1..9f8eb276 100644
--- a/src/plugins/pixelart/Tools.zig
+++ b/src/plugins/pixelart/src/Tools.zig
@@ -1,6 +1,7 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
const Tools = @This();
@@ -162,7 +163,7 @@ pub fn set(self: *Tools, tool: Tool) void {
self.current = tool;
self.setStrokeSize(self.strokeSizeFor(tool));
if (tool == .pencil or tool == .eraser) {
- fizzy.editor.requestCompositeWarmup();
+ Globals.state.host.requestCompositeWarmup();
}
}
}
@@ -194,8 +195,8 @@ pub fn getIndex(_: *Tools, point: dvui.Point) ?usize {
/// Only used for handling getting the pixels surrounding the origin
/// for stroke sizes larger than 1
pub fn getIndexShapeOffset(self: *Tools, origin: dvui.Point, current_index: usize) ?usize {
- const shape = fizzy.pixelart.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size);
+ const shape = self.stroke_shape;
+ const s: i32 = @intCast(self.stroke_size);
if (s == 1) {
if (current_index != 0)
@@ -298,7 +299,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
}));
defer vbox2.deinit();
- fizzy.dvui.labelWithKeybind(
+ pixelart.core.dvui.labelWithKeybind(
tool_name,
switch (tool) {
.pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{},
@@ -334,10 +335,10 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
});
defer mode_row.deinit();
- const atlas_size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
+ const atlas_size: dvui.Size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
var mode_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
mode_color = palette.getDVUIColor(4);
}
@@ -367,7 +368,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
2 => "COLOR",
else => unreachable,
};
- const selected = fizzy.pixelart.tools.selection_mode == mode;
+ const selected = Globals.state.tools.selection_mode == mode;
var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .none,
@@ -377,9 +378,9 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
defer mode_col.deinit();
const sprite = switch (mode) {
- .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default],
- .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default],
- .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default],
+ .box => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_default],
+ .pixel => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_default],
+ .color => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_default],
};
const uv = dvui.Rect{
.x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w,
@@ -430,7 +431,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
rs.r.w = width;
rs.r.h = height;
- dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{
+ dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{
.uv = uv,
.fade = 0.0,
}) catch {
@@ -438,7 +439,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64
};
if (mode_button.clicked()) {
- fizzy.pixelart.tools.selection_mode = mode;
+ Globals.state.tools.selection_mode = mode;
}
}
}
diff --git a/src/plugins/pixelart/Transform.zig b/src/plugins/pixelart/src/Transform.zig
similarity index 85%
rename from src/plugins/pixelart/Transform.zig
rename to src/plugins/pixelart/src/Transform.zig
index a4f44975..edbddffb 100644
--- a/src/plugins/pixelart/Transform.zig
+++ b/src/plugins/pixelart/src/Transform.zig
@@ -1,6 +1,7 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
pub const Transform = @This();
@@ -34,24 +35,24 @@ pub fn point(self: *Transform, transform_point: TransformPoint) *dvui.Point {
/// Note: `textureReadTarget` reads the full render target; the dominant cost is often GPU→CPU
/// bandwidth rather than the merge loops below.
pub fn accept(self: *Transform) void {
- if (fizzy.editor.open_files.getPtr(self.file_id)) |file| {
+ if (Globals.state.docs.fileById(self.file_id)) |file| {
var layer = file.getLayer(self.layer_id) orelse return;
- const t_all: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_all: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
const layer_px: u64 = @as(u64, file.width()) * @as(u64, file.height());
const pix = dvui.textureReadTarget(dvui.currentWindow().arena(), self.target_texture) catch {
dvui.log.err("Failed to read target texture", .{});
return;
};
- const t_after_gpu: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_after_gpu: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
file.buffers.stroke.clearAndReserveCapacity(@intCast(layer_px)) catch {
dvui.log.err("Failed to reserve stroke map for transform accept", .{});
return;
};
- const t_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_loop: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
// Two passes: undo keys use the pre-write layer; writes are independent per index, so order
// matches the original interleaved loop without mutating layer between undo decisions.
for (pix, file.editor.transform_layer.pixels(), layer.pixels(), 0..) |temp_pixel, transform_pixel, layer_pixel, pixel_index| {
@@ -70,7 +71,7 @@ pub fn accept(self: *Transform) void {
// Paste / transform accept writes new pixels but does not go through `processSelection`; the
// overlay uses `selection_layer.mask ∩ active_layer.mask`. Keep the mask aligned with the
// committed transform so copied/pasted (and moved) pixels show the selection outline.
- if (fizzy.pixelart.tools.current == .selection) {
+ if (Globals.state.tools.current == .selection) {
file.editor.selection_layer.clearMask();
for (pix, 0..) |temp_pixel, pixel_index| {
if (temp_pixel.a != 0) {
@@ -79,28 +80,28 @@ pub fn accept(self: *Transform) void {
}
}
- const t_after_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_after_loop: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
- const t_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_to_change: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
const change = file.buffers.stroke.toChange(self.layer_id) catch null;
- const t_after_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_after_to_change: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
- const t_hist: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t_hist: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
if (change) |c| {
file.history.append(c) catch {
dvui.log.err("Failed to append stroke change to history", .{});
};
}
- const t_end: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
-
- if (fizzy.perf.record) {
- fizzy.perf.transform_accept_last_total_ns = @intCast(t_end - t_all);
- fizzy.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all);
- fizzy.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop);
- fizzy.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change);
- fizzy.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist);
- fizzy.perf.transform_accept_last_layer_pixels = layer_px;
- fizzy.perf.logTransformAcceptIf();
+ const t_end: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
+
+ if (pixelart.perf.record) {
+ pixelart.perf.transform_accept_last_total_ns = @intCast(t_end - t_all);
+ pixelart.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all);
+ pixelart.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop);
+ pixelart.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change);
+ pixelart.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist);
+ pixelart.perf.transform_accept_last_layer_pixels = layer_px;
+ pixelart.perf.logTransformAcceptIf();
}
layer.invalidate();
@@ -109,14 +110,14 @@ pub fn accept(self: *Transform) void {
file.editor.transform_layer.clearMask();
file.editor.transform_layer.invalidate();
file.editor.transform = null;
- fizzy.app.allocator.free(fizzy.image.bytes(self.source));
+ Globals.allocator().free(pixelart.image.bytes(self.source));
self.* = undefined;
}
}
/// Cancels the transform and restores the layer to its original state
pub fn cancel(self: *Transform) void {
- if (fizzy.editor.open_files.getPtr(self.file_id)) |file| {
+ if (Globals.state.docs.fileById(self.file_id)) |file| {
var layer = file.getLayer(self.layer_id) orelse return;
var iterator = file.editor.transform_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
while (iterator.next()) |pixel_index| {
@@ -129,7 +130,7 @@ pub fn cancel(self: *Transform) void {
file.editor.transform_layer.clearMask();
file.editor.transform_layer.invalidate();
file.editor.transform = null;
- fizzy.app.allocator.free(fizzy.image.bytes(self.source));
+ Globals.allocator().free(pixelart.image.bytes(self.source));
self.* = undefined;
}
}
diff --git a/src/plugins/pixelart/algorithms/algorithms.zig b/src/plugins/pixelart/src/algorithms/algorithms.zig
similarity index 100%
rename from src/plugins/pixelart/algorithms/algorithms.zig
rename to src/plugins/pixelart/src/algorithms/algorithms.zig
diff --git a/src/plugins/pixelart/algorithms/brezenham.zig b/src/plugins/pixelart/src/algorithms/brezenham.zig
similarity index 86%
rename from src/plugins/pixelart/algorithms/brezenham.zig
rename to src/plugins/pixelart/src/algorithms/brezenham.zig
index 4d114cc8..2e7f40b1 100644
--- a/src/plugins/pixelart/algorithms/brezenham.zig
+++ b/src/plugins/pixelart/src/algorithms/brezenham.zig
@@ -1,10 +1,11 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point {
// Bresenham's line algorithm for integer grid points
- var output = std.array_list.Managed(dvui.Point).init(fizzy.pixelart.host.arena());
+ var output = std.array_list.Managed(dvui.Point).init(Globals.state.host.arena());
// Round input points to nearest integer grid
const x0: i32 = @intFromFloat(@floor(start.x));
diff --git a/src/plugins/pixelart/algorithms/reduce.zig b/src/plugins/pixelart/src/algorithms/reduce.zig
similarity index 100%
rename from src/plugins/pixelart/algorithms/reduce.zig
rename to src/plugins/pixelart/src/algorithms/reduce.zig
diff --git a/src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c
similarity index 100%
rename from src/plugins/pixelart/deps/msf_gif/fizzy_msf_gif_wasm.c
rename to src/plugins/pixelart/src/deps/msf_gif/fizzy_msf_gif_wasm.c
diff --git a/src/plugins/pixelart/deps/msf_gif/msf_gif.c b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.c
similarity index 100%
rename from src/plugins/pixelart/deps/msf_gif/msf_gif.c
rename to src/plugins/pixelart/src/deps/msf_gif/msf_gif.c
diff --git a/src/plugins/pixelart/deps/msf_gif/msf_gif.h b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.h
similarity index 100%
rename from src/plugins/pixelart/deps/msf_gif/msf_gif.h
rename to src/plugins/pixelart/src/deps/msf_gif/msf_gif.h
diff --git a/src/plugins/pixelart/deps/msf_gif/msf_gif.zig b/src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig
similarity index 100%
rename from src/plugins/pixelart/deps/msf_gif/msf_gif.zig
rename to src/plugins/pixelart/src/deps/msf_gif/msf_gif.zig
diff --git a/src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h
similarity index 100%
rename from src/plugins/pixelart/deps/msf_gif/wasm_shim/string.h
rename to src/plugins/pixelart/src/deps/msf_gif/wasm_shim/string.h
diff --git a/src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c
similarity index 100%
rename from src/plugins/pixelart/deps/stbi/fizzy_stbi_libc.c
rename to src/plugins/pixelart/src/deps/stbi/fizzy_stbi_libc.c
diff --git a/src/plugins/pixelart/deps/stbi/stb_image_resize2.h b/src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h
similarity index 100%
rename from src/plugins/pixelart/deps/stbi/stb_image_resize2.h
rename to src/plugins/pixelart/src/deps/stbi/stb_image_resize2.h
diff --git a/src/plugins/pixelart/deps/stbi/stb_rect_pack.h b/src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h
similarity index 100%
rename from src/plugins/pixelart/deps/stbi/stb_rect_pack.h
rename to src/plugins/pixelart/src/deps/stbi/stb_rect_pack.h
diff --git a/src/plugins/pixelart/deps/stbi/zstbi.c b/src/plugins/pixelart/src/deps/stbi/zstbi.c
similarity index 100%
rename from src/plugins/pixelart/deps/stbi/zstbi.c
rename to src/plugins/pixelart/src/deps/stbi/zstbi.c
diff --git a/src/plugins/pixelart/deps/stbi/zstbi.zig b/src/plugins/pixelart/src/deps/stbi/zstbi.zig
similarity index 100%
rename from src/plugins/pixelart/deps/stbi/zstbi.zig
rename to src/plugins/pixelart/src/deps/stbi/zstbi.zig
diff --git a/src/plugins/pixelart/deps/zip/build.zig b/src/plugins/pixelart/src/deps/zip/build.zig
similarity index 100%
rename from src/plugins/pixelart/deps/zip/build.zig
rename to src/plugins/pixelart/src/deps/zip/build.zig
diff --git a/src/plugins/pixelart/deps/zip/fizzy_zip_libc.c b/src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c
similarity index 100%
rename from src/plugins/pixelart/deps/zip/fizzy_zip_libc.c
rename to src/plugins/pixelart/src/deps/zip/fizzy_zip_libc.c
diff --git a/src/plugins/pixelart/deps/zip/fizzy_zip_strings.c b/src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c
similarity index 100%
rename from src/plugins/pixelart/deps/zip/fizzy_zip_strings.c
rename to src/plugins/pixelart/src/deps/zip/fizzy_zip_strings.c
diff --git a/src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h
similarity index 100%
rename from src/plugins/pixelart/deps/zip/fizzy_zip_wasm.h
rename to src/plugins/pixelart/src/deps/zip/fizzy_zip_wasm.h
diff --git a/src/plugins/pixelart/deps/zip/src/miniz.h b/src/plugins/pixelart/src/deps/zip/src/miniz.h
similarity index 100%
rename from src/plugins/pixelart/deps/zip/src/miniz.h
rename to src/plugins/pixelart/src/deps/zip/src/miniz.h
diff --git a/src/plugins/pixelart/deps/zip/src/zip.c b/src/plugins/pixelart/src/deps/zip/src/zip.c
similarity index 100%
rename from src/plugins/pixelart/deps/zip/src/zip.c
rename to src/plugins/pixelart/src/deps/zip/src/zip.c
diff --git a/src/plugins/pixelart/deps/zip/src/zip.h b/src/plugins/pixelart/src/deps/zip/src/zip.h
similarity index 100%
rename from src/plugins/pixelart/deps/zip/src/zip.h
rename to src/plugins/pixelart/src/deps/zip/src/zip.h
diff --git a/src/plugins/pixelart/deps/zip/zip.zig b/src/plugins/pixelart/src/deps/zip/zip.zig
similarity index 100%
rename from src/plugins/pixelart/deps/zip/zip.zig
rename to src/plugins/pixelart/src/deps/zip/zip.zig
diff --git a/src/plugins/pixelart/dialogs/Export.zig b/src/plugins/pixelart/src/dialogs/Export.zig
similarity index 85%
rename from src/plugins/pixelart/dialogs/Export.zig
rename to src/plugins/pixelart/src/dialogs/Export.zig
index a6a1fd64..4024005f 100644
--- a/src/plugins/pixelart/dialogs/Export.zig
+++ b/src/plugins/pixelart/src/dialogs/Export.zig
@@ -1,16 +1,17 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const zigimg = @import("zigimg");
const msf_gif = @import("msf_gif");
const zstbi = @import("zstbi");
-const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../editor/WebFileIo.zig") else struct {};
+const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../../editor/WebFileIo.zig") else struct {};
const ExportImageFormat = enum { png, jpg };
-const Dialogs = @import("../../../editor/dialogs/Dialogs.zig");
+const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
pub var mode: enum(usize) {
single,
@@ -39,7 +40,7 @@ pub const min_scale: u32 = 1;
pub var anim_frame_index: usize = 0;
/// Animation to export/preview: uses the animation selected in the editor.
-fn exportAnimationIndex(file: *fizzy.Internal.File) ?usize {
+fn exportAnimationIndex(file: *pixelart.internal.File) ?usize {
const idx = file.selected_animation_index orelse return null;
if (idx >= file.animations.len) return null;
return idx;
@@ -49,7 +50,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
// Export stays non-modal so the user can click the canvas to adjust selections. Switch to
// the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close.
if (dvui.firstFrame(id)) {
- fizzy.pixelart.tools.set(.pointer);
+ Globals.state.tools.set(.pointer);
}
var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
@@ -145,7 +146,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
.all => try allDialog(id),
};
- return mode_valid and (fizzy.editor.activeFile() != null);
+ return mode_valid and (Globals.state.docs.activeFile(Globals.state.host) != null);
}
pub fn singleDialog(_: dvui.Id) anyerror!bool {
@@ -153,14 +154,14 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool {
var max_scale: f32 = 16.0;
var valid: bool = false;
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
if (file.editor.selected_sprites.findFirstSet() != null) {
max_scale = @min(@divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))), @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))));
valid = true;
}
}
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
if (file.editor.selected_sprites.findFirstSet()) |sprite_index| {
renderExportPreviewSprite(file, sprite_index);
}
@@ -168,7 +169,7 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool {
exportScaleSlider(max_scale);
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
if (file.editor.selected_sprites.findFirstSet() != null) {
const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
@@ -184,7 +185,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
var max_scale: f32 = 16.0;
var preview_sprite: ?usize = null;
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
max_scale = @min(
@divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))),
@divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))),
@@ -222,7 +223,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
}
}
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
if (preview_sprite) |sprite_index| {
renderExportPreviewSprite(file, sprite_index);
}
@@ -231,7 +232,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
exportScaleSlider(max_scale);
if (preview_sprite) |_| {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale);
const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
exportDimensionsLabelForExport(column_width, row_height);
@@ -242,20 +243,20 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool {
}
pub fn layerDialog(_: dvui.Id) anyerror!bool {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
renderExportPreview(file, .layer);
}
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
exportDimensionsLabelForExport(file.width(), file.height());
}
return true;
}
pub fn allDialog(_: dvui.Id) anyerror!bool {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
renderExportPreview(file, .composite);
}
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
exportDimensionsLabelForExport(file.width(), file.height());
}
return true;
@@ -267,11 +268,11 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
switch (mode) {
.animation => {
const default = blk: {
- const file = fizzy.editor.activeFile() orelse {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse {
break :blk "animation.gif";
};
- const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.gif", .{
+ const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.gif", .{
if (exportAnimationIndex(file)) |animation_index| file.animations.items(.name)[animation_index] else "animation",
}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
@@ -281,32 +282,32 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
break :blk default_filename;
};
- fizzy.pixelart.host.showSaveDialog(
+ Globals.state.host.showSaveDialog(
saveAnimationCallback,
- &[_]fizzy.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }},
+ &[_]pixelart.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }},
default,
null, // Passing null here means use the last save folder location
);
},
.single => {
- const file = fizzy.editor.activeFile() orelse return;
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse return;
const sprite_index = file.editor.selected_sprites.findFirstSet() orelse return;
- const base = file.spriteExportName(fizzy.app.allocator, sprite_index) catch {
+ const base = file.spriteExportName(Globals.allocator(), sprite_index) catch {
dvui.log.err("Failed to allocate default export name", .{});
return;
};
- defer fizzy.app.allocator.free(base);
+ defer Globals.allocator().free(base);
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
+ const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
return;
};
- defer fizzy.app.allocator.free(default);
+ defer Globals.allocator().free(default);
- fizzy.pixelart.host.showSaveDialog(
+ Globals.state.host.showSaveDialog(
exportCurrentSpriteCallback,
- &[_]fizzy.sdk.SaveDialogFilter{
+ &[_]pixelart.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -315,22 +316,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
);
},
.layer => {
- const file = fizzy.editor.activeFile() orelse return;
- const base = file.layerExportBaseName(fizzy.app.allocator) catch {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse return;
+ const base = file.layerExportBaseName(Globals.allocator()) catch {
dvui.log.err("Failed to allocate default export name", .{});
return;
};
- defer fizzy.app.allocator.free(base);
+ defer Globals.allocator().free(base);
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
+ const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
return;
};
- defer fizzy.app.allocator.free(default);
+ defer Globals.allocator().free(default);
- fizzy.pixelart.host.showSaveDialog(
+ Globals.state.host.showSaveDialog(
exportLayerCallback,
- &[_]fizzy.sdk.SaveDialogFilter{
+ &[_]pixelart.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -339,22 +340,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
);
},
.all => {
- const file = fizzy.editor.activeFile() orelse return;
- const base = file.allExportBaseName(fizzy.app.allocator) catch {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse return;
+ const base = file.allExportBaseName(Globals.allocator()) catch {
dvui.log.err("Failed to allocate default export name", .{});
return;
};
- defer fizzy.app.allocator.free(base);
+ defer Globals.allocator().free(base);
- const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch {
+ const default = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{base}, 0) catch {
dvui.log.err("Failed to allocate filename", .{});
return;
};
- defer fizzy.app.allocator.free(default);
+ defer Globals.allocator().free(default);
- fizzy.pixelart.host.showSaveDialog(
+ Globals.state.host.showSaveDialog(
exportAllCallback,
- &[_]fizzy.sdk.SaveDialogFilter{
+ &[_]pixelart.sdk.SaveDialogFilter{
.{ .name = "PNG", .pattern = "png" },
.{ .name = "JPEG", .pattern = "jpg;jpeg" },
},
@@ -372,7 +373,7 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
/// One call site for the export preview scroll+tile so widget ids (and first-frame layout) stay
/// stable when switching between Single and Animation. Otherwise `renderLayers` early-outs for
/// one frame with `content_rs.s == 0` on a fresh scroll id.
-fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) void {
+fn renderExportPreviewSprite(file: *pixelart.internal.File, sprite_index: usize) void {
const sprite_rect = file.spriteRect(sprite_index);
const max_size_content: dvui.Size = .{
.w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2,
@@ -413,7 +414,7 @@ fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) vo
const local_natural = dvui.Rect{ .x = 0, .y = 0, .w = sprite_rect.w * scale, .h = sprite_rect.h * scale };
drawCheckerboardCell(file, sprite_index, local_natural, box.data().rectScale());
- fizzy.render.renderLayers(.{
+ pixelart.render.renderLayers(.{
.file = file,
.rs = box.data().rectScale(),
.uv = uv,
@@ -497,8 +498,8 @@ fn exportCheckerboardVertexColor(
return tone.lerp(c_corner, t);
}
-fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+fn exportSpriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usize) ?dvui.Color {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
var animation_index: ?usize = null;
if (file.selected_animation_index) |selected_animation_index| {
@@ -530,13 +531,13 @@ fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: u
}
fn exportCheckerboardCellCornerColor(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
sprite_index: usize,
pal: CheckerboardPalette,
u: f32,
v: f32,
) dvui.Color {
- switch (fizzy.pixelart.settings.transparency_effect) {
+ switch (Globals.state.settings.transparency_effect) {
.none => return pal.tone,
.rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone),
.animation => {
@@ -558,7 +559,7 @@ fn exportCheckerboardCellCornerColor(
fn appendCheckerboardCellQuad(
builder: *dvui.Triangles.Builder,
quad_idx: *usize,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
sprite_index: usize,
pal: CheckerboardPalette,
geometry_natural: dvui.Rect,
@@ -597,7 +598,7 @@ fn appendCheckerboardCellQuad(
}
fn drawCheckerboardCell(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
sprite_index: usize,
geometry_natural: dvui.Rect,
rs_box: dvui.RectScale,
@@ -619,7 +620,7 @@ fn drawCheckerboardCell(
};
}
-fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) void {
+fn drawCheckerboardFileGrid(file: *pixelart.internal.File, rs_box: dvui.RectScale) void {
const n = file.spriteCount();
if (n == 0) return;
@@ -645,13 +646,13 @@ fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale)
/// Full-canvas preview at 1:1 logical pixels: checkerboard + either the selected layer only or the
/// flattened composite (all visible layers). One scroll + box `call site for stable widget ids.
-fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) void {
+fn renderExportPreview(file: *pixelart.internal.File, kind: ExportFullPreviewKind) void {
const w = file.width();
const h = file.height();
if (w == 0 or h == 0) return;
if (kind == .composite) {
- fizzy.render.syncLayerComposite(file) catch {
+ pixelart.render.syncLayerComposite(file) catch {
dvui.log.err("Export preview: failed to build layer composite", .{});
return;
};
@@ -689,13 +690,13 @@ fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind)
const full_uv = dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 };
const rs = box.data().rectScale();
- var path_tris: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path_tris: dvui.Path.Builder = .init(Globals.allocator());
defer path_tris.deinit();
path_tris.addRect(rs.r, .all(0));
- var tris = path_tris.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0.0 }) catch {
+ var tris = path_tris.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0.0 }) catch {
return;
};
- defer tris.deinit(fizzy.app.allocator);
+ defer tris.deinit(Globals.allocator());
tris.uvFromRectuv(rs.r, full_uv);
switch (kind) {
@@ -724,20 +725,20 @@ fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind)
fn writeImageToPath(source: dvui.ImageSource, path: []const u8, format: ExportImageFormat) !void {
if (comptime builtin.target.cpu.arch == .wasm32) {
- var out = std.Io.Writer.Allocating.init(fizzy.app.allocator);
+ var out = std.Io.Writer.Allocating.init(Globals.allocator());
errdefer out.deinit();
switch (format) {
- .png => try fizzy.image.writePngToWriter(source, &out.writer, 0),
- .jpg => try fizzy.image.writeJpgPpiToWriter(source, &out.writer, 0),
+ .png => try pixelart.image.writePngToWriter(source, &out.writer, 0),
+ .jpg => try pixelart.image.writeJpgPpiToWriter(source, &out.writer, 0),
}
const bytes = try out.toOwnedSlice();
- defer fizzy.app.allocator.free(bytes);
+ defer Globals.allocator().free(bytes);
try WebFileIo.downloadBytes(path, bytes);
return;
}
switch (format) {
- .png => try fizzy.image.writeToPngResolution(source, path, 0),
- .jpg => try fizzy.image.writeToJpgPpi(source, path, 0),
+ .png => try pixelart.image.writeToPngResolution(source, path, 0),
+ .jpg => try pixelart.image.writeToJpgPpi(source, path, 0),
}
}
@@ -751,7 +752,7 @@ fn writeGifBytes(path: []const u8, data: []const u8) !void {
/// Flatten visible layers for one sprite tile. Layer index `0` is the front (drawn last on canvas);
/// higher indices sit behind. `blitData` composites its **first** buffer (upper) over the **second** (lower).
-fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.File, sprite_index: usize) ![][4]u8 {
+fn compositedSpritePixels(allocator: std.mem.Allocator, file: *pixelart.internal.File, sprite_index: usize) ![][4]u8 {
const sprite_rect = file.spriteRect(sprite_index);
const w: usize = @intFromFloat(sprite_rect.w);
const h: usize = @intFromFloat(sprite_rect.h);
@@ -772,7 +773,7 @@ fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.Fi
const layer_pixels = lower.pixelsFromRect(allocator, sprite_rect) orelse continue;
defer allocator.free(layer_pixels);
- fizzy.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true);
+ pixelart.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true);
}
return pixels;
@@ -832,7 +833,7 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -848,14 +849,14 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void {
export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale);
}
- const pixels = try compositedSpritePixels(fizzy.app.allocator, file, sprite_index);
- defer fizzy.app.allocator.free(pixels);
+ const pixels = try compositedSpritePixels(Globals.allocator(), file, sprite_index);
+ defer Globals.allocator().free(pixels);
if (scale != 1.0) {
- const resized = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch {
+ const resized = Globals.allocator().alloc([4]u8, export_width * export_height) catch {
return error.OutOfMemory;
};
- defer fizzy.app.allocator.free(resized);
+ defer Globals.allocator().free(resized);
if (zstbi.resize(
pixels,
file.column_width,
@@ -894,7 +895,7 @@ pub fn exportLayerToPath(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -914,7 +915,7 @@ pub fn exportAllToPath(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -923,18 +924,18 @@ pub fn exportAllToPath(path: []const u8) anyerror!void {
const h = file.height();
if (w == 0 or h == 0) return error.InvalidImageSize;
- try fizzy.render.syncLayerComposite(file);
+ try pixelart.render.syncLayerComposite(file);
const target = file.editor.layer_composite_target orelse {
return error.NoLayerComposite;
};
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
- var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr);
+ var tmp_layer: pixelart.internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr);
defer tmp_layer.deinit();
const format: ExportImageFormat = if (is_png) .png else .jpg;
@@ -950,7 +951,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void {
return error.InvalidExtension;
}
- const file = fizzy.editor.activeFile() orelse {
+ const file = Globals.state.docs.activeFile(Globals.state.host) orelse {
dvui.log.err("Export: No active file", .{});
return error.NoActiveFile;
};
@@ -962,7 +963,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void {
const animation_index = exportAnimationIndex(file) orelse return error.NoSelectedAnimation;
{
- const anim: fizzy.Internal.Animation = file.animations.get(animation_index);
+ const anim: pixelart.internal.Animation = file.animations.get(animation_index);
var export_width = file.column_width;
var export_height = file.row_height;
@@ -981,11 +982,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void {
msf_gif.msf_gif_alpha_threshold = 240;
for (anim.frames) |frame| {
- const pixels = compositedSpritePixels(fizzy.app.allocator, file, frame.sprite_index) catch |err| {
+ const pixels = compositedSpritePixels(Globals.allocator(), file, frame.sprite_index) catch |err| {
if (err == error.NoPixels) continue;
return err;
};
- defer fizzy.app.allocator.free(pixels);
+ defer Globals.allocator().free(pixels);
{ // msf_gif will error if there are only transparent pixels
const valid = blk: {
@@ -1005,11 +1006,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void {
}
if (scale != 1.0) {
- const resized_pixels = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch {
+ const resized_pixels = Globals.allocator().alloc([4]u8, export_width * export_height) catch {
dvui.log.err("Failed to allocate resized pixels", .{});
continue;
};
- defer fizzy.app.allocator.free(resized_pixels);
+ defer Globals.allocator().free(resized_pixels);
_ = zstbi.resize(
pixels,
diff --git a/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig
similarity index 79%
rename from src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig
rename to src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig
index fd38b7b9..d301db5b 100644
--- a/src/plugins/pixelart/dialogs/FlatRasterSaveWarning.zig
+++ b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig
@@ -1,6 +1,7 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save.
pub var pending_from_save_all_quit: bool = false;
@@ -17,7 +18,7 @@ pub fn request(file_id: u64, mode: Mode) void {
if (mode == .editor_save) {
pending_from_save_all_quit = false;
}
- var mutex = fizzy.dvui.dialog(@src(), .{
+ var mutex = pixelart.core.dvui.dialog(@src(), .{
.displayFn = dialog,
.callafterFn = callAfter,
.title = "Save as .fiz or current extension?",
@@ -33,8 +34,8 @@ pub fn request(file_id: u64, mode: Mode) void {
mutex.mutex.unlock(dvui.io);
}
-fn fileRef(file_id: u64) ?*fizzy.Internal.File {
- return fizzy.editor.open_files.getPtr(file_id);
+fn fileRef(file_id: u64) ?*pixelart.internal.File {
+ return Globals.state.docs.fileById(file_id);
}
fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool {
@@ -113,24 +114,24 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
}
fn onChooseFizzy(file_id: u64) !void {
- const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
- fizzy.editor.setActiveFile(idx);
+ const idx = Globals.state.host.docIndex(file_id) orelse return;
+ Globals.state.host.setActiveDocIndex(idx);
if (pending_mode == .save_and_close) {
- fizzy.editor.pending_close_file_id = file_id;
+ Globals.state.host.setPendingCloseDocId(file_id);
}
- fizzy.dvui.closeFloatingDialogAnchored();
- fizzy.editor.requestSaveAs();
+ pixelart.core.dvui.closeFloatingDialogAnchored();
+ Globals.state.host.requestSaveAs();
}
fn onChooseFlatRaster(file_id: u64) !void {
const f = fileRef(file_id) orelse return;
switch (pending_mode) {
.editor_save => {
- fizzy.dvui.closeFloatingDialogAnchored();
+ pixelart.core.dvui.closeFloatingDialogAnchored();
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
- fizzy.editor.setActiveFile(idx);
- fizzy.editor.requestWebSaveDialog(.save);
+ const idx = Globals.state.host.docIndex(file_id) orelse return;
+ Globals.state.host.setActiveDocIndex(idx);
+ Globals.state.host.requestWebSave(.save);
} else {
try f.saveAsync();
}
@@ -143,32 +144,32 @@ fn onChooseFlatRaster(file_id: u64) !void {
// otherwise this is a single-doc save-and-close.
f.saveAsync() catch |err| {
dvui.log.err("Save failed: {s}", .{@errorName(err)});
- if (pending_from_save_all_quit) fizzy.editor.abortSaveAllQuit();
+ if (pending_from_save_all_quit) Globals.state.host.abortSaveAllQuit();
return;
};
if (pending_from_save_all_quit) {
- fizzy.editor.quit_saves_in_flight.put(fizzy.app.allocator, file_id, {}) catch |err| {
+ Globals.state.host.trackQuitSaveInFlight(file_id) catch |err| {
dvui.log.err("Save all quit track: {s}", .{@errorName(err)});
- fizzy.editor.abortSaveAllQuit();
+ Globals.state.host.abortSaveAllQuit();
return;
};
- fizzy.editor.pending_quit_continue = true;
+ Globals.state.host.resumeSaveAllQuit();
} else {
- try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {});
+ try Globals.state.host.queueCloseAfterSave(file_id);
}
- fizzy.dvui.closeFloatingDialogAnchored();
+ pixelart.core.dvui.closeFloatingDialogAnchored();
},
}
}
fn onCancel() void {
- fizzy.editor.cancelPendingSaveDialog();
- fizzy.dvui.closeFloatingDialogAnchored();
+ Globals.state.host.cancelPendingSaveDialog();
+ pixelart.core.dvui.closeFloatingDialogAnchored();
}
pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void {
switch (response) {
- .cancel => fizzy.editor.cancelPendingSaveDialog(),
+ .cancel => Globals.state.host.cancelPendingSaveDialog(),
else => {},
}
}
diff --git a/src/plugins/pixelart/dialogs/GridLayout.zig b/src/plugins/pixelart/src/dialogs/GridLayout.zig
similarity index 93%
rename from src/plugins/pixelart/dialogs/GridLayout.zig
rename to src/plugins/pixelart/src/dialogs/GridLayout.zig
index d047845f..9f021824 100644
--- a/src/plugins/pixelart/dialogs/GridLayout.zig
+++ b/src/plugins/pixelart/src/dialogs/GridLayout.zig
@@ -6,15 +6,15 @@
//! preview on the right that expands with the window. The preview uses `CanvasWidget` so
//! panning / zooming honour `Settings.resolvedPanZoomScheme` (`auto` follows DVUI scroll heuristics).
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const std = @import("std");
const NewFile = @import("NewFile.zig");
-const CanvasWidget = fizzy.dvui.CanvasWidget;
+const CanvasWidget = pixelart.core.dvui.CanvasWidget;
const CanvasBridge = @import("../widgets/CanvasBridge.zig");
-const FloatingWindowWidget = fizzy.dvui.FloatingWindowWidget;
-const builtin = @import("builtin");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
+const FloatingWindowWidget = pixelart.core.dvui.FloatingWindowWidget;
/// Editable grid fields for one mode (Slice vs Resize each keep their own backing).
pub const GridFormState = struct {
@@ -77,7 +77,7 @@ var preview_prev_slice_full_layer: bool = false;
/// a trackpad). Small epsilon tracks real layout drift; fit only runs when dimensions actually move.
const preview_layout_min_delta: f32 = 0.01;
-const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{
+const anchors: [9]pixelart.math.layout_anchor.LayoutAnchor = .{
.nw, .n, .ne,
.w, .c, .e,
.sw, .s, .se,
@@ -86,7 +86,7 @@ const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{
const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", "SE" };
/// Seed both mode forms with the active file's current grid so the dialog opens "no-op" by default.
-pub fn presetFromFile(file: *fizzy.Internal.File) void {
+pub fn presetFromFile(file: *pixelart.internal.File) void {
resize_form = .{
.column_width = file.column_width,
.row_height = file.row_height,
@@ -125,14 +125,11 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void {
/// Same as `Workspace.drawCanvas` / `workspaceMainCanvasVbox` behind the file widget.
fn workspaceCanvasChromeColor() dvui.Color {
var content_color = dvui.themeGet().color(.window, .fill);
- switch (builtin.os.tag) {
- .macos, .windows => {
- content_color = if (!fizzy.pixelart.host.isMaximized())
- content_color.opacity(fizzy.pixelart.host.contentOpacity())
- else
- content_color;
- },
- else => {},
+ if (Globals.state.host.appliesNativeWindowOpacity()) {
+ content_color = if (!Globals.state.host.isMaximized())
+ content_color.opacity(Globals.state.host.contentOpacity())
+ else
+ content_color;
}
return content_color;
}
@@ -211,7 +208,7 @@ fn font() dvui.Font {
/// Checkerboard behind the preview: one quad per grid cell with UV 0..1 (same as
/// `FileWidget.drawCheckerboardCellsBatched`). Per-cell so vertex colors can vary.
fn drawCheckerboardPreviewTiled(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
cv: *CanvasWidget,
rs_box: dvui.RectScale,
cols: u32,
@@ -222,7 +219,7 @@ fn drawCheckerboardPreviewTiled(
if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return;
const pal = previewCheckerboardPalette();
- const te = fizzy.pixelart.settings.transparency_effect;
+ const te = Globals.state.settings.transparency_effect;
const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0);
const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0);
const nw = cell_w * cols_f;
@@ -394,7 +391,7 @@ fn appendTexturedRectQuad(
/// Samples the layer composite texture per **old grid cell**, mapping each sprite through `cellAnchoredBlit`
/// so the preview matches the result of `applyGridLayout` independently in every tile.
fn drawCompositePreviewPerCells(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
rs_box: dvui.RectScale,
old_cols: u32,
old_rows: u32,
@@ -404,9 +401,9 @@ fn drawCompositePreviewPerCells(
new_rows: u32,
new_cw_: u32,
new_rh_: u32,
- anchor_vis: fizzy.math.layout_anchor.LayoutAnchor,
+ anchor_vis: pixelart.math.layout_anchor.LayoutAnchor,
) void {
- fizzy.render.syncLayerComposite(file) catch {
+ pixelart.render.syncLayerComposite(file) catch {
dvui.log.err("Grid layout preview: composite failed", .{});
return;
};
@@ -426,7 +423,7 @@ fn drawCompositePreviewPerCells(
defer builder.deinit(arena);
const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha));
- const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis);
+ const blk = pixelart.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis);
if (blk.sw == 0 or blk.sh == 0) return;
var nrow: u32 = 0;
@@ -459,9 +456,9 @@ fn drawCompositePreviewPerCells(
}
/// One quad for the full layer composite (slice preview — no per-cell remapping).
-fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void {
+fn drawCompositePreviewFullLayer(file: *pixelart.internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void {
if (nw <= 0 or nh <= 0) return;
- fizzy.render.syncLayerComposite(file) catch {
+ pixelart.render.syncLayerComposite(file) catch {
dvui.log.err("Grid layout preview: composite failed", .{});
return;
};
@@ -485,7 +482,7 @@ fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectSc
/// When entering Slice, keep the current form values if they already tile the layer exactly;
/// otherwise snap from the file's authoritative grid (never force 1×1 unless metadata disagrees
/// with pixel dimensions).
-fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void {
+fn harmonizeSliceStateWithLayer(file: *pixelart.internal.File) void {
const canvas = file.canvasPixelSize();
const tw = canvas.w;
const th = canvas.h;
@@ -515,14 +512,14 @@ fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void {
fn renderPreview(
mutex_id: dvui.Id,
dlg_id: dvui.Id,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
nw: u32,
nh: u32,
new_cw_: u32,
new_rh_: u32,
new_cols: u32,
new_rows: u32,
- anchor_vis: fizzy.math.layout_anchor.LayoutAnchor,
+ anchor_vis: pixelart.math.layout_anchor.LayoutAnchor,
slice_full_layer: bool,
host_rect: dvui.Rect,
) void {
@@ -830,7 +827,7 @@ fn gridLayoutDrawModePill(dlg_id: dvui.Id) void {
if (button.clicked()) {
const new_mode: Mode = @enumFromInt(i);
if (new_mode == .slice and mode != .slice) {
- if (file_id_for_dialog) |fid| if (fizzy.editor.open_files.getPtr(fid)) |tf|
+ if (file_id_for_dialog) |fid| if (Globals.state.docs.fileById(fid)) |tf|
harmonizeSliceStateWithLayer(tf);
}
mode = new_mode;
@@ -846,8 +843,8 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
const form_font = font();
const file_id_for_dialog = dvui.dataGet(null, id, "_grid_layout_file_id", u64);
- const target_file: ?*fizzy.Internal.File = if (file_id_for_dialog) |fid|
- fizzy.editor.open_files.getPtr(fid)
+ const target_file: ?*pixelart.internal.File = if (file_id_for_dialog) |fid|
+ Globals.state.docs.fileById(fid)
else
null;
@@ -878,10 +875,10 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
defer {
if (dialog_middle_scroll.offset(.vertical) > 0.0)
- fizzy.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{});
if (dialog_middle_scroll.virtual_size.h > dialog_middle_scroll.viewport.h)
- fizzy.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{});
}
// Form (intrinsic width, full height) + preview (expands horizontally with the window).
@@ -941,18 +938,18 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
const v_scroll = left_scroll.offset(.vertical);
const h_scroll = left_scroll.offset(.horizontal);
if (v_scroll > 0.0) {
- fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{});
}
if (left_scroll.virtual_size.h > left_scroll.viewport.h) {
- fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{});
}
pane_left.deinit();
if (left_scroll.virtual_size.w > left_scroll.viewport.w) {
- fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{});
+ pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{});
}
if (h_scroll > 0.0) {
- fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{});
+ pixelart.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{});
}
shell_left.deinit();
}
@@ -988,7 +985,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
slice_form.rows,
anchors[@min(anchor_ix, anchors.len - 1)],
};
- break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(fizzy.math.layout_anchor.LayoutAnchor, .nw) };
+ break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(pixelart.math.layout_anchor.LayoutAnchor, .nw) };
}
break :blk switch (mode) {
.slice => .{
@@ -1019,15 +1016,15 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
defer {
const rs_scroll = preview_host.data().rectScale();
- fizzy.dvui.drawEdgeShadow(rs_scroll, .top, .{});
- fizzy.dvui.drawEdgeShadow(rs_scroll, .bottom, .{});
- fizzy.dvui.drawEdgeShadow(rs_scroll, .left, .{});
- fizzy.dvui.drawEdgeShadow(rs_scroll, .right, .{});
+ pixelart.core.dvui.drawEdgeShadow(rs_scroll, .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(rs_scroll, .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(rs_scroll, .left, .{});
+ pixelart.core.dvui.drawEdgeShadow(rs_scroll, .right, .{});
}
if (target_file) |tf| {
const host_rect = preview_host.data().contentRect();
- const dims_ok = fizzy.Internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows);
+ const dims_ok = pixelart.internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows);
if (dims_ok) {
renderPreview(
id,
@@ -1084,7 +1081,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
/// Resize-mode form: cell width (x), cell height (y), columns (x), rows (y); 9-way anchor; current vs after readout.
fn drawResizeForm(
unique_id: dvui.Id,
- target_file: ?*fizzy.Internal.File,
+ target_file: ?*pixelart.internal.File,
form_font: dvui.Font,
) bool {
var valid: bool = true;
@@ -1110,7 +1107,7 @@ fn drawResizeForm(
.color_text = dvui.themeGet().color(.control, .text),
});
- if (!fizzy.Internal.File.validateGridLayoutProposedDims(
+ if (!pixelart.internal.File.validateGridLayoutProposedDims(
resize_form.column_width,
resize_form.row_height,
resize_form.columns,
@@ -1303,7 +1300,7 @@ fn drawResizeForm(
/// multiply back to the locked total.
fn drawSliceForm(
unique_id: dvui.Id,
- target_file: ?*fizzy.Internal.File,
+ target_file: ?*pixelart.internal.File,
form_font: dvui.Font,
) bool {
var valid: bool = true;
@@ -1466,7 +1463,7 @@ fn drawSliceForm(
return valid;
}
-/// Custom window shell for the grid-layout dialog: matches `fizzy.dvui.dialogWindow` (open
+/// Custom window shell for the grid-layout dialog: matches `pixelart.core.dvui.dialogWindow` (open
/// `autoSize()` animation, nudge + center on modal rect). `min_size_content` is half the main
/// window so the first layout pass does not collapse the shell; DVUI then grows to fit content
/// (see `FloatingWindowWidget` `Size.max(min_size, min_sizeGet)`). Do not use `max_size_content`
@@ -1479,7 +1476,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
};
if (modal) {
- fizzy.dvui.modal_dim_titlebar = true;
+ pixelart.core.dvui.modal_dim_titlebar = true;
}
const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse {
@@ -1492,8 +1489,8 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
};
const cancel_label = dvui.dataGetSlice(null, id, "_cancel_label", []u8);
const default = dvui.dataGet(null, id, "_default", dvui.enums.DialogResponse);
- const callafter = dvui.dataGet(null, id, "_callafter", fizzy.dvui.CallAfterFn);
- const displayFn = dvui.dataGet(null, id, "_displayFn", fizzy.dvui.DisplayFn);
+ const callafter = dvui.dataGet(null, id, "_callafter", pixelart.core.dvui.CallAfterFn);
+ const displayFn = dvui.dataGet(null, id, "_displayFn", pixelart.core.dvui.DisplayFn);
// Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger.
const wr = dvui.windowRect();
@@ -1501,7 +1498,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
const init_h = @round(wr.h * 0.52);
const center_on = dvui.currentWindow().subwindows.current_rect;
- var win = fizzy.dvui.floatingWindow(@src(), .{
+ var win = pixelart.core.dvui.floatingWindow(@src(), .{
.modal = modal,
.center_on = center_on,
.window_avoid = .nudge,
@@ -1533,12 +1530,12 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
if (dvui.animationGet(win.data().id, "_close_x")) |a| {
if (a.done()) {
- fizzy.dvui.dialog_close_rect_override = null;
+ pixelart.core.dvui.dialog_close_rect_override = null;
dvui.dialogRemove(id);
}
- } else if (fizzy.dvui.dialog_close_rect_override) |close_rect| {
+ } else if (pixelart.core.dvui.dialog_close_rect_override) |close_rect| {
dvui.dataSet(null, win.data().id, "_close_rect", close_rect);
- fizzy.dvui.dialog_close_rect_override = null;
+ pixelart.core.dvui.dialog_close_rect_override = null;
} else {
// Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the
// window keeps animating/snapping to content min size — user resize appears "locked".
@@ -1563,16 +1560,16 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
var shell = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both });
defer shell.deinit();
- const header_kind: fizzy.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) {
- @intFromEnum(fizzy.dvui.DialogHeaderKind.none) => .none,
- @intFromEnum(fizzy.dvui.DialogHeaderKind.info) => .info,
- @intFromEnum(fizzy.dvui.DialogHeaderKind.warning) => .warning,
- @intFromEnum(fizzy.dvui.DialogHeaderKind.err) => .err,
+ const header_kind: pixelart.core.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) {
+ @intFromEnum(pixelart.core.dvui.DialogHeaderKind.none) => .none,
+ @intFromEnum(pixelart.core.dvui.DialogHeaderKind.info) => .info,
+ @intFromEnum(pixelart.core.dvui.DialogHeaderKind.warning) => .warning,
+ @intFromEnum(pixelart.core.dvui.DialogHeaderKind.err) => .err,
else => .none,
};
var header_openflag = true;
- win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind));
+ win.dragAreaSet(pixelart.core.dvui.windowHeader(title, "", &header_openflag, header_kind));
if (!header_openflag) {
if (callafter) |ca| {
ca(id, .cancel) catch {
@@ -1605,7 +1602,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void {
}
}
- { // Footer — match `fizzy.dvui.dialogWindow` (horizontal strip, gravity_x centered).
+ { // Footer — match `pixelart.core.dvui.dialogWindow` (horizontal strip, gravity_x centered).
var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
.gravity_x = 0.5,
.padding = .{ .y = 6, .h = 8 },
@@ -1695,12 +1692,12 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
switch (response) {
.ok => {
const file_id = dvui.dataGet(null, id, "_grid_layout_file_id", u64) orelse return;
- const file = fizzy.editor.open_files.getPtr(file_id) orelse return;
+ const file = Globals.state.docs.fileById(file_id) orelse return;
switch (mode) {
.slice => {
const s = slice_form;
- if (!fizzy.Internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows))
+ if (!pixelart.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows))
return;
file.applyGridSliceOnly(.{
.column_width = s.column_width,
@@ -1714,7 +1711,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
},
.resize => {
const r = resize_form;
- if (!fizzy.Internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows))
+ if (!pixelart.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows))
return;
file.applyGridLayout(.{
.column_width = r.column_width,
@@ -1730,7 +1727,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
}
dvui.refresh(null, @src(), dvui.currentWindow().data().id);
- fizzy.editor.requestCompositeWarmup();
+ Globals.state.host.requestCompositeWarmup();
},
.cancel => {},
else => {},
diff --git a/src/plugins/pixelart/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig
similarity index 90%
rename from src/plugins/pixelart/dialogs/NewFile.zig
rename to src/plugins/pixelart/src/dialogs/NewFile.zig
index f6a591c9..9554493c 100644
--- a/src/plugins/pixelart/dialogs/NewFile.zig
+++ b/src/plugins/pixelart/src/dialogs/NewFile.zig
@@ -1,8 +1,10 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
-const Dialogs = @import("../../../editor/dialogs/Dialogs.zig");
+const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
+const fizzy = @import("../../../../fizzy.zig");
pub var mode: enum(usize) {
single,
@@ -188,18 +190,16 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
switch (response) {
.ok => {
if (parent_path) |parent| {
- const new_path = try std.fs.path.join(fizzy.app.allocator, &.{ parent, "untitled.fiz" });
- defer fizzy.app.allocator.free(new_path);
+ const new_path = try std.fs.path.join(Globals.allocator(), &.{ parent, "untitled.fiz" });
+ defer Globals.allocator().free(new_path);
- const file = fizzy.editor.newFile(new_path, .{
+ const doc = try Globals.state.host.createDocument(new_path, .{
.column_width = column_width,
.row_height = row_height,
.columns = if (mode == .single) 1 else columns,
.rows = if (mode == .single) 1 else rows,
- }) catch {
- dvui.log.err("Failed to create file in folder: {s}", .{parent});
- return error.FailedToCreateFile;
- };
+ });
+ const file = Globals.state.docs.fileFrom(doc);
// Save synchronously so the tree's directory scan sees the new file on the next draw
// (saveAsync would finish later and the fly-to / rename row would never match).
@@ -209,22 +209,19 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
};
if (fizzy.Editor.Explorer.files.new_file_path) |old| {
- fizzy.app.allocator.free(old);
+ Globals.allocator().free(old);
}
- fizzy.Editor.Explorer.files.new_file_path = try fizzy.app.allocator.dupe(u8, file.path);
+ fizzy.Editor.Explorer.files.new_file_path = try Globals.allocator().dupe(u8, file.path);
dvui.refresh(null, @src(), dvui.currentWindow().data().id);
} else {
- const new_path = try fizzy.editor.allocNextUntitledPath();
- defer fizzy.app.allocator.free(new_path);
- _ = fizzy.editor.newFile(new_path, .{
+ const new_path = try Globals.state.host.allocUntitledPath();
+ defer Globals.allocator().free(new_path);
+ _ = try Globals.state.host.createDocument(new_path, .{
.column_width = column_width,
.row_height = row_height,
.columns = if (mode == .single) 1 else columns,
.rows = if (mode == .single) 1 else rows,
- }) catch {
- dvui.log.err("Failed to create new untitled file", .{});
- return error.FailedToCreateFile;
- };
+ });
}
},
.cancel => {},
diff --git a/src/plugins/pixelart/explorer/project.zig b/src/plugins/pixelart/src/explorer/project.zig
similarity index 89%
rename from src/plugins/pixelart/explorer/project.zig
rename to src/plugins/pixelart/src/explorer/project.zig
index 63bc7528..59ce990a 100644
--- a/src/plugins/pixelart/explorer/project.zig
+++ b/src/plugins/pixelart/src/explorer/project.zig
@@ -2,8 +2,9 @@ const std = @import("std");
const builtin = @import("builtin");
const icons = @import("icons");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
pub fn draw() !void {
// On web there's no project folder concept. Render a simplified pane that
@@ -14,8 +15,8 @@ pub fn draw() !void {
return;
}
- if (fizzy.pixelart.host.folder()) |folder| {
- if (fizzy.pixelart.project) |_| {
+ if (Globals.state.host.folder()) |folder| {
+ if (Globals.state.project) |_| {
const tl = dvui.textLayout(@src(), .{}, .{
.expand = .none,
.margin = dvui.Rect.all(0),
@@ -34,7 +35,7 @@ pub fn draw() !void {
} else {
var box = dvui.box(@src(), .{ .dir = .vertical }, .{
.expand = .horizontal,
- .max_size_content = .{ .w = fizzy.pixelart.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) },
+ .max_size_content = .{ .w = Globals.state.host.explorerVirtualSize().w, .h = std.math.floatMax(f32) },
});
defer box.deinit();
@@ -44,19 +45,19 @@ pub fn draw() !void {
tl.deinit();
if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) {
- fizzy.pixelart.project = .{};
+ Globals.state.project = .{};
}
return;
}
- const packing = fizzy.editor.isPackingActive();
+ const packing = Globals.state.host.isPackingActive();
if (packProjectButton(packing)) {
- fizzy.editor.startPackProject() catch |err| {
+ Globals.state.host.startPackProject() catch |err| {
dvui.log.err("Failed to start project pack: {any}", .{err});
};
}
- if (fizzy.packer.atlas != null) {
+ if (Globals.packer.atlas != null) {
drawPackedAtlasStats();
}
@@ -67,8 +68,8 @@ pub fn draw() !void {
dvui.log.err("Failed to draw path text entry", .{});
};
- if (fizzy.pixelart.project) |project| {
- if (fizzy.packer.atlas) |atlas| {
+ if (Globals.state.project) |project| {
+ if (Globals.packer.atlas) |atlas| {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } });
if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{
.expand = .horizontal,
@@ -129,13 +130,13 @@ pub fn draw() !void {
// break :blk true;
// };
- // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{
+ // if (dvui.dialogNativeFileSave(Globals.allocator(), .{
// .title = "Select Atlas Data Output",
// .filters = &.{".atlas"},
// .filter_description = "Atlas file",
// .path = if (valid_path) project.packed_atlas_output else null,
// }) catch null) |path| {
- // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null;
+ // project.packed_atlas_output = Globals.allocator().dupe(u8, path[0..]) catch null;
// set_text = true;
// } else {
// dvui.log.err("Project failed to copy new path", .{});
@@ -162,7 +163,7 @@ pub fn draw() !void {
// if (te.text_changed) {
// const t = te.getText();
// if (t.len > 0) {
- // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, t) catch null;
+ // project.packed_atlas_output = Globals.allocator().dupe(u8, t) catch null;
// } else {
// project.packed_atlas_output = null;
// }
@@ -210,13 +211,13 @@ pub fn draw() !void {
// break :blk true;
// };
- // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{
+ // if (dvui.dialogNativeFileSave(Globals.allocator(), .{
// .title = "Select Atlas Image Output",
// .filters = &.{".png"},
// .filter_description = "Image file",
// .path = if (valid_path) project.packed_image_output else null,
// }) catch null) |path| {
- // project.packed_image_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null;
+ // project.packed_image_output = Globals.allocator().dupe(u8, path[0..]) catch null;
// set_text = true;
// } else {
// dvui.log.err("Project failed to copy new path", .{});
@@ -243,7 +244,7 @@ pub fn draw() !void {
// if (te.text_changed) {
// const t = te.getText();
// if (t.len > 0) {
- // project.packed_image_output = fizzy.app.allocator.dupe(u8, t) catch null;
+ // project.packed_image_output = Globals.allocator().dupe(u8, t) catch null;
// } else {
// project.packed_image_output = null;
// }
@@ -258,7 +259,7 @@ const PathType = enum {
};
fn pathTextEntry(path_type: PathType) !void {
- if (fizzy.pixelart.project) |*project| {
+ if (Globals.state.project) |*project| {
const output_path = switch (path_type) {
.atlas => &project.packed_atlas_output,
.image => &project.packed_image_output,
@@ -315,7 +316,7 @@ fn pathTextEntry(path_type: PathType) !void {
break :blk true;
};
- fizzy.pixelart.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
+ Globals.state.host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{
if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" },
}, "", if (valid_path) output_path.* else null);
set_text = true;
@@ -342,7 +343,7 @@ fn pathTextEntry(path_type: PathType) !void {
if (te.text_changed) {
const t = te.getText();
if (t.len > 0) {
- output_path.* = fizzy.app.allocator.dupe(u8, t) catch null;
+ output_path.* = Globals.allocator().dupe(u8, t) catch null;
} else {
output_path.* = null;
}
@@ -351,8 +352,8 @@ fn pathTextEntry(path_type: PathType) !void {
}
fn drawPackedAtlasStats() void {
- const atlas = &fizzy.packer.atlas.?;
- const image_size = fizzy.image.size(atlas.source);
+ const atlas = &Globals.packer.atlas.?;
+ const image_size = pixelart.image.size(atlas.source);
const atlas_w: u32 = @intFromFloat(image_size.w);
const atlas_h: u32 = @intFromFloat(image_size.h);
@@ -371,7 +372,7 @@ fn drawPackedAtlasStats() void {
const label_opts: dvui.Options = .{ .font = body, .color_text = label_color };
const value_opts: dvui.Options = .{ .font = body, .color_text = value_color };
- if (fizzy.packer.last_packed_at_ns) |packed_at_ns| {
+ if (Globals.packer.last_packed_at_ns) |packed_at_ns| {
var when_buf: [64]u8 = undefined;
const when = formatLastPacked(&when_buf, packed_at_ns);
tl.addText("Last packed: ", label_opts);
@@ -396,7 +397,7 @@ fn drawPackedAtlasStats() void {
}
fn formatLastPacked(buf: []u8, packed_at_ns: i128) []const u8 {
- const elapsed_s = @divTrunc(fizzy.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s);
+ const elapsed_s = @divTrunc(pixelart.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s);
if (elapsed_s < 10) {
return std.fmt.bufPrint(buf, "just now", .{}) catch "recently";
}
@@ -442,7 +443,7 @@ fn packProjectButton(packing: bool) bool {
// Spinner overlays at the right edge — same content rect as the label, but anchored to
// `gravity_x = 1.0`. Sized to roughly match the cap height so it doesn't fight the label.
if (packing) {
- fizzy.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{
+ pixelart.core.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{
.min_size_content = .{ .w = 16, .h = 16 },
.gravity_x = 1.0,
.gravity_y = 0.5,
@@ -455,24 +456,24 @@ fn packProjectButton(packing: bool) bool {
}
pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.pixelart.project) |*project| {
+ if (Globals.state.project) |*project| {
const output_path = &project.packed_atlas_output;
if (paths) |paths_| {
for (paths_) |path| {
- output_path.* = fizzy.app.allocator.dupe(u8, path) catch null;
+ output_path.* = Globals.allocator().dupe(u8, path) catch null;
}
}
}
}
pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void {
- if (fizzy.pixelart.project) |*project| {
+ if (Globals.state.project) |*project| {
const output_path = &project.packed_image_output;
if (paths) |paths_| {
for (paths_) |path| {
- output_path.* = fizzy.app.allocator.dupe(u8, path) catch null;
+ output_path.* = Globals.allocator().dupe(u8, path) catch null;
}
}
}
@@ -482,7 +483,7 @@ pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void {
/// the Pack button (operates on currently-open files) and Download buttons for
/// the resulting atlas/image data.
fn drawWeb() !void {
- if (fizzy.editor.open_files.count() == 0) {
+ if (Globals.state.host.openDocCount() == 0) {
dvui.labelNoFmt(
@src(),
"Open one or more files to pack.",
@@ -500,19 +501,19 @@ fn drawWeb() !void {
.style = .highlight,
};
- const packing = fizzy.editor.isPackingActive();
+ const packing = Globals.state.host.isPackingActive();
if (packProjectButton(packing)) {
- fizzy.editor.startPackProject() catch |err| {
+ Globals.state.host.startPackProject() catch |err| {
dvui.log.err("Failed to pack open files: {any}", .{err});
};
}
- if (fizzy.packer.atlas != null) {
+ if (Globals.packer.atlas != null) {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
drawPackedAtlasStats();
}
- if (fizzy.packer.atlas) |atlas| {
+ if (Globals.packer.atlas) |atlas| {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } });
if (dvui.button(@src(), "Download Atlas JSON", .{ .draw_focus = false }, btn_opts)) {
atlas.save("atlas.atlas", .data) catch {
diff --git a/src/plugins/pixelart/explorer/sprites.zig b/src/plugins/pixelart/src/explorer/sprites.zig
similarity index 90%
rename from src/plugins/pixelart/explorer/sprites.zig
rename to src/plugins/pixelart/src/explorer/sprites.zig
index 9b20679d..1e55629d 100644
--- a/src/plugins/pixelart/explorer/sprites.zig
+++ b/src/plugins/pixelart/src/explorer/sprites.zig
@@ -1,8 +1,10 @@
const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
+const fizzy = @import("../../../../fizzy.zig");
-const fizzy = @import("../../../fizzy.zig");
const Editor = fizzy.Editor;
const Sprites = @This();
@@ -91,7 +93,7 @@ pub fn init() Sprites {
return .{};
}
-fn selectionUiKey(file: *fizzy.Internal.File) u64 {
+fn selectionUiKey(file: *pixelart.internal.File) u64 {
const c = file.editor.selected_sprites.count();
if (c == 0) return 0;
const first = file.editor.selected_sprites.findFirstSet() orelse return 0;
@@ -101,7 +103,7 @@ fn selectionUiKey(file: *fizzy.Internal.File) u64 {
return (@as(u64, c) << 48) ^ (@as(u64, first) << 24) ^ @as(u64, last);
}
-fn selectionOriginsDifferFrom(file: *fizzy.Internal.File, indices: []const usize, old_vals: []const [2]f32) bool {
+fn selectionOriginsDifferFrom(file: *pixelart.internal.File, indices: []const usize, old_vals: []const [2]f32) bool {
for (indices, old_vals) |si, ov| {
const cur = file.sprites.get(si).origin;
if (cur[0] != ov[0] or cur[1] != ov[1]) return true;
@@ -113,36 +115,36 @@ fn freeOriginAxisDragSnapshot(self: *Sprites, axis: enum { x, y }) void {
switch (axis) {
.x => {
if (self.origin_x_drag_indices) |s| {
- fizzy.app.allocator.free(s);
+ Globals.allocator().free(s);
self.origin_x_drag_indices = null;
}
if (self.origin_x_drag_old_vals) |v| {
- fizzy.app.allocator.free(v);
+ Globals.allocator().free(v);
self.origin_x_drag_old_vals = null;
}
},
.y => {
if (self.origin_y_drag_indices) |s| {
- fizzy.app.allocator.free(s);
+ Globals.allocator().free(s);
self.origin_y_drag_indices = null;
}
if (self.origin_y_drag_old_vals) |v| {
- fizzy.app.allocator.free(v);
+ Globals.allocator().free(v);
self.origin_y_drag_old_vals = null;
}
},
}
}
-fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.Internal.File, axis: enum { x, y }) !void {
+fn beginOriginAxisDragSnapshot(self: *Sprites, file: *pixelart.internal.File, axis: enum { x, y }) !void {
switch (axis) {
.x => if (self.origin_x_drag_indices != null) return,
.y => if (self.origin_y_drag_indices != null) return,
}
const count = file.editor.selected_sprites.count();
- const indices = try fizzy.app.allocator.alloc(usize, count);
- errdefer fizzy.app.allocator.free(indices);
- const old_vals = try fizzy.app.allocator.alloc([2]f32, count);
+ const indices = try Globals.allocator().alloc(usize, count);
+ errdefer Globals.allocator().free(indices);
+ const old_vals = try Globals.allocator().alloc([2]f32, count);
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
var i: usize = 0;
while (iter.next()) |si| : (i += 1) {
@@ -161,15 +163,15 @@ fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.Internal.File, axis:
}
}
-fn appendOriginsHistory(file: *fizzy.Internal.File, indices: []usize, old_vals: [][2]f32) !void {
+fn appendOriginsHistory(file: *pixelart.internal.File, indices: []usize, old_vals: [][2]f32) !void {
file.history.append(.{ .origins = .{ .indices = indices, .values = old_vals } }) catch |err| {
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
+ Globals.allocator().free(indices);
+ Globals.allocator().free(old_vals);
return err;
};
}
-fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) void {
+fn applySpriteOriginAxisNoHistory(file: *pixelart.internal.File, axis: enum { x, y }, new_val: f32) void {
const cw = @as(f32, @floatFromInt(file.column_width));
const rh = @as(f32, @floatFromInt(file.row_height));
const max_v: f32 = switch (axis) {
@@ -186,7 +188,7 @@ fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y
}
}
-fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) !void {
+fn commitSpriteOriginAxis(file: *pixelart.internal.File, axis: enum { x, y }, new_val: f32) !void {
const cw = @as(f32, @floatFromInt(file.column_width));
const rh = @as(f32, @floatFromInt(file.row_height));
const max_v: f32 = switch (axis) {
@@ -198,10 +200,10 @@ fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_v
const count = file.editor.selected_sprites.count();
if (count == 0) return;
- const indices = try fizzy.app.allocator.alloc(usize, count);
- errdefer fizzy.app.allocator.free(indices);
- const old_vals = try fizzy.app.allocator.alloc([2]f32, count);
- errdefer fizzy.app.allocator.free(old_vals);
+ const indices = try Globals.allocator().alloc(usize, count);
+ errdefer Globals.allocator().free(indices);
+ const old_vals = try Globals.allocator().alloc([2]f32, count);
+ errdefer Globals.allocator().free(old_vals);
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
var i: usize = 0;
@@ -221,14 +223,14 @@ fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_v
for (indices, 0..) |si, j| {
file.sprites.items(.origin)[si] = old_vals[j];
}
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
+ Globals.allocator().free(indices);
+ Globals.allocator().free(old_vals);
return err;
};
}
pub fn draw(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
const parent_height = dvui.parentGet().data().rect.h - 2.0 * dvui.currentWindow().natural_scale;
const parent_data = dvui.parentGet().data();
@@ -289,7 +291,7 @@ pub fn draw(self: *Sprites) !void {
}
pub fn drawOriginControls(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
if (file.editor.selected_sprites.count() == 0) return;
const key = selectionUiKey(file);
@@ -418,8 +420,8 @@ pub fn drawOriginControls(self: *Sprites) !void {
if (selectionOriginsDifferFrom(file, indices, old_vals)) {
try appendOriginsHistory(file, indices, old_vals);
} else {
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
+ Globals.allocator().free(indices);
+ Globals.allocator().free(old_vals);
}
}
}
@@ -489,8 +491,8 @@ pub fn drawOriginControls(self: *Sprites) !void {
if (selectionOriginsDifferFrom(file, indices, old_vals)) {
try appendOriginsHistory(file, indices, old_vals);
} else {
- fizzy.app.allocator.free(indices);
- fizzy.app.allocator.free(old_vals);
+ Globals.allocator().free(indices);
+ Globals.allocator().free(old_vals);
}
}
}
@@ -507,7 +509,7 @@ pub fn drawAnimationControls(self: *Sprites) !void {
const icon_color = dvui.themeGet().color(.control, .text);
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
{
var add_animation_button: dvui.ButtonWidget = undefined;
add_animation_button.init(@src(), .{}, .{
@@ -698,7 +700,7 @@ pub fn drawAnimations(self: *Sprites) !void {
controls_box.deinit();
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
// Make sure to update the prev anim count!
defer self.prev_anim_count = file.animations.len;
@@ -732,17 +734,17 @@ pub fn drawAnimations(self: *Sprites) !void {
defer {
if (file.editor.animations_scroll_info.viewport.w < file.editor.animations_scroll_info.virtual_size.w) {
if (file.editor.animations_scroll_info.offset(.horizontal) < file.editor.animations_scroll_info.scrollMax(.horizontal)) {
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{});
}
if (file.editor.animations_scroll_info.offset(.horizontal) > 0.0) {
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{});
}
}
}
const vertical_scroll = file.editor.animations_scroll_info.offset(.vertical);
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
+ var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
.expand = .horizontal,
.background = false,
});
@@ -769,8 +771,8 @@ pub fn drawAnimations(self: *Sprites) !void {
}
}
- var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Animation, sources.len);
- defer fizzy.app.allocator.free(moved);
+ var moved = try Globals.allocator().alloc(pixelart.internal.Animation, sources.len);
+ defer Globals.allocator().free(moved);
for (sources, 0..) |s, i| {
moved[i] = file.animations.get(s);
}
@@ -781,11 +783,11 @@ pub fn drawAnimations(self: *Sprites) !void {
file.animations.orderedRemove(sources[ri]);
}
- const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
+ const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
const target = @min(target_raw, file.animations.len);
for (moved, 0..) |anim, i| {
- file.animations.insert(fizzy.app.allocator, target + i, anim) catch {
+ file.animations.insert(Globals.allocator(), target + i, anim) catch {
dvui.log.err("Failed to insert animation", .{});
};
}
@@ -796,7 +798,7 @@ pub fn drawAnimations(self: *Sprites) !void {
file.editor.selected_animation_indices.clearRetainingCapacity();
for (0..moved.len) |i| {
- file.editor.selected_animation_indices.append(fizzy.app.allocator, target + i) catch {
+ file.editor.selected_animation_indices.append(Globals.allocator(), target + i) catch {
dvui.log.err("Failed to update animation selection", .{});
};
}
@@ -829,7 +831,7 @@ pub fn drawAnimations(self: *Sprites) !void {
const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi);
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(anim_id));
}
@@ -994,13 +996,13 @@ pub fn drawAnimations(self: *Sprites) !void {
file.history.append(.{
.animation_name = .{
.index = anim_index,
- .name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[anim_index]),
+ .name = try Globals.allocator().dupe(u8, file.animations.items(.name)[anim_index]),
},
}) catch {
dvui.log.err("Failed to append history", .{});
};
- fizzy.app.allocator.free(file.animations.items(.name)[anim_index]);
- file.animations.items(.name)[anim_index] = try fizzy.app.allocator.dupe(u8, te.getText());
+ Globals.allocator().free(file.animations.items(.name)[anim_index]);
+ file.animations.items(.name)[anim_index] = try Globals.allocator().dupe(u8, te.getText());
}
if (te.enter_pressed) {
file.selected_animation_index = anim_index;
@@ -1050,10 +1052,10 @@ pub fn drawAnimations(self: *Sprites) !void {
const anim_si = file.editor.animations_scroll_info;
const anim_v_max = anim_si.scrollMax(.vertical);
if (vertical_scroll > scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
if (anim_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < anim_v_max - scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
}
}
@@ -1063,7 +1065,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
});
defer box.deinit();
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
const index = if (file.selected_animation_index) |i| i else 0;
var animation = file.animations.get(index);
@@ -1109,8 +1111,8 @@ pub fn drawFrameControls(_: *Sprites) !void {
dvui.alphaSet(alpha);
if (sort_anim_asc_button.clicked()) {
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
- std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.asc);
+ const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames);
+ std.mem.sort(pixelart.internal.Animation.Frame, animation.frames, {}, FrameSort.asc);
if (!animation.eqlFrames(prev_order)) {
file.history.append(.{
@@ -1124,7 +1126,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
}
}
@@ -1169,8 +1171,8 @@ pub fn drawFrameControls(_: *Sprites) !void {
dvui.alphaSet(alpha);
if (sort_anim_desc_button.clicked()) {
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
- std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.desc);
+ const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames);
+ std.mem.sort(pixelart.internal.Animation.Frame, animation.frames, {}, FrameSort.desc);
if (!animation.eqlFrames(prev_order)) {
file.history.append(.{
@@ -1184,7 +1186,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
}
}
@@ -1232,7 +1234,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
if (add_sprite_button.clicked()) {
if (file.editor.selected_sprites.count() > 0) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- var frames = std.array_list.Managed(fizzy.Animation.Frame).init(dvui.currentWindow().arena());
+ var frames = std.array_list.Managed(pixelart.internal.Animation.Frame).init(dvui.currentWindow().arena());
while (iter.next()) |sprite_index| {
frames.append(.{
.sprite_index = sprite_index,
@@ -1243,9 +1245,9 @@ pub fn drawFrameControls(_: *Sprites) !void {
};
}
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames);
- animation.appendFrames(fizzy.app.allocator, frames.items) catch {
+ animation.appendFrames(Globals.allocator(), frames.items) catch {
dvui.log.err("Failed to append frames", .{});
};
@@ -1261,7 +1263,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
}
}
@@ -1320,12 +1322,12 @@ pub fn drawFrameControls(_: *Sprites) !void {
if (duplicate_animation_button.clicked()) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames);
while (iter.next()) |sprite_index| {
for (animation.frames) |frame| {
if (frame.sprite_index == sprite_index) {
- try animation.appendFrame(fizzy.app.allocator, .{
+ try animation.appendFrame(Globals.allocator(), .{
.sprite_index = frame.sprite_index,
.ms = frame.ms,
});
@@ -1346,7 +1348,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.selected_animation_frame_index = 0;
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
}
}
@@ -1392,13 +1394,13 @@ pub fn drawFrameControls(_: *Sprites) !void {
if (delete_animation_button.clicked()) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames);
while (iter.next()) |sprite_index| {
var i: usize = animation.frames.len;
while (i > 0) : (i -= 1) {
if (animation.frames[i - 1].sprite_index == sprite_index) {
- animation.removeFrame(fizzy.app.allocator, i - 1);
+ animation.removeFrame(Globals.allocator(), i - 1);
break;
}
}
@@ -1416,7 +1418,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
file.selected_animation_frame_index = 0;
file.animations.set(index, animation);
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
}
}
@@ -1424,7 +1426,7 @@ pub fn drawFrameControls(_: *Sprites) !void {
}
pub fn drawFrames(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
var anim = dvui.animate(@src(), .{ .kind = .horizontal, .duration = 450_000, .easing = dvui.easing.outBack }, .{});
defer anim.deinit();
@@ -1482,7 +1484,7 @@ pub fn drawFrames(self: *Sprites) !void {
defer self.prev_sprite_count = animation.frames.len;
defer self.prev_anim_id = animation.id;
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
+ var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
.expand = .horizontal,
.background = false,
});
@@ -1495,7 +1497,7 @@ pub fn drawFrames(self: *Sprites) !void {
if (removed_frame_indices_len > 0) {
const sources = removed_frame_indices_buf[0..removed_frame_indices_len];
- const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames);
+ const prev_order = try Globals.allocator().dupe(pixelart.internal.Animation.Frame, animation.frames);
defer file.animations.set(animation_index, animation);
const primary_before = file.selected_animation_frame_index;
@@ -1509,14 +1511,14 @@ pub fn drawFrames(self: *Sprites) !void {
}
}
- var moved = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, sources.len);
- defer fizzy.app.allocator.free(moved);
+ var moved = try Globals.allocator().alloc(pixelart.internal.Animation.Frame, sources.len);
+ defer Globals.allocator().free(moved);
for (sources, 0..) |s, i| {
moved[i] = animation.frames[s];
}
- var remaining = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len - sources.len);
- defer fizzy.app.allocator.free(remaining);
+ var remaining = try Globals.allocator().alloc(pixelart.internal.Animation.Frame, animation.frames.len - sources.len);
+ defer Globals.allocator().free(remaining);
{
var ri: usize = 0;
var wi: usize = 0;
@@ -1535,7 +1537,7 @@ pub fn drawFrames(self: *Sprites) !void {
}
}
- const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
+ const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
const target = @min(target_raw, remaining.len);
var wi: usize = 0;
@@ -1558,7 +1560,7 @@ pub fn drawFrames(self: *Sprites) !void {
file.editor.selected_frame_indices.clearRetainingCapacity();
for (0..moved.len) |i| {
- file.editor.selected_frame_indices.append(fizzy.app.allocator, target + i) catch {
+ file.editor.selected_frame_indices.append(Globals.allocator(), target + i) catch {
dvui.log.err("Failed to update frame selection", .{});
};
}
@@ -1576,7 +1578,7 @@ pub fn drawFrames(self: *Sprites) !void {
dvui.log.err("Failed to append history", .{});
};
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
self.sprite_insert_before_index = null;
@@ -1600,7 +1602,7 @@ pub fn drawFrames(self: *Sprites) !void {
for (animation.frames, 0..) |*frame, frame_index| {
var anim_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
anim_color = palette.getDVUIColor(@intCast(animation.id));
}
@@ -1782,10 +1784,10 @@ pub fn drawFrames(self: *Sprites) !void {
const frames_si = file.editor.sprites_scroll_info;
const frames_v_max = frames_si.scrollMax(.vertical);
if (vertical_scroll > scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
if (frames_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < frames_v_max - scroll_list_shadow_deadzone_ns)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
}
}
@@ -1799,21 +1801,21 @@ const FrameRowHit = struct {
hbox_tl: dvui.Point.Physical,
};
-fn frameGestureMatches(file: *const fizzy.Internal.File, anim_id: u64) bool {
+fn frameGestureMatches(file: *const pixelart.internal.File, anim_id: u64) bool {
return frame_row_gesture != null and frame_row_gesture.?.file_id == file.id and frame_row_gesture.?.anim_id == anim_id;
}
-fn frameTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void {
+fn frameTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void {
frame_row_gesture = null;
}
-fn frameTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void {
+fn frameTreeResetRowPointerGesture(_: *const pixelart.internal.File) void {
dvui.dragEnd();
frame_row_gesture = null;
}
/// After `selected_frame_indices` changes, make tile selection match exactly those frames' sprites.
-fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index: usize) void {
+fn syncSpritesFromCurrentFrameSelection(file: *pixelart.internal.File, anim_index: usize) void {
const frames = file.animations.get(anim_index).frames;
file.clearSelectedSprites();
for (file.editor.selected_frame_indices.items) |fi| {
@@ -1825,7 +1827,7 @@ fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index:
/// Frame selection is scoped to one animation at a time. `selected_frame_indices` always mirrors
/// `selected_sprites` for this animation's frames (so canvas changes can't leave stale tree state).
-fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64) void {
+fn ensureFrameSelection(file: *pixelart.internal.File, anim_index: usize, anim_id: u64) void {
const frames = file.animations.get(anim_index).frames;
if (file.editor.selected_frame_indices_for_animation_id != anim_id) {
@@ -1848,7 +1850,7 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id:
file.editor.selected_frame_indices.clearRetainingCapacity();
for (frames, 0..) |f, i| {
if (f.sprite_index < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(f.sprite_index)) {
- file.editor.selected_frame_indices.append(fizzy.app.allocator, i) catch return;
+ file.editor.selected_frame_indices.append(Globals.allocator(), i) catch return;
}
}
std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize));
@@ -1879,11 +1881,11 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id:
}
fn applyFrameClick(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
anim_index: usize,
anim_id: u64,
clicked: usize,
- mode: fizzy.dvui.TreeSelection.ClickMode,
+ mode: pixelart.core.dvui.TreeSelection.ClickMode,
) !bool {
ensureFrameSelection(file, anim_index, anim_id);
@@ -1904,7 +1906,7 @@ fn applyFrameClick(
}
var out: std.ArrayList(usize) = .empty;
- defer out.deinit(fizzy.app.allocator);
+ defer out.deinit(Globals.allocator());
// When anchor is null, shift-extend uses `primary_opt` as the range endpoint. During playback
// that index is the animated playhead, not the editor's last stable focus — use a selection
@@ -1916,8 +1918,8 @@ fn applyFrameClick(
break :blk file.editor.selected_frame_indices.items[0];
} else file.selected_animation_frame_index;
- const res = try fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
+ const res = try pixelart.core.dvui.TreeSelection.applyClickUsize(
+ Globals.allocator(),
prev_multi,
primary_for_tree,
file.editor.frame_selection_anchor,
@@ -1928,7 +1930,7 @@ fn applyFrameClick(
);
file.editor.selected_frame_indices.clearRetainingCapacity();
- try file.editor.selected_frame_indices.appendSlice(fizzy.app.allocator, out.items);
+ try file.editor.selected_frame_indices.appendSlice(Globals.allocator(), out.items);
file.editor.selected_frame_indices_for_animation_id = anim_id;
file.editor.frame_selection_anchor = res.anchor;
if (res.primary) |p| file.selected_animation_frame_index = p;
@@ -1936,16 +1938,16 @@ fn applyFrameClick(
return false;
}
-fn narrowFrameSelectionTo(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64, clicked: usize) void {
+fn narrowFrameSelectionTo(file: *pixelart.internal.File, anim_index: usize, anim_id: u64, clicked: usize) void {
file.editor.selected_frame_indices.clearRetainingCapacity();
- file.editor.selected_frame_indices.append(fizzy.app.allocator, clicked) catch return;
+ file.editor.selected_frame_indices.append(Globals.allocator(), clicked) catch return;
file.editor.selected_frame_indices_for_animation_id = anim_id;
file.editor.frame_selection_anchor = clicked;
file.selected_animation_frame_index = clicked;
syncSpritesFromCurrentFrameSelection(file, anim_index);
}
-fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize {
+fn buildFrameMultiDragIds(file: *const pixelart.internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize {
const frames = file.animations.get(animation_index).frames;
var len: usize = 0;
const playhead = file.selected_animation_frame_index;
@@ -1982,8 +1984,8 @@ fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usi
}
fn processFrameTreePointerEvents(
- tree: *fizzy.dvui.TreeWidget,
- file: *fizzy.Internal.File,
+ tree: *pixelart.core.dvui.TreeWidget,
+ file: *pixelart.internal.File,
anim_id: u64,
animation_index: usize,
hits: []const FrameRowHit,
@@ -2013,7 +2015,7 @@ fn processFrameTreePointerEvents(
frameTreeClearGestureKeysOnly(file);
dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
+ const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod);
const narrow_on_release = applyFrameClick(file, animation_index, anim_id, h.frame_index, mode) catch blk: {
dvui.log.err("Failed to apply frame click", .{});
break :blk false;
@@ -2143,15 +2145,15 @@ const AnimationRowHit = struct {
hbox_tl: dvui.Point.Physical,
};
-fn animationGestureMatches(file: *const fizzy.Internal.File) bool {
+fn animationGestureMatches(file: *const pixelart.internal.File) bool {
return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id;
}
-fn animationTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void {
+fn animationTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void {
animation_row_gesture = null;
}
-fn animationTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void {
+fn animationTreeResetRowPointerGesture(_: *const pixelart.internal.File) void {
dvui.dragEnd();
animation_row_gesture = null;
}
@@ -2174,7 +2176,7 @@ fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Re
return true;
}
-fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
+fn animationTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
const tr = tree.data().borderRectScale().r;
if (!tr.contains(p)) return false;
@@ -2182,12 +2184,12 @@ fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point
return true;
}
-fn animationTreePointerInTreeBorder(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
+fn animationTreePointerInTreeBorder(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
return tree.data().borderRectScale().r.contains(p);
}
-fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool {
+fn animationTreeMotionAllowsReorder(tree: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool {
if (e.target_widgetId) |fwid| {
if (fwid == tree.data().id) return true;
}
@@ -2199,7 +2201,7 @@ fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event
return in_surface or in_border;
}
-fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) void {
+fn syncAnimationSelectionFrames(file: *pixelart.internal.File, anim_index: usize) void {
const anim = file.animations.get(anim_index);
if (anim.frames.len > 0) {
if (file.selected_animation_frame_index >= anim.frames.len) {
@@ -2210,7 +2212,7 @@ fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) v
}
}
-fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bool {
+fn animationIndexInMulti(file: *const pixelart.internal.File, anim_index: usize) bool {
for (file.editor.selected_animation_indices.items) |i| {
if (i == anim_index) return true;
}
@@ -2220,7 +2222,7 @@ fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bo
/// Keep `selected_animation_indices` consistent with the authoritative single-selection and the
/// current animation count. The set may be empty (no animations yet), but if `selected_animation_index`
/// is set we guarantee it appears in the set.
-fn ensureAnimationSelection(file: *fizzy.Internal.File) void {
+fn ensureAnimationSelection(file: *pixelart.internal.File) void {
const count = file.animations.len;
if (count == 0) {
file.editor.selected_animation_indices.clearRetainingCapacity();
@@ -2251,7 +2253,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void {
}
}
if (!found) {
- file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return;
+ file.editor.selected_animation_indices.append(Globals.allocator(), p) catch return;
std.sort.pdq(usize, file.editor.selected_animation_indices.items, {}, std.sort.asc(usize));
}
}
@@ -2263,7 +2265,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void {
/// Apply a modifier-aware click to the animation selection. Returns whether the click should defer
/// narrowing until release (Finder-style): plain click on an already-multi-selected row.
-fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.dvui.TreeSelection.ClickMode) !bool {
+fn applyAnimationClick(file: *pixelart.internal.File, clicked: usize, mode: pixelart.core.dvui.TreeSelection.ClickMode) !bool {
const prev_multi = file.editor.selected_animation_indices.items;
const was_in_multi = animationIndexInMulti(file, clicked);
const was_multi = prev_multi.len > 1;
@@ -2271,20 +2273,20 @@ fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.d
const defer_narrow = (mode == .replace and was_multi and was_in_multi);
var out: std.ArrayList(usize) = .empty;
- defer out.deinit(fizzy.app.allocator);
+ defer out.deinit(Globals.allocator());
if (defer_narrow) {
- try out.appendSlice(fizzy.app.allocator, prev_multi);
+ try out.appendSlice(Globals.allocator(), prev_multi);
std.sort.pdq(usize, out.items, {}, std.sort.asc(usize));
file.editor.selected_animation_indices.clearRetainingCapacity();
- try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items);
+ try file.editor.selected_animation_indices.appendSlice(Globals.allocator(), out.items);
file.selected_animation_index = clicked;
syncAnimationSelectionFrames(file, clicked);
return true;
}
- const res = try fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
+ const res = try pixelart.core.dvui.TreeSelection.applyClickUsize(
+ Globals.allocator(),
prev_multi,
file.selected_animation_index,
file.editor.animation_selection_anchor,
@@ -2295,16 +2297,16 @@ fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.d
);
file.editor.selected_animation_indices.clearRetainingCapacity();
- try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items);
+ try file.editor.selected_animation_indices.appendSlice(Globals.allocator(), out.items);
file.editor.animation_selection_anchor = res.anchor;
file.selected_animation_index = res.primary;
if (res.primary) |p| syncAnimationSelectionFrames(file, p);
return false;
}
-fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void {
+fn narrowAnimationSelectionTo(file: *pixelart.internal.File, clicked: usize) void {
file.editor.selected_animation_indices.clearRetainingCapacity();
- file.editor.selected_animation_indices.append(fizzy.app.allocator, clicked) catch return;
+ file.editor.selected_animation_indices.append(Globals.allocator(), clicked) catch return;
file.editor.animation_selection_anchor = clicked;
file.selected_animation_index = clicked;
syncAnimationSelectionFrames(file, clicked);
@@ -2312,7 +2314,7 @@ fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void {
/// Populate `out` with the branch-ids of every selected animation row (primary first), for
/// `TreeWidget.dragStartMulti`. Returns a slice into `out` with just the written entries.
-fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const AnimationRowHit, out: []usize) []usize {
+fn buildAnimationMultiDragIds(file: *const pixelart.internal.File, hits: []const AnimationRowHit, out: []usize) []usize {
var len: usize = 0;
const primary = file.selected_animation_index;
if (primary) |p| {
@@ -2341,7 +2343,7 @@ fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const An
return out[0..len];
}
-fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void {
+fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixelart.core.dvui.TreeWidget, file: *pixelart.internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void {
if (!tree.init_options.enable_reordering) return;
for (dvui.events()) |*e| {
@@ -2367,7 +2369,7 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget,
animationTreeClearGestureKeysOnly(file);
dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
+ const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod);
const narrow_on_release = applyAnimationClick(file, h.anim_index, mode) catch blk: {
dvui.log.err("Failed to apply animation click", .{});
break :blk false;
@@ -2489,11 +2491,11 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget,
}
const FrameSort = struct {
- pub fn asc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool {
+ pub fn asc(_: void, a: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool {
return a.sprite_index < b.sprite_index;
}
- pub fn desc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool {
+ pub fn desc(_: void, a: pixelart.internal.Animation.Frame, b: pixelart.internal.Animation.Frame) bool {
return a.sprite_index > b.sprite_index;
}
};
diff --git a/src/plugins/pixelart/explorer/tools.zig b/src/plugins/pixelart/src/explorer/tools.zig
similarity index 90%
rename from src/plugins/pixelart/explorer/tools.zig
rename to src/plugins/pixelart/src/explorer/tools.zig
index d26445b1..904b1a32 100644
--- a/src/plugins/pixelart/explorer/tools.zig
+++ b/src/plugins/pixelart/src/explorer/tools.zig
@@ -1,9 +1,10 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const icons = @import("icons");
const assets = @import("assets");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
const Tools = @This();
@@ -68,10 +69,10 @@ pub fn draw(self: *Tools) !void {
drawLayerControls() catch {};
// Collect layers length to trigger a refit of the panel
- const layer_count: usize = if (fizzy.editor.activeFile()) |file| file.layers.len else 0;
+ const layer_count: usize = if (Globals.state.docs.activeFile(Globals.state.host)) |file| file.layers.len else 0;
defer prev_layer_count = layer_count;
- var paned = fizzy.dvui.paned(@src(), .{
+ var paned = pixelart.core.dvui.paned(@src(), .{
.direction = .vertical,
.collapsed_size = 0,
.handle_size = 10,
@@ -81,7 +82,7 @@ pub fn draw(self: *Tools) !void {
if (paned.dragging) {
max_split_ratio = paned.split_ratio.*;
- fizzy.pixelart.layers_ratio = paned.split_ratio.*;
+ Globals.state.layers_ratio = paned.split_ratio.*;
}
if (paned.showFirst()) {
@@ -97,7 +98,7 @@ pub fn draw(self: *Tools) !void {
const autofit = !paned.dragging and !paned.collapsed_state and !paned.animating;
// Refit must be done between showFirst and showSecond
- if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.pixelart.pinned_palettes) {
+ if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !Globals.state.pinned_palettes) {
if (dvui.firstFrame(paned.data().id) and layer_count == 0)
paned.split_ratio.* = 0.0;
@@ -108,7 +109,7 @@ pub fn draw(self: *Tools) !void {
// next frame when min sizes are valid.
if (dvui.firstFrame(paned.data().id) and layer_count > 0) {
paned.split_ratio.* = 0.01;
- //fizzy.pixelart.layers_ratio = paned.split_ratio.*;
+ //Globals.state.layers_ratio = paned.split_ratio.*;
} else {
const ratio = paned.getFirstFittedRatio(
.{
@@ -129,9 +130,9 @@ pub fn draw(self: *Tools) !void {
if (layer_count == 0)
paned.split_ratio.* = 0.0
else
- paned.split_ratio.* = fizzy.pixelart.layers_ratio;
+ paned.split_ratio.* = Globals.state.layers_ratio;
- fizzy.pixelart.layers_ratio = paned.split_ratio.*;
+ Globals.state.layers_ratio = paned.split_ratio.*;
}
}
@@ -159,28 +160,28 @@ pub fn drawTools() !void {
.padding = .{ .h = 10.0, .w = 4.0, .x = 4.0, .y = 4.0 },
});
defer toolbox.deinit();
- for (0..std.meta.fields(fizzy.Editor.Tools.Tool).len) |i| {
- const tool: fizzy.Editor.Tools.Tool = @enumFromInt(i);
+ for (0..std.meta.fields(pixelart.Tools.Tool).len) |i| {
+ const tool: pixelart.Tools.Tool = @enumFromInt(i);
const id_extra = i;
- const selected = fizzy.pixelart.tools.current == tool;
+ const selected = Globals.state.tools.current == tool;
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(i);
}
- const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
- .pixel => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default],
- .box => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default],
- .color => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default],
+ const selection_sprite = switch (Globals.state.tools.selection_mode) {
+ .pixel => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_default],
+ .box => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_default],
+ .color => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_default],
};
const sprite = switch (tool) {
- .pointer => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.cursor_default],
- .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default],
- .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default],
- .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default],
+ .pointer => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.cursor_default],
+ .pencil => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pencil_default],
+ .eraser => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.eraser_default],
+ .bucket => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.bucket_default],
.selection => selection_sprite,
};
var button: dvui.ButtonWidget = undefined;
@@ -204,13 +205,13 @@ pub fn drawTools() !void {
});
defer button.deinit();
- fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {};
+ Globals.state.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {};
if (button.hovered()) {
button.data().options.color_border = color;
}
- const size: dvui.Size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
+ const size: dvui.Size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch .{ .w = 0, .h = 0 };
const uv = dvui.Rect{
.x = @as(f32, @floatFromInt(sprite.source[0])) / size.w,
@@ -232,7 +233,7 @@ pub fn drawTools() !void {
rs.r.w = width;
rs.r.h = height;
- dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{
+ dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{
.uv = uv,
.fade = 0.0,
}) catch {
@@ -240,7 +241,7 @@ pub fn drawTools() !void {
};
if (button.clicked()) {
- fizzy.pixelart.tools.set(tool);
+ Globals.state.tools.set(tool);
}
}
}
@@ -253,7 +254,7 @@ pub fn drawLayerControls() !void {
defer box.deinit();
dvui.labelNoFmt(@src(), "LAYERS", .{}, .{ .font = dvui.Font.theme(.heading), .gravity_y = 0.5 });
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
.expand = .none,
.background = false,
@@ -402,7 +403,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
});
defer vbox.deinit();
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
layer_rename_hit_te_id = null;
layer_rename_hit_rect = null;
file.editor.layer_drag_preview_removed = null;
@@ -424,7 +425,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
const vertical_scroll = file.editor.layers_scroll_info.offset(.vertical);
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
+ var tree = pixelart.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{
.expand = .horizontal,
.background = false,
});
@@ -438,7 +439,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
if (removed_layer_indices_len > 0) {
const sources = removed_layer_indices_buf[0..removed_layer_indices_len];
- const prev_order = try fizzy.app.allocator.alloc(u64, file.layers.len);
+ const prev_order = try Globals.allocator().alloc(u64, file.layers.len);
for (file.layers.items(.id), 0..) |id, i| {
prev_order[i] = id;
}
@@ -455,8 +456,8 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
}
// Snapshot moved layers before any removal so indices stay valid.
- var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Layer, sources.len);
- defer fizzy.app.allocator.free(moved);
+ var moved = try Globals.allocator().alloc(pixelart.internal.Layer, sources.len);
+ defer Globals.allocator().free(moved);
for (sources, 0..) |s, i| {
moved[i] = file.layers.get(s);
}
@@ -468,11 +469,11 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
file.layers.orderedRemove(sources[ri]);
}
- const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
+ const target_raw = pixelart.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw);
const target = @min(target_raw, file.layers.len);
for (moved, 0..) |layer, i| {
- file.layers.insert(fizzy.app.allocator, target + i, layer) catch {
+ file.layers.insert(Globals.allocator(), target + i, layer) catch {
dvui.log.err("Failed to insert layer", .{});
};
}
@@ -487,7 +488,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
// After a group move the moved rows become contiguous; resync multi-selection to reflect that.
file.editor.selected_layer_indices.clearRetainingCapacity();
for (0..moved.len) |i| {
- file.editor.selected_layer_indices.append(fizzy.app.allocator, target + i) catch {
+ file.editor.selected_layer_indices.append(Globals.allocator(), target + i) catch {
dvui.log.err("Failed to update layer selection", .{});
};
}
@@ -505,7 +506,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
dvui.log.err("Failed to append history", .{});
};
} else {
- fizzy.app.allocator.free(prev_order);
+ Globals.allocator().free(prev_order);
}
insert_before_index = null;
@@ -539,7 +540,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
const font = if (visible) dvui.Font.theme(.body) else dvui.Font.theme(.body).withStyle(.italic);
var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(layer_id));
}
@@ -589,7 +590,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
if (file.editor.isolate_layer) {
if (file.peek_layer_index) |peek_layer_index| {
min_layer_index = peek_layer_index;
- } else if (!fizzy.pixelart.tools_pane.layersHovered()) {
+ } else if (!Globals.state.tools_pane.layersHovered()) {
min_layer_index = file.selected_layer_index;
}
}
@@ -719,13 +720,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
file.history.append(.{
.layer_name = .{
.index = layer_index,
- .name = try fizzy.app.allocator.dupe(u8, file.layers.items(.name)[layer_index]),
+ .name = try Globals.allocator().dupe(u8, file.layers.items(.name)[layer_index]),
},
}) catch {
dvui.log.err("Failed to append history", .{});
};
- fizzy.app.allocator.free(file.layers.items(.name)[layer_index]);
- file.layers.items(.name)[layer_index] = try fizzy.app.allocator.dupe(u8, te.getText());
+ Globals.allocator().free(file.layers.items(.name)[layer_index]);
+ file.layers.items(.name)[layer_index] = try Globals.allocator().dupe(u8, te.getText());
}
if (te.enter_pressed) {
file.selected_layer_index = layer_index;
@@ -917,13 +918,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical {
// Only draw shadow if the scroll bar has been scrolled some
if (vertical_scroll > 0.0)
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{});
if (file.editor.layers_scroll_info.virtual_size.h > file.editor.layers_scroll_info.viewport.h + 1 and vertical_scroll < file.editor.layers_scroll_info.scrollMax(.vertical))
- fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{});
}
- if (fizzy.dvui.hovered(vbox.data())) {
+ if (pixelart.core.dvui.hovered(vbox.data())) {
const mp = dvui.currentWindow().mouse_pt;
if (tools.layers_scroll_viewport_rect) |vr| {
if (!vr.contains(mp)) return null;
@@ -945,8 +946,8 @@ pub fn drawColors() !void {
});
defer hbox.deinit();
- const primary: dvui.Color = .{ .r = fizzy.pixelart.colors.primary[0], .g = fizzy.pixelart.colors.primary[1], .b = fizzy.pixelart.colors.primary[2], .a = fizzy.pixelart.colors.primary[3] };
- const secondary: dvui.Color = .{ .r = fizzy.pixelart.colors.secondary[0], .g = fizzy.pixelart.colors.secondary[1], .b = fizzy.pixelart.colors.secondary[2], .a = fizzy.pixelart.colors.secondary[3] };
+ const primary: dvui.Color = .{ .r = Globals.state.colors.primary[0], .g = Globals.state.colors.primary[1], .b = Globals.state.colors.primary[2], .a = Globals.state.colors.primary[3] };
+ const secondary: dvui.Color = .{ .r = Globals.state.colors.secondary[0], .g = Globals.state.colors.secondary[1], .b = Globals.state.colors.secondary[2], .a = Globals.state.colors.secondary[3] };
const button_opts: dvui.Options = .{
.expand = .both,
@@ -978,7 +979,7 @@ pub fn drawColors() !void {
primary_button.init(@src(), .{}, button_opts);
defer primary_button.deinit();
- try drawColorPicker(primary_button.data().rectScale().r, &fizzy.pixelart.colors.primary, 0);
+ try drawColorPicker(primary_button.data().rectScale().r, &Globals.state.colors.primary, 0);
primary_button.processEvents();
primary_button.drawBackground();
@@ -991,7 +992,7 @@ pub fn drawColors() !void {
secondary_button.init(@src(), .{}, button_opts.override(secondary_overrider));
defer secondary_button.deinit();
- try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.pixelart.colors.secondary, 1);
+ try drawColorPicker(secondary_button.data().rectScale().r, &Globals.state.colors.secondary, 1);
secondary_button.processEvents();
secondary_button.drawBackground();
@@ -1000,7 +1001,7 @@ pub fn drawColors() !void {
}
if (clicked) {
- std.mem.swap([4]u8, &fizzy.pixelart.colors.primary, &fizzy.pixelart.colors.secondary);
+ std.mem.swap([4]u8, &Globals.state.colors.primary, &Globals.state.colors.secondary);
}
}
@@ -1069,9 +1070,9 @@ pub fn drawPaletteControls() !void {
.corner_radius = dvui.Rect.all(1000),
},
.rotation = std.math.pi * 0.25,
- .style = if (fizzy.pixelart.pinned_palettes) .highlight else .control,
+ .style = if (Globals.state.pinned_palettes) .highlight else .control,
})) {
- fizzy.pixelart.pinned_palettes = !fizzy.pixelart.pinned_palettes;
+ Globals.state.pinned_palettes = !Globals.state.pinned_palettes;
}
}
@@ -1103,7 +1104,7 @@ pub fn drawPalettes() !void {
.gravity_x = 1.0,
});
- if (fizzy.pixelart.colors.palette) |*palette| {
+ if (Globals.state.colors.palette) |*palette| {
dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) });
} else {
dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) });
@@ -1133,7 +1134,7 @@ pub fn drawPalettes() !void {
const ext = std.fs.path.extension(entry.name);
if (std.mem.eql(u8, ext, ".hex")) {
if (dropdown.addChoiceLabel(entry.name)) {
- fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| {
+ Globals.state.colors.palette = pixelart.internal.Palette.loadFromBytes(Globals.allocator(), entry.name, data) catch |err| {
dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
return error.FailedToLoadPalette;
};
@@ -1157,12 +1158,12 @@ pub fn drawPalettes() !void {
}
{
- if (fizzy.pixelart.colors.palette) |*palette| {
+ if (Globals.state.colors.palette) |*palette| {
var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{
.expand = .horizontal,
.max_size_content = .{
- .w = fizzy.pixelart.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale,
- .h = fizzy.pixelart.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale,
+ .w = Globals.state.host.explorerRect().w - 20 * dvui.currentWindow().natural_scale,
+ .h = Globals.state.host.explorerRect().h - 20 * dvui.currentWindow().natural_scale,
},
});
@@ -1244,9 +1245,9 @@ pub fn drawPalettes() !void {
switch (evt) {
.mouse => |mouse_evt| {
if (mouse_evt.button.pointer() or mouse_evt.button.touch()) {
- @memcpy(&fizzy.pixelart.colors.primary, &color);
+ @memcpy(&Globals.state.colors.primary, &color);
} else if (mouse_evt.button == .right) {
- @memcpy(&fizzy.pixelart.colors.secondary, &color);
+ @memcpy(&Globals.state.colors.secondary, &color);
}
},
else => {},
@@ -1267,7 +1268,7 @@ pub fn drawPalettes() !void {
}
fn searchPalettes(dropdown: *dvui.DropdownWidget) !void {
const io = dvui.io;
- const palette_folder = fizzy.pixelart.host.paletteFolder() orelse return;
+ const palette_folder = Globals.state.host.paletteFolder() orelse return;
var dir_opt = std.Io.Dir.cwd().openDir(io, palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null;
if (dir_opt) |*dir| {
defer dir.close(io);
@@ -1280,10 +1281,10 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void {
if (dropdown.addChoiceLabel(label)) {
const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ palette_folder, entry.name });
- if (fizzy.pixelart.colors.palette) |*palette|
+ if (Globals.state.colors.palette) |*palette|
palette.deinit();
- fizzy.pixelart.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| {
+ Globals.state.colors.palette = pixelart.internal.Palette.loadFromFile(Globals.allocator(), abs_path) catch |err| {
dvui.log.err("Failed to load palette: {s}", .{@errorName(err)});
return error.FailedToLoadPalette;
};
@@ -1317,12 +1318,12 @@ fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool {
return false;
}
-fn layerGestureMatches(file: *const fizzy.Internal.File) bool {
+fn layerGestureMatches(file: *const pixelart.internal.File) bool {
return layer_row_gesture != null and layer_row_gesture.?.file_id == file.id;
}
/// True if `layer_index` is present in the multi-selection set (the primary index is always implicitly selected).
-fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool {
+fn layerIndexInMulti(file: *const pixelart.internal.File, layer_index: usize) bool {
for (file.editor.selected_layer_indices.items) |i| {
if (i == layer_index) return true;
}
@@ -1331,7 +1332,7 @@ fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool
/// Sync the multi-selection list with `file.selected_layer_index` and the current layer count.
/// The primary must always be present; stale / out-of-range entries from deletions are dropped.
-fn ensureLayerSelection(file: *fizzy.Internal.File) void {
+fn ensureLayerSelection(file: *pixelart.internal.File) void {
var sel = &file.editor.selected_layer_indices;
// Drop out-of-range entries.
@@ -1358,7 +1359,7 @@ fn ensureLayerSelection(file: *fizzy.Internal.File) void {
}
}
if (!has_primary and file.layers.len > 0) {
- sel.append(fizzy.app.allocator, file.selected_layer_index) catch return;
+ sel.append(Globals.allocator(), file.selected_layer_index) catch return;
std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize));
}
}
@@ -1373,9 +1374,9 @@ const LayerClickApplied = struct {
};
fn applyLayerClick(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
clicked: usize,
- mode: fizzy.dvui.TreeSelection.ClickMode,
+ mode: pixelart.core.dvui.TreeSelection.ClickMode,
) LayerClickApplied {
const count_before = file.editor.selected_layer_indices.items.len;
@@ -1386,10 +1387,10 @@ fn applyLayerClick(
}
var tmp: std.ArrayList(usize) = .empty;
- defer tmp.deinit(fizzy.app.allocator);
+ defer tmp.deinit(Globals.allocator());
- const res = fizzy.dvui.TreeSelection.applyClickUsize(
- fizzy.app.allocator,
+ const res = pixelart.core.dvui.TreeSelection.applyClickUsize(
+ Globals.allocator(),
file.editor.selected_layer_indices.items,
file.selected_layer_index,
file.editor.layer_selection_anchor,
@@ -1400,7 +1401,7 @@ fn applyLayerClick(
) catch return .{ .primary = file.selected_layer_index, .narrow_on_release = false };
file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.appendSlice(fizzy.app.allocator, tmp.items) catch {};
+ file.editor.selected_layer_indices.appendSlice(Globals.allocator(), tmp.items) catch {};
const new_primary = res.primary orelse clicked;
file.selected_layer_index = new_primary;
@@ -1411,9 +1412,9 @@ fn applyLayerClick(
/// Narrow the multi-selection to just `clicked` — used when the user performed a plain press on an
/// already-multi-selected row and released without dragging. Mirrors Finder-style behavior.
-fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void {
+fn narrowLayerSelectionTo(file: *pixelart.internal.File, clicked: usize) void {
file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.append(fizzy.app.allocator, clicked) catch {};
+ file.editor.selected_layer_indices.append(Globals.allocator(), clicked) catch {};
file.selected_layer_index = clicked;
file.editor.layer_selection_anchor = clicked;
}
@@ -1423,7 +1424,7 @@ fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void {
/// in the row-hits buffer are included (out-of-viewport selections are allowed because hits are
/// populated for every drawn row, not just hovered ones).
fn buildLayerMultiDragIds(
- file: *const fizzy.Internal.File,
+ file: *const pixelart.internal.File,
hits: []const LayerRowHit,
out: []usize,
) usize {
@@ -1443,12 +1444,12 @@ fn buildLayerMultiDragIds(
}
/// Clear in-flight gesture only (no `dragEnd`). Used before arming a new row press.
-fn layerTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void {
+fn layerTreeClearGestureKeysOnly(_: *const pixelart.internal.File) void {
layer_row_gesture = null;
}
/// Clear gesture and global `Dragging` (stale prestart/drag from other widgets).
-fn layerTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void {
+fn layerTreeResetRowPointerGesture(_: *const pixelart.internal.File) void {
dvui.dragEnd();
layer_row_gesture = null;
}
@@ -1475,7 +1476,7 @@ fn layerPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.P
return true;
}
-fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
+fn layerTreePointerInTreeSurface(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
const tr = tree.data().borderRectScale().r;
if (!tr.contains(p)) return false;
@@ -1483,14 +1484,14 @@ fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Phy
return true;
}
-fn layerTreePointerInTreeBorder(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
+fn layerTreePointerInTreeBorder(tree: *pixelart.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool {
if (floating_win != dvui.subwindowCurrentId()) return false;
return tree.data().borderRectScale().r.contains(p);
}
/// While another widget holds capture, `target_widgetId` may not be the tree. Allow starting a reorder drag
/// when the pointer is over the tree border (scroll clip can disagree with visible row geometry).
-fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool {
+fn layerTreeMotionAllowsLayerReorder(tree: *pixelart.core.dvui.TreeWidget, e: *dvui.Event) bool {
if (e.target_widgetId) |fwid| {
if (fwid == tree.data().id) return true;
}
@@ -1504,7 +1505,7 @@ fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Even
/// One pass over `events()` in frame order: press → motion → release.
/// Runs after layer rows (and rename `textEntry`) are built so geometry and `e.handled` reflect z-order.
-fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void {
+fn processLayerTreePointerEvents(tree: *pixelart.core.dvui.TreeWidget, file: *pixelart.internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void {
if (!tree.init_options.enable_reordering) return;
for (dvui.events()) |*e| {
@@ -1530,7 +1531,7 @@ fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Inte
layerTreeClearGestureKeysOnly(file);
dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) });
- const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
+ const mode = pixelart.core.dvui.TreeSelection.clickModeFromMod(me.mod);
const applied = applyLayerClick(file, h.layer_index, mode);
layer_row_gesture = .{
diff --git a/src/plugins/pixelart/internal/Animation.zig b/src/plugins/pixelart/src/internal/Animation.zig
similarity index 100%
rename from src/plugins/pixelart/internal/Animation.zig
rename to src/plugins/pixelart/src/internal/Animation.zig
diff --git a/src/plugins/pixelart/internal/Atlas.zig b/src/plugins/pixelart/src/internal/Atlas.zig
similarity index 76%
rename from src/plugins/pixelart/internal/Atlas.zig
rename to src/plugins/pixelart/src/internal/Atlas.zig
index bf8a25b6..03fb0c88 100644
--- a/src/plugins/pixelart/internal/Atlas.zig
+++ b/src/plugins/pixelart/src/internal/Atlas.zig
@@ -1,15 +1,16 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const Atlas = @This();
const ExternalAtlas = @import("../Atlas.zig");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
const alpha_checkerboard_count: u32 = 8;
/// The packed atlas texture
source: dvui.ImageSource,
-canvas: fizzy.dvui.CanvasWidget = .{},
+canvas: pixelart.core.dvui.CanvasWidget = .{},
/// Checkerboard tile for the project-tab atlas preview (not tied to open files).
checkerboard_tile: ?dvui.Texture = null,
@@ -22,11 +23,11 @@ data: ExternalAtlas,
pub fn initCheckerboardTile(atlas: *Atlas) void {
deinitCheckerboardTile(atlas);
- atlas.checkerboard_tile = fizzy.image.checkerboardTile(
+ atlas.checkerboard_tile = pixelart.image.checkerboardTile(
alpha_checkerboard_count,
alpha_checkerboard_count,
- fizzy.pixelart.settings.checker_color_even,
- fizzy.pixelart.settings.checker_color_odd,
+ Globals.state.settings.checker_color_even,
+ Globals.state.settings.checker_color_odd,
);
}
@@ -48,23 +49,23 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
// below writes through `std.Io.Dir.cwd()` which requires `posix.AT` (not
// available on `wasm32-freestanding`).
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
- const allocator = fizzy.pixelart.host.arena();
+ const allocator = Globals.state.host.arena();
switch (selector) {
.source => {
const ext = std.fs.path.extension(path);
var out = std.Io.Writer.Allocating.init(allocator);
errdefer out.deinit();
if (std.mem.eql(u8, ext, ".png")) {
- try fizzy.image.writePngToWriter(atlas.source, &out.writer, 72);
+ try pixelart.image.writePngToWriter(atlas.source, &out.writer, 72);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- try fizzy.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72);
+ try pixelart.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72);
} else {
std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{});
return error.InvalidExtension;
}
const bytes = try out.toOwnedSlice();
defer allocator.free(bytes);
- try @import("../../../editor/WebFileIo.zig").downloadBytes(path, bytes);
+ try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, bytes);
},
.data => {
if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) {
@@ -74,7 +75,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
const options: std.json.Stringify.Options = .{};
const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options);
defer allocator.free(output);
- try @import("../../../editor/WebFileIo.zig").downloadBytes(path, output);
+ try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, output);
},
}
return;
@@ -83,12 +84,12 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
switch (selector) {
.source => {
const ext = std.fs.path.extension(path);
- const write_path = std.fmt.allocPrintSentinel(fizzy.pixelart.host.arena(), "{s}", .{path}, 0) catch unreachable;
+ const write_path = std.fmt.allocPrintSentinel(Globals.state.host.arena(), "{s}", .{path}, 0) catch unreachable;
if (std.mem.eql(u8, ext, ".png")) {
- try fizzy.image.writeToPng(atlas.source, write_path);
+ try pixelart.image.writeToPng(atlas.source, write_path);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- try fizzy.image.writeToJpg(atlas.source, write_path);
+ try pixelart.image.writeToJpg(atlas.source, write_path);
} else {
std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{});
return error.InvalidExtension;
@@ -101,7 +102,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
}
const options: std.json.Stringify.Options = .{};
- const output = try std.json.Stringify.valueAlloc(fizzy.pixelart.host.arena(), atlas.data, options);
+ const output = try std.json.Stringify.valueAlloc(Globals.state.host.arena(), atlas.data, options);
std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = output }) catch return error.CouldNotWriteAtlasData;
},
diff --git a/src/plugins/pixelart/internal/Buffers.zig b/src/plugins/pixelart/src/internal/Buffers.zig
similarity index 76%
rename from src/plugins/pixelart/internal/Buffers.zig
rename to src/plugins/pixelart/src/internal/Buffers.zig
index 968c63ca..b498e92f 100644
--- a/src/plugins/pixelart/internal/Buffers.zig
+++ b/src/plugins/pixelart/src/internal/Buffers.zig
@@ -1,7 +1,8 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const History = @import("History.zig");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
const Buffers = @This();
stroke: Stroke,
@@ -12,7 +13,7 @@ pub const Stroke = struct {
//values: std.ArrayList([4]u8),
pixels: std.AutoHashMap(usize, [4]u8),
- //canvas: fizzy.Internal.file.gui.canvas = .primary,
+ //canvas: pixelart.file.gui.canvas = .primary,
pub fn init(allocator: std.mem.Allocator) Stroke {
return .{
@@ -24,9 +25,9 @@ pub const Stroke = struct {
pub fn append(stroke: *Stroke, index: usize, value: [4]u8) !void {
const ptr = try stroke.pixels.getOrPut(index);
- if (fizzy.perf.record) {
- fizzy.perf.stroke_append_calls += 1;
- if (!ptr.found_existing) fizzy.perf.stroke_append_new_keys += 1;
+ if (pixelart.perf.record) {
+ pixelart.perf.stroke_append_calls += 1;
+ if (!ptr.found_existing) pixelart.perf.stroke_append_new_keys += 1;
}
if (!ptr.found_existing)
ptr.value_ptr.* = value;
@@ -48,9 +49,9 @@ pub const Stroke = struct {
/// Like `append` but the map must already have capacity for new keys (see `clearAndReserveCapacity`).
pub fn appendAssumeCapacity(stroke: *Stroke, index: usize, value: [4]u8) void {
const gop = stroke.pixels.getOrPutAssumeCapacity(index);
- if (fizzy.perf.record) {
- fizzy.perf.stroke_append_calls += 1;
- if (!gop.found_existing) fizzy.perf.stroke_append_new_keys += 1;
+ if (pixelart.perf.record) {
+ pixelart.perf.stroke_append_calls += 1;
+ if (!gop.found_existing) pixelart.perf.stroke_append_new_keys += 1;
}
if (!gop.found_existing)
gop.value_ptr.* = value;
@@ -67,14 +68,14 @@ pub const Stroke = struct {
}
pub fn toChange(stroke: *Stroke, layer_id: u64) !History.Change {
- const t0: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0;
+ const t0: i128 = if (pixelart.perf.record) pixelart.perf.nanoTimestamp() else 0;
const n = stroke.pixels.count();
// Exact-size allocations; transform accept pre-reserves the hash map to avoid rehash during fills.
- var indices = fizzy.app.allocator.alloc(usize, n) catch return error.MemoryAllocationFailed;
- errdefer fizzy.app.allocator.free(indices);
- var values = fizzy.app.allocator.alloc([4]u8, n) catch return error.MemoryAllocationFailed;
- errdefer fizzy.app.allocator.free(values);
+ var indices = Globals.allocator().alloc(usize, n) catch return error.MemoryAllocationFailed;
+ errdefer Globals.allocator().free(indices);
+ var values = Globals.allocator().alloc([4]u8, n) catch return error.MemoryAllocationFailed;
+ errdefer Globals.allocator().free(values);
var it = stroke.pixels.iterator();
@@ -87,10 +88,10 @@ pub const Stroke = struct {
stroke.pixels.clearAndFree();
- if (fizzy.perf.record) {
- fizzy.perf.stroke_to_change_ns +%= @intCast(fizzy.perf.nanoTimestamp() - t0);
- fizzy.perf.stroke_to_change_calls += 1;
- fizzy.perf.stroke_to_change_pixels_out +%= n;
+ if (pixelart.perf.record) {
+ pixelart.perf.stroke_to_change_ns +%= @intCast(pixelart.perf.nanoTimestamp() - t0);
+ pixelart.perf.stroke_to_change_calls += 1;
+ pixelart.perf.stroke_to_change_pixels_out +%= n;
}
return .{ .pixels = .{
diff --git a/src/plugins/pixelart/internal/File.zig b/src/plugins/pixelart/src/internal/File.zig
similarity index 86%
rename from src/plugins/pixelart/internal/File.zig
rename to src/plugins/pixelart/src/internal/File.zig
index d72ef320..f9f9e654 100644
--- a/src/plugins/pixelart/internal/File.zig
+++ b/src/plugins/pixelart/src/internal/File.zig
@@ -1,16 +1,18 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
-const pixelart = @import("../plugin.zig");
const zip = @import("zip");
const dvui = @import("dvui");
-const Editor = fizzy.Editor;
+const Transform = @import("../Transform.zig");
+const Tools = @import("../Tools.zig");
const File = @This();
const Layer = @import("Layer.zig");
const Sprite = @import("Sprite.zig");
const Animation = @import("Animation.zig");
+const pixelart = @import("../../pixelart.zig");
+const plugin = @import("../plugin.zig");
+const Globals = pixelart.Globals;
const alpha_checkerboard_count: u32 = 8;
@@ -62,12 +64,12 @@ pub const EditorData = struct {
/// Set by the shell each frame before draw: request the canvas recenter this frame
/// (true while a workspace/panel pane is mid-animation). Read by the document render.
center: bool = false,
- canvas: fizzy.dvui.CanvasWidget = .{},
+ canvas: pixelart.core.dvui.CanvasWidget = .{},
layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
animations_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto },
animations_scroll_to_index: ?usize = null,
- transform: ?Editor.Transform = null,
+ transform: ?Transform = null,
playing: bool = false,
saving: bool = false,
@@ -184,7 +186,7 @@ pub const EditorData = struct {
was_saving: bool = false,
/// Set from any thread in `setSaving(false)`; main-thread `tickSaveDoneFlash` arms the flash.
save_complete: std.atomic.Value(bool) = .init(false),
- /// Monotonic deadline (`fizzy.perf.nanoTimestamp`): save-complete affordance in tab / tree.
+ /// Monotonic deadline (`pixelart.perf.nanoTimestamp`): save-complete affordance in tab / tree.
save_complete_show_duration: ?i128 = null,
/// Set with `save_complete_show_duration` when the flash arms (`isSaving` → false).
save_complete_show_start: ?i128 = null,
@@ -200,40 +202,40 @@ pub const InitOptions = struct {
row_height: u32,
};
-pub fn init(path: []const u8, options: InitOptions) !fizzy.Internal.File {
- var internal: fizzy.Internal.File = .{
- .id = fizzy.editor.newFileID(),
- .path = try fizzy.app.allocator.dupe(u8, path),
+pub fn init(path: []const u8, options: InitOptions) !pixelart.internal.File {
+ var internal: pixelart.internal.File = .{
+ .id = Globals.state.host.allocDocId(),
+ .path = try Globals.allocator().dupe(u8, path),
.columns = options.columns,
.rows = options.rows,
.column_width = options.column_width,
.row_height = options.row_height,
- .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
+ .history = pixelart.internal.File.History.init(Globals.allocator()),
+ .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()),
};
// Initialize editor layers and selected sprites
internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount());
+ internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.spriteCount());
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height());
+ internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height());
// Create a layer-sized checkerboard pattern for selection tools
for (0..internal.width() * internal.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
internal.editor.checkerboard.setValue(i, value);
}
{
// Create a single layer for the file
- const layer: fizzy.Internal.Layer = try .init(internal.newLayerID(), "Layer", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.layers.append(fizzy.app.allocator, layer) catch return error.LayerCreateError;
+ const layer: Layer = try .init(internal.newLayerID(), "Layer", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
+ internal.layers.append(Globals.allocator(), layer) catch return error.LayerCreateError;
}
// Initialize sprites
for (0..internal.spriteCount()) |_| {
- internal.sprites.append(fizzy.app.allocator, .{
+ internal.sprites.append(Globals.allocator(), .{
.origin = .{ 0.0, 0.0 },
}) catch return error.FileLoadError;
}
@@ -260,11 +262,11 @@ pub fn checkerboardTileTexture(file: *File) ?dvui.Texture {
dvui.textureDestroyLater(t);
file.editor.checkerboard_tile = null;
}
- file.editor.checkerboard_tile = fizzy.image.checkerboardTile(
+ file.editor.checkerboard_tile = pixelart.image.checkerboardTile(
want.w,
want.h,
- fizzy.pixelart.settings.checker_color_even,
- fizzy.pixelart.settings.checker_color_odd,
+ Globals.state.settings.checker_color_even,
+ Globals.state.settings.checker_color_odd,
);
return file.editor.checkerboard_tile;
}
@@ -292,7 +294,7 @@ pub fn setSaving(file: *File, v: bool) void {
} else {
// Arm the finish animation immediately so synchronous wasm saves (and any save
// that completes between frames) don't leave `save_complete` stuck true.
- const now = fizzy.perf.nanoTimestamp();
+ const now = pixelart.perf.nanoTimestamp();
file.editor.save_complete_show_start = now;
file.editor.save_complete_show_duration = now + save_done_flash_duration_ns;
file.editor.save_complete.store(false, .monotonic);
@@ -318,7 +320,7 @@ const save_done_flash_duration_ns: i128 = 2 * std.time.ns_per_s;
/// Call once per frame from the main thread. Arms save-complete feedback when
/// `isSaving()` falls from true to false.
pub fn tickSaveDoneFlash(file: *File) void {
- const now = fizzy.perf.nanoTimestamp();
+ const now = pixelart.perf.nanoTimestamp();
const saving = file.isSaving();
const pending = file.editor.save_complete.swap(false, .monotonic);
if (!saving and (pending or file.editor.was_saving) and file.editor.save_complete_show_duration == null) {
@@ -349,12 +351,12 @@ pub fn showSaveDoneFlash(file: *const File) bool {
return timeSinceSaveComplete(file) != null;
}
-/// Nanoseconds since save finished (`null` when inactive). Drives [`fizzy.dvui.bubbleSpinner`]'s
+/// Nanoseconds since save finished (`null` when inactive). Drives [`pixelart.core.dvui.bubbleSpinner`]'s
/// finish animation (sync → pop → check).
pub fn timeSinceSaveComplete(file: *const File) ?i128 {
const until = file.editor.save_complete_show_duration orelse return null;
const st = file.editor.save_complete_show_start orelse return null;
- const now = fizzy.perf.nanoTimestamp();
+ const now = pixelart.perf.nanoTimestamp();
if (now >= until) return null;
return @max(@as(i128, 0), now - st);
}
@@ -386,7 +388,7 @@ pub fn invalidateActiveLayerTransparencyMaskCache(file: *File) void {
pub const layerOrderAfterMove = @import("layer_order.zig").layerOrderAfterMove;
/// Load from in-memory bytes (browser file picker). `path` is used for extension detection and display name.
-pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File {
+pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File {
const extension = std.fs.path.extension(path);
if (isFlatImageExtension(extension)) {
return fromBytesFlatImage(path, file_bytes);
@@ -398,7 +400,7 @@ pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File
}
/// Attempts to load a file from the given path to create a new file
-pub fn fromPath(path: []const u8) !?fizzy.Internal.File {
+pub fn fromPath(path: []const u8) !?pixelart.internal.File {
const extension = std.fs.path.extension(path[0..path.len]);
if (isFlatImageExtension(extension)) {
const file = fromPathFlatImage(path) catch |err| {
@@ -424,23 +426,23 @@ pub fn isFizzyExtension(ext: []const u8) bool {
return std.mem.eql(u8, ext, ".fiz") or std.mem.eql(u8, ext, ".pixi");
}
-pub fn fromPathFizzy(path: []const u8) !?fizzy.Internal.File {
+pub fn fromPathFizzy(path: []const u8) !?pixelart.internal.File {
return loadFizzyZip(path, null);
}
-pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File {
+pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File {
return loadFizzyZip(path, file_bytes);
}
-fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File {
+fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixelart.internal.File {
if (!isFizzyExtension(std.fs.path.extension(path[0..path.len])))
return error.InvalidExtension;
const null_terminated_path = if (file_bytes == null)
- try fizzy.app.allocator.dupeZ(u8, path)
+ try Globals.allocator().dupeZ(u8, path)
else
"";
- defer if (file_bytes == null) fizzy.app.allocator.free(null_terminated_path);
+ defer if (file_bytes == null) Globals.allocator().free(null_terminated_path);
zip_open: {
const fizzy_file = if (file_bytes) |bytes|
@@ -473,19 +475,19 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
.ignore_unknown_fields = true,
};
- var try_parse: ?std.json.Parsed(fizzy.File) = null;
- try_parse = std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, content, options) catch null;
+ var try_parse: ?std.json.Parsed(pixelart.File) = null;
+ try_parse = std.json.parseFromSlice(pixelart.File, Globals.allocator(), content, options) catch null;
- var ext: fizzy.File = if (try_parse) |parsed| parsed.value else undefined;
+ var ext: pixelart.File = if (try_parse) |parsed| parsed.value else undefined;
if (try_parse == null) {
// If we are here, we have tried to load the file but hit an issue because the old animation format
- if (std.json.parseFromSlice(fizzy.File.FileV3, fizzy.app.allocator, content, options) catch null) |old_file| {
+ if (std.json.parseFromSlice(pixelart.File.FileV3, Globals.allocator(), content, options) catch null) |old_file| {
std.log.info("Loading file v3: {s}", .{path});
- const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len);
+ const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len);
for (animations, old_file.value.animations) |*animation, old_animation| {
- animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name);
- animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len);
+ animation.name = try Globals.allocator().dupe(u8, old_animation.name);
+ animation.frames = try Globals.allocator().alloc(Animation.Frame, old_animation.frames.len);
for (animation.frames, old_animation.frames) |*frame, old_frame| {
frame.sprite_index = old_frame;
frame.ms = @intFromFloat(1000 / old_animation.fps);
@@ -502,12 +504,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
.sprites = old_file.value.sprites,
.animations = animations,
};
- } else if (std.json.parseFromSlice(fizzy.File.FileV2, fizzy.app.allocator, content, options) catch null) |old_file| {
+ } else if (std.json.parseFromSlice(pixelart.File.FileV2, Globals.allocator(), content, options) catch null) |old_file| {
std.log.info("Loading file v2: {s}", .{path});
- const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len);
+ const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len);
for (animations, old_file.value.animations) |*animation, old_animation| {
- animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name);
- animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len);
+ animation.name = try Globals.allocator().dupe(u8, old_animation.name);
+ animation.frames = try Globals.allocator().alloc(Animation.Frame, old_animation.frames.len);
for (animation.frames, old_animation.frames) |*frame, old_frame| {
frame.sprite_index = old_frame;
frame.ms = @intFromFloat(1000 / old_animation.fps);
@@ -524,12 +526,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
.sprites = old_file.value.sprites,
.animations = animations,
};
- } else if (std.json.parseFromSlice(fizzy.File.FileV1, fizzy.app.allocator, content, options) catch null) |old_file| {
+ } else if (std.json.parseFromSlice(pixelart.File.FileV1, Globals.allocator(), content, options) catch null) |old_file| {
std.log.info("Loading file v1: {s}", .{path});
- const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len);
+ const animations = try Globals.allocator().alloc(pixelart.Animation, old_file.value.animations.len);
for (animations, 0..) |*animation, i| {
- animation.name = try fizzy.app.allocator.dupe(u8, old_file.value.animations[i].name);
- animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_file.value.animations[i].length);
+ animation.name = try Globals.allocator().dupe(u8, old_file.value.animations[i].name);
+ animation.frames = try Globals.allocator().alloc(Animation.Frame, old_file.value.animations[i].length);
for (animation.frames, 0..old_file.value.animations[i].length) |*frame, j| {
frame.sprite_index = old_file.value.animations[i].start + j;
frame.ms = @intFromFloat(1000 / old_file.value.animations[i].fps);
@@ -553,15 +555,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
//defer parsed.deinit();
- var internal: fizzy.Internal.File = .{
- .id = fizzy.editor.newFileID(),
- .path = try fizzy.app.allocator.dupe(u8, path),
+ var internal: pixelart.internal.File = .{
+ .id = Globals.state.host.allocDocId(),
+ .path = try Globals.allocator().dupe(u8, path),
.columns = ext.columns,
.rows = ext.rows,
.column_width = ext.column_width,
.row_height = ext.row_height,
- .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
+ .history = pixelart.internal.File.History.init(Globals.allocator()),
+ .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()),
};
//Initialize editor layers and selected sprites
@@ -570,21 +572,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount());
+ internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.spriteCount());
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height());
+ internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height());
// Create a layer-sized checkerboard pattern for selection tools
for (0..internal.width() * internal.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
internal.editor.checkerboard.setValue(i, value);
}
var set_layer_index: bool = false;
for (ext.layers, 0..) |l, i| {
- const layer_image_name = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed";
- defer fizzy.app.allocator.free(layer_image_name);
- const png_image_name = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed";
- defer fizzy.app.allocator.free(png_image_name);
+ const layer_image_name = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed";
+ defer Globals.allocator().free(layer_image_name);
+ const png_image_name = std.fmt.allocPrintSentinel(Globals.allocator(), "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed";
+ defer Globals.allocator().free(png_image_name);
var img_buf: ?*anyopaque = null;
var img_len: usize = 0;
@@ -593,7 +595,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
_ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len);
const data = img_buf orelse continue;
- var new_layer: fizzy.Internal.Layer = try .fromPixelsPMA(
+ var new_layer: Layer = try .fromPixelsPMA(
internal.newLayerID(),
l.name,
@as([*]dvui.Color.PMA, @ptrCast(@constCast(data)))[0..(internal.width() * internal.height())],
@@ -607,7 +609,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
new_layer.setMaskFromTransparency(true);
- internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError;
+ internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError;
if (l.visible and !set_layer_index) {
internal.selected_layer_index = i;
@@ -617,7 +619,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
_ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len);
const data = img_buf orelse continue;
- var new_layer: fizzy.Internal.Layer = try .fromImageFileBytes(
+ var new_layer: Layer = try .fromImageFileBytes(
internal.newLayerID(),
l.name,
@as([*]u8, @ptrCast(data))[0..img_len],
@@ -629,7 +631,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
new_layer.setMaskFromTransparency(true);
- internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError;
+ internal.layers.append(Globals.allocator(), new_layer) catch return error.FileLoadError;
if (l.visible and !set_layer_index) {
internal.selected_layer_index = i;
@@ -643,21 +645,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
for (0..internal.spriteCount()) |sprite_index| {
if (sprite_index >= ext.sprites.len) {
- internal.sprites.append(fizzy.app.allocator, .{
+ internal.sprites.append(Globals.allocator(), .{
.origin = .{ 0, 0 },
}) catch return error.FileLoadError;
} else {
- internal.sprites.append(fizzy.app.allocator, .{
+ internal.sprites.append(Globals.allocator(), .{
.origin = .{ ext.sprites[sprite_index].origin[0], ext.sprites[sprite_index].origin[1] },
}) catch return error.FileLoadError;
}
}
for (ext.animations) |animation| {
- internal.animations.append(fizzy.app.allocator, .{
+ internal.animations.append(Globals.allocator(), .{
.id = internal.newAnimationID(),
- .name = try fizzy.app.allocator.dupe(u8, animation.name),
- .frames = try fizzy.app.allocator.dupe(Animation.Frame, animation.frames),
+ .name = try Globals.allocator().dupe(u8, animation.name),
+ .frames = try Globals.allocator().dupe(Animation.Frame, animation.frames),
}) catch return error.FileLoadError;
}
return internal;
@@ -666,7 +668,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
// var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
// var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
- // if (fizzy.fs.read(fizzy.app.allocator, path) catch null) |file_bytes| {
+ // if (pixelart.fs.read(Globals.allocator(), path) catch null) |file_bytes| {
// std.log.debug("Read file bytes!", .{});
// var input = std.io.fixedBufferStream(file_bytes);
// var iter = std.tar.iterator(input.reader(), .{
@@ -674,7 +676,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
// .link_name_buffer = &link_name_buffer,
// });
- // var json_content = std.array_list.Managed(u8).init(fizzy.app.allocator);
+ // var json_content = std.array_list.Managed(u8).init(Globals.allocator());
// defer json_content.deinit();
// while (try iter.next()) |entry| {
@@ -689,23 +691,23 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
// .ignore_unknown_fields = true,
// };
- // if (std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, json_content.items, options) catch null) |parsed| {
+ // if (std.json.parseFromSlice(pixelart.File, Globals.allocator(), json_content.items, options) catch null) |parsed| {
// defer parsed.deinit();
// std.log.debug("Parsed fizzydata.json!", .{});
// const ext = parsed.value;
- // var internal: fizzy.Internal.File = .{
- // .id = fizzy.editor.newFileID(),
- // .path = try fizzy.app.allocator.dupe(u8, path),
+ // var internal: pixelart.internal.File = .{
+ // .id = Globals.state.host.allocDocId(),
+ // .path = try Globals.allocator().dupe(u8, path),
// .width = ext.width,
// .height = ext.height,
// .tile_width = ext.tile_width,
// .tile_height = ext.tile_height,
- // .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- // .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
- // .checkerboard = fizzy.image.init(
+ // .history = pixelart.internal.File.History.init(Globals.allocator()),
+ // .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()),
+ // .checkerboard = pixelart.image.init(
// ext.tile_width * 2,
// ext.tile_height * 2,
// .{ .r = 0, .g = 0, .b = 0, .a = 0 },
@@ -714,7 +716,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
// .temporary_layer = undefined,
// .selection_layer = undefined,
// .selected_sprites = try std.DynamicBitSet.initEmpty(
- // fizzy.app.allocator,
+ // Globals.allocator(),
// @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height),
// ),
// };
@@ -737,15 +739,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File
// std.log.debug("Entry name: {s}", .{entry.name});
// if (std.mem.eql(u8, entry.name, layer_image_name)) {
- // var layer_content = std.array_list.Managed(u8).init(fizzy.app.allocator);
+ // var layer_content = std.array_list.Managed(u8).init(Globals.allocator());
// try entry.writeAll(layer_content.writer());
- // var cond: ?fizzy.Internal.Layer = fizzy.Internal.Layer.fromPixels(internal.newID(), fizzy.app.allocator.dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null;
+ // var cond: ?pixelart.Layer = pixelart.Layer.fromPixels(internal.newID(), Globals.allocator().dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null;
// if (cond) |*new_layer| {
// new_layer.visible = ext_layer.visible;
// new_layer.collapse = ext_layer.collapse;
- // internal.layers.append(fizzy.app.allocator, new_layer.*) catch return error.FileLoadError;
+ // internal.layers.append(Globals.allocator(), new_layer.*) catch return error.FileLoadError;
// } else {
// std.log.err("Failed to create layer from pixels", .{});
// }
@@ -791,12 +793,12 @@ pub fn shouldConfirmFlatRasterSave(self: File) bool {
return requiresFizzyCompatibleSave(self);
}
-pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File {
+pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?pixelart.internal.File {
if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len])))
return error.InvalidExtension;
- const image_layer: fizzy.Internal.Layer = try fizzy.Internal.Layer.fromImageFileBytes(
- fizzy.editor.newFileID(),
+ const image_layer: Layer = try Layer.fromImageFileBytes(
+ Globals.state.host.allocDocId(),
"Layer",
file_bytes,
.ptr,
@@ -806,42 +808,42 @@ pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.Inte
/// Loads a PNG or JPEG as the first layer of a new file, and retains the path
/// when saved; layers will be flattened to that file
-pub fn fromPathFlatImage(path: []const u8) !?fizzy.Internal.File {
+pub fn fromPathFlatImage(path: []const u8) !?pixelart.internal.File {
if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len])))
return error.InvalidExtension;
- const image_layer: fizzy.Internal.Layer = try fizzy.Internal.Layer.fromImageFilePath(fizzy.editor.newFileID(), "Layer", path, .ptr);
+ const image_layer: Layer = try Layer.fromImageFilePath(Globals.state.host.allocDocId(), "Layer", path, .ptr);
return finishFlatImageFile(path, image_layer);
}
-fn finishFlatImageFile(path: []const u8, image_layer: fizzy.Internal.Layer) !?fizzy.Internal.File {
+fn finishFlatImageFile(path: []const u8, image_layer: Layer) !?pixelart.internal.File {
const size = image_layer.size();
const column_width: u32 = @intFromFloat(size.w);
const row_height: u32 = @intFromFloat(size.h);
- var internal: fizzy.Internal.File = .{
- .id = fizzy.editor.newFileID(),
- .path = try fizzy.app.allocator.dupe(u8, path),
+ var internal: pixelart.internal.File = .{
+ .id = Globals.state.host.allocDocId(),
+ .path = try Globals.allocator().dupe(u8, path),
.columns = 1,
.rows = 1,
.column_width = column_width,
.row_height = row_height,
- .history = fizzy.Internal.File.History.init(fizzy.app.allocator),
- .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator),
+ .history = pixelart.internal.File.History.init(Globals.allocator()),
+ .buffers = pixelart.internal.File.Buffers.init(Globals.allocator()),
};
- internal.layers.append(fizzy.app.allocator, image_layer) catch return error.LayerCreateError;
+ internal.layers.append(Globals.allocator(), image_layer) catch return error.LayerCreateError;
// Initialize editor layers and selected sprites
internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount());
+ internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.spriteCount());
- internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height());
+ internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), internal.width() * internal.height());
// Create a layer-sized checkerboard pattern for selection tools
for (0..internal.width() * internal.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i);
internal.editor.checkerboard.setValue(i, value);
}
@@ -853,7 +855,7 @@ pub const ResizeOptions = struct {
rows: u32,
history: bool = true, // If true, layer data will be recorded for undo/redo
layer_data: ?[][][4]u8 = null, // If provided, the layer data will be applied to the layers after resizing
- animation_data: ?[][]fizzy.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing
+ animation_data: ?[][]pixelart.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing
sprite_data: ?[][2]f32 = null, // If provided, the sprite data will be applied to the sprites after resizing
};
@@ -876,22 +878,22 @@ pub fn resize(file: *File, options: ResizeOptions) !void {
if (options.history) {
file.history.append(.{ .resize = .{ .width = file.width(), .height = file.height() } }) catch return error.HistoryAppendError;
- var layer_data = try fizzy.app.allocator.alloc([][4]u8, file.layers.len);
+ var layer_data = try Globals.allocator().alloc([][4]u8, file.layers.len);
for (0..file.layers.len) |layer_index| {
var layer = file.layers.get(layer_index);
- layer_data[layer_index] = fizzy.app.allocator.dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed;
+ layer_data[layer_index] = Globals.allocator().dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed;
}
file.history.undo_layer_data_stack.append(layer_data) catch return error.MemoryAllocationFailed;
// Store all the animations before the resize event
- var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len);
+ var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len);
for (0..file.animations.len) |anim_index| {
const animation = file.animations.get(anim_index);
- anim_data[anim_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed;
+ anim_data[anim_index] = Globals.allocator().dupe(pixelart.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed;
}
file.history.undo_animation_data_stack.append(anim_data) catch return error.MemoryAllocationFailed;
- var sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount());
+ var sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount());
for (0..file.spriteCount()) |sprite_index| {
sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
}
@@ -903,22 +905,22 @@ pub fn resize(file: *File, options: ResizeOptions) !void {
var current_animation = file.animations.get(anim_index);
const current_data = anim_data[anim_index];
- var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError;
+ var new_animation = Animation.init(Globals.allocator(), current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError;
defer file.animations.set(anim_index, new_animation);
- defer current_animation.deinit(fizzy.app.allocator);
+ defer current_animation.deinit(Globals.allocator());
for (current_data) |frame| {
- new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError;
+ new_animation.appendFrame(Globals.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError;
}
}
} else for (0..file.animations.len) |anim_index| {
var animation = file.animations.get(anim_index);
- var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, animation.id, animation.name, &.{}) catch return error.AnimationCreateError;
+ var new_animation = Animation.init(Globals.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError;
defer file.animations.set(anim_index, new_animation);
- defer animation.deinit(fizzy.app.allocator);
+ defer animation.deinit(Globals.allocator());
for (0..animation.frames.len) |frame_index| {
const old_sprite_index = animation.frames[frame_index].sprite_index;
if (file.getResizedIndex(old_sprite_index, new_columns, new_rows)) |new_sprite_index| {
- new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError;
+ new_animation.appendFrame(Globals.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError;
}
}
}
@@ -927,10 +929,10 @@ pub fn resize(file: *File, options: ResizeOptions) !void {
const new_sprite_count = new_columns * new_rows;
var old_origins_snapshot: ?[][2]f32 = null;
- defer if (old_origins_snapshot) |s| fizzy.app.allocator.free(s);
+ defer if (old_origins_snapshot) |s| Globals.allocator().free(s);
if (options.sprite_data == null) {
- const snapshot = try fizzy.app.allocator.alloc([2]f32, old_sprite_count);
+ const snapshot = try Globals.allocator().alloc([2]f32, old_sprite_count);
for (0..old_sprite_count) |i| {
snapshot[i] = file.sprites.items(.origin)[i];
}
@@ -938,7 +940,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void {
}
file.sprites.resize(
- fizzy.app.allocator,
+ Globals.allocator(),
new_sprite_count,
) catch return error.MemoryAllocationFailed;
@@ -982,7 +984,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void {
file.editor.checkerboard.resize(new_width * new_height, false) catch return error.MemoryAllocationFailed;
for (0..new_width * new_height) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i);
file.editor.checkerboard.setValue(i, value);
}
@@ -1310,7 +1312,7 @@ pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_inde
}
pub fn deinit(file: *File) void {
- fizzy.render.destroyLayerCompositeResources(file);
+ pixelart.render.destroyLayerCompositeResources(file);
strokeUndoFreeSnapshot(file);
@@ -1318,15 +1320,15 @@ pub fn deinit(file: *File) void {
file.buffers.deinit();
for (file.layers.items(.name)) |name| {
- fizzy.app.allocator.free(name);
+ Globals.allocator().free(name);
}
for (file.animations.items(.name)) |name| {
- fizzy.app.allocator.free(name);
+ Globals.allocator().free(name);
}
for (file.animations.items(.frames)) |frames| {
- fizzy.app.allocator.free(frames);
+ Globals.allocator().free(frames);
}
file.editor.temporary_layer.deinit();
@@ -1337,16 +1339,16 @@ pub fn deinit(file: *File) void {
file.editor.checkerboard_tile = null;
}
- file.editor.selected_layer_indices.deinit(fizzy.app.allocator);
- file.editor.selected_animation_indices.deinit(fizzy.app.allocator);
- file.editor.selected_frame_indices.deinit(fizzy.app.allocator);
+ file.editor.selected_layer_indices.deinit(Globals.allocator());
+ file.editor.selected_animation_indices.deinit(Globals.allocator());
+ file.editor.selected_frame_indices.deinit(Globals.allocator());
- file.layers.deinit(fizzy.app.allocator);
- file.deleted_layers.deinit(fizzy.app.allocator);
- file.sprites.deinit(fizzy.app.allocator);
- file.animations.deinit(fizzy.app.allocator);
- file.deleted_animations.deinit(fizzy.app.allocator);
- fizzy.app.allocator.free(file.path);
+ file.layers.deinit(Globals.allocator());
+ file.deleted_layers.deinit(Globals.allocator());
+ file.sprites.deinit(Globals.allocator());
+ file.animations.deinit(Globals.allocator());
+ file.deleted_animations.deinit(Globals.allocator());
+ Globals.allocator().free(file.path);
}
pub fn dirty(self: File) bool {
@@ -1616,7 +1618,7 @@ pub fn promotePrimarySprite(file: *File, sprite_index: usize) void {
pub fn collapseAnimationSelectionToPrimary(file: *File) void {
if (file.selected_animation_index) |p| {
file.editor.selected_animation_indices.clearRetainingCapacity();
- file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return;
+ file.editor.selected_animation_indices.append(Globals.allocator(), p) catch return;
file.editor.animation_selection_anchor = p;
}
}
@@ -1678,9 +1680,9 @@ pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions
}
}
} else {
- var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
+ var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.pixelart.tools.offset_table[i];
+ const offset = Globals.state.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (select_options.constrain_to_tile) {
@@ -1724,29 +1726,29 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op
const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height));
const diff = point2.diff(point1).normalize().scale(4, dvui.Point);
- const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size);
+ const stroke_size: usize = @intCast(Tools.max_brush_size);
- const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) };
- var mask = fizzy.pixelart.tools.stroke;
+ const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) };
+ var mask = Globals.state.tools.stroke;
- if (select_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) {
+ if (select_options.stroke_size > Tools.min_full_stroke_size) {
for (0..(stroke_size * stroke_size)) |index| {
- if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
+ if (Globals.state.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
mask.unset(i);
}
}
}
- if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| {
+ if (pixelart.algorithms.brezenham.process(point1, point2) catch null) |points| {
for (points, 0..) |point, point_i| {
- if (select_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) {
+ if (select_options.stroke_size < Tools.min_full_stroke_size) {
selectPoint(file, point, select_options);
} else {
- var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask;
+ var stroke = if (point_i == 0) Globals.state.tools.stroke else mask;
var iter = stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.pixelart.tools.offset_table[i];
+ const offset = Globals.state.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (select_options.constrain_to_tile) {
@@ -1804,16 +1806,16 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void
const bounds = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) });
if (!bounds.contains(p)) return;
- const start_idx = fizzy.image.pixelIndex(read_layer.source, p) orelse return;
+ const start_idx = pixelart.image.pixelIndex(read_layer.source, p) orelse return;
const original_color = read_layer.pixels()[start_idx];
const n = read_layer.pixels().len;
if (selection_layer.mask.capacity() != n) return;
- var visited = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, n);
+ var visited = try std.DynamicBitSet.initEmpty(Globals.allocator(), n);
defer visited.deinit();
- var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator);
+ var queue = std.array_list.Managed(dvui.Point).init(Globals.allocator());
defer queue.deinit();
try queue.append(p);
@@ -1827,7 +1829,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void
};
while (queue.pop()) |qp| {
- const idx = fizzy.image.pixelIndex(read_layer.source, qp) orelse continue;
+ const idx = pixelart.image.pixelIndex(read_layer.source, qp) orelse continue;
if (!std.meta.eql(original_color, read_layer.pixels()[idx])) continue;
selection_layer.mask.setValue(idx, value);
@@ -1835,7 +1837,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void
for (directions) |direction| {
const np = qp.plus(direction);
if (!bounds.contains(np)) continue;
- if (fizzy.image.pixelIndex(read_layer.source, np)) |ni| {
+ if (pixelart.image.pixelIndex(read_layer.source, np)) |ni| {
if (visited.isSet(ni)) continue;
if (!std.meta.eql(original_color, read_layer.pixels()[ni])) continue;
visited.set(ni);
@@ -1941,7 +1943,7 @@ pub fn brushStampRect(file: *const File, point: dvui.Point, stroke_size: usize)
fn strokeUndoFreeSnapshot(file: *File) void {
if (file.editor.stroke_undo_pixels) |p| {
- fizzy.app.allocator.free(p);
+ Globals.allocator().free(p);
file.editor.stroke_undo_pixels = null;
}
file.editor.stroke_undo_x = 0;
@@ -1968,7 +1970,7 @@ pub fn strokeUndoBegin(file: *File, cover: dvui.Rect) !void {
}
const n = @as(usize, b.w) * @as(usize, b.h) * 4;
- const buf = try fizzy.app.allocator.alloc(u8, n);
+ const buf = try Globals.allocator().alloc(u8, n);
const layer = file.layers.get(file.selected_layer_index);
const pix = layer.pixels();
@@ -2019,7 +2021,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void {
}
const new_n = @as(usize, tw) * @as(usize, th) * 4;
- const new_buf = try fizzy.app.allocator.alloc(u8, new_n);
+ const new_buf = try Globals.allocator().alloc(u8, new_n);
const layer = file.layers.get(file.selected_layer_index);
const pix = layer.pixels();
@@ -2045,7 +2047,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void {
}
}
- fizzy.app.allocator.free(old_buf);
+ Globals.allocator().free(old_buf);
file.editor.stroke_undo_pixels = new_buf;
file.editor.stroke_undo_x = tx;
file.editor.stroke_undo_y = ty;
@@ -2339,9 +2341,9 @@ pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options:
}
}
} else {
- var iter = fizzy.pixelart.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
+ var iter = Globals.state.tools.stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.pixelart.tools.offset_table[i];
+ const offset = Globals.state.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (clip_rect) |cr| {
@@ -2427,26 +2429,26 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw
const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height));
const diff = point2.diff(point1).normalize().scale(4, dvui.Point);
- const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size);
+ const stroke_size: usize = @intCast(Tools.max_brush_size);
- const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) };
- var mask = fizzy.pixelart.tools.stroke;
+ const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) };
+ var mask = Globals.state.tools.stroke;
- if (draw_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) {
+ if (draw_options.stroke_size > Tools.min_full_stroke_size) {
for (0..(stroke_size * stroke_size)) |index| {
- if (fizzy.pixelart.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
+ if (Globals.state.tools.getIndexShapeOffset(center.diff(diff), index)) |i| {
mask.unset(i);
}
}
}
- if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| {
+ if (pixelart.algorithms.brezenham.process(point1, point2) catch null) |points| {
for (points, 0..) |point, point_i| {
if (clip_rect) |cr| {
const br = brushRect(point, draw_options.stroke_size, iw, ih);
if (br.intersect(cr).empty()) continue;
}
- if (draw_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) {
+ if (draw_options.stroke_size < Tools.min_full_stroke_size) {
drawPoint(file, point, layer, .{
.color = draw_options.color,
.stroke_size = draw_options.stroke_size,
@@ -2457,11 +2459,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw
.clip_rect = draw_options.clip_rect,
});
} else {
- var stroke = if (point_i == 0) fizzy.pixelart.tools.stroke else mask;
+ var stroke = if (point_i == 0) Globals.state.tools.stroke else mask;
var iter = stroke.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
- const offset = fizzy.pixelart.tools.offset_table[i];
+ const offset = Globals.state.tools.offset_table[i];
const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] };
if (clip_rect) |cr| {
@@ -2609,7 +2611,7 @@ pub fn getLayer(self: *File, id: u64) ?Layer {
}
pub fn deleteLayer(self: *File, index: usize) !void {
- try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(index));
+ try self.deleted_layers.append(Globals.allocator(), self.layers.slice().get(index));
self.layers.orderedRemove(index);
self.editor.layer_composite_dirty = true;
self.editor.split_composite_dirty = true;
@@ -2645,10 +2647,10 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i:
const dest_id = self.layers.items(.id)[dest_i];
const src_id = self.layers.items(.id)[src_i];
- const dest_pixels_before = try fizzy.app.allocator.dupe([4]u8, dest.pixels());
- errdefer fizzy.app.allocator.free(dest_pixels_before);
+ const dest_pixels_before = try Globals.allocator().dupe([4]u8, dest.pixels());
+ errdefer Globals.allocator().free(dest_pixels_before);
- var dest_mask_before = try dest.mask.clone(fizzy.app.allocator);
+ var dest_mask_before = try dest.mask.clone(Globals.allocator());
errdefer dest_mask_before.deinit();
for (0..pix_n) |i| {
@@ -2663,7 +2665,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i:
dest.invalidate();
self.layers.set(dest_i, dest);
- try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(src_i));
+ try self.deleted_layers.append(Globals.allocator(), self.layers.slice().get(src_i));
self.layers.orderedRemove(src_i);
self.editor.layer_composite_dirty = true;
@@ -2682,7 +2684,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i:
.dest_pixels_before = dest_pixels_before,
.dest_mask_before = dest_mask_before,
} });
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
+ Globals.state.host.setActiveSidebarView(plugin.view_tools);
}
pub fn duplicateLayer(self: *File, index: usize) !u64 {
@@ -2697,7 +2699,7 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 {
@memcpy(new_layer.pixels(), layer.pixels());
- self.layers.insert(fizzy.app.allocator, 0, new_layer) catch {
+ self.layers.insert(Globals.allocator(), 0, new_layer) catch {
dvui.log.err("Failed to append layer", .{});
};
@@ -2718,8 +2720,8 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 {
}
pub fn createLayer(self: *File) !u64 {
- if (fizzy.Internal.Layer.init(self.newLayerID(), "New Layer", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch null) |layer| {
- self.layers.insert(fizzy.app.allocator, 0, layer) catch {
+ if (Layer.init(self.newLayerID(), "New Layer", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch null) |layer| {
+ self.layers.insert(Globals.allocator(), 0, layer) catch {
dvui.log.err("Failed to append layer", .{});
};
self.selected_layer_index = 0;
@@ -2743,14 +2745,14 @@ pub fn createLayer(self: *File) !u64 {
pub fn createAnimation(self: *File) !usize {
var animation = Animation.init(
- fizzy.app.allocator,
+ Globals.allocator(),
self.newAnimationID(),
"New Animation",
&[_]Animation.Frame{},
) catch return error.FailedToCreateAnimation;
if (self.editor.selected_sprites.count() > 0) {
- animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, self.editor.selected_sprites.count());
+ animation.frames = try Globals.allocator().alloc(Animation.Frame, self.editor.selected_sprites.count());
var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
var i: usize = 0;
@@ -2759,7 +2761,7 @@ pub fn createAnimation(self: *File) !usize {
}
}
- self.animations.append(fizzy.app.allocator, animation) catch {
+ self.animations.append(Globals.allocator(), animation) catch {
dvui.log.err("Failed to append animation", .{});
};
return self.animations.len - 1;
@@ -2768,15 +2770,15 @@ pub fn createAnimation(self: *File) !usize {
pub fn duplicateAnimation(self: *File, index: usize) !usize {
const animation = self.animations.slice().get(index);
const new_name = try std.fmt.allocPrint(dvui.currentWindow().lifo(), "{s}_copy", .{animation.name});
- const new_animation = Animation.init(fizzy.app.allocator, self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation;
- self.animations.insert(fizzy.app.allocator, index + 1, new_animation) catch {
+ const new_animation = Animation.init(Globals.allocator(), self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation;
+ self.animations.insert(Globals.allocator(), index + 1, new_animation) catch {
dvui.log.err("Failed to append animation", .{});
};
return index + 1;
}
pub fn deleteAnimation(self: *File, index: usize) !void {
- try self.deleted_animations.append(fizzy.app.allocator, self.animations.slice().get(index));
+ try self.deleted_animations.append(Globals.allocator(), self.animations.slice().get(index));
self.animations.orderedRemove(index);
try self.history.append(.{ .animation_restore_delete = .{
.action = .restore,
@@ -2795,16 +2797,16 @@ pub fn redo(self: *File) !void {
pub fn saveTar(self: *File, window: *dvui.Window) !void {
if (self.saving) return;
self.saving = true;
- var ext = try self.external(fizzy.app.allocator);
- defer ext.deinit(fizzy.app.allocator);
+ var ext = try self.external(Globals.allocator());
+ defer ext.deinit(Globals.allocator());
- const output_path = try fizzy.pixelart.host.arena().dupeZ(u8, self.path);
+ const output_path = try Globals.state.host.arena().dupeZ(u8, self.path);
var handle = try std.fs.cwd().createFile(output_path, .{});
defer handle.close();
var wrt = std.tar.writer(handle.writer());
- var json = std.array_list.Managed(u8).init(fizzy.app.allocator);
+ var json = std.array_list.Managed(u8).init(Globals.allocator());
const out_stream = json.writer();
const options = std.json.StringifyOptions{};
@@ -2826,14 +2828,14 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void {
else => return error.InvalidImageSource,
};
- try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.pixelart.host.arena(), "{s}.layer", .{layer.name}), data, .{});
+ try wrt.writeFileBytes(try std.fmt.allocPrintZ(Globals.state.host.arena(), "{s}.layer", .{layer.name}), data, .{});
}
}
try wrt.finish();
{
- const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
+ const id_mutex = dvui.toastAdd(window, @src(), 0, pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s}", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -2850,26 +2852,26 @@ fn writeFlattenedLayersToPath(self: *File, out_path: []const u8, window: *dvui.W
const h = self.height();
if (w == 0 or h == 0) return error.InvalidImageSize;
- try fizzy.render.syncLayerComposite(self);
+ try pixelart.render.syncLayerComposite(self);
const target = self.editor.layer_composite_target orelse return error.NoLayerComposite;
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
- var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr);
+ var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr);
defer tmp_layer.deinit();
switch (kind) {
.png => {
const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254));
- try fizzy.image.writeToPngResolution(tmp_layer.source, out_path, r);
+ try pixelart.image.writeToPngResolution(tmp_layer.source, out_path, r);
},
.jpg => {
const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeToJpgPpi(tmp_layer.source, out_path, ppi);
+ try pixelart.image.writeToJpgPpi(tmp_layer.source, out_path, ppi);
},
}
}
@@ -2884,7 +2886,7 @@ pub fn savePng(self: *File, window: *dvui.Window) !void {
{
// `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic
// u64s; in practice they fit, so an `@intCast` is safe and panics if not.
- const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
+ const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -2905,7 +2907,7 @@ pub fn saveJpg(self: *File, window: *dvui.Window) !void {
{
// `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic
// u64s; in practice they fit, so an `@intCast` is safe and panics if not.
- const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
+ const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -2924,8 +2926,8 @@ pub fn saveZip(self: *File, window: *dvui.Window) !void {
// already the only writer of `self.layers` — so a snapshot would be pointless
// copying. Build the snapshot inline and immediately consume it. We still
// use the same code path so there's a single zip-writing function.
- var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator);
- defer snap.deinit(fizzy.app.allocator);
+ var snap = try SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator());
+ defer snap.deinit(Globals.allocator());
try writeSnapshotToZip(self.id, window, &snap);
}
@@ -2934,7 +2936,7 @@ pub fn saveZip(self: *File, window: *dvui.Window) !void {
/// `*File`, so user edits during the save can't tear `self.layers` mid-iteration
/// (manifested as MultiArrayList slice OOB / corrupt layer.name).
pub const SaveSnapshot = struct {
- ext: fizzy.File,
+ ext: pixelart.File,
layer_bytes: [][]u8,
layer_entry_names: [][:0]const u8,
null_terminated_path: [:0]u8,
@@ -3000,7 +3002,7 @@ pub const SaveQueue = struct {
pub fn submit(self: *SaveQueue, job: Job) !void {
self.mutex.lockUncancelable(dvui.io);
defer self.mutex.unlock(dvui.io);
- try self.queue.append(fizzy.app.allocator, job);
+ try self.queue.append(Globals.allocator(), job);
self.cond.signal(dvui.io);
}
};
@@ -3033,10 +3035,10 @@ pub fn deinitSaveQueue() void {
// Anything still queued after worker exit is leaked snapshots — shouldn't
// happen since the worker drains before exit, but clean up defensively.
for (save_queue.queue.items) |*job| {
- job.snap.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(job.snap);
+ job.snap.deinit(Globals.allocator());
+ Globals.allocator().destroy(job.snap);
}
- save_queue.queue.deinit(fizzy.app.allocator);
+ save_queue.queue.deinit(Globals.allocator());
}
fn saveQueueWorker() void {
@@ -3060,9 +3062,9 @@ fn saveQueueWorker() void {
// becomes stale (silently aliasing a different file) as soon as the GUI
// thread closes any earlier file from the in-flight set.
defer {
- job.snap.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(job.snap);
- if (fizzy.editor.open_files.getPtr(job.file_id)) |f| f.setSaving(false);
+ job.snap.deinit(Globals.allocator());
+ Globals.allocator().destroy(job.snap);
+ if (Globals.state.docs.fileById(job.file_id)) |f| f.setSaving(false);
dvui.refresh(job.window, @src(), null);
}
writeSnapshotToZip(job.file_id, job.window, job.snap) catch |err| {
@@ -3095,7 +3097,7 @@ fn writeSnapshotToZip(file_id: u64, window: *dvui.Window, snap: *const SaveSnaps
zip.zip_close(z);
}
- if (fizzy.editor.open_files.getPtr(file_id)) |f| f.history.bookmark = 0;
+ if (Globals.state.docs.fileById(file_id)) |f| f.history.bookmark = 0;
}
fn zipEntryOk(rc: c_int) !void {
@@ -3104,8 +3106,8 @@ fn zipEntryOk(rc: c_int) !void {
fn writeSnapshotEntriesToZip(z: *zip.struct_zip_t, snap: *const SaveSnapshot) !void {
const options = std.json.Stringify.Options{};
- const output = try std.json.Stringify.valueAlloc(fizzy.app.allocator, snap.ext, options);
- defer fizzy.app.allocator.free(output);
+ const output = try std.json.Stringify.valueAlloc(Globals.allocator(), snap.ext, options);
+ defer Globals.allocator().free(output);
try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json"));
try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len));
@@ -3147,25 +3149,25 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void {
const ext = std.fs.path.extension(self.path);
if (isFizzyExtension(ext)) {
- var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator);
- defer snap.deinit(fizzy.app.allocator);
- const bytes = try writeSnapshotToZipBytes(&snap, fizzy.app.allocator);
- defer fizzy.app.allocator.free(bytes);
- try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
+ var snap = try SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator());
+ defer snap.deinit(Globals.allocator());
+ const bytes = try writeSnapshotToZipBytes(&snap, Globals.allocator());
+ defer Globals.allocator().free(bytes);
+ try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
} else if (std.mem.eql(u8, ext, ".png")) {
const bytes = try flattenedImageBytes(self, window, .png);
- defer fizzy.app.allocator.free(bytes);
- try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes);
+ defer Globals.allocator().free(bytes);
+ try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
const bytes = try flattenedImageBytes(self, window, .jpg);
- defer fizzy.app.allocator.free(bytes);
- try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
+ defer Globals.allocator().free(bytes);
+ try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
} else {
return;
}
self.history.bookmark = 0;
- const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
+ const id_mutex = dvui.toastAdd(window, @src(), 0, pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Downloaded {s}", .{basename}) catch "Downloaded file";
dvui.dataSetSlice(window, id, "_message", message);
@@ -3177,28 +3179,28 @@ fn flattenedImageBytes(self: *File, window: *dvui.Window, comptime kind: enum {
const h = self.height();
if (w == 0 or h == 0) return error.InvalidImageSize;
- try fizzy.render.syncLayerComposite(self);
+ try pixelart.render.syncLayerComposite(self);
const target = self.editor.layer_composite_target orelse return error.NoLayerComposite;
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
- var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr);
+ var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr);
defer tmp_layer.deinit();
- var out = std.Io.Writer.Allocating.init(fizzy.app.allocator);
+ var out = std.Io.Writer.Allocating.init(Globals.allocator());
errdefer out.deinit();
switch (kind) {
.png => {
const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254));
- try fizzy.image.writePngToWriter(tmp_layer.source, &out.writer, r);
+ try pixelart.image.writePngToWriter(tmp_layer.source, &out.writer, r);
},
.jpg => {
const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi);
+ try pixelart.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi);
},
}
return out.toOwnedSlice();
@@ -3214,10 +3216,10 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi
return saveZip(self, window);
}
const old_path = self.path;
- const new_owned = try fizzy.app.allocator.dupe(u8, new_path);
+ const new_owned = try Globals.allocator().dupe(u8, new_path);
self.path = new_owned;
errdefer {
- fizzy.app.allocator.free(self.path[0..self.path.len]);
+ Globals.allocator().free(self.path[0..self.path.len]);
self.path = old_path;
}
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
@@ -3225,7 +3227,7 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi
} else {
try saveZip(self, window);
}
- fizzy.app.allocator.free(old_path[0..old_path.len]);
+ Globals.allocator().free(old_path[0..old_path.len]);
}
/// Default filename (with `.fiz`) for a Save As dialog, derived from the current path.
@@ -3249,10 +3251,10 @@ fn deinitAllUserLayers(self: *File) void {
fn clearAnimationsForSaveAs(self: *File) void {
for (self.animations.items(.name)) |n| {
- fizzy.app.allocator.free(n);
+ Globals.allocator().free(n);
}
for (self.animations.items(.frames)) |frames| {
- fizzy.app.allocator.free(frames);
+ Globals.allocator().free(frames);
}
self.animations.clearRetainingCapacity();
self.deleted_animations.clearRetainingCapacity();
@@ -3276,15 +3278,15 @@ fn reinitEditorSurfaceForFlatDocument(self: *File) !void {
self.editor.temporary_layer = try .init(self.newLayerID(), "Temporary", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
self.editor.selection_layer = try .init(self.newLayerID(), "Selection", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
self.editor.transform_layer = try .init(self.newLayerID(), "Transform", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr);
- self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.spriteCount());
+ self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(Globals.allocator(), self.spriteCount());
- self.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.width() * self.height());
+ self.editor.checkerboard = try std.DynamicBitSet.initEmpty(Globals.allocator(), self.width() * self.height());
for (0..self.width() * self.height()) |i| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i);
self.editor.checkerboard.setValue(i, value);
}
self.editor.selected_layer_indices.clearRetainingCapacity();
- try self.editor.selected_layer_indices.append(fizzy.app.allocator, 0);
+ try self.editor.selected_layer_indices.append(Globals.allocator(), 0);
}
/// Flattens visible layers (via GPU composite), writes PNG or JPEG to `output_path`, and replaces
@@ -3302,16 +3304,16 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
return error.InvalidImageSize;
}
- try fizzy.render.syncLayerComposite(self);
+ try pixelart.render.syncLayerComposite(self);
const target = self.editor.layer_composite_target orelse {
self.setSaving(false);
return error.NoLayerComposite;
};
- const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target);
+ const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(Globals.allocator(), target);
defer {
const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA);
- fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
+ Globals.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]);
}
const ext = std.fs.path.extension(output_path);
@@ -3322,49 +3324,49 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
return error.InvalidExtension;
}
- var single_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "Layer", pma_read, w, h, .ptr);
+ var single_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "Layer", pma_read, w, h, .ptr);
errdefer single_layer.deinit();
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
const bytes = if (is_png) blk: {
const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254));
- var out = std.Io.Writer.Allocating.init(fizzy.app.allocator);
+ var out = std.Io.Writer.Allocating.init(Globals.allocator());
errdefer out.deinit();
- try fizzy.image.writePngToWriter(single_layer.source, &out.writer, r);
+ try pixelart.image.writePngToWriter(single_layer.source, &out.writer, r);
break :blk try out.toOwnedSlice();
} else blk: {
const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- var out = std.Io.Writer.Allocating.init(fizzy.app.allocator);
+ var out = std.Io.Writer.Allocating.init(Globals.allocator());
errdefer out.deinit();
- try fizzy.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi);
+ try pixelart.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi);
break :blk try out.toOwnedSlice();
};
- defer fizzy.app.allocator.free(bytes);
+ defer Globals.allocator().free(bytes);
const dl_ext = if (is_png) ".png" else ".jpg";
- try @import("../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
+ try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
} else if (is_png) {
const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254));
- try fizzy.image.writeToPngResolution(single_layer.source, output_path, r);
+ try pixelart.image.writeToPngResolution(single_layer.source, output_path, r);
} else {
const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0));
- try fizzy.image.writeToJpgPpi(single_layer.source, output_path, ppi);
+ try pixelart.image.writeToJpgPpi(single_layer.source, output_path, ppi);
}
- fizzy.render.destroyLayerCompositeResources(self);
- fizzy.render.destroySplitCompositeResources(self);
+ pixelart.render.destroyLayerCompositeResources(self);
+ pixelart.render.destroySplitCompositeResources(self);
deinitAllUserLayers(self);
clearAnimationsForSaveAs(self);
self.sprites.clearRetainingCapacity();
for (0..self.spriteCount()) |_| {
- self.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0, 0 } }) catch {
+ self.sprites.append(Globals.allocator(), .{ .origin = .{ 0, 0 } }) catch {
single_layer.deinit();
return error.FileLoadError;
};
}
- const new_path = try fizzy.app.allocator.dupe(u8, output_path);
- fizzy.app.allocator.free(self.path[0..self.path.len]);
+ const new_path = try Globals.allocator().dupe(u8, output_path);
+ Globals.allocator().free(self.path[0..self.path.len]);
self.path = new_path;
self.columns = 1;
self.rows = 1;
@@ -3372,13 +3374,13 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
self.row_height = h;
self.selected_layer_index = 0;
self.peek_layer_index = null;
- self.layers.append(fizzy.app.allocator, single_layer) catch {
+ self.layers.append(Globals.allocator(), single_layer) catch {
single_layer.deinit();
return error.LayerCreateError;
};
self.history.deinit();
- self.history = .init(fizzy.app.allocator);
+ self.history = .init(Globals.allocator());
try reinitEditorSurfaceForFlatDocument(self);
self.editor.layer_composite_dirty = true;
@@ -3387,13 +3389,13 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
{
// `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic
// u64s; in practice they fit, so an `@intCast` is safe and panics if not.
- const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000);
+ const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixelart.core.dvui.save_toast_subwindow_id, pixelart.core.dvui.saveCompleteToastDisplay, 2_500_000);
const id = id_mutex.id;
const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file";
dvui.dataSetSlice(window, id, "_message", message);
id_mutex.mutex.unlock(dvui.io);
}
- fizzy.editor.requestCompositeWarmup();
+ Globals.state.host.requestCompositeWarmup();
}
pub const GridLayoutOptions = struct {
@@ -3401,7 +3403,7 @@ pub const GridLayoutOptions = struct {
row_height: u32,
columns: u32,
rows: u32,
- anchor: fizzy.math.layout_anchor.LayoutAnchor,
+ anchor: pixelart.math.layout_anchor.LayoutAnchor,
/// When true (default), `applyGridLayout` snapshots the previous state and pushes a
/// `grid_layout` change to the file's history before mutating. Internal callers driving
/// undo/redo restoration should pass `false` so the swap doesn't loop into itself.
@@ -3410,34 +3412,34 @@ pub const GridLayoutOptions = struct {
/// Captures everything `applyGridLayout` mutates, owning all returned slices. The caller is
/// responsible for freeing via `Change.deinit` (see `History.Change.GridLayout.deinit`).
-pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.History.Change.GridLayout {
+pub fn captureGridLayoutSnapshot(file: *File) !History.Change.GridLayout {
const total: usize = @as(usize, file.column_width) * @as(usize, file.columns) *
@as(usize, file.row_height) * @as(usize, file.rows);
const layer_count = file.layers.len;
- var layer_ids = try fizzy.app.allocator.alloc(u64, layer_count);
- errdefer fizzy.app.allocator.free(layer_ids);
+ var layer_ids = try Globals.allocator().alloc(u64, layer_count);
+ errdefer Globals.allocator().free(layer_ids);
- var layer_pixels = try fizzy.app.allocator.alloc([][4]u8, layer_count);
+ var layer_pixels = try Globals.allocator().alloc([][4]u8, layer_count);
var allocated: usize = 0;
errdefer {
- for (layer_pixels[0..allocated]) |buf| fizzy.app.allocator.free(buf);
- fizzy.app.allocator.free(layer_pixels);
+ for (layer_pixels[0..allocated]) |buf| Globals.allocator().free(buf);
+ Globals.allocator().free(layer_pixels);
}
for (0..layer_count) |i| {
layer_ids[i] = file.layers.items(.id)[i];
const src = file.layers.get(i).pixels();
std.debug.assert(src.len == total);
- const dst = try fizzy.app.allocator.alloc([4]u8, total);
+ const dst = try Globals.allocator().alloc([4]u8, total);
@memcpy(dst, src);
layer_pixels[i] = dst;
allocated += 1;
}
const sprite_count = file.sprites.len;
- var sprite_origins = try fizzy.app.allocator.alloc([2]f32, sprite_count);
- errdefer fizzy.app.allocator.free(sprite_origins);
+ var sprite_origins = try Globals.allocator().alloc([2]f32, sprite_count);
+ errdefer Globals.allocator().free(sprite_origins);
for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i];
return .{
@@ -3457,7 +3459,7 @@ pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.History.Change.Gri
/// Restores the file to the exact state described by `snap`. Mirrors the structural updates of
/// `applyGridLayout` (resize layer buffers, sprite list, scratch layers, checkerboard, composite
/// tear-down) but copies pixel data verbatim instead of re-anchoring it.
-pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.GridLayout) !void {
+pub fn applyGridLayoutSnapshot(file: *File, snap: History.Change.GridLayout) !void {
const new_w: u32 = snap.column_width * snap.columns;
const new_h: u32 = snap.row_height * snap.rows;
const total: usize = @as(usize, new_w) * @as(usize, new_h);
@@ -3473,7 +3475,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.
break :blk null;
};
- var rebuilt = fizzy.Internal.Layer.init(
+ var rebuilt = Layer.init(
live.id,
live.name,
new_w,
@@ -3494,25 +3496,25 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.
file.editor.temporary_layer.deinit();
file.editor.selection_layer.deinit();
file.editor.transform_layer.deinit();
- file.editor.temporary_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
- file.editor.selection_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
- file.editor.transform_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
+ file.editor.temporary_layer = Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
+ file.editor.selection_layer = Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
+ file.editor.transform_layer = Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
file.sprites.shrinkRetainingCapacity(0);
const new_sprite_count: usize = @as(usize, snap.columns) * @as(usize, snap.rows);
var i: usize = 0;
while (i < new_sprite_count) : (i += 1) {
const origin: [2]f32 = if (i < snap.sprite_origins.len) snap.sprite_origins[i] else .{ 0.0, 0.0 };
- file.sprites.append(fizzy.app.allocator, .{ .origin = origin }) catch return error.MemoryAllocationFailed;
+ file.sprites.append(Globals.allocator(), .{ .origin = origin }) catch return error.MemoryAllocationFailed;
}
file.editor.selected_sprites.deinit();
- file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed;
+ file.editor.selected_sprites = std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed;
file.editor.checkerboard.deinit();
- file.editor.checkerboard = std.DynamicBitSet.initEmpty(fizzy.app.allocator, total) catch return error.MemoryAllocationFailed;
+ file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), total) catch return error.MemoryAllocationFailed;
for (0..total) |idx| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
file.editor.checkerboard.setValue(idx, value);
}
@@ -3528,7 +3530,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.
file.columns = snap.columns;
file.rows = snap.rows;
- fizzy.render.destroyLayerCompositeResources(file);
+ pixelart.render.destroyLayerCompositeResources(file);
file.invalidateActiveLayerTransparencyMaskCache();
}
@@ -3567,12 +3569,12 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void {
options.rows == file.rows;
if (same) return;
- var snapshot_opt: ?fizzy.Internal.History.Change.GridLayout = if (options.history)
+ var snapshot_opt: ?History.Change.GridLayout = if (options.history)
try file.captureGridLayoutSnapshot()
else
null;
errdefer if (snapshot_opt) |snap| {
- var ch = fizzy.Internal.History.Change{ .grid_layout = snap };
+ var ch = History.Change{ .grid_layout = snap };
ch.deinit();
};
@@ -3583,7 +3585,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void {
const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows);
const old_sprite_count = file.sprites.len;
- file.sprites.resize(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed;
+ file.sprites.resize(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed;
if (new_sprite_count > old_sprite_count) {
var i: usize = old_sprite_count;
@@ -3592,7 +3594,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void {
}
}
- var new_selected = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count);
+ var new_selected = try std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count);
const sel_copy = @min(old_sprite_count, new_sprite_count);
for (0..sel_copy) |i| {
if (file.editor.selected_sprites.isSet(i)) new_selected.set(i);
@@ -3605,7 +3607,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void {
file.columns = new_cols;
file.rows = new_rows;
- fizzy.render.destroyLayerCompositeResources(file);
+ pixelart.render.destroyLayerCompositeResources(file);
file.invalidateActiveLayerTransparencyMaskCache();
if (snapshot_opt) |snap| {
@@ -3638,12 +3640,12 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
// Capture undo state up front. If allocation fails we abort *before* mutating, so the file
// is left untouched and the user can retry.
- var snapshot_opt: ?fizzy.Internal.History.Change.GridLayout = if (options.history)
+ var snapshot_opt: ?History.Change.GridLayout = if (options.history)
try file.captureGridLayoutSnapshot()
else
null;
errdefer if (snapshot_opt) |snap| {
- var ch = fizzy.Internal.History.Change{ .grid_layout = snap };
+ var ch = History.Change{ .grid_layout = snap };
ch.deinit();
};
@@ -3672,7 +3674,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
var old_layer = file.layers.get(layer_index);
const old_pix = old_layer.pixels();
- var new_layer = fizzy.Internal.Layer.init(
+ var new_layer = Layer.init(
old_layer.id,
old_layer.name,
new_w,
@@ -3693,7 +3695,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
while (nrow < @min(new_rows, old_rows)) : (nrow += 1) {
var ncol: u32 = 0;
while (ncol < @min(new_cols, old_cols)) : (ncol += 1) {
- const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor);
+ const blk = pixelart.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor);
if (blk.sw == 0 or blk.sh == 0) continue;
const src_x0: u32 = ncol * old_cw + blk.sx;
@@ -3723,25 +3725,25 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
file.editor.temporary_layer.deinit();
file.editor.selection_layer.deinit();
file.editor.transform_layer.deinit();
- file.editor.temporary_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
- file.editor.selection_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
- file.editor.transform_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
+ file.editor.temporary_layer = Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
+ file.editor.selection_layer = Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
+ file.editor.transform_layer = Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError;
// Sprite origins reset: cell positions and meaning change with cell size, so re-anchoring is undefined.
file.sprites.shrinkRetainingCapacity(0);
const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows);
var i: usize = 0;
while (i < new_sprite_count) : (i += 1) {
- file.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed;
+ file.sprites.append(Globals.allocator(), .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed;
}
file.editor.selected_sprites.deinit();
- file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed;
+ file.editor.selected_sprites = std.DynamicBitSet.initEmpty(Globals.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed;
file.editor.checkerboard.deinit();
- file.editor.checkerboard = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed;
+ file.editor.checkerboard = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed;
for (0..@as(usize, new_w) * @as(usize, new_h)) |idx| {
- const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
+ const value = pixelart.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx);
file.editor.checkerboard.setValue(idx, value);
}
@@ -3756,7 +3758,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void {
file.columns = new_cols;
file.rows = new_rows;
- fizzy.render.destroyLayerCompositeResources(file);
+ pixelart.render.destroyLayerCompositeResources(file);
file.invalidateActiveLayerTransparencyMaskCache();
if (snapshot_opt) |snap| {
@@ -3788,12 +3790,12 @@ pub fn saveAsync(self: *File) !void {
// Snapshot all save-relevant data on the GUI thread NOW, before the worker
// could observe a torn `self.layers` (the user can still draw / add layers
// while the async save runs). Worker reads only the snapshot.
- const snap_ptr = fizzy.app.allocator.create(SaveSnapshot) catch |err| {
+ const snap_ptr = Globals.allocator().create(SaveSnapshot) catch |err| {
self.setSaving(false);
return err;
};
- snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator) catch |err| {
- fizzy.app.allocator.destroy(snap_ptr);
+ snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, Globals.allocator()) catch |err| {
+ Globals.allocator().destroy(snap_ptr);
self.setSaving(false);
return err;
};
@@ -3812,8 +3814,8 @@ pub fn saveAsync(self: *File) !void {
.window = dvui.currentWindow(),
.snap = snap_ptr,
}) catch |err| {
- snap_ptr.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(snap_ptr);
+ snap_ptr.deinit(Globals.allocator());
+ Globals.allocator().destroy(snap_ptr);
self.setSaving(false);
return err;
};
@@ -3825,10 +3827,10 @@ pub fn saveAsync(self: *File) !void {
}
}
-pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File {
- const layers = try allocator.alloc(fizzy.Layer, self.layers.slice().len);
- const sprites = try allocator.alloc(fizzy.Sprite, self.sprites.slice().len);
- const animations = try allocator.alloc(fizzy.Animation, self.animations.slice().len);
+pub fn external(self: File, allocator: std.mem.Allocator) !pixelart.File {
+ const layers = try allocator.alloc(pixelart.Layer, self.layers.slice().len);
+ const sprites = try allocator.alloc(pixelart.Sprite, self.sprites.slice().len);
+ const animations = try allocator.alloc(pixelart.Animation, self.animations.slice().len);
for (layers, 0..) |*working_layer, i| {
working_layer.name = try allocator.dupe(u8, self.layers.items(.name)[i]);
@@ -3846,7 +3848,7 @@ pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File {
}
return .{
- .version = fizzy.version,
+ .version = pixelart.version,
.columns = self.columns,
.rows = self.rows,
.column_width = self.column_width,
diff --git a/src/plugins/pixelart/internal/History.zig b/src/plugins/pixelart/src/internal/History.zig
similarity index 87%
rename from src/plugins/pixelart/internal/History.zig
rename to src/plugins/pixelart/src/internal/History.zig
index 45025e7c..8b9e501a 100644
--- a/src/plugins/pixelart/internal/History.zig
+++ b/src/plugins/pixelart/src/internal/History.zig
@@ -1,11 +1,11 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
-const pixelart = @import("../plugin.zig");
const zgui = @import("zgui");
const History = @This();
-const Editor = fizzy.Editor;
const dvui = @import("dvui");
const Layer = @import("Layer.zig");
+const pixelart = @import("../../pixelart.zig");
+const plugin = @import("../plugin.zig");
+const Globals = pixelart.Globals;
pub const Action = enum { undo, redo };
pub const RestoreDelete = enum { restore, delete };
@@ -58,7 +58,7 @@ pub const Change = union(ChangeType) {
pub const AnimationFrames = struct {
index: usize,
- frames: []fizzy.Animation.Frame,
+ frames: []pixelart.Animation.Frame,
};
pub const AnimationRestoreDelete = struct {
@@ -188,7 +188,7 @@ pub const Change = union(ChangeType) {
.selected = 0,
} },
.layer_name => .{ .animation_name = .{
- .name = [_:0]u8{0} ** Editor.Constants.max_name_len,
+ .name = [_:0]u8{0} ** pixelart.max_name_len,
.index = 0,
} },
else => error.NotSupported,
@@ -198,25 +198,25 @@ pub const Change = union(ChangeType) {
pub fn deinit(self: *Change) void {
switch (self.*) {
.pixels => |*pixels| {
- fizzy.app.allocator.free(pixels.indices);
- fizzy.app.allocator.free(pixels.values);
+ Globals.allocator().free(pixels.indices);
+ Globals.allocator().free(pixels.values);
},
.origins => |*origins| {
- fizzy.app.allocator.free(origins.indices);
- fizzy.app.allocator.free(origins.values);
+ Globals.allocator().free(origins.indices);
+ Globals.allocator().free(origins.values);
},
.layers_order => |*layers_order| {
- fizzy.app.allocator.free(layers_order.order);
+ Globals.allocator().free(layers_order.order);
},
.layer_merge => |*layer_merge| {
- fizzy.app.allocator.free(layer_merge.dest_pixels_before);
+ Globals.allocator().free(layer_merge.dest_pixels_before);
layer_merge.dest_mask_before.deinit();
},
.grid_layout => |*gl| {
- for (gl.layer_pixels) |buf| fizzy.app.allocator.free(buf);
- fizzy.app.allocator.free(gl.layer_pixels);
- fizzy.app.allocator.free(gl.layer_ids);
- fizzy.app.allocator.free(gl.sprite_origins);
+ for (gl.layer_pixels) |buf| Globals.allocator().free(buf);
+ Globals.allocator().free(gl.layer_pixels);
+ Globals.allocator().free(gl.layer_ids);
+ Globals.allocator().free(gl.sprite_origins);
},
else => {},
}
@@ -230,8 +230,8 @@ redo_stack: std.array_list.Managed(Change),
undo_layer_data_stack: std.array_list.Managed([][][4]u8),
redo_layer_data_stack: std.array_list.Managed([][][4]u8),
-undo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame),
-redo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame),
+undo_animation_data_stack: std.array_list.Managed([][]pixelart.Animation.Frame),
+redo_animation_data_stack: std.array_list.Managed([][]pixelart.Animation.Frame),
undo_sprite_data_stack: std.array_list.Managed([][2]f32),
redo_sprite_data_stack: std.array_list.Managed([][2]f32),
@@ -244,8 +244,8 @@ pub fn init(allocator: std.mem.Allocator) History {
.undo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator),
.redo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator),
- .undo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator),
- .redo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator),
+ .undo_animation_data_stack = std.array_list.Managed([][]pixelart.Animation.Frame).init(allocator),
+ .redo_animation_data_stack = std.array_list.Managed([][]pixelart.Animation.Frame).init(allocator),
.undo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator),
.redo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator),
@@ -253,12 +253,12 @@ pub fn init(allocator: std.mem.Allocator) History {
}
pub fn append(self: *History, change: Change) !void {
- const track_pixels = fizzy.perf.record and std.meta.activeTag(change) == .pixels;
+ const track_pixels = pixelart.perf.record and std.meta.activeTag(change) == .pixels;
const pixel_slots: usize = if (track_pixels) switch (change) {
.pixels => |p| p.indices.len,
else => 0,
} else 0;
- const t_hist: i128 = if (track_pixels) fizzy.perf.nanoTimestamp() else 0;
+ const t_hist: i128 = if (track_pixels) pixelart.perf.nanoTimestamp() else 0;
if (self.redo_stack.items.len > 0) {
for (self.redo_stack.items) |*c| {
@@ -270,9 +270,9 @@ pub fn append(self: *History, change: Change) !void {
if (self.redo_layer_data_stack.items.len > 0) {
for (self.redo_layer_data_stack.items) |data| {
for (data) |layer| {
- fizzy.app.allocator.free(layer);
+ Globals.allocator().free(layer);
}
- fizzy.app.allocator.free(data);
+ Globals.allocator().free(data);
}
self.redo_layer_data_stack.clearRetainingCapacity();
}
@@ -364,13 +364,13 @@ pub fn append(self: *History, change: Change) !void {
}
if (track_pixels and t_hist != 0) {
- fizzy.perf.history_append_pixels_ns +%= @intCast(fizzy.perf.nanoTimestamp() - t_hist);
- fizzy.perf.history_append_pixels_calls += 1;
- fizzy.perf.history_append_pixels_slots +%= pixel_slots;
+ pixelart.perf.history_append_pixels_ns +%= @intCast(pixelart.perf.nanoTimestamp() - t_hist);
+ pixelart.perf.history_append_pixels_calls += 1;
+ pixelart.perf.history_append_pixels_slots +%= pixel_slots;
}
}
-fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
+fn layerMergeUndo(file: *pixelart.internal.File, lm: *Change.LayerMerge) !void {
const dest_i = for (file.layers.items(.id), 0..) |id, i| {
if (id == lm.dest_layer_id) break i;
} else return error.InvalidLayerMerge;
@@ -378,21 +378,21 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
var dest = file.layers.get(dest_i);
@memcpy(dest.pixels(), lm.dest_pixels_before);
dest.mask.deinit();
- dest.mask = try lm.dest_mask_before.clone(fizzy.app.allocator);
+ dest.mask = try lm.dest_mask_before.clone(Globals.allocator());
dest.invalidate();
file.layers.set(dest_i, dest);
const restored = file.deleted_layers.pop() orelse return error.InvalidLayerMerge;
- try file.layers.insert(fizzy.app.allocator, lm.source_index, restored);
+ try file.layers.insert(Globals.allocator(), lm.source_index, restored);
file.editor.layer_composite_dirty = true;
file.editor.split_composite_dirty = true;
file.selected_layer_index = lm.source_index;
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
+ Globals.state.host.setActiveSidebarView(plugin.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
-fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
+fn layerMergeRedo(file: *pixelart.internal.File, lm: *Change.LayerMerge) !void {
const src_i = for (file.layers.items(.id), 0..) |id, i| {
if (id == lm.source_layer_id) break i;
} else return error.InvalidLayerMerge;
@@ -420,7 +420,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
dest.invalidate();
file.layers.set(dest_i, dest);
- try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(src_i));
+ try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(src_i));
file.layers.orderedRemove(src_i);
file.editor.layer_composite_dirty = true;
@@ -430,13 +430,13 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void {
.up => dest_i,
.down => dest_i - 1,
};
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
+ Globals.state.host.setActiveSidebarView(plugin.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
}
// Handling cases in this function details how an undo/redo action works, and must be symmetrical.
// This means that `change` needs to be modified to contain the active state prior to changing the active state
-pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !void {
+pub fn undoRedo(self: *History, file: *pixelart.internal.File, action: Action) !void {
var active_stack = switch (action) {
.undo => &self.undo_stack,
.redo => &self.redo_stack,
@@ -459,8 +459,8 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
// direct `@intCast` to `usize` crashes the safe-mode build with an "integer cast
// truncates value" panic every time the user undoes/redoes. `id_extra` only needs
// to be a salt that varies between toasts, so truncate via u128 → low bits of usize.
- const ts_us: u128 = @intCast(@divTrunc(fizzy.perf.nanoTimestamp(), 1000));
- const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, fizzy.dvui.toastDisplay, 2_000_000);
+ const ts_us: u128 = @intCast(@divTrunc(pixelart.perf.nanoTimestamp(), 1000));
+ const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, pixelart.core.dvui.toastDisplay, 2_000_000);
const id = id_mutex.id;
const action_text = switch (action) {
.undo => "Undo:",
@@ -619,14 +619,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
//try file.editor.selected_sprites.append(sprite_index);
}
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites);
+ Globals.state.host.setActiveSidebarView(plugin.view_sprites);
},
.layers_order => |*layers_order| {
file.editor.layer_composite_dirty = true;
file.editor.split_composite_dirty = true;
// `new_order` holds layer ids (u64 in the on-disk format), not
// indices — `layers_order.order` below is `[]u64` so this matches.
- var new_order = try fizzy.app.allocator.alloc(u64, layers_order.order.len);
+ var new_order = try Globals.allocator().alloc(u64, layers_order.order.len);
for (0..file.layers.len) |layer_index| {
new_order[layer_index] = file.layers.items(.id)[layer_index];
}
@@ -656,7 +656,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
}
@memcpy(layers_order.order, new_order);
- fizzy.app.allocator.free(new_order);
+ Globals.allocator().free(new_order);
file.invalidateActiveLayerTransparencyMaskCache();
},
.layer_restore_delete => |*layer_restore_delete| {
@@ -665,24 +665,24 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
const a = layer_restore_delete.action;
switch (a) {
.restore => {
- try file.layers.insert(fizzy.app.allocator, layer_restore_delete.index, file.deleted_layers.pop().?);
+ try file.layers.insert(Globals.allocator(), layer_restore_delete.index, file.deleted_layers.pop().?);
layer_restore_delete.action = .delete;
},
.delete => {
- try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(layer_restore_delete.index));
+ try file.deleted_layers.append(Globals.allocator(), file.layers.slice().get(layer_restore_delete.index));
file.layers.orderedRemove(layer_restore_delete.index);
layer_restore_delete.action = .restore;
},
}
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
+ Globals.state.host.setActiveSidebarView(plugin.view_tools);
file.invalidateActiveLayerTransparencyMaskCache();
},
.layer_name => |*layer_name| {
- const name = try fizzy.app.allocator.dupe(u8, file.layers.items(.name)[layer_name.index]);
- fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]);
- file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name);
+ const name = try Globals.allocator().dupe(u8, file.layers.items(.name)[layer_name.index]);
+ Globals.allocator().free(file.layers.items(.name)[layer_name.index]);
+ file.layers.items(.name)[layer_name.index] = try Globals.allocator().dupe(u8, layer_name.name);
layer_name.name = name;
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
+ Globals.state.host.setActiveSidebarView(plugin.view_tools);
},
.layer_settings => |*layer_settings| {
const idx = layer_settings.index;
@@ -701,21 +701,21 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
if (visibility_changed) {
file.editor.split_composite_dirty = true;
}
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_tools);
+ Globals.state.host.setActiveSidebarView(plugin.view_tools);
},
.animation_restore_delete => |*animation_restore_delete| {
const a = animation_restore_delete.action;
switch (a) {
.restore => {
const animation = file.deleted_animations.pop().?;
- try file.animations.insert(fizzy.app.allocator, animation_restore_delete.index, animation);
+ try file.animations.insert(Globals.allocator(), animation_restore_delete.index, animation);
animation_restore_delete.action = .delete;
file.selected_animation_index = animation_restore_delete.index;
},
.delete => {
const animation = file.animations.slice().get(animation_restore_delete.index);
file.animations.orderedRemove(animation_restore_delete.index);
- try file.deleted_animations.append(fizzy.app.allocator, animation);
+ try file.deleted_animations.append(Globals.allocator(), animation);
animation_restore_delete.action = .restore;
if (file.selected_animation_index) |selected_animation_index| {
@@ -727,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
}
},
}
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites);
+ Globals.state.host.setActiveSidebarView(plugin.view_sprites);
},
.animation_name => |*animation_name| {
- const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]);
- fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]);
- file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name);
+ const name = try Globals.allocator().dupe(u8, file.animations.items(.name)[animation_name.index]);
+ Globals.allocator().free(file.animations.items(.name)[animation_name.index]);
+ file.animations.items(.name)[animation_name.index] = try Globals.allocator().dupe(u8, animation_name.name);
animation_name.name = name;
- fizzy.pixelart.host.setActiveSidebarView(pixelart.view_sprites);
+ Globals.state.host.setActiveSidebarView(plugin.view_sprites);
},
.animation_settings => {},
.animation_order => |*animation_order| {
@@ -772,7 +772,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
const history_frames = &animation_frames.frames;
const current_frames = &file.animations.items(.frames)[animation_frames.index];
- std.mem.swap([]fizzy.Animation.Frame, history_frames, current_frames);
+ std.mem.swap([]pixelart.Animation.Frame, history_frames, current_frames);
file.selected_animation_index = animation_frames.index;
},
@@ -783,7 +783,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
resize.height = file.height();
var layer_data: ?[][][4]u8 = null;
- var animation_data: ?[][]fizzy.Animation.Frame = null;
+ var animation_data: ?[][]pixelart.Animation.Frame = null;
var sprite_data: ?[][2]f32 = null;
switch (action) {
@@ -796,9 +796,9 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
if (self.undo_animation_data_stack.pop()) |ad| {
animation_data = ad;
- var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len);
+ var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len);
for (0..file.animations.len) |animation_index| {
- anim_data[animation_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed;
+ anim_data[animation_index] = Globals.allocator().dupe(pixelart.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed;
}
try self.redo_animation_data_stack.append(anim_data);
}
@@ -806,7 +806,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
if (self.undo_sprite_data_stack.pop()) |sd| {
sprite_data = sd;
- const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount());
+ const new_sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount());
for (0..file.spriteCount()) |sprite_index| {
new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
}
@@ -821,16 +821,16 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
if (self.redo_animation_data_stack.pop()) |ad| {
animation_data = ad;
- var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len);
+ var anim_data = try Globals.allocator().alloc([]pixelart.Animation.Frame, file.animations.len);
for (0..file.animations.len) |animation_index| {
- anim_data[animation_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed;
+ anim_data[animation_index] = Globals.allocator().dupe(pixelart.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed;
}
try self.undo_animation_data_stack.append(anim_data);
}
if (self.redo_sprite_data_stack.pop()) |sd| {
sprite_data = sd;
- const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount());
+ const new_sprite_data = try Globals.allocator().alloc([2]f32, file.spriteCount());
for (0..file.spriteCount()) |sprite_index| {
new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index];
}
@@ -849,11 +849,11 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi
}) catch return error.ResizeError;
if (animation_data) |ad| {
- fizzy.app.allocator.free(ad);
+ Globals.allocator().free(ad);
}
if (sprite_data) |sd| {
- fizzy.app.allocator.free(sd);
+ Globals.allocator().free(sd);
}
file.invalidateActiveLayerTransparencyMaskCache();
@@ -945,16 +945,16 @@ pub fn clearRetainingCapacity(self: *History) void {
pub fn deinit(self: *History) void {
for (self.undo_layer_data_stack.items) |data| {
for (data) |layer| {
- fizzy.app.allocator.free(layer);
+ Globals.allocator().free(layer);
}
- fizzy.app.allocator.free(data);
+ Globals.allocator().free(data);
}
for (self.redo_layer_data_stack.items) |data| {
for (data) |layer| {
- fizzy.app.allocator.free(layer);
+ Globals.allocator().free(layer);
}
- fizzy.app.allocator.free(data);
+ Globals.allocator().free(data);
}
self.undo_layer_data_stack.deinit();
diff --git a/src/plugins/pixelart/internal/Layer.zig b/src/plugins/pixelart/src/internal/Layer.zig
similarity index 82%
rename from src/plugins/pixelart/internal/Layer.zig
rename to src/plugins/pixelart/src/internal/Layer.zig
index 21a4fe60..29a2bd66 100644
--- a/src/plugins/pixelart/internal/Layer.zig
+++ b/src/plugins/pixelart/src/internal/Layer.zig
@@ -1,7 +1,8 @@
const std = @import("std");
const dvui = @import("dvui");
-const fizzy = @import("../../../fizzy.zig");
const zip = @import("zip");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
const Layer = @This();
@@ -33,13 +34,13 @@ dirty: bool = false,
pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: dvui.Color, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
const num_pixels = width * height;
- const p = fizzy.app.allocator.alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed;
+ const p = Globals.allocator().alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed;
@memset(p, default_color.toRGBA());
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = .{
.pixelsPMA = .{
.rgba = @ptrCast(p),
@@ -49,29 +50,29 @@ pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: d
.invalidation = invalidation,
},
},
- .mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, num_pixels) catch return error.MemoryAllocationFailed,
+ .mask = std.DynamicBitSet.initEmpty(Globals.allocator(), num_pixels) catch return error.MemoryAllocationFailed,
};
}
pub fn fromImageFilePath(id: u64, name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- const source = fizzy.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource;
- const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
+ const source = pixelart.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
}
pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
- const source = fizzy.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource;
- const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
+ const source = pixelart.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
@@ -79,12 +80,12 @@ pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, in
pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
if (pixel_data.len != width * height) return error.InvalidPixelDataLength;
- const source = fizzy.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource;
- const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed;
+ const source = pixelart.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
@@ -92,24 +93,24 @@ pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, wi
pub fn fromPixels(id: u64, name: []const u8, pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer {
if (pixel_data.len != width * height) return error.InvalidPixelDataLength;
- const source = fizzy.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource;
- const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed;
+ const source = pixelart.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
}
pub fn fromTexture(id: u64, name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) Layer {
- const source = fizzy.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource;
- const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
+ const source = pixelart.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource;
+ const mask = std.DynamicBitSet.initEmpty(Globals.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed;
return .{
.id = id,
- .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed,
+ .name = Globals.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed,
.source = source,
.mask = mask,
};
@@ -121,53 +122,53 @@ pub fn size(self: Layer) dvui.Size {
pub fn deinit(self: *Layer) void {
switch (self.source) {
- .imageFile => |image| fizzy.app.allocator.free(image.bytes),
- .pixels => |p| fizzy.app.allocator.free(p.rgba),
- .pixelsPMA => |p| fizzy.app.allocator.free(p.rgba),
+ .imageFile => |image| Globals.allocator().free(image.bytes),
+ .pixels => |p| Globals.allocator().free(p.rgba),
+ .pixelsPMA => |p| Globals.allocator().free(p.rgba),
.texture => |t| dvui.textureDestroyLater(t),
}
- fizzy.app.allocator.free(self.name);
+ Globals.allocator().free(self.name);
self.mask.deinit();
}
/// Casts the source pixels into a slice of [4]u8
pub fn pixels(self: *const Layer) [][4]u8 {
- return fizzy.image.pixels(self.source);
+ return pixelart.image.pixels(self.source);
}
/// Caller owns memory that must be freed!
pub fn pixelsFromRect(self: *const Layer, allocator: std.mem.Allocator, rect: dvui.Rect) ?[][4]u8 {
- return fizzy.image.pixelsFromRect(allocator, self.source, rect);
+ return pixelart.image.pixelsFromRect(allocator, self.source, rect);
}
/// Casts the source pixels into a slice of bytes
pub fn bytes(self: *const Layer) []u8 {
- return fizzy.image.bytes(self.source);
+ return pixelart.image.bytes(self.source);
}
/// Returns the index of the pixel at the given point
/// returns null if the point is out of bounds
pub fn pixelIndex(self: *Layer, p: dvui.Point) ?usize {
- return fizzy.image.pixelIndex(self.source, p);
+ return pixelart.image.pixelIndex(self.source, p);
}
/// Returns the point at the given index
/// returns null if the index is out of bounds
pub fn point(self: *Layer, index: usize) ?dvui.Point {
- return fizzy.image.point(self.source, index);
+ return pixelart.image.point(self.source, index);
}
/// Returns the color at the given point
/// returns null if the point is out of bounds
pub fn pixel(self: *Layer, p: dvui.Point) ?[4]u8 {
- return fizzy.image.pixel(self.source, p);
+ return pixelart.image.pixel(self.source, p);
}
/// Sets the color at the given point
/// does not invalidate the layer
pub fn setPixel(self: *Layer, p: dvui.Point, color: [4]u8) void {
- fizzy.image.setPixel(self.source, p, color);
+ pixelart.image.setPixel(self.source, p, color);
}
/// Sets the mask at the given point
@@ -217,7 +218,7 @@ pub fn setColorFromMask(self: *Layer, color: dvui.Color) void {
pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bool) !void {
if (!bounds.contains(p)) return;
- var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator);
+ var queue = std.array_list.Managed(dvui.Point).init(Globals.allocator());
defer queue.deinit();
queue.append(p) catch return error.MemoryAllocationFailed;
@@ -249,7 +250,7 @@ pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bo
}
pub fn setPixelIndex(self: *Layer, index: usize, color: [4]u8) void {
- fizzy.image.setPixelIndex(self.source, index, color);
+ pixelart.image.setPixelIndex(self.source, index, color);
}
pub const ShapeOffsetResult = struct {
@@ -266,8 +267,8 @@ pub fn invalidate(self: *Layer) void {
/// Only used for handling getting the pixels surrounding the origin
/// for stroke sizes larger than 1
pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usize) ?ShapeOffsetResult {
- const shape = fizzy.pixelart.tools.stroke_shape;
- const s: i32 = @intCast(fizzy.pixelart.tools.stroke_size);
+ const shape = Globals.state.tools.stroke_shape;
+ const s: i32 = @intCast(Globals.state.tools.stroke_size);
if (s == 1) {
if (current_index != 0)
@@ -322,15 +323,15 @@ pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usiz
/// Porter–Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout).
/// `top` is composited over `bottom`. The implementation is generic byte math and
/// lives in `core` math; re-exported here for the pixel-art call sites.
-pub const blendPmaSrcOver = fizzy.math.blendPmaSrcOver;
+pub const blendPmaSrcOver = pixelart.math.blendPmaSrcOver;
pub fn clearRect(self: *Layer, rect: dvui.Rect) void {
- fizzy.image.clearRect(self.source, rect);
+ pixelart.image.clearRect(self.source, rect);
self.invalidate();
}
pub fn setRect(self: *Layer, rect: dvui.Rect, color: [4]u8) void {
- fizzy.image.setRect(self.source, rect, color);
+ pixelart.image.setRect(self.source, rect, color);
self.invalidate();
}
@@ -412,10 +413,10 @@ pub fn writeSourceToZip(
const w = @as(c_int, @intFromFloat(s.w));
const h = @as(c_int, @intFromFloat(s.h));
- var writer = std.Io.Writer.Allocating.init(fizzy.pixelart.host.arena());
+ var writer = std.Io.Writer.Allocating.init(Globals.state.host.arena());
- try fizzy.image.ensurePngWriterBuffer(&writer.writer);
- try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution);
+ try pixelart.image.ensurePngWriterBuffer(&writer.writer);
+ try dvui.PNGEncoder.writeWithResolution(&writer.writer, pixelart.image.bytes(source), @intCast(w), @intCast(h), resolution);
if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| {
_ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len));
@@ -423,7 +424,7 @@ pub fn writeSourceToZip(
}
pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void {
- return fizzy.fs.writeSourceToPng(layer.source, path);
+ return pixelart.fs.writeSourceToPng(layer.source, path);
}
pub fn resize(layer: *Layer, new_size: dvui.Size) !void {
@@ -432,7 +433,7 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void {
var new_layer = Layer.init(
layer.id,
- fizzy.app.allocator.dupe(u8, layer.name) catch return error.MemoryAllocationFailed,
+ Globals.allocator().dupe(u8, layer.name) catch return error.MemoryAllocationFailed,
@as(u32, @intFromFloat(new_size.w)),
@as(u32, @intFromFloat(new_size.h)),
.{ .r = 0, .g = 0, .b = 0, .a = 0 },
@@ -460,14 +461,14 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void {
/// Tighten `src` to the smallest sub-rect of this layer containing every opaque pixel.
/// Returns null when `src` is empty, off-layer, or covers only fully-transparent pixels.
///
-/// Pure scalar logic lives in `fizzy.algorithms.reduce.reduce` so it can be exercised by
+/// Pure scalar logic lives in `pixelart.algorithms.reduce.reduce` so it can be exercised by
/// unit tests without dvui / fizzy globals — see that module for the contract details.
pub fn reduce(layer: *Layer, src: dvui.Rect) ?dvui.Rect {
const sz = layer.size();
const layer_w: u32 = @intFromFloat(sz.w);
const layer_h: u32 = @intFromFloat(sz.h);
- const r = fizzy.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{
+ const r = pixelart.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{
.x = @intFromFloat(src.x),
.y = @intFromFloat(src.y),
.w = @intFromFloat(src.w),
diff --git a/src/plugins/pixelart/internal/Palette.zig b/src/plugins/pixelart/src/internal/Palette.zig
similarity index 81%
rename from src/plugins/pixelart/internal/Palette.zig
rename to src/plugins/pixelart/src/internal/Palette.zig
index 63e1b0f1..bc15b826 100644
--- a/src/plugins/pixelart/internal/Palette.zig
+++ b/src/plugins/pixelart/src/internal/Palette.zig
@@ -1,8 +1,9 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const palette_parse = @import("palette_parse.zig");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
pub const Palette = @This();
@@ -19,8 +20,8 @@ pub fn loadFromFile(allocator: std.mem.Allocator, file: []const u8) !Palette {
const ext = std.fs.path.extension(file);
if (std.mem.eql(u8, ext, ".hex")) {
- if (fizzy.fs.read(fizzy.app.allocator, dvui.io, file) catch null) |read| {
- defer fizzy.app.allocator.free(read);
+ if (pixelart.fs.read(Globals.allocator(), dvui.io, file) catch null) |read| {
+ defer Globals.allocator().free(read);
return loadFromBytes(allocator, std.fs.path.basename(file), read);
}
@@ -46,6 +47,6 @@ pub fn loadFromBytes(allocator: std.mem.Allocator, name: []const u8, bytes: []co
}
pub fn deinit(self: *Palette) void {
- fizzy.app.allocator.free(self.name);
- fizzy.app.allocator.free(self.colors);
+ Globals.allocator().free(self.name);
+ Globals.allocator().free(self.colors);
}
diff --git a/src/plugins/pixelart/internal/Sprite.zig b/src/plugins/pixelart/src/internal/Sprite.zig
similarity index 100%
rename from src/plugins/pixelart/internal/Sprite.zig
rename to src/plugins/pixelart/src/internal/Sprite.zig
diff --git a/src/plugins/pixelart/internal/grid_layout_validate.zig b/src/plugins/pixelart/src/internal/grid_layout_validate.zig
similarity index 100%
rename from src/plugins/pixelart/internal/grid_layout_validate.zig
rename to src/plugins/pixelart/src/internal/grid_layout_validate.zig
diff --git a/src/plugins/pixelart/internal/layer_order.zig b/src/plugins/pixelart/src/internal/layer_order.zig
similarity index 100%
rename from src/plugins/pixelart/internal/layer_order.zig
rename to src/plugins/pixelart/src/internal/layer_order.zig
diff --git a/src/plugins/pixelart/internal/palette_parse.zig b/src/plugins/pixelart/src/internal/palette_parse.zig
similarity index 100%
rename from src/plugins/pixelart/internal/palette_parse.zig
rename to src/plugins/pixelart/src/internal/palette_parse.zig
diff --git a/src/plugins/pixelart/panel/sprites.zig b/src/plugins/pixelart/src/panel/sprites.zig
similarity index 97%
rename from src/plugins/pixelart/panel/sprites.zig
rename to src/plugins/pixelart/src/panel/sprites.zig
index 76c03c15..f2b6b06e 100644
--- a/src/plugins/pixelart/panel/sprites.zig
+++ b/src/plugins/pixelart/src/panel/sprites.zig
@@ -1,11 +1,11 @@
const std = @import("std");
const icons = @import("icons");
const dvui = @import("dvui");
-const fizzy = @import("../../../fizzy.zig");
-const Editor = fizzy.Editor;
-const ReflectionLagSample = fizzy.sprite_render.ReflectionLagSample;
-const reflection_surface_cols = fizzy.sprite_render.reflection_surface_cols;
-const wsurf = fizzy.water_surface;
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
+const ReflectionLagSample = pixelart.sprite_render.ReflectionLagSample;
+const reflection_surface_cols = pixelart.sprite_render.reflection_surface_cols;
+const wsurf = pixelart.water_surface;
const Sprites = @This();
@@ -114,12 +114,12 @@ const SpriteSlot = struct {
}
};
-/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.Fling`.
+/// Cover-flow scrub momentum tuning (sprite-index units). See `pixelart.Fling`.
/// Mouse/trackpad release velocity is measured over a position/time window
/// (`releaseWindowed`), not a per-frame EMA — the EMA converged per frame, so a quick
/// flick built up too little velocity at 60 Hz (e.g. Safari on a deployed build) even
/// though it worked at 120 Hz. The window is wall-clock based, so it's refresh-independent.
-const sprite_fling: fizzy.Fling.Tuning = .{
+const sprite_fling: pixelart.Fling.Tuning = .{
.decay = 4.0,
.min_start = 1.2,
.stop = 0.6,
@@ -131,7 +131,7 @@ const sprite_fling_window_s: f32 = 0.08;
/// Touch scrub: a finger flick is short and bursty, so start coasting at a lower
/// speed and tolerate the small gap the browser leaves before `touchend`. Velocity is
/// measured over a position/time window (`releaseWindowed`) rather than the last frame.
-const sprite_fling_touch: fizzy.Fling.Tuning = .{
+const sprite_fling_touch: pixelart.Fling.Tuning = .{
.decay = 4.0,
.min_start = 0.6,
.stop = 0.6,
@@ -186,7 +186,7 @@ moved_since_press: bool = false,
/// True when the active scrub began with a touch press (not mouse).
drag_was_touch: bool = false,
/// Release momentum for the scrub: coasts the flow after a flick, then snaps.
-fling: fizzy.Fling = .{},
+fling: pixelart.Fling = .{},
/// Set once we've seeded `scroll_pos` from the initial selection.
initialized: bool = false,
/// Previous "flown" state (see `sideCardsFlown`), so we can fire the fly-out /
@@ -209,7 +209,7 @@ prev_scroll_pos: f32 = 0.0,
shelf_vel: f32 = 0.0,
pub fn draw(self: *Sprites) !void {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
const prev_clip = dvui.clip(dvui.parentGet().data().rectScale().r);
defer dvui.clipSet(prev_clip);
@@ -222,10 +222,10 @@ pub fn draw(self: *Sprites) !void {
// Since not all panel screens will likely want shadows, which should be reserved for canvases?
// Text editors, consoles, etc would likely want flat panels or to handle shadows themselves.
defer {
- fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .top, .{ .opacity = 0.15 });
- fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .bottom, .{ .opacity = 0.15 });
- fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .left, .{ .opacity = 0.15 });
- fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .right, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .top, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .bottom, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .left, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .right, .{ .opacity = 0.15 });
}
const parent = dvui.parentGet().data().rect;
@@ -275,7 +275,7 @@ pub fn draw(self: *Sprites) !void {
// ---- Animated fit-scale: aim the front sprite at a fraction of the
// pane so several neighbours are visible at once. ----
const scale = blk: {
- const steps = fizzy.pixelart.settings.zoom_steps;
+ const steps = Globals.state.settings.zoom_steps;
const sprite_width = src_rect.w;
const sprite_height = src_rect.h;
const target_width = parent.w * 0.34;
@@ -438,8 +438,8 @@ pub fn draw(self: *Sprites) !void {
return;
}
- const perf_sp = fizzy.perf.spritePreviewBegin();
- defer fizzy.perf.spritePreviewEnd(perf_sp);
+ const perf_sp = pixelart.perf.spritePreviewBegin();
+ defer pixelart.perf.spritePreviewEnd(perf_sp);
const center_x = parent.center().x;
// Lift the row a little so the reflection has room below it.
@@ -749,7 +749,7 @@ pub fn draw(self: *Sprites) !void {
const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0;
const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness);
- _ = fizzy.sprite_render.sprite(SpriteSlot.src(), .{
+ _ = pixelart.sprite_render.sprite(SpriteSlot.src(), .{
.source = file.layers.items(.source)[file.selected_layer_index],
.file = file,
.alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null,
@@ -803,12 +803,12 @@ pub fn draw(self: *Sprites) !void {
/// Side cards lift away during playback, while a drawing tool is active, or when
/// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane).
fn sideCardsFlown(playing: bool) bool {
- return playing or drawingToolActive() or !fizzy.pixelart.settings.scrolling_cards;
+ return playing or drawingToolActive() or !Globals.state.settings.scrolling_cards;
}
/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee).
fn drawingToolActive() bool {
- return switch (fizzy.pixelart.tools.current) {
+ return switch (Globals.state.tools.current) {
.pointer, .selection => false,
.pencil, .eraser, .bucket => true,
};
@@ -1050,7 +1050,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px
// Dialogs/subwindows stack above the sprites pane in z-order but share the same
// screen rect — don't capture clicks meant for their footer or chrome.
- if (fizzy.dvui.canvasPointerInputSuppressed()) {
+ if (pixelart.core.dvui.canvasPointerInputSuppressed()) {
if (dvui.captured(id)) {
for (dvui.events()) |*e| {
if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) {
@@ -1190,7 +1190,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px
}
pub fn drawAnimationControlsDialog(_: *Sprites) void {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
const rect = dvui.parentGet().data().rectScale().r;
if (dvui.parentGet().data().rect.h < 48.0) {
@@ -1237,8 +1237,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void {
!fly_forced,
flown,
) and !fly_forced) {
- fizzy.pixelart.settings.scrolling_cards = !fizzy.pixelart.settings.scrolling_cards;
- fizzy.pixelart.settings.save(fizzy.pixelart.host);
+ Globals.state.settings.scrolling_cards = !Globals.state.settings.scrolling_cards;
+ Globals.state.settings.save(Globals.state.host);
dvui.refresh(null, @src(), dvui.parentGet().data().id);
}
}
diff --git a/src/plugins/pixelart/plugin.zig b/src/plugins/pixelart/src/plugin.zig
similarity index 86%
rename from src/plugins/pixelart/plugin.zig
rename to src/plugins/pixelart/src/plugin.zig
index 6a1934bb..db5fbd15 100644
--- a/src/plugins/pixelart/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -4,16 +4,19 @@
//! through the `fizzy.*` globals. Registered from `Editor.postInit`.
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
-const sdk = fizzy.sdk;
+const pixelart = @import("../pixelart.zig");
+const sdk = pixelart.sdk;
+const Globals = pixelart.Globals;
+const State = pixelart.State;
const CanvasData = @import("CanvasData.zig");
const FileWidget = @import("widgets/FileWidget.zig");
const ImageWidget = @import("widgets/ImageWidget.zig");
const PixelArtSettings = @import("Settings.zig");
const DocHandle = sdk.DocHandle;
-const Internal = fizzy.Internal;
+const Internal = pixelart.internal;
/// Stable contribution ids (plugin-namespaced) referenced across modules.
pub const view_tools = "pixelart.tools";
@@ -41,11 +44,10 @@ const vtable: sdk.Plugin.VTable = .{
.drawDocument = drawDocument,
};
-/// A `DocHandle` whose `ptr` is one of this plugin's `*Internal.File`s. The shell
-/// gets the owning plugin from the file-type registry and round-trips the document
-/// back through these hooks, so it never needs to know the concrete pixel-art type.
+/// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id`
+/// because `docs.files` may reallocate and stale `doc.ptr` values.
fn docFile(doc: DocHandle) *Internal.File {
- return @ptrCast(@alignCast(doc.ptr));
+ return Globals.state.docs.fileById(doc.id).?;
}
/// Priority for opening `ext` (lower wins). Pixel art owns its native `.fiz`/`.pixi`
@@ -111,18 +113,18 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
defer chrome.processColumnReorder(file);
defer chrome.processRowReorder(file);
- fizzy.perf.canvasPaneDrawn();
+ pixelart.perf.canvasPaneDrawn();
- if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) {
- defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .top, .{});
+ if (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) {
+ defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .top, .{});
chrome.drawRuler(file, .horizontal);
}
var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both });
defer canvas_hbox.deinit();
- if (fizzy.pixelart.settings.show_rulers and !dvui.firstFrame(container.id)) {
- defer fizzy.dvui.drawEdgeShadow(container.rectScale(), .left, .{});
+ if (Globals.state.settings.show_rulers and !dvui.firstFrame(container.id)) {
+ defer pixelart.core.dvui.drawEdgeShadow(container.rectScale(), .left, .{});
chrome.drawRuler(file, .vertical);
}
@@ -164,20 +166,17 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
var content_color = dvui.themeGet().color(.window, .fill);
- switch (builtin.os.tag) {
- .macos => {
- content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
- },
- .windows => {
- content_color = if (!fizzy.pixelart.host.isMaximized()) content_color.opacity(fizzy.pixelart.host.contentOpacity()) else content_color;
- },
- else => {},
+ if (Globals.state.host.appliesNativeWindowOpacity()) {
+ content_color = if (!Globals.state.host.isMaximized())
+ content_color.opacity(Globals.state.host.contentOpacity())
+ else
+ content_color;
}
const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32)
- fizzy.packer.atlas != null
+ Globals.packer.atlas != null
else
- fizzy.pixelart.host.folder() != null and fizzy.packer.atlas != null;
+ Globals.state.host.folder() != null and Globals.packer.atlas != null;
// Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage).
var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping);
@@ -188,7 +187,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
}
if (show_packed_atlas) {
- const atlas = &fizzy.packer.atlas.?;
+ const atlas = &Globals.packer.atlas.?;
var image_widget = ImageWidget.init(@src(), .{
.source = atlas.source,
.canvas = &atlas.canvas,
@@ -218,7 +217,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32)
"Pack open files to see the preview."
- else if (fizzy.pixelart.host.folder() == null)
+ else if (Globals.state.host.folder() == null)
"Open a project folder, then pack to see the preview."
else
"Pack the project to see the preview.";
@@ -248,10 +247,11 @@ fn redo(_: *anyopaque, doc: DocHandle) anyerror!void {
}
pub fn register(host: *sdk.Host) !void {
- // Adopt the app-owned pixel-art state as this plugin's `state`. Stage B keeps
- // it reachable through the `fizzy.pixelart` global too; Stage D drops the global
- // and routes plugin access through `state` + the SDK.
- plugin.state = fizzy.pixelart;
+ // Adopt the app-owned pixel-art state as this plugin's `state`. Wire Globals
+ // here too so plugin code and the shell share one injection site (App also sets
+ // these before State.init, but register re-syncs after postInit ordering).
+ Globals.state = fizzy.pixelart;
+ plugin.state = @ptrCast(@alignCast(fizzy.pixelart));
try host.registerPlugin(&plugin);
try host.registerSidebarView(.{
.id = view_tools,
@@ -289,24 +289,30 @@ pub fn register(host: *sdk.Host) !void {
});
}
+/// Stable `*Plugin` for constructing `DocHandle.owner` fields.
+pub fn pluginPtr() *sdk.Plugin {
+ return &plugin;
+}
+
fn drawTools(_: ?*anyopaque) anyerror!void {
- try fizzy.pixelart.tools_pane.draw();
+ try Globals.state.tools_pane.draw();
}
fn drawSprites(_: ?*anyopaque) anyerror!void {
- try fizzy.pixelart.sprites_pane.draw();
+ try Globals.state.sprites_pane.draw();
}
fn drawProject(_: ?*anyopaque) anyerror!void {
try fizzy.Editor.Explorer.project.draw();
}
fn drawSpritesPanel(_: ?*anyopaque) anyerror!void {
- try fizzy.editor.panel.sprites.draw();
+ try Globals.state.sprites_panel.draw();
}
/// Pixel-art editing + tool keybinds. The shell registers its own global/region
/// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see
-/// `Keybinds.register` for why `fizzy.platform.isMacOS()` (not `builtin`) is used.
-fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void {
- if (fizzy.platform.isMacOS()) {
+/// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used.
+fn contributeKeybinds(state: *anyopaque, win: *dvui.Window) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ if (st.host.isMacOS()) {
try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .command = true });
try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .command = true, .shift = false });
try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .command = true, .shift = true });
diff --git a/src/plugins/pixelart/render.zig b/src/plugins/pixelart/src/render.zig
similarity index 92%
rename from src/plugins/pixelart/render.zig
rename to src/plugins/pixelart/src/render.zig
index a3ec3daf..4cefd7e2 100644
--- a/src/plugins/pixelart/render.zig
+++ b/src/plugins/pixelart/src/render.zig
@@ -1,14 +1,15 @@
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
-const perf = fizzy.perf;
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const perf = pixelart.perf;
/// Monotonic frame counter, incremented once per frame from Editor.tick.
pub var frame_index: u64 = 0;
pub const RenderFileOptions = struct {
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
rs: dvui.RectScale,
color_mod: dvui.Color = .white,
fade: f32 = 0.0,
@@ -60,7 +61,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void {
uploadSubRectAndSyncCache(
source_key,
&tex,
- fizzy.image.bytes(source).ptr,
+ pixelart.image.bytes(source).ptr,
@intFromFloat(dirty.x),
@intFromFloat(dirty.y),
@intFromFloat(dirty.w),
@@ -84,7 +85,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void {
uploadSubRectAndSyncCache(
temp_key,
&tex,
- fizzy.image.bytes(temp_source).ptr,
+ pixelart.image.bytes(temp_source).ptr,
@intFromFloat(dirty.x),
@intFromFloat(dirty.y),
@intFromFloat(dirty.w),
@@ -112,7 +113,7 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde
if (init_opts.file.editor.isolate_layer) {
if (init_opts.file.peek_layer_index) |peek_layer_index| {
min_layer_index = peek_layer_index;
- } else if (!fizzy.pixelart.tools_pane.layersHovered()) {
+ } else if (!Globals.state.tools_pane.layersHovered()) {
min_layer_index = init_opts.file.selected_layer_index;
}
}
@@ -122,11 +123,11 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde
}
/// Non-null while layer list DnD preview is active (`File.editor.layer_drag_preview_*`); maps list position → storage index.
-fn layerOrderBufForDragPreview(file: *fizzy.Internal.File, buf: []usize) ?[]const usize {
+fn layerOrderBufForDragPreview(file: *pixelart.internal.File, buf: []usize) ?[]const usize {
const r = file.editor.layer_drag_preview_removed orelse return null;
const ins = file.editor.layer_drag_preview_insert_before orelse return null;
if (file.layers.len == 0 or file.layers.len > buf.len) return null;
- fizzy.Internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]);
+ pixelart.internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]);
return buf[0..file.layers.len];
}
@@ -288,22 +289,22 @@ pub fn renderLayersMagnifierSample(init_opts: RenderFileOptions) !void {
const vs = layerViewStateForRender(init_opts);
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(Globals.allocator());
defer path.deinit();
path.addRect(init_opts.rs.r, dvui.Rect.Physical.all(0));
- var triangles = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade });
- defer triangles.deinit(fizzy.app.allocator);
+ var triangles = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade });
+ defer triangles.deinit(Globals.allocator());
triangles.uvFromRectuv(init_opts.rs.r, init_opts.uv);
var dimmed_triangles: ?dvui.Triangles = null;
defer {
- if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator);
+ if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator());
}
if (vs.needs_dimmed) {
- var dt = try triangles.dupe(fizzy.app.allocator);
+ var dt = try triangles.dupe(Globals.allocator());
dt.color(.gray);
dimmed_triangles = dt;
}
@@ -370,7 +371,7 @@ fn splitCompositeEligible(
/// Pixel size of the flattened layer stack — prefers the first layer (`canvasPixelSize`) so the
/// composite matches bitmap data even when `columns × column_width` / `rows × row_height` disagree
/// (slice/grid previews use the canvas as the locked image rect).
-fn layerCompositeExtent(file: *fizzy.Internal.File) struct { w: u32, h: u32 } {
+fn layerCompositeExtent(file: *pixelart.internal.File) struct { w: u32, h: u32 } {
const c = file.canvasPixelSize();
if (c.w > 0 and c.h > 0) return .{ .w = c.w, .h = c.h };
const w = file.width();
@@ -389,7 +390,7 @@ pub fn compositeTargetPixelFormat() dvui.enums.TexturePixelFormat {
/// Rebuilds the full-canvas flattened layer texture (all layers included).
/// Used when NOT actively drawing.
-pub fn syncLayerComposite(file: *fizzy.Internal.File) !void {
+pub fn syncLayerComposite(file: *pixelart.internal.File) !void {
const ce = layerCompositeExtent(file);
const w = ce.w;
const h = ce.h;
@@ -441,7 +442,7 @@ pub fn syncLayerComposite(file: *fizzy.Internal.File) !void {
/// The "below" target flattens layers visually below (higher index), and
/// the "above" target flattens layers visually above (lower index).
/// Only rebuilt when the split layer changes or a structural change occurs.
-fn syncSplitComposite(file: *fizzy.Internal.File) !void {
+fn syncSplitComposite(file: *pixelart.internal.File) !void {
const ce = layerCompositeExtent(file);
const w = ce.w;
const h = ce.h;
@@ -526,7 +527,7 @@ fn syncSplitComposite(file: *fizzy.Internal.File) !void {
/// Pre-builds split-composite GPU targets and touches temp/selection textures so the first
/// stroke does not pay allocation + flatten cost. Safe to call once after open or when
/// selecting a drawing tool; no-op if composites are already current.
-pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void {
+pub fn warmupDrawingComposites(file: *pixelart.internal.File) !void {
const w0 = perf.nanoTimestamp();
try syncSplitComposite(file);
_ = file.editor.temporary_layer.source.getTexture() catch null;
@@ -539,7 +540,7 @@ pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void {
/// from high index (visually bottom) to low index (visually top). An optional
/// `skip_index` excludes a single layer.
fn renderLayersIntoTarget(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
target: dvui.Texture.Target,
min_index: usize,
max_index: usize,
@@ -563,12 +564,12 @@ fn renderLayersIntoTarget(
defer dvui.clipSet(prev_clip);
dvui.clipSet(image_rect);
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(Globals.allocator());
defer path.deinit();
path.addRect(image_rect, dvui.Rect.Physical.all(0));
- var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 });
- defer tris.deinit(fizzy.app.allocator);
+ var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0 });
+ defer tris.deinit(Globals.allocator());
tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 });
var order_buf: [1024]usize = undefined;
@@ -596,7 +597,7 @@ fn renderLayersIntoTarget(
/// sprite panel then draws each card (front and reflection) as a single textured
/// pass sampling this, instead of replaying the whole stack as several
/// overlapping alpha-blended fills per card. Rebuilt at most once per frame.
-pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void {
+pub fn syncPreviewComposite(file: *pixelart.internal.File) !void {
const ce = layerCompositeExtent(file);
const w = ce.w;
const h = ce.h;
@@ -658,32 +659,32 @@ pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void {
// 1) Opaque content-fill base — the transparency backdrop, matching the card.
{
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(Globals.allocator());
defer path.deinit();
path.addRect(image_rect, dvui.Rect.Physical.all(0));
- var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 });
- defer tris.deinit(fizzy.app.allocator);
+ var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 });
+ defer tris.deinit(Globals.allocator());
dvui.renderTriangles(tris, null) catch {};
}
// 2) Checkerboard tile — one tile per sprite cell (uv repeats columns × rows).
if (file.checkerboardTileTexture()) |checker| {
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(Globals.allocator());
defer path.deinit();
path.addRect(image_rect, dvui.Rect.Physical.all(0));
const tint = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5);
- var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = tint, .fade = 0 });
- defer tris.deinit(fizzy.app.allocator);
+ var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = tint, .fade = 0 });
+ defer tris.deinit(Globals.allocator());
tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = @floatFromInt(file.columns), .h = @floatFromInt(file.rows) });
dvui.renderTriangles(tris, checker) catch {};
}
// 3) Flattened layers, then selection + temp overlays — sampled 1:1.
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(Globals.allocator());
defer path.deinit();
path.addRect(image_rect, dvui.Rect.Physical.all(0));
- var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 });
- defer tris.deinit(fizzy.app.allocator);
+ var tris = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = .white, .fade = 0 });
+ defer tris.deinit(Globals.allocator());
tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 });
if (file.editor.layer_composite_target) |ct| {
@@ -700,7 +701,7 @@ pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void {
/// Returns the baked cover-flow preview composite texture for single-pass card
/// drawing, or null when the fast path isn't eligible (peek / isolate / dimming /
/// active drawing / transform). Callers fall back to the multi-pass stack.
-pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture {
+pub fn spritePreviewComposite(file: *pixelart.internal.File) ?dvui.Texture {
if (file.peek_layer_index != null) return null;
if (file.editor.isolate_layer) return null;
if (file.editor.transform != null) return null;
@@ -712,7 +713,7 @@ pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture {
return dvui.Texture.fromTargetTemp(t) catch null;
}
-pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void {
+pub fn destroyLayerCompositeResources(file: *pixelart.internal.File) void {
if (file.editor.layer_composite_target) |t| {
t.destroyLater();
file.editor.layer_composite_target = null;
@@ -728,7 +729,7 @@ pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void {
destroySplitCompositeResources(file);
}
-pub fn destroySplitCompositeResources(file: *fizzy.Internal.File) void {
+pub fn destroySplitCompositeResources(file: *pixelart.internal.File) void {
if (file.editor.split_composite_below) |t| {
t.destroyLater();
file.editor.split_composite_below = null;
@@ -766,35 +767,35 @@ pub fn renderLayers(init_opts: RenderFileOptions) !void {
var triangles = if (init_opts.quad) |q| blk: {
// Skewed quad: build a subdivided mesh so the texture follows the
// perspective instead of being mapped onto an axis-aligned rect.
- var qpath: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var qpath: dvui.Path.Builder = .init(Globals.allocator());
defer qpath.deinit();
qpath.addPoint(q[0]);
qpath.addPoint(q[1]);
qpath.addPoint(q[2]);
qpath.addPoint(q[3]);
- break :blk try fizzy.sprite_render.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{
+ break :blk try pixelart.sprite_render.pathToSubdividedQuad(qpath.build(), Globals.allocator(), .{
.subdivisions = init_opts.quad_subdivisions,
.uv = init_opts.uv,
.color_mod = init_opts.color_mod,
});
} else blk: {
- var path: dvui.Path.Builder = .init(fizzy.app.allocator);
+ var path: dvui.Path.Builder = .init(Globals.allocator());
defer path.deinit();
path.addRect(content_rs.r, init_opts.corner_radius.scale(content_rs.s, dvui.Rect.Physical));
- var t = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade });
+ var t = try path.build().fillConvexTriangles(Globals.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade });
t.uvFromRectuv(content_rs.r, init_opts.uv);
break :blk t;
};
- defer triangles.deinit(fizzy.app.allocator);
+ defer triangles.deinit(Globals.allocator());
var dimmed_triangles: ?dvui.Triangles = null;
defer {
- if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator);
+ if (dimmed_triangles) |*dt| dt.deinit(Globals.allocator());
}
if (needs_dimmed) {
- var dt = try triangles.dupe(fizzy.app.allocator);
+ var dt = try triangles.dupe(Globals.allocator());
dt.color(.gray);
dimmed_triangles = dt;
}
diff --git a/src/plugins/pixelart/sprite_render.zig b/src/plugins/pixelart/src/sprite_render.zig
similarity index 97%
rename from src/plugins/pixelart/sprite_render.zig
rename to src/plugins/pixelart/src/sprite_render.zig
index efd519dd..2b0d705e 100644
--- a/src/plugins/pixelart/sprite_render.zig
+++ b/src/plugins/pixelart/src/sprite_render.zig
@@ -2,16 +2,17 @@
//!
//! Heavy rendering on top of `core.Sprite` rects: layer compositing, file previews,
//! reflections, and water-surface meshes. Shell/workbench UI icons use
-//! `fizzy.core.Sprite.draw` from core instead of this module.
+//! `pixelart.core_sprite.draw` from core instead of this module.
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
pub const SpriteInitOptions = struct {
source: dvui.ImageSource,
- file: ?*fizzy.Internal.File = null,
+ file: ?*pixelart.internal.File = null,
alpha_source: ?dvui.ImageSource = null,
- sprite: fizzy.core.Sprite,
+ sprite: pixelart.core_sprite,
scale: f32 = 1.0,
depth: f32 = 0.0, // -1.0 is front, 1.0 is back
reflection: bool = false,
@@ -33,7 +34,7 @@ pub const SpriteInitOptions = struct {
/// Columns the reflection mesh samples across a card's width (waterline strip).
/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card.
-pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols;
+pub const reflection_surface_cols = pixelart.water_surface.reflection_surface_cols;
/// Reflection-only waterline sample across the card width (logical px). `cols_dx`
/// is horizontal refraction from surface slope; `cols_dy` is vertical height at
@@ -144,7 +145,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt
// checker + layers + selection + temp are baked into one texture once per
// frame, so each card (front and reflection) is a single textured pass
// instead of several overlapping alpha-blended fills. Null → multi-pass path.
- const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null;
+ const preview_tex: ?dvui.Texture = if (init_opts.file) |f| pixelart.render.spritePreviewComposite(f) else null;
if (init_opts.reflection) {
var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena());
@@ -237,7 +238,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt
};
if (init_opts.file) |file| {
- const preview_opts = fizzy.render.RenderFileOptions{
+ const preview_opts = pixelart.render.RenderFileOptions{
.file = file,
.rs = .{
.r = wd.contentRectScale().r,
@@ -246,7 +247,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt
.uv = uv,
.corner_radius = .all(0),
};
- fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| {
+ pixelart.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| {
dvui.log.err("Failed to render reflection layer stack: {any}", .{err});
};
@@ -328,7 +329,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt
dvui.log.err("Failed to render sprite preview composite", .{});
};
} else if (init_opts.file) |file| {
- fizzy.render.renderLayers(.{
+ pixelart.render.renderLayers(.{
.file = file,
.rs = .{
.r = wd.contentRectScale().r,
@@ -646,7 +647,7 @@ pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, optio
return builder.build();
}
-pub fn renderSprite(source: dvui.ImageSource, s: fizzy.core.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
+pub fn renderSprite(source: dvui.ImageSource, s: pixelart.core_sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void {
const atlas_size = dvui.imageSize(source) catch {
std.log.err("Failed to get atlas size", .{});
return;
diff --git a/src/plugins/pixelart/widgets/CanvasBridge.zig b/src/plugins/pixelart/src/widgets/CanvasBridge.zig
similarity index 66%
rename from src/plugins/pixelart/widgets/CanvasBridge.zig
rename to src/plugins/pixelart/src/widgets/CanvasBridge.zig
index 93d05774..7fe8869a 100644
--- a/src/plugins/pixelart/widgets/CanvasBridge.zig
+++ b/src/plugins/pixelart/src/widgets/CanvasBridge.zig
@@ -1,12 +1,13 @@
//! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the
//! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable
//! viewport; these helpers supply the pixel-art editor's wiring at the install sites.
-const fizzy = @import("../../../fizzy.zig");
-const CanvasWidget = fizzy.dvui.CanvasWidget;
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
+const CanvasWidget = pixelart.core.dvui.CanvasWidget;
/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum.
pub fn scheme() CanvasWidget.PanZoomScheme {
- return switch (fizzy.PixelArt.Settings.resolvedPanZoomScheme(&fizzy.pixelart.settings)) {
+ return switch (pixelart.Settings.resolvedPanZoomScheme(&Globals.state.settings, Globals.state.host)) {
.mouse => .mouse,
.trackpad => .trackpad,
};
@@ -14,10 +15,10 @@ pub fn scheme() CanvasWidget.PanZoomScheme {
/// Suppression hook for a main-scope canvas (the document editing surface, image previews).
pub fn mainSuppressed(_: ?*anyopaque) bool {
- return fizzy.dvui.canvasPointerInputSuppressed();
+ return pixelart.core.dvui.canvasPointerInputSuppressed();
}
/// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout).
pub fn dialogSuppressed(_: ?*anyopaque) bool {
- return fizzy.dvui.dialogCanvasPointerInputSuppressed();
+ return pixelart.core.dvui.dialogCanvasPointerInputSuppressed();
}
diff --git a/src/plugins/pixelart/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig
similarity index 95%
rename from src/plugins/pixelart/widgets/FileWidget.zig
rename to src/plugins/pixelart/src/widgets/FileWidget.zig
index 217c3209..3f4328c6 100644
--- a/src/plugins/pixelart/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/src/widgets/FileWidget.zig
@@ -1,7 +1,7 @@
const std = @import("std");
const math = std.math;
const dvui = @import("dvui");
-const fizzy = @import("../../../fizzy.zig");
+const fizzy = @import("../../../../fizzy.zig");
const builtin = @import("builtin");
const sdl3 = @import("backend").c;
@@ -16,24 +16,26 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget;
const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
-const CanvasWidget = fizzy.dvui.CanvasWidget;
+const CanvasWidget = pixelart.core.dvui.CanvasWidget;
const CanvasBridge = @import("CanvasBridge.zig");
const Workspace = fizzy.Editor.Workspace;
const CanvasData = @import("../CanvasData.zig");
const icons = @import("icons");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is
// otherwise a generic viewport; these supply the editor's behavior at install time. ----
/// Off-artboard tap (no move, no hold) → clear the current selection.
fn onEmptyTap(_: ?*anyopaque) void {
- fizzy.editor.cancel() catch {};
+ Globals.state.host.cancel() catch {};
}
/// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press
/// point. The canvas releases its own capture afterward so the menu buttons can be hovered.
fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void {
- const rm = &fizzy.pixelart.tools.radial_menu;
+ const rm = &Globals.state.tools.radial_menu;
rm.mouse_position = press_p;
rm.center = press_p;
rm.visible = true;
@@ -45,7 +47,7 @@ fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void {
/// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's
/// while the pointer tool is active — yield it instead of starting a viewport pan.
fn yieldModifiedEmptyPress(_: ?*anyopaque) bool {
- return fizzy.pixelart.tools.current == .pointer;
+ return Globals.state.tools.current == .pointer;
}
init_options: InitOptions,
@@ -76,7 +78,7 @@ const SpriteReorderMode = enum {
};
pub const InitOptions = struct {
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
center: bool = false,
};
@@ -241,7 +243,7 @@ pub fn processSample(self: *FileWidget) void {
/// Set `file.peek_layer_index` to the visible layer with an opaque pixel at `point`, mirroring
/// `sampleColorAtPoint`'s selection rule (bottommost match wins). Called every frame while the
/// sample key is held so other layers dim like during layer-list hover.
-pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void {
+pub fn peekLayerAtPoint(file: *pixelart.internal.File, point: dvui.Point) void {
if (file.editor.isolate_layer) return;
var layer_index: usize = file.layers.len;
@@ -261,7 +263,7 @@ pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void {
/// Walk visible layers for an opaque pixel at `point`. Optionally selects the hit layer,
/// sets the primary color (`apply_primary`), and/or adjusts the active tool (`change_tool`).
pub fn sampleColorAtPoint(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
point: dvui.Point,
change_layer: bool,
apply_primary: bool,
@@ -273,7 +275,7 @@ pub fn sampleColorAtPoint(
if (file.editor.isolate_layer) {
if (file.peek_layer_index) |peek_layer_index| {
min_layer_index = peek_layer_index;
- } else if (!fizzy.pixelart.tools_pane.layersHovered()) {
+ } else if (!Globals.state.tools_pane.layersHovered()) {
min_layer_index = file.selected_layer_index;
}
}
@@ -293,7 +295,7 @@ pub fn sampleColorAtPoint(
// Sample acts as a focused layer-pick: narrow multi-selection to just this layer
// so the ctrl modifier (also the layer-list multi-select toggle) doesn't accumulate.
file.editor.selected_layer_indices.clearRetainingCapacity();
- file.editor.selected_layer_indices.append(fizzy.app.allocator, layer_index) catch {};
+ file.editor.selected_layer_indices.append(Globals.allocator(), layer_index) catch {};
file.editor.layer_selection_anchor = layer_index;
}
}
@@ -307,27 +309,27 @@ pub fn sampleColorAtPoint(
if (off_canvas) {
// Sampling the empty margin outside the artboard isn't an erase — drop back
// to the pointer tool so the click reads as "leave drawing mode".
- if (fizzy.pixelart.tools.current != .pointer) {
- fizzy.pixelart.tools.set(.pointer);
+ if (Globals.state.tools.current != .pointer) {
+ Globals.state.tools.set(.pointer);
}
} else if (color[3] == 0) {
- if (fizzy.pixelart.tools.current != .eraser) {
- fizzy.pixelart.tools.set(.eraser);
+ if (Globals.state.tools.current != .eraser) {
+ Globals.state.tools.set(.eraser);
}
} else {
- fizzy.pixelart.colors.primary = color;
- if (switch (fizzy.pixelart.tools.current) {
+ Globals.state.colors.primary = color;
+ if (switch (Globals.state.tools.current) {
.pencil, .bucket => false,
else => true,
})
- fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool);
+ Globals.state.tools.set(Globals.state.tools.previous_drawing_tool);
}
} else if (apply_primary and color[3] > 0) {
- fizzy.pixelart.colors.primary = color;
+ Globals.state.colors.primary = color;
}
}
-fn sample(self: *FileWidget, file: *fizzy.Internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void {
+fn sample(self: *FileWidget, file: *pixelart.internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void {
if (!file.editor.canvas.samplePointerInViewport(screen_p)) {
self.sample_data_point = null;
return;
@@ -349,7 +351,7 @@ pub fn processAnimationSelection(self: *FileWidget) void {
switch (e.evt) {
.mouse => |me| {
- if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.pixelart.tools.current != .pointer and self.sample_data_point == null)) {
+ if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (Globals.state.tools.current != .pointer and self.sample_data_point == null)) {
if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| {
var found: bool = false;
for (file.animations.items(.frames), 0..) |frames, anim_index| {
@@ -378,7 +380,7 @@ pub fn processAnimationSelection(self: *FileWidget) void {
}
pub fn processCellReorder(self: *FileWidget) void {
- if (fizzy.pixelart.tools.current != .pointer) return;
+ if (Globals.state.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
if (self.drag_data_point != null) return;
@@ -444,12 +446,12 @@ pub fn processCellReorder(self: *FileWidget) void {
if (self.removed_sprite_indices) |removed_sprite_indices| {
if (self.insert_before_sprite_indices) |insert_before_sprite_indices| {
- fizzy.app.allocator.free(insert_before_sprite_indices);
+ Globals.allocator().free(insert_before_sprite_indices);
self.insert_before_sprite_indices = null;
}
// This will actually trigger the drag/drop
- var insert_before_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch {
+ var insert_before_sprite_indices = Globals.allocator().alloc(usize, file.editor.selected_sprites.count()) catch {
dvui.log.err("Failed to allocate insert before sprite indices", .{});
return;
};
@@ -474,11 +476,11 @@ pub fn processCellReorder(self: *FileWidget) void {
file.history.append(.{
.reorder_cell = .{
- .removed_sprite_indices = fizzy.app.allocator.dupe(usize, removed_sprite_indices) catch {
+ .removed_sprite_indices = Globals.allocator().dupe(usize, removed_sprite_indices) catch {
dvui.log.err("Failed to duplicate removed sprite indices", .{});
return;
},
- .insert_before_sprite_indices = fizzy.app.allocator.dupe(usize, insert_before_sprite_indices) catch {
+ .insert_before_sprite_indices = Globals.allocator().dupe(usize, insert_before_sprite_indices) catch {
dvui.log.err("Failed to duplicate insert before sprite indices", .{});
return;
},
@@ -502,7 +504,7 @@ pub fn processCellReorder(self: *FileWidget) void {
dvui.cursorSet(.hand);
defer e.handle(@src(), file.editor.canvas.scroll_container.data());
if (self.removed_sprite_indices == null and file.editor.selected_sprites.count() > 0) {
- var removed_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch {
+ var removed_sprite_indices = Globals.allocator().alloc(usize, file.editor.selected_sprites.count()) catch {
dvui.log.err("Failed to allocate removed sprite indices", .{});
return;
};
@@ -529,7 +531,7 @@ pub fn processCellReorder(self: *FileWidget) void {
///
/// Supports add/remove, drag selection, etc.
pub fn processSpriteSelection(self: *FileWidget) void {
- if (fizzy.pixelart.tools.current != .pointer) return;
+ if (Globals.state.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -604,7 +606,7 @@ pub fn processSpriteSelection(self: *FileWidget) void {
file.editor.primary_sprite_index = sprite_index;
}
} else if (!file.editor.canvas.hovered) {
- fizzy.editor.cancel() catch {
+ Globals.state.host.cancel() catch {
dvui.log.err("Failed to cancel", .{});
};
}
@@ -706,7 +708,7 @@ fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared {
const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id;
const cw = dvui.currentWindow();
- const tool_not_pointer = fizzy.pixelart.tools.current != .pointer;
+ const tool_not_pointer = Globals.state.tools.current != .pointer;
const mod_shift = cw.modifiers.matchBind("shift");
const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
const sample_active = self.sample_data_point != null;
@@ -878,10 +880,10 @@ pub fn drawSpriteBubbles(self: *FileWidget) void {
const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id;
const cw = dvui.currentWindow();
const drag_sprite_selection = dvui.dragName("sprite_selection_drag");
- const tool_not_pointer = fizzy.pixelart.tools.current != .pointer;
+ const tool_not_pointer = Globals.state.tools.current != .pointer;
const mod_shift = cw.modifiers.matchBind("shift");
const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd");
- const radial_visible = fizzy.pixelart.tools.radial_menu.visible;
+ const radial_visible = Globals.state.tools.radial_menu.visible;
const sample_active = self.sample_data_point != null;
const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or
self.init_options.file.editor.canvas.gestureActive();
@@ -1097,7 +1099,7 @@ fn bubbleSpriteDataRect(col_in_row: usize, base_y: f32, col_w: f32, row_h: f32)
/// When `accs` is null and `shadow_only` is false, only UI elements are drawn.
fn drawSpriteBubbleForRow(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
sprite_index: usize,
sprite_rect: dvui.Rect,
accs: ?*BubbleAccs,
@@ -1134,7 +1136,7 @@ fn drawSpriteBubbleForRow(
if (animation_index) |ai| {
const id = file.animations.get(ai).id;
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
color = palette.getDVUIColor(@intCast(id));
}
if (file.selected_animation_index == ai) {
@@ -1440,7 +1442,7 @@ pub fn drawSpriteBubble(
var add_rem_message: ?[]const u8 = null;
var border_color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
if (self.init_options.file.selected_animation_index) |index| {
border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id));
add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch {
@@ -1543,7 +1545,7 @@ pub fn drawSpriteBubble(
var anim = self.init_options.file.animations.get(anim_index);
- var frames = std.array_list.Managed(fizzy.Animation.Frame).init(fizzy.app.allocator);
+ var frames = std.array_list.Managed(pixelart.Animation.Frame).init(Globals.allocator());
frames.appendSlice(anim.frames) catch {
dvui.log.err("Failed to append frames", .{});
return false;
@@ -1620,7 +1622,7 @@ pub fn drawSpriteBubble(
self.init_options.file.history.append(.{
.animation_frames = .{
.index = anim_index,
- .frames = fizzy.app.allocator.dupe(fizzy.Animation.Frame, anim.frames) catch {
+ .frames = Globals.allocator().dupe(pixelart.Animation.Frame, anim.frames) catch {
dvui.log.err("Failed to dupe frames", .{});
return false;
},
@@ -1629,7 +1631,7 @@ pub fn drawSpriteBubble(
dvui.log.err("Failed to append history", .{});
};
- fizzy.app.allocator.free(anim.frames);
+ Globals.allocator().free(anim.frames);
anim.frames = frames.toOwnedSlice() catch {
dvui.log.err("Failed to free frames", .{});
return false;
@@ -1642,12 +1644,12 @@ pub fn drawSpriteBubble(
self.init_options.file.selected_animation_index = anim_index;
self.init_options.file.collapseAnimationSelectionToPrimary();
self.init_options.file.editor.animations_scroll_to_index = anim_index;
- fizzy.pixelart.sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
- fizzy.pixelart.host.setActiveSidebarView(@import("../plugin.zig").view_sprites);
+ Globals.state.sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index];
+ Globals.state.host.setActiveSidebarView(@import("../plugin.zig").view_sprites);
var anim = self.init_options.file.animations.get(anim_index);
if (anim.frames.len == 0) {
- anim.appendFrame(fizzy.app.allocator, .{ .sprite_index = sprite_index, .ms = temp_ms }) catch {
+ anim.appendFrame(Globals.allocator(), .{ .sprite_index = sprite_index, .ms = temp_ms }) catch {
dvui.log.err("Failed to append frame", .{});
return false;
};
@@ -1781,7 +1783,7 @@ pub fn drawSpriteBubble(
/// Draw the highlight colored selection box for each selected sprite.
pub fn drawSpriteSelection(self: *FileWidget) void {
- if (fizzy.pixelart.tools.current != .pointer) return;
+ if (Globals.state.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -1911,8 +1913,8 @@ fn strokePolylineDashedPhysical(
}
fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void {
- if (fizzy.pixelart.tools.current != .selection) return;
- if (fizzy.pixelart.tools.selection_mode != .box) return;
+ if (Globals.state.tools.current != .selection) return;
+ if (Globals.state.tools.selection_mode != .box) return;
const start = self.drag_data_point orelse return;
if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return;
@@ -1957,8 +1959,8 @@ fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void {
/// Preview for rectangular selection while dragging (box mode).
fn applySelectionBoxPreview(
- file: *fizzy.Internal.File,
- active_layer: *const fizzy.Internal.Layer,
+ file: *pixelart.internal.File,
+ active_layer: *const pixelart.internal.Layer,
start: dvui.Point,
end: dvui.Point,
mod: dvui.enums.Mod,
@@ -2001,7 +2003,7 @@ fn applySelectionBoxPreview(
/// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove.
/// The selection uses the same logic as the stroke tool to brush the selection over existing pixels.
pub fn processSelection(self: *FileWidget) void {
- if (switch (fizzy.pixelart.tools.current) {
+ if (switch (Globals.state.tools.current) {
.selection,
=> false,
else => true,
@@ -2024,7 +2026,7 @@ pub fn processSelection(self: *FileWidget) void {
// Pixel mode: draw the committed selection before handling events (brush preview layers on top).
// Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing
// here would use stale data until the next frame. Box repaints from the current mask after events.
- if (fizzy.pixelart.tools.selection_mode == .pixel or fizzy.pixelart.tools.selection_mode == .color) {
+ if (Globals.state.tools.selection_mode == .pixel or Globals.state.tools.selection_mode == .color) {
@memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 });
file.editor.temporary_layer.clearMask();
@@ -2044,21 +2046,21 @@ pub fn processSelection(self: *FileWidget) void {
switch (e.evt) {
.key => |ke| {
var update: bool = false;
- if (fizzy.pixelart.tools.selection_mode == .pixel) {
+ if (Globals.state.tools.selection_mode == .pixel) {
if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.pixelart.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1)
- fizzy.pixelart.tools.stroke_size += 1;
+ if (Globals.state.tools.stroke_size < pixelart.Tools.max_brush_size - 1)
+ Globals.state.tools.stroke_size += 1;
- fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
+ Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size);
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
update = true;
}
if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.pixelart.tools.stroke_size > 1)
- fizzy.pixelart.tools.stroke_size -= 1;
+ if (Globals.state.tools.stroke_size > 1)
+ Globals.state.tools.stroke_size -= 1;
- fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
+ Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size);
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
update = true;
}
@@ -2081,7 +2083,7 @@ pub fn processSelection(self: *FileWidget) void {
.temporary,
.{
.mask_only = true,
- .stroke_size = fizzy.pixelart.tools.stroke_size,
+ .stroke_size = Globals.state.tools.stroke_size,
},
);
@@ -2099,8 +2101,8 @@ pub fn processSelection(self: *FileWidget) void {
const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p);
if (me.action == .position) {
- const box_mode = fizzy.pixelart.tools.selection_mode == .box;
- const color_mode = fizzy.pixelart.tools.selection_mode == .color;
+ const box_mode = Globals.state.tools.selection_mode == .box;
+ const color_mode = Globals.state.tools.selection_mode == .color;
const is_drag = dvui.dragging(me.p, "stroke_drag") != null;
const box_drag = box_mode and is_drag and self.drag_data_point != null;
@@ -2151,7 +2153,7 @@ pub fn processSelection(self: *FileWidget) void {
.temporary,
.{
.mask_only = true,
- .stroke_size = fizzy.pixelart.tools.stroke_size,
+ .stroke_size = Globals.state.tools.stroke_size,
},
);
@@ -2182,7 +2184,7 @@ pub fn processSelection(self: *FileWidget) void {
if (!widget_active) continue;
e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data());
- if (fizzy.pixelart.tools.selection_mode == .color) {
+ if (Globals.state.tools.selection_mode == .color) {
// Only clear the mask if we don't have ctrl/cmd pressed
if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
file.editor.selection_layer.clearMask();
@@ -2200,14 +2202,14 @@ pub fn processSelection(self: *FileWidget) void {
if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift"))
file.editor.selection_layer.clearMask();
- if (fizzy.pixelart.tools.selection_mode == .box) {
+ if (Globals.state.tools.selection_mode == .box) {
self.drag_data_point = current_point;
} else {
file.selectPoint(
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.pixelart.tools.stroke_size,
+ .stroke_size = Globals.state.tools.stroke_size,
},
);
@@ -2220,23 +2222,23 @@ pub fn processSelection(self: *FileWidget) void {
dvui.captureMouse(null, e.num);
dvui.dragEnd();
- if (fizzy.pixelart.tools.selection_mode == .box) {
+ if (Globals.state.tools.selection_mode == .box) {
if (self.drag_data_point) |start| {
file.selectRectBetweenPoints(
start,
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.pixelart.tools.stroke_size,
+ .stroke_size = Globals.state.tools.stroke_size,
},
);
}
- } else if (fizzy.pixelart.tools.selection_mode != .color) {
+ } else if (Globals.state.tools.selection_mode != .color) {
file.selectPoint(
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.pixelart.tools.stroke_size,
+ .stroke_size = Globals.state.tools.stroke_size,
},
);
}
@@ -2268,14 +2270,14 @@ pub fn processSelection(self: *FileWidget) void {
});
}
- if (fizzy.pixelart.tools.selection_mode == .pixel) {
+ if (Globals.state.tools.selection_mode == .pixel) {
if (self.drag_data_point) |previous_point| {
file.selectLine(
previous_point,
current_point,
.{
.value = !me.mod.matchBind("shift"),
- .stroke_size = fizzy.pixelart.tools.stroke_size,
+ .stroke_size = Globals.state.tools.stroke_size,
},
);
}
@@ -2290,7 +2292,7 @@ pub fn processSelection(self: *FileWidget) void {
}
}
- if (fizzy.pixelart.tools.selection_mode == .box) {
+ if (Globals.state.tools.selection_mode == .box) {
const mouse_pt = dvui.currentWindow().mouse_pt;
const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null;
if (!(is_drag and self.drag_data_point != null)) {
@@ -2312,7 +2314,7 @@ pub fn processSelection(self: *FileWidget) void {
fn processStrokeDragSegment(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
previous_point: dvui.Point,
current_point: dvui.Point,
screen_pt: dvui.Point.Physical,
@@ -2373,7 +2375,7 @@ fn processStrokeDragSegment(
.stroke_size = stroke_size,
},
);
- fizzy.perf.draw_event_count += 1;
+ pixelart.perf.draw_event_count += 1;
} else |err| {
dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err});
}
@@ -2388,7 +2390,7 @@ fn processStrokeDragSegment(
{
if (self.sample_data_point == null or color[3] == 0) {
clearTempPreview(&file.editor);
- const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
+ const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
file.drawPoint(
current_point,
.temporary,
@@ -2409,12 +2411,12 @@ fn processStrokeDragSegment(
/// Supports using shift to draw a line between two points, and increasing/decreasing stroke size
pub fn processStroke(self: *FileWidget) void {
const file = self.init_options.file;
- const stroke_size = fizzy.pixelart.tools.stroke_size;
+ const stroke_size = Globals.state.tools.stroke_size;
const widget_active = self.active();
if (self.cell_reorder_point != null) return;
- if (switch (fizzy.pixelart.tools.current) {
+ if (switch (Globals.state.tools.current) {
.pencil,
.eraser,
=> false,
@@ -2423,8 +2425,8 @@ pub fn processStroke(self: *FileWidget) void {
if (self.sample_key_down or self.right_mouse_down) return;
- const color: [4]u8 = switch (fizzy.pixelart.tools.current) {
- .pencil => fizzy.pixelart.colors.primary,
+ const color: [4]u8 = switch (Globals.state.tools.current) {
+ .pencil => Globals.state.colors.primary,
.eraser => [_]u8{ 0, 0, 0, 0 },
else => unreachable,
};
@@ -2569,7 +2571,7 @@ pub fn processStroke(self: *FileWidget) void {
self.sample_data_point == null)
{
clearTempPreview(&file.editor);
- const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
+ const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
file.drawPoint(
current_point,
.temporary,
@@ -2595,10 +2597,10 @@ pub fn processStroke(self: *FileWidget) void {
/// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color,
/// or without modifiers to flood fill the layer with the new color.
pub fn processFill(self: *FileWidget) void {
- if (fizzy.pixelart.tools.current != .bucket) return;
+ if (Globals.state.tools.current != .bucket) return;
if (self.sample_key_down) return;
const file = self.init_options.file;
- const color = fizzy.pixelart.colors.primary;
+ const color = Globals.state.colors.primary;
const widget_active = self.active();
// Skip the cursor-follow temp preview on touch: the finger occludes the pixel and
@@ -2608,7 +2610,7 @@ pub fn processFill(self: *FileWidget) void {
self.sample_data_point == null)
{
clearTempPreview(&file.editor);
- const temp_color = if (fizzy.pixelart.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
+ const temp_color = if (Globals.state.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 };
const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt);
file.drawPoint(
fill_preview_pt,
@@ -2681,7 +2683,7 @@ pub fn processTransform(self: *FileWidget) void {
triangles.rotate(.{ .x = transform.point(.pivot).x, .y = transform.point(.pivot).y }, transform.rotation);
for (transform.data_points[0..6], 0..) |*data_point, point_index| {
- const transform_point = @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index));
+ const transform_point = @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index));
const screen_point = if (point_index < 4) file.editor.canvas.screenFromDataPoint(.{ .x = triangles.vertexes[point_index].pos.x, .y = triangles.vertexes[point_index].pos.y }) else file.editor.canvas.screenFromDataPoint(data_point.*);
var screen_rect = dvui.Rect.Physical.fromPoint(screen_point);
@@ -2698,7 +2700,7 @@ pub fn processTransform(self: *FileWidget) void {
if (screen_rect.contains(dvui.currentWindow().mouse_pt)) {
dvui.cursorSet(.hand);
} else if (transform.active_point) |active_point| {
- if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) {
+ if (active_point == @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index))) {
dvui.cursorSet(.hand);
}
}
@@ -2793,7 +2795,7 @@ pub fn processTransform(self: *FileWidget) void {
new_point.y = @round(new_point.y);
// Now we have to un-rotate the vertex and set the original location
- new_point = fizzy.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation);
+ new_point = pixelart.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation);
const opposite_index: usize = switch (point_index) {
0 => 2,
@@ -2844,8 +2846,8 @@ pub fn processTransform(self: *FileWidget) void {
const opposite_point = &transform.data_points[opposite_index];
- var rotation_direction: dvui.Point = fizzy.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0);
- var rotation_perp: dvui.Point = fizzy.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0);
+ var rotation_direction: dvui.Point = pixelart.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0);
+ var rotation_perp: dvui.Point = pixelart.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0);
// Calculate the difference between the adjacent points and the new point
@@ -2899,7 +2901,7 @@ pub fn processTransform(self: *FileWidget) void {
transform.rotation = std.math.degreesToRadians(@round(std.math.radiansToDegrees(transform.start_rotation + (angle - drag_angle))));
if (me.mod.matchBind("ctrl/cmd")) { // Lock rotation to cardinal directions
- const direction = fizzy.math.Direction.fromRadians(transform.rotation);
+ const direction = pixelart.math.Direction.fromRadians(transform.rotation);
transform.rotation = switch (direction) {
.n => std.math.pi / 2.0,
.ne => std.math.pi / 4.0,
@@ -3037,7 +3039,7 @@ pub fn drawTransform(self: *FileWidget) void {
}
var centroid = transform.centroid();
- centroid = fizzy.math.rotate(centroid, transform.point(.pivot).*, transform.rotation);
+ centroid = pixelart.math.rotate(centroid, transform.point(.pivot).*, transform.rotation);
// Full-sprite center guides (magenta). When ortho cell dimensions are shown, centering is
// indicated on those dimension lines (blue) instead — avoids overlapping magenta guides.
@@ -3308,7 +3310,7 @@ pub fn drawTransform(self: *FileWidget) void {
const bottom_left_v = triangles.vertexes[3].pos;
const bottom_right_v = triangles.vertexes[2].pos;
- const offset_v = fizzy.math.rotate(
+ const offset_v = pixelart.math.rotate(
dvui.Point{ .x = label_off_screen, .y = 0 },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3320,7 +3322,7 @@ pub fn drawTransform(self: *FileWidget) void {
const simple_v = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_h_f)))}) catch "—";
renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v));
- const offset_h = fizzy.math.rotate(
+ const offset_h = pixelart.math.rotate(
dvui.Point{ .x = 0, .y = -label_off_screen },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3337,7 +3339,7 @@ pub fn drawTransform(self: *FileWidget) void {
const bottom_left = triangles.vertexes[3].pos;
const bottom_right = triangles.vertexes[2].pos;
- const offset_v = fizzy.math.rotate(
+ const offset_v = pixelart.math.rotate(
dvui.Point{ .x = label_off_screen, .y = 0 },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3353,7 +3355,7 @@ pub fn drawTransform(self: *FileWidget) void {
) catch "—";
renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v));
- const offset_h = fizzy.math.rotate(
+ const offset_h = pixelart.math.rotate(
dvui.Point{ .x = 0, .y = -label_off_screen },
.{ .x = 0, .y = 0 },
transform.rotation,
@@ -3453,7 +3455,7 @@ pub fn drawTransform(self: *FileWidget) void {
var color = dvui.themeGet().color(.window, .text);
if (transform.active_point) |active_point| {
- if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) {
+ if (active_point == @as(pixelart.Transform.TransformPoint, @enumFromInt(point_index))) {
color = dvui.themeGet().color(.highlight, .fill);
}
} else if (screen_rect.contains(dvui.currentWindow().mouse_pt)) {
@@ -3563,7 +3565,7 @@ fn doubleStrokeDimensionTickColor(points: []const dvui.Point.Physical, thickness
/// axis-aligned quad (4 vertices, 2 triangles) submitted via one `renderTriangles`.
fn drawBatchedGridLines(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
columns: usize,
rows: usize,
grid_color: dvui.Color,
@@ -3689,7 +3691,7 @@ fn appendLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br:
}
/// Viewport in data space + row/column index range for culling (matches bubble / grid logic).
-fn fileCanvasVisibleGridParams(file: *fizzy.Internal.File) ?struct {
+fn fileCanvasVisibleGridParams(file: *pixelart.internal.File) ?struct {
visible_data: dvui.Rect,
row_h: f32,
col_w: f32,
@@ -3776,7 +3778,7 @@ fn appendHorizontalGridRunsForRow(
/// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint).
fn drawBatchedResizeOverlayGrid(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
columns: usize,
layer_rect: dvui.Rect,
grid_thickness: f32,
@@ -3863,8 +3865,8 @@ fn checkerboardVertexColor(
}
/// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation).
-fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color {
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
+fn spriteAnimationPaletteColor(file: *pixelart.internal.File, sprite_index: usize) ?dvui.Color {
+ if (Globals.state.colors.file_tree_palette) |*palette| {
var animation_index: ?usize = null;
if (file.selected_animation_index) |selected_animation_index| {
@@ -3896,8 +3898,8 @@ fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize)
}
fn checkerboardCellCornerColor(
- effect: fizzy.PixelArt.Settings.TransparencyEffect,
- file: *fizzy.Internal.File,
+ effect: pixelart.Settings.TransparencyEffect,
+ file: *pixelart.internal.File,
sprite_index: usize,
c_tl: dvui.Color,
c_tr: dvui.Color,
@@ -3938,10 +3940,10 @@ fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr:
}
/// Same tint as the batched checkerboard for the cell under `sprite_index` (center UV), for bubbles etc.
-fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color {
+fn checkerboardTintAtSpriteCellCenter(file: *pixelart.internal.File, sprite_index: usize) dvui.Color {
const pal = checkerboardGridPalette();
const tone = pal.tone;
- switch (fizzy.pixelart.settings.transparency_effect) {
+ switch (Globals.state.settings.transparency_effect) {
.none => return tone,
.rainbow => {
const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 };
@@ -3960,11 +3962,11 @@ fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index:
/// Checkerboard behind layers: one batched quad per visible cell (UV 0..1 per cell — vertex colors
/// vary per cell for rainbow / animation effects, which is why this isn't a single wrapped quad).
-fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void {
+fn drawCheckerboardCellsBatched(file: *pixelart.internal.File) void {
const n = file.spriteCount();
if (n == 0) return;
- const te = fizzy.pixelart.settings.transparency_effect;
+ const te = Globals.state.settings.transparency_effect;
const pal = checkerboardGridPalette();
const tone = pal.tone;
const rs = file.editor.canvas.screen_rect_scale;
@@ -4063,7 +4065,7 @@ fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void {
}
pub fn active(self: *FileWidget) bool {
- if (fizzy.editor.activeFile()) |file| {
+ if (Globals.state.docs.activeFile(Globals.state.host)) |file| {
if (file.id == self.init_options.file.id) {
return true;
}
@@ -4072,9 +4074,9 @@ pub fn active(self: *FileWidget) bool {
}
pub fn drawCursor(self: *FileWidget) void {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
- if (fizzy.pixelart.tools.current == .pointer and self.sample_data_point == null) return;
- if (fizzy.pixelart.tools.radial_menu.visible) return;
+ if (pixelart.core.dvui.canvasPointerInputSuppressed()) return;
+ if (Globals.state.tools.current == .pointer and self.sample_data_point == null) return;
+ if (Globals.state.tools.radial_menu.visible) return;
if (self.init_options.file.editor.transform != null) return;
if (self.init_options.file.editor.canvas.gestureActive()) return;
if (self.init_options.file.editor.canvas.trackpadPinching()) return;
@@ -4113,20 +4115,20 @@ pub fn drawCursor(self: *FileWidget) void {
const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point);
- const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
- .box => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.box_selection_default],
- .pixel => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pixel_selection_default],
- .color => if (subtract) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.color_selection_default],
+ const selection_sprite = switch (Globals.state.tools.selection_mode) {
+ .box => if (subtract) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_rem_default] else if (add) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_add_default] else Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.box_selection_default],
+ .pixel => if (subtract) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_rem_default] else if (add) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_add_default] else Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pixel_selection_default],
+ .color => if (subtract) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_rem_default] else if (add) Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_add_default] else Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.color_selection_default],
};
- if (switch (fizzy.pixelart.tools.current) {
- .pencil => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.pencil_default],
- .eraser => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.eraser_default],
- .bucket => fizzy.pixelart.host.uiAtlas().sprites[fizzy.atlas.sprites.bucket_default],
+ if (switch (Globals.state.tools.current) {
+ .pencil => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.pencil_default],
+ .eraser => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.eraser_default],
+ .bucket => Globals.state.host.uiAtlas().sprites[pixelart.atlas.sprites.bucket_default],
.selection => selection_sprite,
else => null,
}) |sprite| {
- const atlas_size = dvui.imageSize(fizzy.pixelart.host.uiAtlas().source) catch {
+ const atlas_size = dvui.imageSize(Globals.state.host.uiAtlas().source) catch {
dvui.log.err("Failed to get atlas size", .{});
return;
};
@@ -4164,7 +4166,7 @@ pub fn drawCursor(self: *FileWidget) void {
const rs = box.data().rectScale();
- dvui.renderImage(fizzy.pixelart.host.uiAtlas().source, rs, .{
+ dvui.renderImage(Globals.state.host.uiAtlas().source, rs, .{
.uv = uv,
}) catch {
dvui.log.err("Failed to render cursor image", .{});
@@ -4215,7 +4217,7 @@ fn mapDataRectToPhysicalStrip(sr: dvui.Rect, parent_data: dvui.Rect, parent_phys
/// Draw the checkerboard alpha pattern into `dest_phys`. Uses wrap=.repeat on the tile texture so
/// the entire region is one quad with UV scaled so each `cw × ch` of data space spans one tile.
fn drawSampleMagnifierCheckerboardTiles(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
region_data: dvui.Rect,
dest_phys: dvui.Rect.Physical,
scale: f32,
@@ -4242,7 +4244,7 @@ fn drawSampleMagnifierCheckerboardTiles(
/// Build checkerboard + layers into an offscreen target. Layer composites are synced on the screen
/// target first so `renderLayers` does not rebind this target via `syncLayerComposite`.
fn drawSampleMagnifierCompositeBuild(
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
region_data: dvui.Rect,
content_rs: dvui.RectScale,
file_w: f32,
@@ -4254,18 +4256,18 @@ fn drawSampleMagnifierCompositeBuild(
const h: u32 = @intFromFloat(@max(@ceil(content_rs.r.h), 1));
const layer_region = region_data.intersect(dvui.Rect{ .x = 0, .y = 0, .w = file_w, .h = file_h });
- const layer_opts_base = fizzy.render.RenderFileOptions{
+ const layer_opts_base = pixelart.render.RenderFileOptions{
.file = file,
.rs = content_rs,
.allow_peek = false,
};
// Refresh cached layer composites on the screen target (not the magnifier target).
- fizzy.render.ensureLayerCompositesForPreview(layer_opts_base) catch {
+ pixelart.render.ensureLayerCompositesForPreview(layer_opts_base) catch {
dvui.log.err("Failed to sync layer composites for magnifier", .{});
};
- const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
dvui.log.err("Failed to create magnifier composite target", .{});
return null;
};
@@ -4291,7 +4293,7 @@ fn drawSampleMagnifierCompositeBuild(
.w = layer_region.w / file_w,
.h = layer_region.h / file_h,
};
- fizzy.render.renderLayersMagnifierSample(.{
+ pixelart.render.renderLayersMagnifierSample(.{
.file = file,
.rs = .{ .r = layer_phys, .s = 1.0 },
.uv = uv_rect,
@@ -4392,9 +4394,9 @@ fn drawSampleMagnifierPresent(
} }, .{ .thickness = 2, .color = .black });
}
-pub fn drawSampleMagnifier(file: *fizzy.Internal.File, data_point: dvui.Point) void {
+pub fn drawSampleMagnifier(file: *pixelart.internal.File, data_point: dvui.Point) void {
const canvas = &file.editor.canvas;
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
+ if (pixelart.core.dvui.canvasPointerInputSuppressed()) return;
if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
_ = dvui.cursorSet(.hidden);
@@ -4492,8 +4494,8 @@ pub fn updateActiveLayerMask(self: *FileWidget) void {
}
pub fn drawLayers(self: *FileWidget) void {
- const perf_t0 = fizzy.perf.drawLayersBegin();
- defer fizzy.perf.drawLayersEnd(perf_t0);
+ const perf_t0 = pixelart.perf.drawLayersBegin();
+ defer pixelart.perf.drawLayersEnd(perf_t0);
var file = self.init_options.file;
var columns: usize = file.columns;
@@ -4567,7 +4569,7 @@ pub fn drawLayers(self: *FileWidget) void {
self.drawColumnRowReorderPreview();
return;
} else {
- fizzy.render.renderLayers(.{
+ pixelart.render.renderLayers(.{
.file = file,
.rs = .{
.r = self.init_options.file.editor.canvas.rect,
@@ -4619,14 +4621,14 @@ pub fn drawLayers(self: *FileWidget) void {
}
// Draw the selection box for the selected sprites
- if (fizzy.pixelart.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) {
+ if (Globals.state.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) {
var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
while (iter.next()) |i| {
const sprite_rect = file.spriteRect(i);
const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect);
// Draw the origins when in the sprites pane
- if (fizzy.pixelart.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) {
+ if (Globals.state.host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) {
const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] };
const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y };
@@ -4659,7 +4661,7 @@ const ReorderAxis = enum { columns, rows };
/// Checkerboard alpha over each cell of the floating column/row, matching `drawCheckerboardCellsBatched` tint/UVs at half opacity.
fn drawCheckerboardReorderFloatingStrip(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
removed_data_rect: dvui.Rect,
strip_phys: dvui.Rect.Physical,
axis: ReorderAxis,
@@ -4689,7 +4691,7 @@ fn drawCheckerboardReorderFloatingStrip(
const c_tr = pal.c_tr;
const c_bl = pal.c_bl;
const c_br = pal.c_br;
- const te = fizzy.pixelart.settings.transparency_effect;
+ const te = Globals.state.settings.transparency_effect;
const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0);
const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0);
@@ -4781,7 +4783,7 @@ fn drawColumnRowReorderPreview(self: *FileWidget) void {
fn renderLayersInDataRect(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
data_rect: dvui.Rect,
screen_rect_override: ?dvui.Rect.Physical,
) void {
@@ -4789,7 +4791,7 @@ fn renderLayersInDataRect(
const w = @as(f32, @floatFromInt(file.width()));
const h = @as(f32, @floatFromInt(file.height()));
const r = screen_rect_override orelse file.editor.canvas.screenFromDataRect(data_rect);
- fizzy.render.renderLayers(.{
+ pixelart.render.renderLayers(.{
.file = file,
.rs = .{ .r = r, .s = scale },
.uv = .{
@@ -4803,7 +4805,7 @@ fn renderLayersInDataRect(
fn reorderSegmentRects(
axis: ReorderAxis,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
target_index: usize,
removed_index: usize,
target_rect: dvui.Rect,
@@ -4877,7 +4879,7 @@ fn reorderSegmentRects(
fn drawReorderPreviewForAxis(
self: *FileWidget,
- file: *fizzy.Internal.File,
+ file: *pixelart.internal.File,
axis: ReorderAxis,
target_index: ?usize,
removed_index: usize,
@@ -5027,10 +5029,10 @@ fn drawReorderPreviewForAxis(
});
{
- fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{
+ pixelart.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{
.opacity = 0.5,
});
- fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{
+ pixelart.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{
.opacity = 0.5,
});
}
@@ -5288,22 +5290,22 @@ pub fn drawCellReorderPreview(self: *FileWidget) void {
if (left_index) |left_index_value| {
if (!temp_selected_sprite.isSet(left_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 });
+ pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 });
}
}
if (right_index) |right_index_value| {
if (!temp_selected_sprite.isSet(right_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 });
+ pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 });
}
}
if (top_index) |top_index_value| {
if (!temp_selected_sprite.isSet(top_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 });
+ pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 });
}
}
if (bottom_index) |bottom_index_value| {
if (!temp_selected_sprite.isSet(bottom_index_value)) {
- fizzy.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 });
+ pixelart.core.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 });
}
}
}
@@ -5483,7 +5485,7 @@ fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void {
}
pub fn processResize(self: *FileWidget) void {
- if (fizzy.pixelart.tools.current != .pointer) return;
+ if (Globals.state.tools.current != .pointer) return;
if (self.init_options.file.editor.transform != null) return;
if (self.sample_data_point != null) return;
@@ -5765,7 +5767,7 @@ pub fn processEvents(self: *FileWidget) void {
const canvas_ptr = &self.init_options.file.editor.canvas;
const mouse_pt = dvui.currentWindow().mouse_pt;
- canvas_ptr.hovered = !fizzy.dvui.canvasPointerInputSuppressed() and
+ canvas_ptr.hovered = !pixelart.core.dvui.canvasPointerInputSuppressed() and
canvas_ptr.pointerOverDrawable(mouse_pt);
// Cursor-leave: when hover transitions true → false, the last brush/fill preview
@@ -5807,18 +5809,18 @@ pub fn processEvents(self: *FileWidget) void {
// current single touch will become one — otherwise the bucket/pencil hover preview would
// flash on the pinned finger as the user starts a pan gesture.
if (self.hovered() and !self.init_options.file.editor.canvas.gestureActive()) {
- const pe_t0 = fizzy.perf.processEventsBegin();
- defer fizzy.perf.processEventsEnd(pe_t0);
+ const pe_t0 = pixelart.perf.processEventsBegin();
+ defer pixelart.perf.processEventsEnd(pe_t0);
resetTempLayerPreview(&self.init_options.file.editor);
{
- const mask_t0 = fizzy.perf.updateMaskBegin();
- defer fizzy.perf.updateMaskEnd(mask_t0);
+ const mask_t0 = pixelart.perf.updateMaskBegin();
+ defer pixelart.perf.updateMaskEnd(mask_t0);
self.updateActiveLayerMask();
}
- if (fizzy.pixelart.tools.current == .selection) {
+ if (Globals.state.tools.current == .selection) {
if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) {
self.init_options.file.editor.checkerboard.toggleAll();
@@ -5827,14 +5829,14 @@ pub fn processEvents(self: *FileWidget) void {
}
if (self.init_options.file.editor.transform == null) {
- const tool_t0 = fizzy.perf.toolProcessBegin();
- switch (fizzy.pixelart.tools.current) {
+ const tool_t0 = pixelart.perf.toolProcessBegin();
+ switch (Globals.state.tools.current) {
.bucket => self.processFill(),
.pencil, .eraser => self.processStroke(),
.selection => self.processSelection(),
else => {},
}
- fizzy.perf.toolProcessEnd(tool_t0);
+ pixelart.perf.toolProcessEnd(tool_t0);
}
} else if (self.hovered() and self.init_options.file.editor.canvas.gestureActive()) {
// A 2-finger gesture (or its pending evaluation) just took over. Make sure any
@@ -5889,10 +5891,10 @@ pub fn processEvents(self: *FileWidget) void {
}
// Draw shadows for the scroll container
- fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 });
- fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{});
- fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 });
- fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{});
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{});
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{});
self.drawTransform();
self.processSample();
@@ -5911,7 +5913,7 @@ pub fn deinit(self: *FileWidget) void {
}
pub fn hovered(self: *FileWidget) bool {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return false;
+ if (pixelart.core.dvui.canvasPointerInputSuppressed()) return false;
return self.init_options.file.editor.canvas.hovered;
}
@@ -5965,7 +5967,7 @@ fn tempBrushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32)
}
/// Data-space rect of the on-screen canvas, outset by brush size so edge stamps are not clipped.
-fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal.File, stroke_size: usize) dvui.Rect {
+fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const pixelart.internal.File, stroke_size: usize) dvui.Rect {
const vis = canvas.dataFromScreenRect(canvas.rect);
const m: f32 = @floatFromInt(stroke_size);
const inflated = vis.outsetAll(m);
@@ -5974,7 +5976,7 @@ fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal.
return dvui.Rect.intersect(inflated, .{ .x = 0, .y = 0, .w = iw, .h = ih });
}
-fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Rect) void {
+fn expandTempGpuDirtyRect(editor: *pixelart.internal.File.EditorData, rect: dvui.Rect) void {
if (editor.temp_gpu_dirty_rect) |existing| {
editor.temp_gpu_dirty_rect = existing.unionWith(rect);
} else {
@@ -5988,10 +5990,10 @@ fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Re
/// Clears the pixels covered by the current temp preview dirty rect, then
/// resets the tracking state. Used before redrawing the brush preview at a
/// new position.
-fn clearTempPreview(editor: *fizzy.Internal.File.EditorData) void {
+fn clearTempPreview(editor: *pixelart.internal.File.EditorData) void {
if (editor.temp_preview_dirty_rect) |dirty| {
if (dirty.w > 0 and dirty.h > 0) {
- fizzy.image.clearRect(editor.temporary_layer.source, dirty);
+ pixelart.image.clearRect(editor.temporary_layer.source, dirty);
expandTempGpuDirtyRect(editor, dirty);
}
}
@@ -5999,10 +6001,10 @@ fn clearTempPreview(editor: *fizzy.Internal.File.EditorData) void {
}
/// Clears the temporary brush preview layer and marks GPU/composite dirty.
-fn resetTempLayerPreview(editor: *fizzy.Internal.File.EditorData) void {
+fn resetTempLayerPreview(editor: *pixelart.internal.File.EditorData) void {
if (editor.temp_preview_dirty_rect) |dirty| {
if (dirty.w > 0 and dirty.h > 0) {
- fizzy.image.clearRect(editor.temporary_layer.source, dirty);
+ pixelart.image.clearRect(editor.temporary_layer.source, dirty);
expandTempGpuDirtyRect(editor, dirty);
}
editor.temp_preview_dirty_rect = null;
diff --git a/src/plugins/pixelart/widgets/ImageWidget.zig b/src/plugins/pixelart/src/widgets/ImageWidget.zig
similarity index 93%
rename from src/plugins/pixelart/widgets/ImageWidget.zig
rename to src/plugins/pixelart/src/widgets/ImageWidget.zig
index 3ce4de64..e314d129 100644
--- a/src/plugins/pixelart/widgets/ImageWidget.zig
+++ b/src/plugins/pixelart/src/widgets/ImageWidget.zig
@@ -1,5 +1,5 @@
pub const ImageWidget = @This();
-const CanvasWidget = fizzy.dvui.CanvasWidget;
+const CanvasWidget = pixelart.core.dvui.CanvasWidget;
const CanvasBridge = @import("CanvasBridge.zig");
init_options: InitOptions,
@@ -144,27 +144,27 @@ fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical)
var color: [4]u8 = .{ 0, 0, 0, 0 };
- if (fizzy.image.pixelIndex(self.init_options.source, point)) |index| {
- const c = fizzy.image.pixels(self.init_options.source)[index];
+ if (pixelart.image.pixelIndex(self.init_options.source, point)) |index| {
+ const c = pixelart.image.pixels(self.init_options.source)[index];
if (c[3] > 0) {
color = c;
}
}
- fizzy.pixelart.colors.primary = color;
+ Globals.state.colors.primary = color;
self.sample_data_point = point;
if (color[3] == 0) {
- if (fizzy.pixelart.tools.current != .eraser) {
- fizzy.pixelart.tools.set(.eraser);
+ if (Globals.state.tools.current != .eraser) {
+ Globals.state.tools.set(.eraser);
}
} else {
- fizzy.pixelart.tools.set(fizzy.pixelart.tools.previous_drawing_tool);
+ Globals.state.tools.set(Globals.state.tools.previous_drawing_tool);
}
}
pub fn drawCursor(self: *ImageWidget) void {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
+ if (pixelart.core.dvui.canvasPointerInputSuppressed()) return;
for (dvui.events()) |*e| {
if (!self.init_options.canvas.scroll_container.matchEvent(e)) {
continue;
@@ -207,7 +207,7 @@ pub fn drawSample(self: *ImageWidget) void {
}
pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data_point: dvui.Point) void {
- if (fizzy.dvui.canvasPointerInputSuppressed()) return;
+ if (pixelart.core.dvui.canvasPointerInputSuppressed()) return;
if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return;
_ = dvui.cursorSet(.hidden);
@@ -268,7 +268,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data
});
defer fw.deinit();
- const size = fizzy.image.size(source);
+ const size = pixelart.image.size(source);
const uv_rect = dvui.Rect{
.x = (data_point.x - sample_region_size / 2) / size.w,
.y = (data_point.y - sample_region_size / 2) / size.h,
@@ -319,7 +319,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data
}
fn packedAtlasCheckerboardTexture() ?dvui.Texture {
- if (fizzy.packer.atlas) |atlas| return atlas.checkerboard_tile;
+ if (Globals.packer.atlas) |atlas| return atlas.checkerboard_tile;
return null;
}
@@ -385,7 +385,7 @@ pub fn drawImage(self: *ImageWidget) void {
// by `syncTransformCachesFromWidgets` before `updateTouchGesture` runs. The mismatch
// is the visible "image moves at a different rate than the alpha layer" jitter on the
// packed-atlas preview during pinch zoom. Mirror FileWidget.drawLayers, which renders
- // its layer textures via `fizzy.render.renderLayers` against the cached `canvas.rect`
+ // its layer textures via `pixelart.render.renderLayers` against the cached `canvas.rect`
// for the same reason.
dvui.renderImage(self.init_options.source, .{
.r = self.init_options.canvas.rect,
@@ -434,10 +434,10 @@ pub fn processEvents(self: *ImageWidget) void {
self.drawImage();
- fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{});
- fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 });
- fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{});
- fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{});
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 });
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{});
+ pixelart.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 });
self.drawCursor();
self.drawSample();
@@ -469,8 +469,9 @@ const ScaleWidget = dvui.ScaleWidget;
const std = @import("std");
const math = std.math;
const dvui = @import("dvui");
-const fizzy = @import("../../../fizzy.zig");
const builtin = @import("builtin");
+const pixelart = @import("../../pixelart.zig");
+const Globals = pixelart.Globals;
test {
@import("std").testing.refAllDecls(@This());
diff --git a/src/plugins/workbench/module.zig b/src/plugins/workbench/module.zig
new file mode 100644
index 00000000..f5996363
--- /dev/null
+++ b/src/plugins/workbench/module.zig
@@ -0,0 +1,11 @@
+//! Workbench plugin compile-time module root.
+//!
+//! Wired in `build.zig` as `b.addModule("workbench", …)` (future). Shell code can
+//! import this as `@import("workbench")`. Plugin files inside `src/` import
+//! `../workbench.zig` for shared sdk/core access.
+pub const workbench = @import("workbench.zig");
+pub const plugin = @import("src/plugin.zig");
+pub const files = @import("src/files.zig");
+pub const Workspace = @import("src/Workspace.zig");
+pub const Workbench = @import("src/Workbench.zig");
+pub const FileLoadJob = @import("src/FileLoadJob.zig");
diff --git a/src/plugins/workbench/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig
similarity index 99%
rename from src/plugins/workbench/FileLoadJob.zig
rename to src/plugins/workbench/src/FileLoadJob.zig
index c2150345..2d58d3ab 100644
--- a/src/plugins/workbench/FileLoadJob.zig
+++ b/src/plugins/workbench/src/FileLoadJob.zig
@@ -15,7 +15,7 @@
//! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields.
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const perf = fizzy.perf;
diff --git a/src/plugins/workbench/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
similarity index 98%
rename from src/plugins/workbench/Workbench.zig
rename to src/plugins/workbench/src/Workbench.zig
index d799a8ef..ea1a6f11 100644
--- a/src/plugins/workbench/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -11,7 +11,7 @@
const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const files = @import("files.zig");
pub const Workbench = @This();
@@ -221,7 +221,7 @@ fn svcOpenCount(ctx: *anyopaque) usize {
fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 {
const editor = editorOf(ctx);
if (index >= editor.open_files.count()) return null;
- return editor.open_files.values()[index].path;
+ return if (editor.fileAt(index)) |file| file.path else null;
}
fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void {
return files.createFilePath(path);
diff --git a/src/plugins/workbench/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
similarity index 95%
rename from src/plugins/workbench/Workspace.zig
rename to src/plugins/workbench/src/Workspace.zig
index 000429a6..19b4cd30 100644
--- a/src/plugins/workbench/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const icons = @import("icons");
const App = fizzy.App;
@@ -183,8 +183,7 @@ fn drawTabs(self: *Workspace) void {
});
defer tabs_hbox.deinit();
- const files = fizzy.editor.open_files.values();
- const files_len = files.len;
+ const files_len = fizzy.editor.open_files.count();
// Find the neighbouring tabs (within this workspace grouping) of the active tab.
var prev_same_group_index: ?usize = null;
@@ -193,7 +192,8 @@ fn drawTabs(self: *Workspace) void {
const active_in_this_group = blk: {
if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false;
if (self.open_file_index >= files_len) break :blk false;
- if (files[self.open_file_index].editor.grouping != self.grouping) break :blk false;
+ const active_file = fizzy.editor.fileAt(self.open_file_index) orelse break :blk false;
+ if (active_file.editor.grouping != self.grouping) break :blk false;
break :blk true;
};
@@ -204,7 +204,8 @@ fn drawTabs(self: *Workspace) void {
var j: usize = active_index;
while (j > 0) {
j -= 1;
- if (files[j].editor.grouping == self.grouping) {
+ const tab_file = fizzy.editor.fileAt(j) orelse continue;
+ if (tab_file.editor.grouping == self.grouping) {
prev_same_group_index = j;
break;
}
@@ -213,14 +214,16 @@ fn drawTabs(self: *Workspace) void {
// Scan right from the active tab to find the next tab in this grouping.
j = active_index + 1;
while (j < files_len) : (j += 1) {
- if (files[j].editor.grouping == self.grouping) {
+ const tab_file = fizzy.editor.fileAt(j) orelse continue;
+ if (tab_file.editor.grouping == self.grouping) {
next_same_group_index = j;
break;
}
}
}
- for (files, 0..) |file, i| {
+ for (0..files_len) |i| {
+ const file = fizzy.editor.fileAt(i) orelse continue;
const is_fizzy_file = fizzy.Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
if (file.editor.grouping != self.grouping) continue;
@@ -494,16 +497,16 @@ pub fn processTabsDrag(self: *Workspace) void {
if (removed > fizzy.editor.open_files.count()) return;
if (removed > insert_before) {
- std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
+ std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
fizzy.editor.setActiveFile(insert_before);
} else {
if (insert_before > 0) {
- std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
+ std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]);
fizzy.editor.setActiveFile(insert_before - 1);
} else {
- std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
+ std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
fizzy.editor.setActiveFile(insert_before);
}
@@ -515,21 +518,21 @@ pub fn processTabsDrag(self: *Workspace) void {
for (fizzy.editor.workspaces.values()) |*workspace| {
if (workspace.tabs_removed_index) |removed| {
if (removed > insert_before) {
- std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
+ std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.open_files.values()[insert_before].editor.grouping = self.grouping;
+ fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping;
fizzy.editor.setActiveFile(insert_before);
} else {
if (insert_before > 0) {
- std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
+ std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]);
- fizzy.editor.open_files.values()[insert_before - 1].editor.grouping = self.grouping;
+ fizzy.editor.fileAt(insert_before - 1).?.editor.grouping = self.grouping;
fizzy.editor.setActiveFile(insert_before - 1);
} else {
- std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
+ std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.open_files.values()[insert_before].editor.grouping = self.grouping;
+ fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping;
fizzy.editor.setActiveFile(insert_before);
}
}
@@ -547,10 +550,11 @@ pub fn processTabsDrag(self: *Workspace) void {
/// Repoint `open_file_index` on workspaces that were showing the dragged tab as active.
fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void {
- const dragged_file = &editor.open_files.values()[drag_index];
+ const dragged_file = editor.fileAt(drag_index) orelse return;
if (tab_bar_workspace) |workspace| {
if (workspace.open_file_index == editor.open_files.getIndex(dragged_file.id)) {
- for (editor.open_files.values()) |f| {
+ for (editor.open_files.values()) |doc| {
+ const f = editor.fileFromDoc(doc);
if (f.editor.grouping == workspace.grouping and f.id != dragged_file.id) {
workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
break;
@@ -560,7 +564,8 @@ fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace
} else {
for (editor.workspaces.values()) |*w| {
if (w.open_file_index == drag_index) {
- for (editor.open_files.values()) |f| {
+ for (editor.open_files.values()) |doc| {
+ const f = editor.fileFromDoc(doc);
if (f.editor.grouping == w.grouping and f.id != dragged_file.id) {
w.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
break;
@@ -634,7 +639,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
+ const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
dragged_file.editor.grouping = fizzy.editor.newGroupingID();
fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
}
@@ -653,7 +658,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
+ const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
dragged_file.editor.grouping = self.grouping;
fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0;
@@ -679,7 +684,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
+ const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
dragged_file.editor.grouping = fizzy.editor.newGroupingID();
fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
}
@@ -697,7 +702,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- var dragged_file = &fizzy.editor.open_files.values()[drag_index];
+ const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
dragged_file.editor.grouping = self.grouping;
fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0;
@@ -796,7 +801,7 @@ pub fn drawCanvas(self: *Workspace) !void {
self.open_file_index = fizzy.editor.open_files.values().len - 1;
}
- const file = &fizzy.editor.open_files.values()[self.open_file_index];
+ if (fizzy.editor.fileAt(self.open_file_index)) |file| {
// The workbench owns only the content region (this container) + tab/split frame;
// bind it to the document and route the entire in-region render to the owning
// plugin (pixel art draws its rulers, overlays, and editing widget itself).
@@ -807,6 +812,7 @@ pub fn drawCanvas(self: *Workspace) !void {
if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
_ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id });
}
+ }
} else {
var box = workspaceEmptyStateCard(content_color, self.grouping);
defer box.deinit();
diff --git a/src/plugins/workbench/files.zig b/src/plugins/workbench/src/files.zig
similarity index 99%
rename from src/plugins/workbench/files.zig
rename to src/plugins/workbench/src/files.zig
index 1e07143a..cff326ac 100644
--- a/src/plugins/workbench/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -1,5 +1,5 @@
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const builtin = @import("builtin");
@@ -1258,7 +1258,8 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File
.directory => {
std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
- for (fizzy.editor.open_files.values()) |*file| {
+ for (fizzy.editor.open_files.values()) |doc| {
+ const file = fizzy.editor.fileFromDoc(doc);
if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) {
const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path";
fizzy.app.allocator.free(file.path);
diff --git a/src/plugins/workbench/plugin.zig b/src/plugins/workbench/src/plugin.zig
similarity index 98%
rename from src/plugins/workbench/plugin.zig
rename to src/plugins/workbench/src/plugin.zig
index 8e6ed926..334cf8df 100644
--- a/src/plugins/workbench/plugin.zig
+++ b/src/plugins/workbench/src/plugin.zig
@@ -3,7 +3,7 @@
//! than owning new code. Later phases move more behind it until it becomes a
//! runtime-loaded dylib. Registered from `Editor.postInit`.
const std = @import("std");
-const fizzy = @import("../../fizzy.zig");
+const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
const files = @import("files.zig");
diff --git a/src/plugins/workbench/workbench.zig b/src/plugins/workbench/workbench.zig
new file mode 100644
index 00000000..0ad8c505
--- /dev/null
+++ b/src/plugins/workbench/workbench.zig
@@ -0,0 +1,9 @@
+//! Intra-plugin import hub for the workbench plugin.
+//!
+//! Files inside `src/plugins/workbench/src/**` import this as `../workbench.zig` (or
+//! `../../workbench.zig` from nested dirs). The compile-time module root is `module.zig`.
+const std = @import("std");
+
+pub const sdk = @import("sdk");
+pub const core = @import("core");
+pub const dvui = @import("dvui");
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index db08f64d..e884d458 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -8,6 +8,7 @@
//! `Editor` type across the SDK boundary.
const std = @import("std");
const dvui = @import("dvui");
+const DocHandle = @import("DocHandle.zig");
const EditorAPI = @This();
@@ -35,6 +36,17 @@ pub const SaveDialogFilter = extern struct {
/// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled.
pub const SaveDialogCallback = *const fn (?[][:0]const u8) void;
+/// Grid dimensions for `createDocument`.
+pub const NewDocGrid = struct {
+ columns: u32 = 1,
+ rows: u32 = 1,
+ column_width: u32,
+ row_height: u32,
+};
+
+/// Web save-dialog kind (wasm only; native ignores).
+pub const WebSaveKind = enum { save, save_as };
+
ctx: *anyopaque,
vtable: *const VTable,
@@ -52,6 +64,10 @@ pub const VTable = struct {
contentOpacity: *const fn (ctx: *anyopaque) f32,
/// Whether the OS window is currently maximized (always false on web).
isMaximized: *const fn (ctx: *anyopaque) bool,
+ /// Runtime macOS detection (uses `navigator.platform` on web, `os.tag` on native).
+ isMacOS: *const fn (ctx: *anyopaque) bool,
+ /// True on native macOS/Windows where unfocused window chrome dims content opacity.
+ appliesNativeWindowOpacity: *const fn (ctx: *anyopaque) bool,
/// The explorer pane's content rect (shell layout); plugins drawn inside the explorer
/// read it to size their content. Zero rect when no shell is installed.
explorerRect: *const fn (ctx: *anyopaque) dvui.Rect,
@@ -70,6 +86,50 @@ pub const VTable = struct {
/// Shell-owned UI icon spritesheet (cursors, tool icons, logo). Stable for the
/// editor lifetime; plugins read `.source` / `.sprites` but never mutate it.
uiAtlas: *const fn (ctx: *anyopaque) UiAtlasView,
+ /// The actively focused open document, or null when none.
+ activeDoc: *const fn (ctx: *anyopaque) ?DocHandle,
+ /// Open document by ordered index (tab order), or null when out of range.
+ docByIndex: *const fn (ctx: *anyopaque, index: usize) ?DocHandle,
+ /// Open document by stable id, or null when not open.
+ docById: *const fn (ctx: *anyopaque, id: u64) ?DocHandle,
+ /// Ordered index of document `id`, or null when not open.
+ docIndex: *const fn (ctx: *anyopaque, id: u64) ?usize,
+ /// Number of open documents.
+ openDocCount: *const fn (ctx: *anyopaque) usize,
+ /// Focus the document at `index` (updates workspace tab selection).
+ setActiveDocIndex: *const fn (ctx: *anyopaque, index: usize) void,
+ /// Allocate the next shell document id (monotonic).
+ allocDocId: *const fn (ctx: *anyopaque) u64,
+
+ // ---- document editing (active file) ----
+ accept: *const fn (ctx: *anyopaque) anyerror!void,
+ cancel: *const fn (ctx: *anyopaque) anyerror!void,
+ copy: *const fn (ctx: *anyopaque) anyerror!void,
+ paste: *const fn (ctx: *anyopaque) anyerror!void,
+ transform: *const fn (ctx: *anyopaque) anyerror!void,
+ save: *const fn (ctx: *anyopaque) anyerror!void,
+ requestCompositeWarmup: *const fn (ctx: *anyopaque) void,
+ requestGridLayoutDialog: *const fn (ctx: *anyopaque) void,
+
+ // ---- new document ----
+ /// Heap-owned unique basename like `untitled-1`; caller frees with the app allocator.
+ allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8,
+ /// Create and open a new document at `path` (path ownership transfers to the shell).
+ createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle,
+
+ // ---- save / quit flow ----
+ requestSaveAs: *const fn (ctx: *anyopaque) void,
+ requestWebSave: *const fn (ctx: *anyopaque, kind: WebSaveKind) void,
+ cancelPendingSaveDialog: *const fn (ctx: *anyopaque) void,
+ setPendingCloseDocId: *const fn (ctx: *anyopaque, id: u64) void,
+ queueCloseAfterSave: *const fn (ctx: *anyopaque, id: u64) anyerror!void,
+ trackQuitSaveInFlight: *const fn (ctx: *anyopaque, id: u64) anyerror!void,
+ resumeSaveAllQuit: *const fn (ctx: *anyopaque) void,
+ abortSaveAllQuit: *const fn (ctx: *anyopaque) void,
+
+ // ---- project pack ----
+ startPackProject: *const fn (ctx: *anyopaque) anyerror!void,
+ isPackingActive: *const fn (ctx: *anyopaque) bool,
};
pub fn arena(self: EditorAPI) std.mem.Allocator {
@@ -96,6 +156,14 @@ pub fn isMaximized(self: EditorAPI) bool {
return self.vtable.isMaximized(self.ctx);
}
+pub fn isMacOS(self: EditorAPI) bool {
+ return self.vtable.isMacOS(self.ctx);
+}
+
+pub fn appliesNativeWindowOpacity(self: EditorAPI) bool {
+ return self.vtable.appliesNativeWindowOpacity(self.ctx);
+}
+
pub fn explorerRect(self: EditorAPI) dvui.Rect {
return self.vtable.explorerRect(self.ctx);
}
@@ -117,3 +185,111 @@ pub fn showSaveDialog(
pub fn uiAtlas(self: EditorAPI) UiAtlasView {
return self.vtable.uiAtlas(self.ctx);
}
+
+pub fn activeDoc(self: EditorAPI) ?DocHandle {
+ return self.vtable.activeDoc(self.ctx);
+}
+
+pub fn docByIndex(self: EditorAPI, index: usize) ?DocHandle {
+ return self.vtable.docByIndex(self.ctx, index);
+}
+
+pub fn docById(self: EditorAPI, id: u64) ?DocHandle {
+ return self.vtable.docById(self.ctx, id);
+}
+
+pub fn docIndex(self: EditorAPI, id: u64) ?usize {
+ return self.vtable.docIndex(self.ctx, id);
+}
+
+pub fn openDocCount(self: EditorAPI) usize {
+ return self.vtable.openDocCount(self.ctx);
+}
+
+pub fn setActiveDocIndex(self: EditorAPI, index: usize) void {
+ self.vtable.setActiveDocIndex(self.ctx, index);
+}
+
+pub fn allocDocId(self: EditorAPI) u64 {
+ return self.vtable.allocDocId(self.ctx);
+}
+
+pub fn accept(self: EditorAPI) !void {
+ return self.vtable.accept(self.ctx);
+}
+
+pub fn cancel(self: EditorAPI) !void {
+ return self.vtable.cancel(self.ctx);
+}
+
+pub fn copy(self: EditorAPI) !void {
+ return self.vtable.copy(self.ctx);
+}
+
+pub fn paste(self: EditorAPI) !void {
+ return self.vtable.paste(self.ctx);
+}
+
+pub fn transform(self: EditorAPI) !void {
+ return self.vtable.transform(self.ctx);
+}
+
+pub fn save(self: EditorAPI) !void {
+ return self.vtable.save(self.ctx);
+}
+
+pub fn requestCompositeWarmup(self: EditorAPI) void {
+ self.vtable.requestCompositeWarmup(self.ctx);
+}
+
+pub fn requestGridLayoutDialog(self: EditorAPI) void {
+ self.vtable.requestGridLayoutDialog(self.ctx);
+}
+
+pub fn allocUntitledPath(self: EditorAPI) ![]u8 {
+ return self.vtable.allocUntitledPath(self.ctx);
+}
+
+pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocHandle {
+ return self.vtable.createDocument(self.ctx, path, grid);
+}
+
+pub fn requestSaveAs(self: EditorAPI) void {
+ self.vtable.requestSaveAs(self.ctx);
+}
+
+pub fn requestWebSave(self: EditorAPI, kind: WebSaveKind) void {
+ self.vtable.requestWebSave(self.ctx, kind);
+}
+
+pub fn cancelPendingSaveDialog(self: EditorAPI) void {
+ self.vtable.cancelPendingSaveDialog(self.ctx);
+}
+
+pub fn setPendingCloseDocId(self: EditorAPI, id: u64) void {
+ self.vtable.setPendingCloseDocId(self.ctx, id);
+}
+
+pub fn queueCloseAfterSave(self: EditorAPI, id: u64) !void {
+ return self.vtable.queueCloseAfterSave(self.ctx, id);
+}
+
+pub fn trackQuitSaveInFlight(self: EditorAPI, id: u64) !void {
+ return self.vtable.trackQuitSaveInFlight(self.ctx, id);
+}
+
+pub fn resumeSaveAllQuit(self: EditorAPI) void {
+ self.vtable.resumeSaveAllQuit(self.ctx);
+}
+
+pub fn abortSaveAllQuit(self: EditorAPI) void {
+ self.vtable.abortSaveAllQuit(self.ctx);
+}
+
+pub fn startPackProject(self: EditorAPI) !void {
+ return self.vtable.startPackProject(self.ctx);
+}
+
+pub fn isPackingActive(self: EditorAPI) bool {
+ return self.vtable.isPackingActive(self.ctx);
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 5b0cad6f..6d401751 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -10,6 +10,7 @@ const dvui = @import("dvui");
const Plugin = @import("Plugin.zig");
const regions = @import("regions.zig");
const EditorAPI = @import("EditorAPI.zig");
+const DocHandle = @import("DocHandle.zig");
pub const Host = @This();
@@ -120,6 +121,14 @@ pub fn isMaximized(self: *Host) bool {
return if (self.shell_api) |a| a.isMaximized() else false;
}
+pub fn isMacOS(self: *Host) bool {
+ return if (self.shell_api) |a| a.isMacOS() else false;
+}
+
+pub fn appliesNativeWindowOpacity(self: *Host) bool {
+ return if (self.shell_api) |a| a.appliesNativeWindowOpacity() else false;
+}
+
/// The explorer pane's content rect (shell layout). Zero rect if no shell installed.
pub fn explorerRect(self: *Host) dvui.Rect {
return if (self.shell_api) |a| a.explorerRect() else .{};
@@ -146,6 +155,115 @@ pub fn uiAtlas(self: *Host) EditorAPI.UiAtlasView {
return self.shell_api.?.uiAtlas();
}
+/// The actively focused open document, or null when none.
+pub fn activeDoc(self: *Host) ?DocHandle {
+ return if (self.shell_api) |a| a.activeDoc() else null;
+}
+
+pub fn docByIndex(self: *Host, index: usize) ?DocHandle {
+ return if (self.shell_api) |a| a.docByIndex(index) else null;
+}
+
+pub fn docById(self: *Host, id: u64) ?DocHandle {
+ return if (self.shell_api) |a| a.docById(id) else null;
+}
+
+pub fn docIndex(self: *Host, id: u64) ?usize {
+ return if (self.shell_api) |a| a.docIndex(id) else null;
+}
+
+pub fn openDocCount(self: *Host) usize {
+ return if (self.shell_api) |a| a.openDocCount() else 0;
+}
+
+pub fn setActiveDocIndex(self: *Host, index: usize) void {
+ if (self.shell_api) |a| a.setActiveDocIndex(index);
+}
+
+pub fn allocDocId(self: *Host) u64 {
+ return if (self.shell_api) |a| a.allocDocId() else 0;
+}
+
+pub fn accept(self: *Host) !void {
+ if (self.shell_api) |a| return a.accept();
+}
+
+pub fn cancel(self: *Host) !void {
+ if (self.shell_api) |a| return a.cancel();
+}
+
+pub fn copy(self: *Host) !void {
+ if (self.shell_api) |a| return a.copy();
+}
+
+pub fn paste(self: *Host) !void {
+ if (self.shell_api) |a| return a.paste();
+}
+
+pub fn transform(self: *Host) !void {
+ if (self.shell_api) |a| return a.transform();
+}
+
+pub fn save(self: *Host) !void {
+ if (self.shell_api) |a| return a.save();
+}
+
+pub fn requestCompositeWarmup(self: *Host) void {
+ if (self.shell_api) |a| a.requestCompositeWarmup();
+}
+
+pub fn requestGridLayoutDialog(self: *Host) void {
+ if (self.shell_api) |a| a.requestGridLayoutDialog();
+}
+
+pub fn allocUntitledPath(self: *Host) ![]u8 {
+ return if (self.shell_api) |a| try a.allocUntitledPath() else error.ShellNotInstalled;
+}
+
+pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid) !DocHandle {
+ return if (self.shell_api) |a| try a.createDocument(path, grid) else error.ShellNotInstalled;
+}
+
+pub fn requestSaveAs(self: *Host) void {
+ if (self.shell_api) |a| a.requestSaveAs();
+}
+
+pub fn requestWebSave(self: *Host, kind: EditorAPI.WebSaveKind) void {
+ if (self.shell_api) |a| a.requestWebSave(kind);
+}
+
+pub fn cancelPendingSaveDialog(self: *Host) void {
+ if (self.shell_api) |a| a.cancelPendingSaveDialog();
+}
+
+pub fn setPendingCloseDocId(self: *Host, id: u64) void {
+ if (self.shell_api) |a| a.setPendingCloseDocId(id);
+}
+
+pub fn queueCloseAfterSave(self: *Host, id: u64) !void {
+ if (self.shell_api) |a| return a.queueCloseAfterSave(id);
+}
+
+pub fn trackQuitSaveInFlight(self: *Host, id: u64) !void {
+ if (self.shell_api) |a| return a.trackQuitSaveInFlight(id);
+}
+
+pub fn resumeSaveAllQuit(self: *Host) void {
+ if (self.shell_api) |a| a.resumeSaveAllQuit();
+}
+
+pub fn abortSaveAllQuit(self: *Host) void {
+ if (self.shell_api) |a| a.abortSaveAllQuit();
+}
+
+pub fn startPackProject(self: *Host) !void {
+ if (self.shell_api) |a| return a.startPackProject();
+}
+
+pub fn isPackingActive(self: *Host) bool {
+ return if (self.shell_api) |a| a.isPackingActive() else false;
+}
+
// ---- per-plugin settings store ---------------------------------------------
/// The stored settings blob for `id` (serialized JSON), or null if none. The returned
diff --git a/src/web_main.zig b/src/web_main.zig
index daafcbbf..734cffef 100644
--- a/src/web_main.zig
+++ b/src/web_main.zig
@@ -57,7 +57,7 @@ comptime {
// Custom dvui wrapper + widgets — types compile even though the widget files
// contain dead `@import("backend")` SDL3 imports at file scope.
- _ = @import("plugins/pixelart/widgets/FileWidget.zig");
+ _ = @import("plugins/pixelart/src/widgets/FileWidget.zig");
_ = fizzy.dvui.CanvasWidget;
// The big ones: Editor + App. Type-level reference only — passes because Zig
From 1fb0af2e9549a07af3200d1101789b8f362c239a Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 17:01:28 -0500
Subject: [PATCH 21/49] Phase 4 stage d and e
---
HANDOFF.md | 60 ++++++++----
build.zig | 93 +++++++++++++++++--
src/editor/Editor.zig | 9 ++
src/editor/dialogs/Dialogs.zig | 79 ++--------------
src/fizzy.zig | 2 +-
src/plugins/pixelart/module.zig | 7 ++
src/plugins/pixelart/pixelart.zig | 11 ++-
src/plugins/pixelart/src/CanvasData.zig | 42 ++-------
src/plugins/pixelart/src/State.zig | 28 ++++++
src/plugins/pixelart/src/dialogs/Export.zig | 12 +--
src/plugins/pixelart/src/dialogs/NewFile.zig | 10 +-
.../pixelart/src/dialogs/dimensions_label.zig | 73 +++++++++++++++
src/plugins/pixelart/src/explorer/sprites.zig | 3 -
src/plugins/pixelart/src/internal/Atlas.zig | 4 +-
src/plugins/pixelart/src/internal/File.zig | 8 +-
src/plugins/pixelart/src/plugin.zig | 38 +++-----
src/plugins/pixelart/src/web_file_io.zig | 30 ++++++
.../pixelart/src/widgets/FileWidget.zig | 13 +--
src/plugins/workbench/src/Workspace.zig | 48 +++-------
src/sdk/EditorAPI.zig | 6 ++
src/sdk/Host.zig | 4 +
src/sdk/WorkbenchPane.zig | 10 ++
src/sdk/pane_layout.zig | 27 ++++++
src/sdk/regions.zig | 6 +-
src/sdk/sdk.zig | 4 +
src/web_main.zig | 2 +-
26 files changed, 397 insertions(+), 232 deletions(-)
create mode 100644 src/plugins/pixelart/src/dialogs/dimensions_label.zig
create mode 100644 src/plugins/pixelart/src/web_file_io.zig
create mode 100644 src/sdk/WorkbenchPane.zig
create mode 100644 src/sdk/pane_layout.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 7a780283..de0a2acd 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -11,12 +11,10 @@ it can become its own compile-time module.
storage inversion, save/pack/editor-action decoupling, platform detection, explorer pane
lift, sprites bottom-panel lift.
-**In progress:** **Stage D** — module scaffold (`module.zig`, `State.zig`, `pixelart.zig`,
-`Globals.zig`), hub consolidation through `fizzy.pixelart_mod`, plugin import migration off
-`fizzy.zig`.
+**In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection,
+Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired.
-**Next:** wire `b.addModule("pixelart", …)` in `build.zig`, break `plugin.zig` →
-`Editor.Workspace` dep. Then Stage E.
+**Next:** Stage E — strip remaining `fizzy.pixelart.*` from shell `Editor.zig`.
> **Read this first if you're a fresh agent:** start at "Stage D — remaining work"
> below. All three build configs are green right now.
@@ -196,6 +194,39 @@ remaining `fizzy.app.allocator` refs in `src/plugins/pixelart/**`.
`wireSdkModule` adds `@import("sdk")` to native, web, and test roots. `fizzy.zig` imports
sdk via `@import("sdk")` (not a duplicate file-path import).
+### SDK pane layout + workspace decoupling (done)
+
+- **`src/sdk/pane_layout.zig`** — shared `mainCanvasVbox` / `emptyStateCard` helpers.
+- **`src/sdk/WorkbenchPane.zig`** — `WorkbenchPaneView { grouping, canvas_rect_physical }`
+ passed to sidebar `draw_workspace` hooks (plugins no longer cast back to `Workspace`).
+- **`State.canvas_by_grouping`** — pixel-art owns per-pane `CanvasData`; `canvasForGrouping` /
+ `removeCanvasPane` replace the old `Workspace.plugin_view_state` opaque slot.
+- **`plugin.zig`** — `drawDocument` uses `CanvasData.forGrouping`; `drawProjectView` uses
+ `sdk.WorkbenchPaneView` + `sdk.pane_layout`; no `fizzy` import.
+- **`FileWidget.zig`** — `canvasData()` reads `Globals.state.canvas_by_grouping`; no `fizzy`.
+- **`workbench/Workspace.zig`** — passes `WorkbenchPaneView` to `draw_workspace`; `deinit`
+ calls `fizzy.State.removeCanvasPane`; layout helpers delegate to `sdk.pane_layout`.
+
+### Runtime fixes (session)
+
+| Bug | Fix |
+|-----|-----|
+| Startup crash in `Tools.init` | Use `self.stroke_shape/size`; set `Globals` before `State.init` |
+| Duplicate `Globals` module | `module.zig`: `pub const Globals = pixelart.Globals` |
+| Crash opening multiple files | Resolve docs by `doc.id`, not cached `doc.ptr` |
+| Crash on close with files open | `State.persistProject()` before `editor.deinit` |
+
+### Build module wired (done)
+
+- **`wirePixelartModule`** in `build.zig` — native, web, and test roots import
+ `@import("pixelart")` with deps: `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`,
+ `msf_gif`, `icons`, `backend` (native/test only).
+- **`fizzy.zig`** — `pixelart_mod = @import("pixelart")` (no path import).
+- **Zero `@import("fizzy.zig")` in plugin** — last shell leaks removed:
+ - `dialogs/dimensions_label.zig` + `web_file_io.zig` (plugin-local helpers)
+ - `EditorAPI.setExplorerNewFilePath` (replaces `Explorer.files.new_file_path` touch)
+ - `web_main.zig` probes `FileWidget` via `@import("pixelart")`
+
### Still direct-importing pixel-art files (shell)
```
@@ -207,19 +238,13 @@ src/web_main.zig → FileWidget.zig force-import (wasm link — mi
## Stage D — remaining work (start here)
-1. **Wire `b.addModule("pixelart", …)` in `build.zig`** (native, web, test) with deps:
- `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`, etc. — mirroring how `core` is wired.
- Point the module root at `module.zig`. Today the plugin compiles through path imports
- in `fizzy.zig`; the build module is scaffold-only.
-
-2. **Break `plugin.zig` dependency on `fizzy.Editor.Workspace`** (project view drawing
- still reaches into shell types).
+1. **Route any straggler shell path imports** of pixel-art files through `pixelart_mod`
+ or `@import("pixelart")` (mostly done; `process_assets.zig` stays separate).
-3. **Route `web_main.zig` FileWidget import** through `pixelart_mod` or the future build
- module.
+2. **Optional:** wire `b.addModule("workbench", …)` the same way.
-4. **Optional cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively —
- shrink as plugin vtable / EditorAPI surface grows (Stage E).
+3. **Stage E cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively —
+ shrink as plugin vtable / EditorAPI surface grows.
Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both
`App.zig` and `fizzy.zig` via a third path; always go through `fizzy.pixelart_mod` in
@@ -299,7 +324,8 @@ grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live
grep -rn 'fizzy\.platform' src/plugins/pixelart → 0
grep -rn 'fizzy\.app\.allocator' src/plugins/pixelart → 0
grep -rn 'bridge\.' src/plugins/pixelart → 0
-grep -rn 'plugins/pixelart/' src --include='*.zig' → process_assets, fizzy module import, web_main
+grep -rn '@import.*fizzy' src/plugins/pixelart → 0
+grep -rn 'editor/(dialogs|WebFileIo)' src/plugins/pixelart → 0
```
All three configs green: `zig build`, `zig build check-web`, `zig build test`.
diff --git a/build.zig b/build.zig
index 85a57c51..79bd5e78 100644
--- a/build.zig
+++ b/build.zig
@@ -358,7 +358,7 @@ pub fn build(b: *std.Build) !void {
core_module_web.addImport("icons", dep.module("icons"));
}
web_exe.root_module.addImport("core", core_module_web);
- wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module);
+ const sdk_module_web = wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module);
// Three editor files have `const sdl3 = @import("backend").c;` at file
// scope. After refactoring all `sdl3.SDL_DialogFileFilter` references
@@ -412,6 +412,18 @@ pub fn build(b: *std.Build) !void {
});
web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module);
+ wirePixelartModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_web_lib.root_module,
+ .msf_gif = msf_gif_web_lib.root_module,
+ .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = null,
+ }, web_exe.root_module);
+
const web_install_dir: std.Build.InstallDir = .{ .custom = "web" };
const install_wasm = b.addInstallArtifact(web_exe, .{
.dest_dir = .{ .override = web_install_dir },
@@ -847,7 +859,18 @@ pub fn build(b: *std.Build) !void {
core_module_test.addImport("icons", dep.module("icons"));
}
fizzy_test_module.addImport("core", core_module_test);
- wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
+ const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
+ wirePixelartModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_module,
+ .msf_gif = msf_gif_module,
+ .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .backend = dvui_testing_dep.module("testing"),
+ }, fizzy_test_module);
if (target.result.os.tag == .macos) {
if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
@@ -1172,7 +1195,24 @@ fn addFizzyExecutableForTarget(
core_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
core_module.addImport("known-folders", known_folders);
exe.root_module.addImport("core", core_module);
- wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module);
+ const sdk_module = wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module);
+ var icons_module: ?*std.Build.Module = null;
+ if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| {
+ exe.root_module.addImport("icons", dep.module("icons"));
+ core_module.addImport("icons", dep.module("icons"));
+ icons_module = dep.module("icons");
+ }
+ wirePixelartModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_module,
+ .msf_gif = msf_gif_module,
+ .icons = icons_module,
+ .backend = dvui_dep.module("sdl3"),
+ }, exe.root_module);
const singleton_app_dep = b.dependency("dvui_singleton_app", .{
.target = resolved_target,
@@ -1180,11 +1220,6 @@ fn addFizzyExecutableForTarget(
});
exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app"));
- if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| {
- exe.root_module.addImport("icons", dep.module("icons"));
- core_module.addImport("icons", dep.module("icons"));
- }
-
if (resolved_target.result.os.tag == .macos) {
if (macos_sdl_paths) |p| {
// Non-"native" macOS targets (`-Dtarget=aarch64-macos` on Apple Silicon, etc.) need the
@@ -1248,7 +1283,7 @@ fn wireSdkModule(
optimize: std.builtin.OptimizeMode,
dvui_module: *std.Build.Module,
consumer: *std.Build.Module,
-) void {
+) *std.Build.Module {
const sdk_module = b.createModule(.{
.target = target,
.optimize = optimize,
@@ -1256,6 +1291,46 @@ fn wireSdkModule(
});
sdk_module.addImport("dvui", dvui_module);
consumer.addImport("sdk", sdk_module);
+ return sdk_module;
+}
+
+const PixelartModuleDeps = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ assets: *std.Build.Module,
+ zip: *std.Build.Module,
+ zstbi: *std.Build.Module,
+ msf_gif: *std.Build.Module,
+ icons: ?*std.Build.Module,
+ backend: ?*std.Build.Module,
+};
+
+/// Pixel-art plugin (`src/plugins/pixelart/module.zig`).
+fn wirePixelartModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ deps: PixelartModuleDeps,
+ consumer: *std.Build.Module,
+) void {
+ const pixelart_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/plugins/pixelart/module.zig"),
+ .link_libc = target.result.cpu.arch != .wasm32,
+ .single_threaded = target.result.cpu.arch == .wasm32,
+ });
+ pixelart_module.addImport("dvui", deps.dvui);
+ pixelart_module.addImport("core", deps.core);
+ pixelart_module.addImport("sdk", deps.sdk);
+ pixelart_module.addImport("assets", deps.assets);
+ pixelart_module.addImport("zip", deps.zip);
+ pixelart_module.addImport("zstbi", deps.zstbi);
+ pixelart_module.addImport("msf_gif", deps.msf_gif);
+ if (deps.icons) |icons| pixelart_module.addImport("icons", icons);
+ if (deps.backend) |backend| pixelart_module.addImport("backend", backend);
+ consumer.addImport("pixelart", pixelart_module);
}
inline fn thisDir() []const u8 {
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 206310bc..c03797a6 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -574,6 +574,7 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.requestGridLayoutDialog = shellRequestGridLayoutDialog,
.allocUntitledPath = shellAllocUntitledPath,
.createDocument = shellCreateDocument,
+ .setExplorerNewFilePath = shellSetExplorerNewFilePath,
.requestSaveAs = shellRequestSaveAs,
.requestWebSave = shellRequestWebSave,
.cancelPendingSaveDialog = shellCancelPendingSaveDialog,
@@ -699,6 +700,14 @@ fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.Ne
const owner = fizzy.pixelart_mod.plugin.pluginPtr();
return .{ .ptr = file, .owner = owner, .id = file.id };
}
+fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void {
+ const Files = fizzy.Explorer.files;
+ if (Files.new_file_path) |old| {
+ fizzy.app.allocator.free(old);
+ }
+ Files.new_file_path = try fizzy.app.allocator.dupe(u8, path);
+ _ = ctx;
+}
fn shellRequestSaveAs(ctx: *anyopaque) void {
shellCtx(ctx).requestSaveAs();
}
diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig
index 43d7cbac..9dc99d99 100644
--- a/src/editor/dialogs/Dialogs.zig
+++ b/src/editor/dialogs/Dialogs.zig
@@ -32,74 +32,13 @@ else
}
};
-pub fn drawDimensionsLabel(src: std.builtin.SourceLocation, width: u32, height: u32, font: dvui.Font, unit: []const u8, opts: dvui.Options) void {
- {
- var hbox = dvui.box(src, .{ .dir = .horizontal }, opts);
- defer hbox.deinit();
-
- dvui.label(
- src,
- "{d}",
- .{width},
- .{
- .font = font,
- .margin = .{ .x = 1, .w = 1 },
- .padding = .all(0),
- .gravity_y = 1.0,
- .id_extra = 1,
- },
- );
-
- dvui.label(
- src,
- "{s}",
- .{unit},
- .{
- .font = dvui.Font.theme(.body).withSize(font.size - 1.0),
- .margin = .{ .x = 1, .w = 1 },
- .padding = .all(0),
- .gravity_y = 0.5,
- .id_extra = 2,
- },
- );
-
- dvui.label(
- src,
- "x",
- .{},
- .{
- .font = dvui.Font.theme(.body).withSize(font.size - 1.0),
- .margin = .{ .x = 1, .w = 1 },
- .padding = .all(0),
- .gravity_y = 0.5,
- .id_extra = 3,
- },
- );
-
- dvui.label(
- src,
- "{d}",
- .{height},
- .{
- .font = font,
- .margin = .{ .x = 1, .w = 1 },
- .padding = .all(0),
- .gravity_y = 0.5,
- .id_extra = 4,
- },
- );
-
- dvui.label(
- src,
- "{s}",
- .{unit},
- .{
- .font = dvui.Font.theme(.body).withSize(font.size - 1.0),
- .margin = .{ .x = 1, .w = 1 },
- .padding = .all(0),
- .gravity_y = 0.5,
- .id_extra = 5,
- },
- );
- }
+pub fn drawDimensionsLabel(
+ src: std.builtin.SourceLocation,
+ width: u32,
+ height: u32,
+ font: dvui.Font,
+ unit: []const u8,
+ opts: dvui.Options,
+) void {
+ fizzy.pixelart_mod.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts);
}
diff --git a/src/fizzy.zig b/src/fizzy.zig
index d9c46493..f684926a 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -17,7 +17,7 @@ pub const version: std.SemanticVersion = .{
pub const atlas = core.atlas;
// Other helpers and namespaces
-pub const pixelart_mod = @import("plugins/pixelart/module.zig");
+pub const pixelart_mod = @import("pixelart");
pub const algorithms = pixelart_mod.algorithms;
pub const render = pixelart_mod.render;
pub const sprite_render = pixelart_mod.sprite_render;
diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixelart/module.zig
index 55dc09c6..348139ab 100644
--- a/src/plugins/pixelart/module.zig
+++ b/src/plugins/pixelart/module.zig
@@ -21,12 +21,19 @@ pub const dialogs = struct {
pub const Export = @import("src/dialogs/Export.zig");
pub const GridLayout = @import("src/dialogs/GridLayout.zig");
pub const FlatRasterSaveWarning = @import("src/dialogs/FlatRasterSaveWarning.zig");
+ pub const DimensionsLabel = @import("src/dialogs/dimensions_label.zig");
};
pub const explorer = struct {
pub const project = @import("src/explorer/project.zig");
};
+pub const widgets = struct {
+ pub const FileWidget = @import("src/widgets/FileWidget.zig");
+ pub const ImageWidget = @import("src/widgets/ImageWidget.zig");
+ pub const CanvasBridge = @import("src/widgets/CanvasBridge.zig");
+};
+
pub const render = @import("src/render.zig");
pub const sprite_render = @import("src/sprite_render.zig");
pub const algorithms = @import("src/algorithms/algorithms.zig");
diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig
index 66e29e95..88d31974 100644
--- a/src/plugins/pixelart/pixelart.zig
+++ b/src/plugins/pixelart/pixelart.zig
@@ -2,11 +2,8 @@
//!
//! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or
//! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals
-//! and shared plugin types. The compile-time module root for the build is `module.zig`;
-//! shell code reaches the plugin through `@import("pixelart")`.
-//!
-//! Files that still need shell workbench types (`Editor.Workspace`) keep a local
-//! `fizzy` import until that surface moves behind EditorAPI.
+//! and shared plugin types. The compile-time module root for the build is `module.zig`
+//! (`@import("pixelart")`); shell code reaches the plugin through `fizzy.pixelart_mod`.
const std = @import("std");
pub const sdk = @import("sdk");
@@ -39,6 +36,10 @@ pub const render = @import("src/render.zig");
pub const sprite_render = @import("src/sprite_render.zig");
pub const algorithms = @import("src/algorithms/algorithms.zig");
+pub const explorer = struct {
+ pub const project = @import("src/explorer/project.zig");
+};
+
pub const internal = struct {
pub const File = @import("src/internal/File.zig");
pub const Layer = @import("src/internal/Layer.zig");
diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig
index 3cb74427..cdb1d48b 100644
--- a/src/plugins/pixelart/src/CanvasData.zig
+++ b/src/plugins/pixelart/src/CanvasData.zig
@@ -4,20 +4,19 @@
//! the column/row rulers, the floating Edit pill and color-sample button, the transform dialog,
//! and the grid (column/row) reorder drag state, plus the matching draw helpers.
//!
-//! It is pixel-art-owned and lives per pane. The plugin lazily allocates one (`ensure`) and
-//! stashes the pointer in the workbench `Workspace.plugin_view_state` opaque slot; the workbench
-//! never dereferences it and frees it through `plugin_view_destroy` when the pane is torn down.
+//! It is pixel-art-owned and lives per workspace pane (keyed by workbench `grouping` id on
+//! `State.canvas_by_grouping`). The workbench never dereferences it; `State.removeCanvasPane`
+//! frees it when a pane is torn down.
//! State the shell itself needs (the pane's physical content rect, used to center load/save
-//! toasts) intentionally stays on `Workspace`.
+//! toasts) stays on the workbench `Workspace` and is exposed through `WorkbenchPaneView`.
const std = @import("std");
const dvui = @import("dvui");
-const fizzy = @import("../../../fizzy.zig");
const icons = @import("icons");
const FileWidget = @import("widgets/FileWidget.zig");
+const Export = @import("dialogs/Export.zig");
const pixelart = @import("../pixelart.zig");
const Globals = pixelart.Globals;
-const Workspace = fizzy.Editor.Workspace;
const File = pixelart.internal.File;
const CanvasData = @This();
@@ -60,30 +59,9 @@ pub fn init(grouping: u64) CanvasData {
/// the pre-relocation behavior where the names lived on `Workspace` and were never freed.
pub fn deinit(_: *CanvasData) void {}
-/// Get the pixel-art chrome for `ws`, lazily allocating it and registering its teardown on
-/// first use. Called from the plugin's `drawDocument` each frame a document pane renders.
-pub fn ensure(ws: *Workspace) *CanvasData {
- if (ws.plugin_view_state) |p| return @ptrCast(@alignCast(p));
- const self = Globals.allocator().create(CanvasData) catch @panic("OOM allocating CanvasData");
- self.* = CanvasData.init(ws.grouping);
- ws.plugin_view_state = self;
- ws.plugin_view_destroy = destroyOpaque;
- return self;
-}
-
-/// The data already attached to `ws`, or null if none exists yet (e.g. the pane has not
-/// drawn a document this session). `FileWidget` uses this for its read-only reorder checks.
-/// Only pixel art writes `plugin_view_state`, so the cast is sound.
-pub fn fromWorkspace(ws: *Workspace) ?*CanvasData {
- const p = ws.plugin_view_state orelse return null;
- return @ptrCast(@alignCast(p));
-}
-
-/// `plugin_view_destroy` target: free the chrome when the workbench tears down its pane.
-fn destroyOpaque(state: *anyopaque) void {
- const self: *CanvasData = @ptrCast(@alignCast(state));
- self.deinit();
- Globals.allocator().destroy(self);
+/// Per-pane chrome for `grouping`, lazily allocated on first document draw.
+pub fn forGrouping(grouping: u64) *CanvasData {
+ return Globals.state.canvasForGrouping(grouping);
}
pub const RulerOrientation = enum {
@@ -1049,8 +1027,8 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
.exportd => {
// Open the Export dialog (same configuration the `export` keybind uses).
var mutex = pixelart.core.dvui.dialog(@src(), .{
- .displayFn = fizzy.Editor.Dialogs.Export.dialog,
- .callafterFn = fizzy.Editor.Dialogs.Export.callAfter,
+ .displayFn = Export.dialog,
+ .callafterFn = Export.callAfter,
.title = "Export...",
.ok_label = "Export",
.cancel_label = "Cancel",
diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig
index e89361c0..f76dad21 100644
--- a/src/plugins/pixelart/src/State.zig
+++ b/src/plugins/pixelart/src/State.zig
@@ -19,6 +19,8 @@ const ToolsPane = @import("explorer/tools.zig");
const SpritesPane = @import("explorer/sprites.zig");
const SpritesPanel = @import("panel/sprites.zig");
const Palette = @import("internal/Palette.zig");
+const CanvasData = @import("CanvasData.zig");
+const Globals = @import("Globals.zig");
pub const Settings = @import("Settings.zig");
pub const Docs = @import("Docs.zig");
@@ -68,6 +70,25 @@ sprite_clipboard: ?SpriteClipboard = null,
/// most recent request produces a visible atlas update.
pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty,
+/// Per-workspace-pane canvas chrome (rulers, edit pill, grid reorder), keyed by grouping id.
+canvas_by_grouping: std.AutoArrayHashMapUnmanaged(u64, *CanvasData) = .{},
+
+pub fn canvasForGrouping(st: *State, grouping: u64) *CanvasData {
+ const gpa = Globals.allocator();
+ if (st.canvas_by_grouping.get(grouping)) |existing| return existing;
+ const cd = gpa.create(CanvasData) catch @panic("OOM allocating CanvasData");
+ cd.* = CanvasData.init(grouping);
+ st.canvas_by_grouping.put(gpa, grouping, cd) catch @panic("OOM allocating CanvasData");
+ return cd;
+}
+
+pub fn removeCanvasPane(st: *State, allocator: std.mem.Allocator, grouping: u64) void {
+ const cd = st.canvas_by_grouping.get(grouping) orelse return;
+ cd.deinit();
+ allocator.destroy(cd);
+ _ = st.canvas_by_grouping.swapRemove(grouping);
+}
+
pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !State {
var st: State = .{
.host = host,
@@ -105,6 +126,13 @@ pub fn deinit(st: *State, allocator: std.mem.Allocator) void {
project.deinit(allocator);
}
+ var canvas_it = st.canvas_by_grouping.iterator();
+ while (canvas_it.next()) |entry| {
+ entry.value_ptr.*.deinit();
+ allocator.destroy(entry.value_ptr.*);
+ }
+ st.canvas_by_grouping.deinit(allocator);
+
st.tools.deinit(allocator);
st.docs.deinit(allocator);
}
diff --git a/src/plugins/pixelart/src/dialogs/Export.zig b/src/plugins/pixelart/src/dialogs/Export.zig
index 4024005f..e28e94f2 100644
--- a/src/plugins/pixelart/src/dialogs/Export.zig
+++ b/src/plugins/pixelart/src/dialogs/Export.zig
@@ -1,18 +1,16 @@
const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
-const zigimg = @import("zigimg");
const msf_gif = @import("msf_gif");
const zstbi = @import("zstbi");
-const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../../../../editor/WebFileIo.zig") else struct {};
-
-const ExportImageFormat = enum { png, jpg };
-
-const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig");
+const DimensionsLabel = @import("dimensions_label.zig");
+const WebFileIo = @import("../web_file_io.zig");
const pixelart = @import("../../pixelart.zig");
const Globals = pixelart.Globals;
+const ExportImageFormat = enum { png, jpg };
+
pub var mode: enum(usize) {
single,
animation,
@@ -443,7 +441,7 @@ fn exportScaleSlider(max_scale_val: f32) void {
fn exportDimensionsLabelForExport(column_w: u32, row_h: u32) void {
const entry_font = dvui.Font.theme(.mono).larger(-2);
- Dialogs.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 });
+ DimensionsLabel.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 });
}
const ExportFullPreviewKind = enum { layer, composite };
diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig
index 9554493c..c9950e30 100644
--- a/src/plugins/pixelart/src/dialogs/NewFile.zig
+++ b/src/plugins/pixelart/src/dialogs/NewFile.zig
@@ -1,10 +1,9 @@
const std = @import("std");
const dvui = @import("dvui");
-const Dialogs = @import("../../../../editor/dialogs/Dialogs.zig");
+const DimensionsLabel = @import("dimensions_label.zig");
const pixelart = @import("../../pixelart.zig");
const Globals = pixelart.Globals;
-const fizzy = @import("../../../../fizzy.zig");
pub var mode: enum(usize) {
single,
@@ -176,7 +175,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
const width = column_width * (if (mode == .single) 1 else columns);
const height = row_height * (if (mode == .single) 1 else rows);
- Dialogs.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 });
+ DimensionsLabel.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 });
return valid;
}
@@ -208,10 +207,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void
return error.FailedToSaveFile;
};
- if (fizzy.Editor.Explorer.files.new_file_path) |old| {
- Globals.allocator().free(old);
- }
- fizzy.Editor.Explorer.files.new_file_path = try Globals.allocator().dupe(u8, file.path);
+ try Globals.state.host.setExplorerNewFilePath(file.path);
dvui.refresh(null, @src(), dvui.currentWindow().data().id);
} else {
const new_path = try Globals.state.host.allocUntitledPath();
diff --git a/src/plugins/pixelart/src/dialogs/dimensions_label.zig b/src/plugins/pixelart/src/dialogs/dimensions_label.zig
new file mode 100644
index 00000000..42db8da8
--- /dev/null
+++ b/src/plugins/pixelart/src/dialogs/dimensions_label.zig
@@ -0,0 +1,73 @@
+//! Shared "W x H unit" label row for New File / Export dialogs.
+const std = @import("std");
+const dvui = @import("dvui");
+
+pub fn drawDimensionsLabel(src: std.builtin.SourceLocation, width: u32, height: u32, font: dvui.Font, unit: []const u8, opts: dvui.Options) void {
+ var hbox = dvui.box(src, .{ .dir = .horizontal }, opts);
+ defer hbox.deinit();
+
+ dvui.label(
+ src,
+ "{d}",
+ .{width},
+ .{
+ .font = font,
+ .margin = .{ .x = 1, .w = 1 },
+ .padding = .all(0),
+ .gravity_y = 1.0,
+ .id_extra = 1,
+ },
+ );
+
+ dvui.label(
+ src,
+ "{s}",
+ .{unit},
+ .{
+ .font = dvui.Font.theme(.body).withSize(font.size - 1.0),
+ .margin = .{ .x = 1, .w = 1 },
+ .padding = .all(0),
+ .gravity_y = 0.5,
+ .id_extra = 2,
+ },
+ );
+
+ dvui.label(
+ src,
+ "x",
+ .{},
+ .{
+ .font = dvui.Font.theme(.body).withSize(font.size - 1.0),
+ .margin = .{ .x = 1, .w = 1 },
+ .padding = .all(0),
+ .gravity_y = 0.5,
+ .id_extra = 3,
+ },
+ );
+
+ dvui.label(
+ src,
+ "{d}",
+ .{height},
+ .{
+ .font = font,
+ .margin = .{ .x = 1, .w = 1 },
+ .padding = .all(0),
+ .gravity_y = 0.5,
+ .id_extra = 4,
+ },
+ );
+
+ dvui.label(
+ src,
+ "{s}",
+ .{unit},
+ .{
+ .font = dvui.Font.theme(.body).withSize(font.size - 1.0),
+ .margin = .{ .x = 1, .w = 1 },
+ .padding = .all(0),
+ .gravity_y = 0.5,
+ .id_extra = 5,
+ },
+ );
+}
diff --git a/src/plugins/pixelart/src/explorer/sprites.zig b/src/plugins/pixelart/src/explorer/sprites.zig
index 1e55629d..888304e5 100644
--- a/src/plugins/pixelart/src/explorer/sprites.zig
+++ b/src/plugins/pixelart/src/explorer/sprites.zig
@@ -3,9 +3,6 @@ const dvui = @import("dvui");
const icons = @import("icons");
const pixelart = @import("../../pixelart.zig");
const Globals = pixelart.Globals;
-const fizzy = @import("../../../../fizzy.zig");
-
-const Editor = fizzy.Editor;
const Sprites = @This();
diff --git a/src/plugins/pixelart/src/internal/Atlas.zig b/src/plugins/pixelart/src/internal/Atlas.zig
index 03fb0c88..dc160a02 100644
--- a/src/plugins/pixelart/src/internal/Atlas.zig
+++ b/src/plugins/pixelart/src/internal/Atlas.zig
@@ -65,7 +65,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
}
const bytes = try out.toOwnedSlice();
defer allocator.free(bytes);
- try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, bytes);
+ try @import("../web_file_io.zig").downloadBytes(path, bytes);
},
.data => {
if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) {
@@ -75,7 +75,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void {
const options: std.json.Stringify.Options = .{};
const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options);
defer allocator.free(output);
- try @import("../../../../editor/WebFileIo.zig").downloadBytes(path, output);
+ try @import("../web_file_io.zig").downloadBytes(path, output);
},
}
return;
diff --git a/src/plugins/pixelart/src/internal/File.zig b/src/plugins/pixelart/src/internal/File.zig
index f9f9e654..a61ca14a 100644
--- a/src/plugins/pixelart/src/internal/File.zig
+++ b/src/plugins/pixelart/src/internal/File.zig
@@ -3153,15 +3153,15 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void {
defer snap.deinit(Globals.allocator());
const bytes = try writeSnapshotToZipBytes(&snap, Globals.allocator());
defer Globals.allocator().free(bytes);
- try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".fiz", bytes);
} else if (std.mem.eql(u8, ext, ".png")) {
const bytes = try flattenedImageBytes(self, window, .png);
defer Globals.allocator().free(bytes);
- try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".png", bytes);
} else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
const bytes = try flattenedImageBytes(self, window, .jpg);
defer Globals.allocator().free(bytes);
- try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".jpg", bytes);
} else {
return;
}
@@ -3343,7 +3343,7 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo
};
defer Globals.allocator().free(bytes);
const dl_ext = if (is_png) ".png" else ".jpg";
- try @import("../../../../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
+ try @import("../web_file_io.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes);
} else if (is_png) {
const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254));
try pixelart.image.writeToPngResolution(single_layer.source, output_path, r);
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index db5fbd15..1d08ee96 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -1,10 +1,9 @@
//! The pixel-art editor plugin. Phase 2 thin shim — the pixel-art stack still
//! lives inline under `src/editor/` (Phase 3 relocates it whole behind this
//! plugin). For now its contributions point at the existing draw entry points
-//! through the `fizzy.*` globals. Registered from `Editor.postInit`.
+//! through the `Globals` injection. Registered from `Editor.postInit`.
const std = @import("std");
const builtin = @import("builtin");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const pixelart = @import("../pixelart.zig");
const sdk = pixelart.sdk;
@@ -97,11 +96,10 @@ fn closeDocument(_: *anyopaque, doc: DocHandle) void {
/// `canvas.id` / `workspace_handle` / `center` before routing here; pixel art owns the
/// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing
/// widget, and the sample magnifier. The per-pane ruler/overlay/reorder state + draw helpers
-/// live on the pixel-art-owned `CanvasData` (stashed in the pane's `plugin_view_state`).
+/// live on the pixel-art-owned `CanvasData` (keyed by workbench pane `grouping` on `State`).
fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
- const ws = fizzy.Editor.Workspace.ofFile(file) orelse return;
- const chrome = CanvasData.ensure(ws);
+ const chrome = CanvasData.forGrouping(file.editor.grouping);
const container = dvui.parentGet().data();
// Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit
@@ -133,7 +131,8 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
// Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom).
chrome.drawSampleButton(container);
- if (ws.grouping != file.editor.grouping) return;
+ const pane_grouping = container.options.id_extra orelse return;
+ if (@as(u64, @intCast(pane_grouping)) != file.editor.grouping) return;
var file_widget = FileWidget.init(@src(), .{
.file = file,
@@ -155,15 +154,8 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
/// Take over a workspace pane to show the pixel-art packed-atlas preview (the "Project"
/// sidebar view's `draw_workspace`). The workbench owns the pane frame and routes here when
-/// `view_project` is the active sidebar view; we cast the opaque handle back to the document
-/// host's `Workspace` and render the whole content region (atlas image or empty-state hint).
-/// Mirrors what `Workspace.drawCanvas` does for documents: reuses the workbench's shared
-/// canvas vbox / empty-state card helpers so switching project ↔ canvas keeps stable widget ids,
-/// and stamps `canvas_rect_physical` (read by the editor's load/save toast overlays).
-fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
- const Workspace = fizzy.Editor.Workspace;
- const ws: *Workspace = @ptrCast(@alignCast(workspace_handle));
-
+/// `view_project` is the active sidebar view.
+fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void {
var content_color = dvui.themeGet().color(.window, .fill);
if (Globals.state.host.appliesNativeWindowOpacity()) {
@@ -178,10 +170,9 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
else
Globals.state.host.folder() != null and Globals.packer.atlas != null;
- // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage).
- var canvas_vbox = Workspace.workspaceMainCanvasVbox(content_color, show_packed_atlas, ws.grouping);
+ var canvas_vbox = sdk.pane_layout.mainCanvasVbox(content_color, show_packed_atlas, pane.grouping);
defer {
- ws.canvas_rect_physical = canvas_vbox.data().contentRectScale().r;
+ pane.canvas_rect_physical.* = canvas_vbox.data().contentRectScale().r;
dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural());
canvas_vbox.deinit();
}
@@ -191,9 +182,9 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
var image_widget = ImageWidget.init(@src(), .{
.source = atlas.source,
.canvas = &atlas.canvas,
- .grouping = ws.grouping,
+ .grouping = pane.grouping,
}, .{
- .id_extra = @intCast(ws.grouping),
+ .id_extra = @intCast(pane.grouping),
.expand = .both,
.background = false,
.color_fill = .transparent,
@@ -208,7 +199,7 @@ fn drawProjectView(_: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void {
}
}
} else {
- var box = Workspace.workspaceEmptyStateCard(content_color, ws.grouping);
+ var box = sdk.pane_layout.emptyStateCard(content_color, pane.grouping);
defer box.deinit();
const alpha = dvui.alpha(1.0);
@@ -250,8 +241,7 @@ pub fn register(host: *sdk.Host) !void {
// Adopt the app-owned pixel-art state as this plugin's `state`. Wire Globals
// here too so plugin code and the shell share one injection site (App also sets
// these before State.init, but register re-syncs after postInit ordering).
- Globals.state = fizzy.pixelart;
- plugin.state = @ptrCast(@alignCast(fizzy.pixelart));
+ plugin.state = @ptrCast(@alignCast(Globals.state));
try host.registerPlugin(&plugin);
try host.registerSidebarView(.{
.id = view_tools,
@@ -301,7 +291,7 @@ fn drawSprites(_: ?*anyopaque) anyerror!void {
try Globals.state.sprites_pane.draw();
}
fn drawProject(_: ?*anyopaque) anyerror!void {
- try fizzy.Editor.Explorer.project.draw();
+ try pixelart.explorer.project.draw();
}
fn drawSpritesPanel(_: ?*anyopaque) anyerror!void {
try Globals.state.sprites_panel.draw();
diff --git a/src/plugins/pixelart/src/web_file_io.zig b/src/plugins/pixelart/src/web_file_io.zig
new file mode 100644
index 00000000..62718bfc
--- /dev/null
+++ b/src/plugins/pixelart/src/web_file_io.zig
@@ -0,0 +1,30 @@
+//! Browser download helpers for the wasm build (no shell `fizzy` dependency).
+const std = @import("std");
+const builtin = @import("builtin");
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+
+fn downloadNameWithExtension(allocator: std.mem.Allocator, filename: []const u8, ext: []const u8) ![]const u8 {
+ if (std.ascii.eqlIgnoreCase(std.fs.path.extension(filename), ext)) {
+ return try allocator.dupe(u8, filename);
+ }
+ const base = std.fs.path.basename(filename);
+ const stem: []const u8 = if (std.mem.lastIndexOf(u8, base, ".")) |i| base[0..i] else base;
+ if (stem.len == 0) {
+ return try std.fmt.allocPrint(allocator, "download{s}", .{ext});
+ }
+ return try std.fmt.allocPrint(allocator, "{s}{s}", .{ stem, ext });
+}
+
+pub fn downloadBytes(filename: []const u8, data: []const u8) !void {
+ if (comptime builtin.target.cpu.arch != .wasm32) return;
+ try dvui.backend.downloadData(filename, data);
+}
+
+pub fn downloadBytesWithExtension(filename: []const u8, ext: []const u8, data: []const u8) !void {
+ if (comptime builtin.target.cpu.arch != .wasm32) return;
+ const name = try downloadNameWithExtension(Globals.allocator(), filename, ext);
+ defer Globals.allocator().free(name);
+ try downloadBytes(name, data);
+}
diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig
index 3f4328c6..6aa49d85 100644
--- a/src/plugins/pixelart/src/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/src/widgets/FileWidget.zig
@@ -1,7 +1,6 @@
const std = @import("std");
const math = std.math;
const dvui = @import("dvui");
-const fizzy = @import("../../../../fizzy.zig");
const builtin = @import("builtin");
const sdl3 = @import("backend").c;
@@ -18,7 +17,6 @@ const ScaleWidget = dvui.ScaleWidget;
pub const FileWidget = @This();
const CanvasWidget = pixelart.core.dvui.CanvasWidget;
const CanvasBridge = @import("CanvasBridge.zig");
-const Workspace = fizzy.Editor.Workspace;
const CanvasData = @import("../CanvasData.zig");
const icons = @import("icons");
const pixelart = @import("../../pixelart.zig");
@@ -679,17 +677,10 @@ const BubblePanShared = struct {
tool_not_pointer: bool,
};
-/// The workspace currently drawing this file, recovered from the file's opaque
-/// slot handle. Valid during draw/processEvents — the shell sets the handle each
-/// frame (in `Workspace.drawCanvas`) before invoking the widget.
-fn workspace(self: *FileWidget) *Workspace {
- return Workspace.ofFile(self.init_options.file).?;
-}
-
/// The pixel-art per-pane `CanvasData` for the pane drawing this file, or null if none is
-/// attached yet. Holds the column/row reorder drag state this widget reads while previewing.
+/// allocated yet. Holds the column/row reorder drag state this widget reads while previewing.
fn canvasData(self: *FileWidget) ?*CanvasData {
- return CanvasData.fromWorkspace(self.workspace());
+ return Globals.state.canvas_by_grouping.get(self.init_options.file.editor.grouping);
}
/// True while a column or row is mid-drag in this pane's rulers.
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index 19b4cd30..87f20b3a 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
+const sdk = @import("sdk");
const fizzy = @import("../../../fizzy.zig");
const icons = @import("icons");
@@ -23,15 +24,6 @@ tabs_drag_index: ?usize = null,
tabs_removed_index: ?usize = null,
tabs_insert_before_index: ?usize = null,
-/// Opaque per-pane state owned by the plugin that renders documents into this pane (today
-/// only pixel art, via `CanvasData`: rulers, edit pill, grid-reorder drag, etc.). The
-/// workbench never dereferences it — it just frees it through `plugin_view_destroy` when the
-/// pane is torn down (`deinit`). Lazily created by the owning plugin on first document draw.
-plugin_view_state: ?*anyopaque = null,
-/// Teardown for `plugin_view_state`, set by the owner alongside the state. Null when no
-/// plugin view has been attached.
-plugin_view_destroy: ?*const fn (state: *anyopaque) void = null,
-
/// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during
/// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view).
/// `null` until the workspace has rendered at least once. Used
@@ -43,14 +35,10 @@ pub fn init(grouping: u64) Workspace {
return .{ .grouping = grouping };
}
-/// Release any plugin-owned per-pane view state. Called when a pane is removed
+/// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed
/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown.
pub fn deinit(self: *Workspace) void {
- if (self.plugin_view_state) |state| {
- if (self.plugin_view_destroy) |destroy| destroy(state);
- self.plugin_view_state = null;
- self.plugin_view_destroy = null;
- }
+ fizzy.State.removeCanvasPane(fizzy.pixelart, fizzy.app.allocator, self.grouping);
}
/// Recover the typed workspace currently drawing `file` from its opaque slot
@@ -109,7 +97,11 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
// workbench owns only the pane frame; it hands the active view the opaque workspace handle.
const active = fizzy.editor.host.activeSidebarView();
if (active != null and active.?.draw_workspace != null) {
- try active.?.draw_workspace.?(active.?.ctx, self);
+ var pane_view: sdk.WorkbenchPaneView = .{
+ .grouping = self.grouping,
+ .canvas_rect_physical = &self.canvas_rect_physical,
+ };
+ try active.?.draw_workspace.?(active.?.ctx, &pane_view);
} else {
self.drawTabs();
try self.drawCanvas();
@@ -120,30 +112,14 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
/// Same `@src()` for every call so DVUI sees one stable id when switching between `drawCanvas` and
/// a plugin's `draw_workspace` takeover (avoids first-frame min-size / layout flash). Use `grouping`
-/// so multi-workspace panes stay distinct.
-/// `pub` so a plugin's `draw_workspace` takeover (pixel art's Project view) can reuse the exact same
-/// vbox so switching project ↔ canvas does not churn the widget id.
+/// so multi-workspace panes stay distinct. Delegates to `sdk.pane_layout` for a single definition.
pub fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget {
- return dvui.box(@src(), .{ .dir = .vertical }, .{
- .expand = .both,
- .background = background,
- .color_fill = content_color,
- .id_extra = @intCast(grouping),
- });
+ return sdk.pane_layout.mainCanvasVbox(content_color, background, grouping);
}
-/// Rounded “card” behind the project empty state and the homepage. Shared id base + `grouping` so
-/// switching project tab ↔ file pane (no open files) does not create a new widget each time.
-/// `pub` so pixel art's Project-view takeover (`draw_workspace`) reuses the identical empty-state card.
+/// Rounded “card” behind the project empty state and the homepage. Delegates to `sdk.pane_layout`.
pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget {
- return dvui.box(@src(), .{ .dir = .horizontal }, .{
- .expand = .both,
- .background = true,
- .color_fill = content_color,
- .corner_radius = dvui.Rect.all(16),
- .margin = .{ .y = 10 },
- .id_extra = @intCast(grouping),
- });
+ return sdk.pane_layout.emptyStateCard(content_color, grouping);
}
fn drawTabs(self: *Workspace) void {
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index e884d458..882589bb 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -116,6 +116,8 @@ pub const VTable = struct {
allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8,
/// Create and open a new document at `path` (path ownership transfers to the shell).
createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle,
+ /// Hint the files tree to scroll/highlight a path just created (e.g. New File dialog).
+ setExplorerNewFilePath: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
// ---- save / quit flow ----
requestSaveAs: *const fn (ctx: *anyopaque) void,
@@ -254,6 +256,10 @@ pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocH
return self.vtable.createDocument(self.ctx, path, grid);
}
+pub fn setExplorerNewFilePath(self: EditorAPI, path: []const u8) !void {
+ return self.vtable.setExplorerNewFilePath(self.ctx, path);
+}
+
pub fn requestSaveAs(self: EditorAPI) void {
self.vtable.requestSaveAs(self.ctx);
}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 6d401751..392278e5 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -224,6 +224,10 @@ pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid)
return if (self.shell_api) |a| try a.createDocument(path, grid) else error.ShellNotInstalled;
}
+pub fn setExplorerNewFilePath(self: *Host, path: []const u8) !void {
+ return if (self.shell_api) |a| try a.setExplorerNewFilePath(path) else error.ShellNotInstalled;
+}
+
pub fn requestSaveAs(self: *Host) void {
if (self.shell_api) |a| a.requestSaveAs();
}
diff --git a/src/sdk/WorkbenchPane.zig b/src/sdk/WorkbenchPane.zig
new file mode 100644
index 00000000..bb3cf5a9
--- /dev/null
+++ b/src/sdk/WorkbenchPane.zig
@@ -0,0 +1,10 @@
+//! Opaque workbench pane handle passed to a sidebar view's `draw_workspace` hook.
+//! Plugins use this instead of casting back to the workbench's internal `Workspace` type.
+const dvui = @import("dvui");
+
+pub const WorkbenchPaneView = struct {
+ grouping: u64,
+ /// Workbench-owned slot; the plugin writes the physical content rect each frame so
+ /// shell toasts can center over the pane the user is looking at.
+ canvas_rect_physical: *?dvui.Rect.Physical,
+};
diff --git a/src/sdk/pane_layout.zig b/src/sdk/pane_layout.zig
new file mode 100644
index 00000000..a896fff5
--- /dev/null
+++ b/src/sdk/pane_layout.zig
@@ -0,0 +1,27 @@
+//! Shared dvui layout helpers for workbench content panes. Used by the workbench when
+//! drawing document canvases and by plugins that take over a pane via `draw_workspace`
+//! (e.g. pixel art's Project atlas preview). Stable `@src()` + `grouping` ids avoid
+//! widget churn when switching between document and project views.
+const dvui = @import("dvui");
+
+/// Main vertical canvas region inside a workspace pane.
+pub fn mainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget {
+ return dvui.box(@src(), .{ .dir = .vertical }, .{
+ .expand = .both,
+ .background = background,
+ .color_fill = content_color,
+ .id_extra = @intCast(grouping),
+ });
+}
+
+/// Rounded card behind empty states (homepage, project hint, etc.).
+pub fn emptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget {
+ return dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .both,
+ .background = true,
+ .color_fill = content_color,
+ .corner_radius = dvui.Rect.all(16),
+ .margin = .{ .y = 10 },
+ .id_extra = @intCast(grouping),
+ });
+}
diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig
index 903254bb..7eeac06f 100644
--- a/src/sdk/regions.zig
+++ b/src/sdk/regions.zig
@@ -10,6 +10,7 @@
//! cross-plugin references survive without a compile-time dependency.
const dvui = @import("dvui");
const Plugin = @import("Plugin.zig");
+const WorkbenchPaneView = @import("WorkbenchPane.zig").WorkbenchPaneView;
/// A left-region (explorer) view, selected by its sidebar icon. Exactly one
/// sidebar view is active at a time; its `draw` fills the left pane.
@@ -24,9 +25,8 @@ pub const SidebarView = struct {
draw: *const fn (ctx: ?*anyopaque) anyerror!void,
/// Optional: while this view is the active sidebar view, it takes over the workspace
/// content region instead of the normal document tabs+canvas. The workbench calls this
- /// per workspace pane, passing the opaque workspace handle (cast back to the document
- /// host's `Workspace`). Used by pixel art's "Project" view to show the packed atlas.
- draw_workspace: ?*const fn (ctx: ?*anyopaque, workspace_handle: *anyopaque) anyerror!void = null,
+ /// per workspace pane with a `WorkbenchPaneView` (grouping + toast rect slot).
+ draw_workspace: ?*const fn (ctx: ?*anyopaque, pane: *WorkbenchPaneView) anyerror!void = null,
};
/// A bottom-panel view. The panel shows a tab strip across all registered views;
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index dbd90bb7..aae47821 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -23,3 +23,7 @@ pub const SaveDialogFilter = EditorAPI.SaveDialogFilter;
pub const SaveDialogCallback = EditorAPI.SaveDialogCallback;
pub const UiSprite = EditorAPI.UiSprite;
pub const UiAtlasView = EditorAPI.UiAtlasView;
+
+pub const WorkbenchPane = @import("WorkbenchPane.zig");
+pub const WorkbenchPaneView = WorkbenchPane.WorkbenchPaneView;
+pub const pane_layout = @import("pane_layout.zig");
diff --git a/src/web_main.zig b/src/web_main.zig
index 734cffef..6534dad9 100644
--- a/src/web_main.zig
+++ b/src/web_main.zig
@@ -57,7 +57,7 @@ comptime {
// Custom dvui wrapper + widgets — types compile even though the widget files
// contain dead `@import("backend")` SDL3 imports at file scope.
- _ = @import("plugins/pixelart/src/widgets/FileWidget.zig");
+ _ = @import("pixelart").widgets.FileWidget;
_ = fizzy.dvui.CanvasWidget;
// The big ones: Editor + App. Type-level reference only — passes because Zig
From 5789d33b3012b288a648a85943625403f364a256 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 17:12:20 -0500
Subject: [PATCH 22/49] Phase 4 stage e
---
HANDOFF.md | 26 +-
src/App.zig | 16 +-
src/editor/Editor.zig | 421 ++++-----------------
src/editor/Keybinds.zig | 70 ----
src/editor/Menu.zig | 6 +-
src/editor/dialogs/Dialogs.zig | 12 +-
src/editor/dialogs/UnsavedClose.zig | 12 +-
src/editor/explorer/Explorer.zig | 4 +-
src/editor/panel/Panel.zig | 3 +-
src/fizzy.zig | 28 +-
src/plugins/pixelart/pixelart.zig | 2 +-
src/plugins/pixelart/src/keybind_ticks.zig | 82 ++++
src/plugins/pixelart/src/plugin.zig | 22 ++
src/plugins/pixelart/src/radial_menu.zig | 238 ++++++++++++
src/plugins/workbench/src/FileLoadJob.zig | 6 +-
src/plugins/workbench/src/Workspace.zig | 8 +-
src/plugins/workbench/src/files.zig | 2 +-
src/sdk/DocHandle.zig | 2 +-
src/sdk/Plugin.zig | 22 ++
src/web_main.zig | 36 +-
tests/fizzy_shim.zig | 14 +-
tests/integration.zig | 51 +--
22 files changed, 568 insertions(+), 515 deletions(-)
create mode 100644 src/plugins/pixelart/src/keybind_ticks.zig
create mode 100644 src/plugins/pixelart/src/radial_menu.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index de0a2acd..789551ff 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -14,7 +14,7 @@ lift, sprites bottom-panel lift.
**In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection,
Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired.
-**Next:** Stage E — strip remaining `fizzy.pixelart.*` from shell `Editor.zig`.
+**Next:** Stage E — trim `fizzy.zig` re-exports; route copy/paste/pack through plugin vtable.
> **Read this first if you're a fresh agent:** start at "Stage D — remaining work"
> below. All three build configs are green right now.
@@ -252,14 +252,22 @@ app code until the build module is fully wired.
---
-## Stage E — strip pixel-art names from shell hubs (later)
-
-- Remove pixel-art type names from `fizzy.zig` hub (consumers import `pixelart` module).
-- Remove `editor/dialogs/` pixel-art dialog aliases (plugins register dialogs via SDK).
-- Shell `Editor` radial-menu / copy-paste / pack code still touches `fizzy.pixelart.tools` —
- route through plugin vtable or EditorAPI.
-- Shell still uses `fizzy.Internal.File` directly in several `Editor.zig` helpers — shrink
- as doc ownership solidifies.
+## Stage E — strip pixel-art names from shell hubs (in progress)
+
+**Done this session:**
+- **`Editor.pixelart_state`** — shell reaches plugin state through the editor, not scattered `fizzy.pixelart.*` (53 → 0 direct field accesses in shell code; `fizzy.pixelart` global remains only in `App.zig` lifecycle).
+- **Plugin vtable hooks** — `tickKeybinds`, `processRadialMenuInput`, `radialMenuVisible`, `drawRadialMenu`; radial menu + tool keybind ticks moved to `pixelart/src/radial_menu.zig` and `keybind_ticks.zig`.
+- **Shell `Keybinds.tick`** — pixel-art handlers removed (shell-only binds remain).
+- **`editor/dialogs/Dialogs.zig`** — imports `@import("pixelart")` directly.
+- **Explorer, UnsavedClose, files, Workspace** — use `fizzy.editor.pixelart_state` or `@import("pixelart")`.
+- **`fizzy.zig` hub trimmed** — removed re-export aliases (`Tools`, `Internal`, `render`, `Packer`, on-disk types, …). Shell/workbench/tests/web probes now `@import("pixelart")` (or `fizzy.pixelart_mod` in integration tests). `fizzy.zig` keeps only `pixelart_mod` alias + lifecycle globals (`app`, `editor`, `packer`, `pixelart`).
+- **`App.zig`** — wires `pixelart.Globals` directly (not `fizzy.pixelart_mod.Globals`).
+
+**Still remaining:**
+- `fizzy.pixelart` global — fold into `Editor.pixelart_state` + `Globals` only.
+- Shell `Editor` copy/paste/pack/project still touch `editor.pixelart_state` fields directly — route through plugin vtable or EditorAPI.
+- `pixelart.internal.File` in workbench + shell helpers — shrink as doc ownership solidifies.
+- Integration test shim updated for `pixelart.State` settings; `check-integration` still blocked on native `backend_native` SDL import under dvui-testing (pre-existing).
---
diff --git a/src/App.zig b/src/App.zig
index e93f5bb3..ac32b7b2 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -8,6 +8,7 @@ const assets = @import("assets");
const icon = assets.files.@"icon.png";
const fizzy = @import("fizzy.zig");
+const pixelart = @import("pixelart");
const auto_update = @import("backend/auto_update.zig");
const update_notify = @import("backend/update_notify.zig");
const singleton = @import("backend/singleton.zig");
@@ -15,7 +16,7 @@ const paths = fizzy.paths;
const App = @This();
const Editor = fizzy.Editor;
-const Packer = fizzy.Packer;
+const Packer = pixelart.Packer;
// App fields
allocator: std.mem.Allocator = undefined,
@@ -168,10 +169,11 @@ pub fn AppInit(win: *dvui.Window) !void {
// Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
// `state`. Owned here for the app's lifetime; torn down in `AppDeinit`.
- fizzy.pixelart = try allocator.create(fizzy.State);
- fizzy.pixelart_mod.Globals.gpa = allocator;
- fizzy.pixelart_mod.Globals.state = fizzy.pixelart;
- fizzy.pixelart.* = fizzy.State.init(allocator, &fizzy.editor.host) catch unreachable;
+ fizzy.pixelart = try allocator.create(pixelart.State);
+ pixelart.Globals.gpa = allocator;
+ pixelart.Globals.state = fizzy.pixelart;
+ fizzy.pixelart.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable;
+ fizzy.editor.pixelart_state = fizzy.pixelart;
// Second-stage init that needs the editor at its final heap address (e.g.
// registering the workbench-api service whose `ctx` is this pointer).
@@ -183,7 +185,7 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.packer = try allocator.create(Packer);
fizzy.packer.* = Packer.init(allocator) catch unreachable;
- fizzy.pixelart_mod.Globals.packer = fizzy.packer;
+ pixelart.Globals.packer = fizzy.packer;
// Hand the window to the listener thread and queue our own argv so the
// first frame opens any files / project folder supplied on the command line.
@@ -231,7 +233,7 @@ pub fn AppDeinit() void {
// Persist the current windowed frame while the window still exists. No-op off macOS.
fizzy.backend.saveWindowGeometry(fizzy.app.window);
// Persist `.fizproject` while `editor.host` and `editor.folder` are still live.
- fizzy.State.persistProject(fizzy.pixelart);
+ pixelart.State.persistProject(fizzy.pixelart);
fizzy.editor.deinit() catch unreachable;
// Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs).
// After the editor so any editor teardown that still reads pixel-art state runs first.
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index c03797a6..00913396 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -14,16 +14,17 @@ const plus_jakarta_sans_ttf = assets.files.fonts.@"PlusJakartaSans-Regular.ttf";
const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf";
const fizzy = @import("../fizzy.zig");
+const pixelart = @import("pixelart");
+const Internal = pixelart.internal;
const dvui = @import("dvui");
const update_notify = @import("../backend/update_notify.zig");
const App = fizzy.App;
const Editor = @This();
-const Project = fizzy.pixelart_mod.Project;
+const Project = pixelart.Project;
pub const Recents = @import("Recents.zig");
pub const Settings = @import("Settings.zig");
-const Tools = fizzy.Tools;
pub const Dialogs = @import("dialogs/Dialogs.zig");
pub const Keybinds = @import("Keybinds.zig");
@@ -36,7 +37,7 @@ pub const Sidebar = @import("Sidebar.zig");
pub const Infobar = @import("Infobar.zig");
pub const Menu = @import("Menu.zig");
pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig");
-const PackJob = fizzy.PackJob;
+const PackJob = pixelart.PackJob;
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
@@ -57,6 +58,9 @@ atlas: fizzy.core.Atlas,
/// Plugin registry + service locator exposed to plugins
host: Host,
+/// Pixel-art plugin runtime state (owned by App; shell reaches it here instead of `fizzy.pixelart`).
+pixelart_state: *pixelart.State,
+
/// File-management workbench (per-branch explorer decorations, …)
workbench: Workbench,
@@ -253,6 +257,7 @@ pub fn init(
},
.themes = .empty,
.host = .init(app.allocator),
+ .pixelart_state = undefined,
.workbench = .init(app.allocator),
};
@@ -270,7 +275,7 @@ pub fn init(
// Start the long-lived save-queue worker. All .fiz async saves get
// serialized through this single thread (see `File.SaveQueue`); concurrent
// worker spawns were causing one save to wedge under contention.
- try fizzy.Internal.File.initSaveQueue();
+ try Internal.File.initSaveQueue();
{ // Setup themes
var fizzy_dark = dvui.themeGet();
@@ -477,7 +482,8 @@ pub fn postInit(editor: *Editor) !void {
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
// editor tick already runs on wasm. Order = sidebar order.
try @import("../plugins/workbench/src/plugin.zig").register(&editor.host);
- try fizzy.pixelart_mod.plugin.register(&editor.host);
+const pixelart_plugin = pixelart.plugin;
+ try pixelart_plugin.register(&editor.host);
// Shell built-in: Settings (owner = null; not a plugin).
try editor.host.registerSidebarView(.{
@@ -697,7 +703,7 @@ fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.Ne
.column_width = grid.column_width,
.row_height = grid.row_height,
});
- const owner = fizzy.pixelart_mod.plugin.pluginPtr();
+ const owner = pixelart.plugin.pluginPtr();
return .{ .ptr = file, .owner = owner, .id = file.id };
}
fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void {
@@ -745,8 +751,8 @@ fn shellIsPackingActive(ctx: *anyopaque) bool {
/// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`:
/// `docs.files` may reallocate and invalidate pointers stored at insert time.
-pub fn fileFromDoc(_: *Editor, doc: sdk.DocHandle) *fizzy.Internal.File {
- return fizzy.pixelart.docs.fileById(doc.id).?;
+pub fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File {
+ return editor.pixelart_state.docs.fileById(doc.id).?;
}
pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle {
@@ -766,9 +772,9 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle {
}
/// Store a loaded/created document in the plugin registry and register its handle.
-pub fn insertOpenDoc(editor: *Editor, file: fizzy.Internal.File, owner: *sdk.Plugin) !void {
- try fizzy.pixelart.docs.files.put(fizzy.app.allocator, file.id, file);
- const ptr = fizzy.pixelart.docs.files.getPtr(file.id).?;
+pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void {
+ try editor.pixelart_state.docs.files.put(fizzy.app.allocator, file.id, file);
+ const ptr = editor.pixelart_state.docs.files.getPtr(file.id).?;
try editor.open_files.put(fizzy.app.allocator, file.id, .{
// `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`.
.ptr = ptr,
@@ -1043,7 +1049,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
editor.setTitlebarColor();
editor.setWindowStyle();
- fizzy.render.frame_index +%= 1;
+ pixelart.render.frame_index +%= 1;
if (fizzy.perf.record) fizzy.perf.beginFrame();
defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog();
@@ -1070,7 +1076,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
const area = @as(u64, w) * @as(u64, h);
// Skip tiny canvases; large docs benefit most from moving split-target work off the first stroke.
if (area >= 512 * 512) {
- fizzy.render.warmupDrawingComposites(file) catch |err| {
+ pixelart.render.warmupDrawingComposites(file) catch |err| {
dvui.log.err("Composite warmup failed: {any}", .{err});
};
}
@@ -1491,18 +1497,16 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
}
}
- { // Radial Menu
-
+ { // Radial Menu (pixel-art plugin)
+ const pa = pixelart.plugin.pluginPtr();
+ try pa.tickKeybinds();
Keybinds.tick() catch {
dvui.log.err("Failed to tick hotkeys", .{});
};
- processHoldOpenRadialMenu(editor);
-
- if (fizzy.pixelart.tools.radial_menu.visible) {
- editor.drawRadialMenu() catch {
- dvui.log.err("Failed to draw radial menu", .{});
- };
+ pa.processRadialMenuInput();
+ if (pa.radialMenuVisible()) {
+ try pa.drawRadialMenu();
}
}
@@ -1697,267 +1701,6 @@ pub fn setWindowStyle(_: *Editor) void {
fizzy.backend.setWindowStyle(dvui.currentWindow());
}
-/// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after
-/// the opening finger lifts; close on tool button click or a non-drag click outside.
-fn processHoldOpenRadialMenu(_: *Editor) void {
- const rm = &fizzy.pixelart.tools.radial_menu;
- if (!rm.visible or !rm.opened_by_press) {
- rm.outside_click_press_p = null;
- return;
- }
-
- const dismiss_move_threshold: f32 = dvui.Dragging.threshold;
-
- for (dvui.events()) |*e| {
- if (e.evt != .mouse) continue;
- const me = e.evt.mouse;
- rm.mouse_position = me.p;
-
- const primary = me.button.pointer() or me.button.touch();
- if (!primary) continue;
-
- switch (me.action) {
- .press => {
- if (!rm.containsPhysical(me.p)) {
- rm.outside_click_press_p = me.p;
- } else {
- rm.outside_click_press_p = null;
- }
- },
- .motion => {
- if (rm.outside_click_press_p) |press_p| {
- if (me.p.diff(press_p).length() > dismiss_move_threshold) {
- rm.outside_click_press_p = null;
- }
- }
- },
- .release => {
- if (rm.suppress_next_pointer_release) {
- rm.suppress_next_pointer_release = false;
- rm.outside_click_press_p = null;
- continue;
- }
- if (rm.outside_click_press_p) |press_p| {
- const moved = me.p.diff(press_p).length() > dismiss_move_threshold;
- if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) {
- rm.close();
- }
- rm.outside_click_press_p = null;
- }
- },
- else => {},
- }
- }
-}
-
-pub fn drawRadialMenu(editor: *Editor) !void {
- var fw: dvui.FloatingWidget = undefined;
- fw.init(@src(), .{}, .{
- .rect = .cast(dvui.windowRect()),
- });
- defer fw.deinit();
-
- const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0);
-
- // `center` is set when the menu opens (Space down or hold on empty workspace) and stays
- // fixed until close so tool buttons remain hoverable/clickable.
- const center = fw.data().rectScale().pointFromPhysical(fizzy.pixelart.tools.radial_menu.center);
-
- const tool_count: usize = std.meta.fields(Tools.Tool).len;
-
- const radius: f32 = 50.0;
- const width: f32 = radius * 2.0;
- const height: f32 = radius * 2.0;
- const step: f32 = (2.0 * std.math.pi) / @as(f32, @floatFromInt(tool_count));
-
- var angle: f32 = 180.0;
-
- var outer_anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{});
-
- const temp_radius: f32 = 3.0 * radius * (outer_anim.val orelse 1.0);
-
- var outer_rect = dvui.Rect.fromPoint(center);
- outer_rect.w = temp_radius;
- outer_rect.h = temp_radius;
- outer_rect.x -= outer_rect.w / 2.0;
- outer_rect.y -= outer_rect.h / 2.0;
-
- var box = dvui.box(@src(), .{ .dir = .horizontal }, .{
- .rect = outer_rect,
- .expand = .none,
- .background = true,
- .corner_radius = dvui.Rect.all(100000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -4.0, .y = 4.0 },
- .fade = 8.0,
- .alpha = 0.35,
- },
- .color_fill = menu_color.opacity(0.75),
- .border = dvui.Rect.all(0.0),
- });
-
- box.deinit();
-
- outer_anim.deinit();
-
- for (0..tool_count) |i| {
- var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{
- .id_extra = i,
- });
- defer anim.deinit();
-
- if (anim.val) |val| {
- angle += ((1 - val) * 100.0) * 0.015;
- }
-
- var color = dvui.themeGet().color(.control, .fill_hover);
- if (fizzy.pixelart.colors.file_tree_palette) |*palette| {
- color = palette.getDVUIColor(i);
- }
-
- const x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle) - width / 2.0);
- const y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle) - height / 2.0);
-
- const new_center = center.plus(.{ .x = x, .y = y });
-
- { // Draw line along pie slice
- // const line_x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle + step / 2.0) - width / 2.0);
- // const line_y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle + step / 2.0) - height / 2.0);
-
- // const new_line_center = center.plus((dvui.Point{ .x = line_x, .y = line_y }).normalize().scale(radius * 1.5, dvui.Point));
-
- // dvui.Path.stroke(.{ .points = &.{ center.scale(scale, dvui.Point.Physical), new_line_center.scale(scale, dvui.Point.Physical) } }, .{
- // .color = dvui.themeGet().color(.control, .text),
- // .thickness = 1.0,
- // });
- }
-
- var rect = dvui.Rect.fromPoint(new_center);
-
- rect.w = 40.0;
- rect.h = 40.0;
- rect.x -= rect.w / 2.0;
- rect.y -= rect.h / 2.0;
-
- const tool = @as(Tools.Tool, @enumFromInt(i));
-
- var button: dvui.ButtonWidget = undefined;
- button.init(@src(), .{}, .{
- .rect = rect,
- .id_extra = i,
- .corner_radius = dvui.Rect.all(1000.0),
- .color_fill = if (tool == fizzy.pixelart.tools.current) dvui.themeGet().color(.content, .fill) else .transparent,
- .box_shadow = if (tool == fizzy.pixelart.tools.current) .{
- .color = .black,
- .offset = .{ .x = -2.5, .y = 2.5 },
- .fade = 4.0,
- .alpha = 0.25,
- .corner_radius = dvui.Rect.all(1000),
- } else null,
- .padding = .all(0),
- .margin = .all(0),
- });
-
- {
- fizzy.pixelart.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {};
- }
-
- const selection_sprite = switch (fizzy.pixelart.tools.selection_mode) {
- .box => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.box_selection_default],
- .pixel => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pixel_selection_default],
- .color => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.color_selection_default],
- };
-
- const sprite = switch (@as(Tools.Tool, @enumFromInt(i))) {
- .pointer => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.cursor_default],
- .pencil => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.pencil_default],
- .eraser => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.eraser_default],
- .bucket => fizzy.editor.atlas.sprites[fizzy.atlas.sprites.bucket_default],
- .selection => selection_sprite,
- };
- const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 1, .h = 1 };
- const atlas_w = if (size.w > 0) size.w else 1;
- const atlas_h = if (size.h > 0) size.h else 1;
-
- const uv = dvui.Rect{
- .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_w,
- .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_h,
- .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_w,
- .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_h,
- };
-
- button.processEvents();
- button.drawBackground();
-
- var rs = button.data().contentRectScale();
-
- const w = @as(f32, @floatFromInt(sprite.source[2])) * rs.s;
- const h = @as(f32, @floatFromInt(sprite.source[3])) * rs.s;
-
- rs.r.x += (rs.r.w - w) / 2.0;
- rs.r.y += (rs.r.h - h) / 2.0;
- rs.r.w = w;
- rs.r.h = h;
-
- dvui.renderImage(fizzy.editor.atlas.source, rs, .{
- .uv = uv,
- .fade = 0.0,
- }) catch {
- std.log.err("Failed to render image", .{});
- };
- angle += step;
-
- if (button.hovered()) {
- fizzy.pixelart.tools.set(tool);
- }
- if (button.clicked()) {
- fizzy.pixelart.tools.set(tool);
- fizzy.pixelart.tools.radial_menu.close();
- }
-
- button.deinit();
- }
-
- { // Center play/pause button
-
- var anim = dvui.animate(@src(), .{ .duration = 100_000, .kind = .alpha, .easing = dvui.easing.linear }, .{
- .id_extra = tool_count + 1,
- });
- defer anim.deinit();
-
- var rect = dvui.Rect.fromPoint(center);
-
- rect.w = 40.0;
- rect.h = 40.0;
- rect.x -= rect.w / 2.0;
- rect.y -= rect.h / 2.0;
-
- {
- if (editor.activeFile()) |file| {
- if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{
- .expand = .none,
- .corner_radius = dvui.Rect.all(1000),
- .box_shadow = .{
- .color = .black,
- .offset = .{ .x = -2.5, .y = 2.5 },
- .fade = 4.0,
- .alpha = 0.25,
- .corner_radius = dvui.Rect.all(1000),
- },
- .color_fill = dvui.themeGet().color(.control, .fill_hover),
- .rect = rect,
- })) {
- file.editor.playing = !file.editor.playing;
- if (fizzy.pixelart.tools.radial_menu.opened_by_press) {
- fizzy.pixelart.tools.radial_menu.close();
- }
- }
- }
- }
- }
-}
-
pub fn rebuildWorkspaces(editor: *Editor) !void {
// Create workspaces for each grouping ID
@@ -2115,7 +1858,7 @@ fn tickPendingSaveCloses(editor: *Editor) void {
var i: usize = 0;
while (i < editor.pending_close_after_save.count()) {
const id = editor.pending_close_after_save.keys()[i];
- if (fizzy.pixelart.docs.fileById(id)) |f| {
+ if (editor.pixelart_state.docs.fileById(id)) |f| {
if (f.isSaving()) {
i += 1;
continue;
@@ -2145,7 +1888,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
// Pass 1: kick off any queued saves we haven't started yet.
while (editor.quit_save_all_ids.items.len > 0) {
const id = editor.quit_save_all_ids.items[0];
- const file_ptr = fizzy.pixelart.docs.fileById(id) orelse {
+ const file_ptr = editor.pixelart_state.docs.fileById(id) orelse {
_ = editor.quit_save_all_ids.swapRemove(0);
continue;
};
@@ -2154,7 +1897,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
continue;
}
- if (!fizzy.Internal.File.hasRecognizedSaveExtension(file_ptr.path)) {
+ if (!Internal.File.hasRecognizedSaveExtension(file_ptr.path)) {
// Save As dialog needs a single active file — bail out of the parallel
// kickoff for this one and let the existing Save As + pending_close_file_id
// flow handle it. Next frame, pending_quit_continue will re-enter us.
@@ -2194,7 +1937,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
var i: usize = 0;
while (i < editor.quit_saves_in_flight.count()) {
const id = editor.quit_saves_in_flight.keys()[i];
- if (fizzy.pixelart.docs.fileById(id)) |f| {
+ if (editor.pixelart_state.docs.fileById(id)) |f| {
if (f.isSaving()) {
i += 1;
continue;
@@ -2238,7 +1981,7 @@ pub fn close(app: *App, editor: *Editor) void {
pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
if (editor.folder) |folder| {
editor.ignore.deinit(fizzy.app.allocator);
- if (fizzy.pixelart.project) |*project| {
+ if (editor.pixelart_state.project) |*project| {
project.save() catch {
dvui.log.err("Failed to save project", .{});
};
@@ -2249,7 +1992,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files);
- fizzy.pixelart.project = Project.load(fizzy.app.allocator) catch null;
+ editor.pixelart_state.project = Project.load(fizzy.app.allocator) catch null;
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
}
@@ -2344,7 +2087,7 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool {
}
/// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`).
-pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !fizzy.Internal.File {
+pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !Internal.File {
for (editor.open_files.values()) |doc| {
const file = editor.fileFromDoc(doc);
if (std.mem.eql(u8, file.path, path)) {
@@ -2361,7 +2104,7 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin
return error.InvalidExtension;
};
- var file: fizzy.Internal.File = undefined;
+ var file: Internal.File = undefined;
const handled = owner.loadDocumentFromBytes(path, bytes, &file) catch |err| {
fizzy.app.allocator.free(path);
return err;
@@ -2499,7 +2242,7 @@ pub fn startPackProject(editor: *Editor) !void {
// predecessor publishes `done` between append and cancel: `processPackJob` walks the list
// newest-first and would otherwise see an old non-cancelled ready job and install its
// (stale) atlas. Cancelled predecessors are skipped during install selection.
- for (fizzy.pixelart.pack_jobs.items) |old| {
+ for (editor.pixelart_state.pack_jobs.items) |old| {
old.cancelled.store(true, .monotonic);
}
@@ -2507,8 +2250,8 @@ pub fn startPackProject(editor: *Editor) !void {
owned_inputs = null;
errdefer job.destroy();
- try fizzy.pixelart.pack_jobs.append(fizzy.app.allocator, job);
- errdefer _ = fizzy.pixelart.pack_jobs.pop();
+ try editor.pixelart_state.pack_jobs.append(fizzy.app.allocator, job);
+ errdefer _ = editor.pixelart_state.pack_jobs.pop();
if (comptime builtin.target.cpu.arch == .wasm32) {
// Worker runs at end of `tick` (after the explorer draws) so the Pack
@@ -2522,8 +2265,8 @@ pub fn startPackProject(editor: *Editor) !void {
/// True while a pack is queued, running, or finished but not yet installed into
/// `fizzy.packer.atlas`. Drives the explorer Pack button spinner.
-pub fn isPackingActive(_: *const Editor) bool {
- for (fizzy.pixelart.pack_jobs.items) |job| {
+pub fn isPackingActive(editor: *const Editor) bool {
+ for (editor.pixelart_state.pack_jobs.items) |job| {
if (job.cancelled.load(.monotonic)) continue;
if (!job.done.load(.acquire)) return true;
if (!job.result_consumed) return true;
@@ -2532,8 +2275,8 @@ pub fn isPackingActive(_: *const Editor) bool {
}
/// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback.
-fn runWasmPackWorkers(_: *Editor) void {
- for (fizzy.pixelart.pack_jobs.items) |job| {
+fn runWasmPackWorkers(editor: *Editor) void {
+ for (editor.pixelart_state.pack_jobs.items) |job| {
if (job.cancelled.load(.monotonic)) continue;
if (job.done.load(.acquire)) continue;
PackJob.workerMain(job);
@@ -2562,7 +2305,7 @@ fn gatherPackInputs(
while (try iter.next(io)) |entry| {
if (entry.kind == .file) {
const ext = std.fs.path.extension(entry.name);
- if (!fizzy.Internal.File.isFizzyExtension(ext)) continue;
+ if (!Internal.File.isFizzyExtension(ext)) continue;
const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name });
defer fizzy.app.allocator.free(abs_path);
@@ -2581,7 +2324,7 @@ fn gatherPackInputs(
}
/// Match a project-tree path to an open file (`file.path` may differ in normalization from `join` vs `joinZ`).
-fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.File {
+fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*Internal.File {
if (editor.getFileFromPath(path)) |file| return file;
const basename = std.fs.path.basename(path);
@@ -2618,17 +2361,17 @@ fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void {
/// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting
/// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases.
pub fn processPackJob(editor: *Editor) void {
- if (fizzy.pixelart.pack_jobs.items.len == 0) return;
+ if (editor.pixelart_state.pack_jobs.items.len == 0) return;
// Identify the newest (last appended) job that finished with a `.ready` result and was
// not cancelled. Only its result is installed; older successful results are stale and
// get discarded along with cancelled / failed ones.
var install_index: ?usize = null;
{
- var i = fizzy.pixelart.pack_jobs.items.len;
+ var i = editor.pixelart_state.pack_jobs.items.len;
while (i > 0) {
i -= 1;
- const job = fizzy.pixelart.pack_jobs.items[i];
+ const job = editor.pixelart_state.pack_jobs.items[i];
if (!job.done.load(.acquire)) continue;
if (job.cancelled.load(.monotonic)) continue;
if (job.currentPhase() == .ready and job.result_atlas != null) {
@@ -2639,7 +2382,7 @@ pub fn processPackJob(editor: *Editor) void {
}
if (install_index) |idx| {
- const job = fizzy.pixelart.pack_jobs.items[idx];
+ const job = editor.pixelart_state.pack_jobs.items[idx];
const new_atlas = job.result_atlas.?;
// Free the previously-installed atlas's allocations so the new one can take its
// place — matches the synchronous `packAndClear` cleanup ordering.
@@ -2659,16 +2402,16 @@ pub fn processPackJob(editor: *Editor) void {
}
fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
job.result_consumed = true;
- editor.host.setActiveSidebarView(fizzy.pixelart_mod.plugin.view_project);
+ editor.host.setActiveSidebarView(pixelart.plugin.view_project);
const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null;
showPackToast("Project packed", toast_canvas);
} else blk: {
// Newest finished job had no atlas (empty inputs / no packable frames). Tell the user
// so the Pack button doesn't look like it silently did nothing.
- var i = fizzy.pixelart.pack_jobs.items.len;
+ var i = editor.pixelart_state.pack_jobs.items.len;
while (i > 0) {
i -= 1;
- const job = fizzy.pixelart.pack_jobs.items[i];
+ const job = editor.pixelart_state.pack_jobs.items[i];
if (!job.done.load(.acquire)) continue;
if (job.cancelled.load(.monotonic)) continue;
if (job.currentPhase() == .ready and job.result_atlas == null) {
@@ -2681,9 +2424,9 @@ pub fn processPackJob(editor: *Editor) void {
// Reap everything that has published `done`. Successful-but-superseded jobs leave their
// `result_atlas` un-consumed; `destroy()` frees those allocations for us.
var write: usize = 0;
- for (fizzy.pixelart.pack_jobs.items) |job| {
+ for (editor.pixelart_state.pack_jobs.items) |job| {
if (!job.done.load(.acquire)) {
- fizzy.pixelart.pack_jobs.items[write] = job;
+ editor.pixelart_state.pack_jobs.items[write] = job;
write += 1;
continue;
}
@@ -2698,7 +2441,7 @@ pub fn processPackJob(editor: *Editor) void {
}
job.destroy();
}
- fizzy.pixelart.pack_jobs.shrinkRetainingCapacity(write);
+ editor.pixelart_state.pack_jobs.shrinkRetainingCapacity(write);
}
/// Returns the active workspace's canvas content rect (physical pixels) captured from the
@@ -2895,21 +2638,21 @@ pub fn requestCompositeWarmup(editor: *Editor) void {
editor.pending_composite_warmup = true;
}
-pub fn newFile(editor: *Editor, path: []const u8, options: fizzy.Internal.File.InitOptions) !*fizzy.Internal.File {
+pub fn newFile(editor: *Editor, path: []const u8, options: Internal.File.InitOptions) !*Internal.File {
if (editor.getFileFromPath(path)) |_| {
return error.FileAlreadyExists;
}
- const file = fizzy.Internal.File.init(path, options) catch {
+ const file = Internal.File.init(path, options) catch {
dvui.log.err("Failed to create file: {s}", .{path});
return error.FailedToCreateFile;
};
- try editor.insertOpenDoc(file, fizzy.pixelart_mod.plugin.pluginPtr());
+ try editor.insertOpenDoc(file, pixelart.plugin.pluginPtr());
editor.setActiveFile(editor.open_files.count() - 1);
editor.pending_composite_warmup = true;
- return fizzy.pixelart.docs.fileById(file.id) orelse return error.FailedToCreateFile;
+ return editor.pixelart_state.docs.fileById(file.id) orelse return error.FailedToCreateFile;
}
/// Heap-owned path like `untitled-1`, unique among open-document basenames.
@@ -2983,22 +2726,22 @@ pub fn setActiveFile(editor: *Editor, index: usize) void {
}
/// Returns the actively focused file, through workspace grouping.
-pub fn activeFile(editor: *Editor) ?*fizzy.Internal.File {
+pub fn activeFile(editor: *Editor) ?*Internal.File {
const doc = editor.activeDoc() orelse return null;
return editor.fileFromDoc(doc);
}
-pub fn getFile(editor: *Editor, index: usize) ?*fizzy.Internal.File {
+pub fn getFile(editor: *Editor, index: usize) ?*Internal.File {
return editor.fileAt(index);
}
-pub fn fileAt(editor: *Editor, index: usize) ?*fizzy.Internal.File {
+pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File {
const doc = editor.docAt(index) orelse return null;
return editor.fileFromDoc(doc);
}
-pub fn getFileFromPath(_: *Editor, path: []const u8) ?*fizzy.Internal.File {
- return fizzy.pixelart.docs.fileFromPath(path);
+pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File {
+ return editor.pixelart_state.docs.fileFromPath(path);
}
pub fn forceCloseFile(editor: *Editor, index: usize) !void {
@@ -3035,15 +2778,15 @@ pub fn copy(editor: *Editor) !void {
if (editor.activeFile()) |file| {
if (file.editor.transform != null) return;
- if (fizzy.pixelart.sprite_clipboard) |*clipboard| {
+ if (editor.pixelart_state.sprite_clipboard) |*clipboard| {
fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source));
- fizzy.pixelart.sprite_clipboard = null;
+ editor.pixelart_state.sprite_clipboard = null;
}
file.editor.transform_layer.clear();
var selected_layer = file.layers.get(file.selected_layer_index);
- switch (fizzy.pixelart.tools.current) {
+ switch (editor.pixelart_state.tools.current) {
.selection => {
// We are in the selection tool, so we should assume that the user has painted a selection
// into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing
@@ -3108,7 +2851,7 @@ pub fn copy(editor: *Editor) !void {
if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
const sprite_tl = file.spritePoint(reduced_data_rect.topLeft());
- fizzy.pixelart.sprite_clipboard = .{
+ editor.pixelart_state.sprite_clipboard = .{
.source = fizzy.image.fromPixelsPMA(
@ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)),
@intFromFloat(reduced_data_rect.w),
@@ -3131,7 +2874,7 @@ pub fn copy(editor: *Editor) !void {
}
pub fn paste(editor: *Editor) !void {
- if (fizzy.pixelart.sprite_clipboard) |*clipboard| {
+ if (editor.pixelart_state.sprite_clipboard) |*clipboard| {
if (editor.activeFile()) |file| {
const active_layer = file.layers.get(file.selected_layer_index);
@@ -3145,7 +2888,7 @@ pub fn paste(editor: *Editor) !void {
dst_rect.y = sprite_rect.y + clipboard.offset.y;
file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
dvui.log.err("Failed to create target texture", .{});
return;
},
@@ -3188,7 +2931,7 @@ pub fn paste(editor: *Editor) !void {
dst_rect.y = rect.y + clipboard.offset.y;
file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
dvui.log.err("Failed to create target texture", .{});
return;
},
@@ -3217,7 +2960,7 @@ pub fn paste(editor: *Editor) !void {
}
file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
dvui.log.err("Failed to create target texture", .{});
return;
},
@@ -3259,7 +3002,7 @@ pub fn transform(editor: *Editor) !void {
var selected_layer = file.layers.get(file.selected_layer_index);
- switch (fizzy.pixelart.tools.current) {
+ switch (editor.pixelart_state.tools.current) {
.selection => {
file.editor.transform_layer.clear();
// We are in the selection tool, so we should assume that the user has painted a selection
@@ -3338,7 +3081,7 @@ pub fn transform(editor: *Editor) !void {
if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
defer file.editor.selection_layer.clearMask();
file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
dvui.log.err("Failed to create target texture", .{});
return;
},
@@ -3374,7 +3117,7 @@ pub fn transform(editor: *Editor) !void {
/// Paths without a recognized on-disk extension (e.g. in-memory `untitled-n`) open Save As instead.
pub fn save(editor: *Editor) !void {
const file = editor.activeFile() orelse return;
- if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) {
+ if (!Internal.File.hasRecognizedSaveExtension(file.path)) {
editor.requestSaveAs();
return;
}
@@ -3407,7 +3150,7 @@ pub fn saveAll(editor: *Editor) !void {
for (editor.open_files.values()) |doc| {
const file = editor.fileFromDoc(doc);
if (!file.dirty()) continue;
- if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue;
+ if (!Internal.File.hasRecognizedSaveExtension(file.path)) continue;
if (file.shouldConfirmFlatRasterSave()) continue;
const plugin = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse continue;
plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }) catch |err| {
@@ -3425,7 +3168,7 @@ const save_as_dialog_filters: [3]fizzy.backend.DialogFileFilter = .{
/// Opens a Save As dialog: `.fiz` (all layers; `.pixi` also accepted for legacy) or flat `.png` / `.jpg` / `.jpeg` (visible layers composited).
pub fn requestSaveAs(_: *Editor) void {
const active = fizzy.editor.activeFile() orelse return;
- const def = fizzy.Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch {
+ const def = Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch {
std.log.err("Failed to build default save-as name", .{});
return;
};
@@ -3453,7 +3196,7 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void {
if (file_id) |id| {
_ = editor.pending_close_after_save.swapRemove(id);
- if (fizzy.pixelart.docs.fileById(id)) |f| {
+ if (editor.pixelart_state.docs.fileById(id)) |f| {
f.resetSaveUIState();
}
} else if (editor.activeFile()) |f| {
@@ -3503,7 +3246,7 @@ fn processPendingSaveAs(editor: *Editor) void {
const file = editor.activeFile() orelse return;
const ext = std.fs.path.extension(path);
const saved: bool = blk: {
- if (fizzy.Internal.File.isFizzyExtension(ext)) {
+ if (Internal.File.isFizzyExtension(ext)) {
file.saveAsFizzy(path, dvui.currentWindow()) catch |err| {
dvui.log.err("Save As: {any}", .{err});
break :blk false;
@@ -3541,7 +3284,7 @@ fn processPendingSaveAs(editor: *Editor) void {
};
const saved: bool = blk: {
- if (fizzy.Internal.File.isFizzyExtension(ext)) {
+ if (Internal.File.isFizzyExtension(ext)) {
file.saveAsFizzy(path, dvui.currentWindow()) catch |err| {
dvui.log.err("Save As: {any}", .{err});
break :blk false;
@@ -3604,7 +3347,7 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void {
pub fn closeFileID(editor: *Editor, id: u64) !void {
if (editor.open_files.contains(id)) {
- if (fizzy.pixelart.docs.fileById(id)) |file| {
+ if (editor.pixelart_state.docs.fileById(id)) |file| {
if (file.dirty()) {
Dialogs.UnsavedClose.request(id);
return;
@@ -3624,11 +3367,11 @@ pub fn closeFile(editor: *Editor, index: usize) !void {
/// the matching `DocHandle` from `open_files`.
fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void {
if (doc.owner.closeDocument(doc)) {
- _ = fizzy.pixelart.docs.files.swapRemove(doc.id);
+ _ = editor.pixelart_state.docs.files.swapRemove(doc.id);
return;
}
editor.fileFromDoc(doc).deinit();
- _ = fizzy.pixelart.docs.files.swapRemove(doc.id);
+ _ = editor.pixelart_state.docs.files.swapRemove(doc.id);
}
pub fn rawCloseFile(editor: *Editor, index: usize) !void {
@@ -3673,14 +3416,14 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
pub fn closeReference(editor: *Editor, index: usize) !void {
editor.open_reference_index = 0;
- var reference: fizzy.Internal.Reference = editor.open_references.orderedRemove(index);
+ var reference: Internal.Reference = editor.open_references.orderedRemove(index);
reference.deinit();
}
pub fn deinit(editor: *Editor) !void {
// Drain & join the save-queue worker before tearing anything else down. Any
// queued jobs need to finish writing or be dropped before File data is freed.
- fizzy.Internal.File.deinitSaveQueue();
+ Internal.File.deinitSaveQueue();
// Signal cancel to any in-flight load workers. They check the flag after `fromPath` returns
// and discard the result; we can't synchronously join them without blocking quit, so we
// accept a brief window where a worker may still be running with a discardable result.
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index c5714204..f7289a5a 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -71,34 +71,6 @@ pub fn tick() !void {
}
}
- if (ke.matchBind("quick_tools")) {
- const rm = &fizzy.pixelart.tools.radial_menu;
- switch (ke.action) {
- .down => {
- const mp = dvui.currentWindow().mouse_pt;
- rm.mouse_position = mp;
- rm.center = mp;
- rm.opened_by_press = false;
- rm.suppress_next_pointer_release = false;
- rm.outside_click_press_p = null;
- rm.visible = true;
- },
- .repeat => rm.visible = true,
- .up => rm.close(),
- }
- // If we include a refresh here, the underlying gui has a chance to reset the cursor
- dvui.refresh(null, @src(), dvui.currentWindow().data().id);
- }
-
- if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) {
- if (fizzy.pixelart.tools.stroke_size < fizzy.Tools.max_brush_size - 1)
- fizzy.pixelart.tools.stroke_size += 1;
-
- fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
- }
- }
-
if (ke.matchBind("save_as") and ke.action == .down) {
fizzy.editor.requestSaveAs();
}
@@ -109,32 +81,6 @@ pub fn tick() !void {
};
}
- if (ke.matchBind("export") and ke.action == .down) {
- // Create a generic dialog that contains typical okay and cancel buttons and header
- // The displayFn will be called during the drawing of the dialog, prior to ok and cancel buttons
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = fizzy.Editor.Dialogs.Export.dialog,
- .callafterFn = fizzy.Editor.Dialogs.Export.callAfter,
- .title = "Export...",
- .ok_label = "Export",
- .cancel_label = "Cancel",
- .resizeable = false,
- .modal = false,
- .header_kind = .info,
- .default = .ok,
- });
- mutex.mutex.unlock(dvui.io);
- }
-
- if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
- if (fizzy.pixelart.tools.current != .selection or fizzy.pixelart.tools.selection_mode == .pixel) {
- if (fizzy.pixelart.tools.stroke_size > 1)
- fizzy.pixelart.tools.stroke_size -= 1;
-
- fizzy.pixelart.tools.setStrokeSize(fizzy.pixelart.tools.stroke_size);
- }
- }
-
if (ke.matchBind("delete_selection_contents")) {
if (ke.action == .down) {
fizzy.editor.deleteSelectedContents();
@@ -210,22 +156,6 @@ pub fn tick() !void {
}
}
}
-
- if (ke.matchBind("pencil") and ke.action == .down) {
- fizzy.pixelart.tools.set(.pencil);
- }
- if (ke.matchBind("eraser") and ke.action == .down) {
- fizzy.pixelart.tools.set(.eraser);
- }
- if (ke.matchBind("bucket") and ke.action == .down) {
- fizzy.pixelart.tools.set(.bucket);
- }
- if (ke.matchBind("pointer") and ke.action == .down) {
- fizzy.pixelart.tools.set(.pointer);
- }
- if (ke.matchBind("selection") and ke.action == .down) {
- fizzy.pixelart.tools.set(.selection);
- }
},
else => {},
}
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index 6a83cc57..cdc13904 100644
--- a/src/editor/Menu.zig
+++ b/src/editor/Menu.zig
@@ -1,5 +1,7 @@
const std = @import("std");
const fizzy = @import("../fizzy.zig");
+const pixelart = @import("pixelart");
+const Internal = pixelart.internal;
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const settings = fizzy.settings;
@@ -134,7 +136,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
_ = dvui.separator(@src(), .{ .expand = .horizontal });
if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeFile()) |file|
- (file.dirty() or !fizzy.Internal.File.hasRecognizedSaveExtension(file.path))
+ (file.dirty() or !Internal.File.hasRecognizedSaveExtension(file.path))
else
false, .{}, .{
.expand = .horizontal,
@@ -159,7 +161,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
const any_dirty = blk: {
for (fizzy.editor.open_files.values()) |doc| {
const f = fizzy.editor.fileFromDoc(doc);
- if (f.dirty() and fizzy.Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true;
+ if (f.dirty() and Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true;
}
break :blk false;
};
diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig
index 9dc99d99..b851d228 100644
--- a/src/editor/dialogs/Dialogs.zig
+++ b/src/editor/dialogs/Dialogs.zig
@@ -1,16 +1,16 @@
const std = @import("std");
const builtin = @import("builtin");
+const pixelart = @import("pixelart");
const dvui = @import("dvui");
-const fizzy = @import("../../fizzy.zig");
const Dialogs = @This();
-pub const NewFile = fizzy.pixelart_mod.dialogs.NewFile;
-pub const Export = fizzy.pixelart_mod.dialogs.Export;
+pub const NewFile = pixelart.dialogs.NewFile;
+pub const Export = pixelart.dialogs.Export;
pub const UnsavedClose = @import("UnsavedClose.zig");
pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig");
-pub const GridLayout = fizzy.pixelart_mod.dialogs.GridLayout;
-pub const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning;
+pub const GridLayout = pixelart.dialogs.GridLayout;
+pub const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning;
pub const AboutFizzy = @import("AboutFizzy.zig");
pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32)
@import("WebFolderUnavailable.zig")
@@ -40,5 +40,5 @@ pub fn drawDimensionsLabel(
unit: []const u8,
opts: dvui.Options,
) void {
- fizzy.pixelart_mod.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts);
+ pixelart.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts);
}
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index 32cb0511..87a0d436 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -1,7 +1,9 @@
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
+const pixelart = @import("pixelart");
+const Internal = pixelart.internal;
const dvui = @import("dvui");
-const FlatRasterSaveWarning = fizzy.pixelart_mod.dialogs.FlatRasterSaveWarning;
+const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning;
pub fn request(file_id: u64) void {
var mutex = fizzy.dvui.dialog(@src(), .{
@@ -21,7 +23,7 @@ pub fn request(file_id: u64) void {
}
fn fileBasename(file_id: u64) []const u8 {
- const file = fizzy.pixelart.docs.fileById(file_id) orelse return "?";
+ const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return "?";
return std.fs.path.basename(file.path);
}
@@ -97,7 +99,7 @@ fn onCancel() void {
/// on the GUI thread) and queue the close for once `File.isSaving()` clears.
/// `Editor.tickPendingSaveCloses` does the actual close on the next frame after
/// the worker settles, so the GUI thread never blocks on the save.
-fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void {
+fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void {
if (file.isSaving()) return;
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
@@ -111,8 +113,8 @@ fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void {
}
fn onSaveAndClose(file_id: u64) !void {
- const file = fizzy.pixelart.docs.fileById(file_id) orelse return;
- if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) {
+ const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return;
+ if (!Internal.File.hasRecognizedSaveExtension(file.path)) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
fizzy.editor.setActiveFile(idx);
fizzy.editor.pending_close_file_id = file_id;
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 731678b5..7af9aed0 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -7,7 +7,7 @@ const icons = @import("icons");
const Core = @import("mach").Core;
const App = fizzy.App;
const Editor = fizzy.Editor;
-const Packer = fizzy.Packer;
+const pixelart = @import("pixelart");
const nfd = @import("nfd");
@@ -16,7 +16,7 @@ pub const Explorer = @This();
pub const files = @import("../../plugins/workbench/src/files.zig");
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
-pub const project = fizzy.pixelart_mod.explorer.project;
+pub const project = pixelart.explorer.project;
pub const settings = @import("settings.zig");
paned: *fizzy.dvui.PanedWidget = undefined,
diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig
index f63c1c39..fc4684e1 100644
--- a/src/editor/panel/Panel.zig
+++ b/src/editor/panel/Panel.zig
@@ -2,12 +2,13 @@ const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
+const pixelart = @import("pixelart");
const fizzy = @import("../../fizzy.zig");
const Core = @import("mach").Core;
const App = fizzy.App;
const Editor = fizzy.Editor;
-const Packer = fizzy.Packer;
+const Packer = pixelart.Packer;
pub const Panel = @This();
diff --git a/src/fizzy.zig b/src/fizzy.zig
index f684926a..61ae745c 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -17,13 +17,6 @@ pub const version: std.SemanticVersion = .{
pub const atlas = core.atlas;
// Other helpers and namespaces
-pub const pixelart_mod = @import("pixelart");
-pub const algorithms = pixelart_mod.algorithms;
-pub const render = pixelart_mod.render;
-pub const sprite_render = pixelart_mod.sprite_render;
-pub const Tools = pixelart_mod.Tools;
-pub const Transform = pixelart_mod.Transform;
-pub const PackJob = pixelart_mod.PackJob;
pub const fs = core.fs;
pub const image = core.image;
pub const perf = core.perf;
@@ -34,28 +27,19 @@ pub const App = @import("App.zig");
pub const Editor = @import("editor/Editor.zig");
pub const Explorer = @import("editor/explorer/Explorer.zig");
pub const Fling = core.Fling;
-pub const Packer = pixelart_mod.Packer;
//pub const Popups = @import("editor/popups/Popups.zig");
pub const Sidebar = @import("editor/Sidebar.zig");
-/// Pixel-art plugin state (Phase 4 Stage B/D): reached via `fizzy.pixelart` global.
-pub const State = pixelart_mod.State;
+/// Pixel-art plugin module. Shell code should `@import("pixelart")` directly;
+/// this alias exists only for `App.zig` lifecycle wiring (can't name it `pixelart`
+/// — that name is the runtime `*State` global below).
+pub const pixelart_mod = @import("pixelart");
// Global pointers
pub var app: *App = undefined;
pub var editor: *Editor = undefined;
-pub var packer: *Packer = undefined;
-pub var pixelart: *State = undefined;
-
-/// Internal runtime types for open documents (cameras, history, buffers, …).
-pub const Internal = pixelart_mod.internal;
-
-/// On-disk / JSON pixel-art types.
-pub const Animation = pixelart_mod.Animation;
-pub const Atlas = pixelart_mod.Atlas;
-pub const File = pixelart_mod.File;
-pub const Layer = pixelart_mod.Layer;
-pub const Sprite = pixelart_mod.Sprite;
+pub var packer: *pixelart_mod.Packer = undefined;
+pub var pixelart: *pixelart_mod.State = undefined;
/// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web
/// builds, where `builtin.os.tag` is always `.freestanding`.
diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig
index 88d31974..7bb86e8c 100644
--- a/src/plugins/pixelart/pixelart.zig
+++ b/src/plugins/pixelart/pixelart.zig
@@ -3,7 +3,7 @@
//! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or
//! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals
//! and shared plugin types. The compile-time module root for the build is `module.zig`
-//! (`@import("pixelart")`); shell code reaches the plugin through `fizzy.pixelart_mod`.
+//! (`@import("pixelart")`); shell code imports the module directly.
const std = @import("std");
pub const sdk = @import("sdk");
diff --git a/src/plugins/pixelart/src/keybind_ticks.zig b/src/plugins/pixelart/src/keybind_ticks.zig
new file mode 100644
index 00000000..fff4a09f
--- /dev/null
+++ b/src/plugins/pixelart/src/keybind_ticks.zig
@@ -0,0 +1,82 @@
+//! Global keybind handlers for pixel-art editing (tool shortcuts, radial menu, export).
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const Tools = pixelart.Tools;
+const Export = @import("dialogs/Export.zig");
+
+pub fn tick() !void {
+ for (dvui.events()) |e| {
+ if (e.handled) continue;
+
+ switch (e.evt) {
+ .key => |ke| {
+ if (ke.matchBind("quick_tools")) {
+ const rm = &Globals.state.tools.radial_menu;
+ switch (ke.action) {
+ .down => {
+ const mp = dvui.currentWindow().mouse_pt;
+ rm.mouse_position = mp;
+ rm.center = mp;
+ rm.opened_by_press = false;
+ rm.suppress_next_pointer_release = false;
+ rm.outside_click_press_p = null;
+ rm.visible = true;
+ },
+ .repeat => rm.visible = true,
+ .up => rm.close(),
+ }
+ dvui.refresh(null, @src(), dvui.currentWindow().data().id);
+ }
+
+ if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
+ if (Globals.state.tools.current != .selection or Globals.state.tools.selection_mode == .pixel) {
+ if (Globals.state.tools.stroke_size < Tools.max_brush_size - 1)
+ Globals.state.tools.stroke_size += 1;
+ Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size);
+ }
+ }
+
+ if (ke.matchBind("export") and ke.action == .down) {
+ var mutex = pixelart.core.dvui.dialog(@src(), .{
+ .displayFn = Export.dialog,
+ .callafterFn = Export.callAfter,
+ .title = "Export...",
+ .ok_label = "Export",
+ .cancel_label = "Cancel",
+ .resizeable = false,
+ .modal = false,
+ .header_kind = .info,
+ .default = .ok,
+ });
+ mutex.mutex.unlock(dvui.io);
+ }
+
+ if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) {
+ if (Globals.state.tools.current != .selection or Globals.state.tools.selection_mode == .pixel) {
+ if (Globals.state.tools.stroke_size > 1)
+ Globals.state.tools.stroke_size -= 1;
+ Globals.state.tools.setStrokeSize(Globals.state.tools.stroke_size);
+ }
+ }
+
+ if (ke.matchBind("pencil") and ke.action == .down) {
+ Globals.state.tools.set(.pencil);
+ }
+ if (ke.matchBind("eraser") and ke.action == .down) {
+ Globals.state.tools.set(.eraser);
+ }
+ if (ke.matchBind("bucket") and ke.action == .down) {
+ Globals.state.tools.set(.bucket);
+ }
+ if (ke.matchBind("pointer") and ke.action == .down) {
+ Globals.state.tools.set(.pointer);
+ }
+ if (ke.matchBind("selection") and ke.action == .down) {
+ Globals.state.tools.set(.selection);
+ }
+ },
+ else => {},
+ }
+ }
+}
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index 1d08ee96..73fd96b0 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -13,6 +13,8 @@ const CanvasData = @import("CanvasData.zig");
const FileWidget = @import("widgets/FileWidget.zig");
const ImageWidget = @import("widgets/ImageWidget.zig");
const PixelArtSettings = @import("Settings.zig");
+const KeybindTicks = @import("keybind_ticks.zig");
+const RadialMenu = @import("radial_menu.zig");
const DocHandle = sdk.DocHandle;
const Internal = pixelart.internal;
@@ -41,6 +43,10 @@ const vtable: sdk.Plugin.VTable = .{
.undo = undo,
.redo = redo,
.drawDocument = drawDocument,
+ .tickKeybinds = tickKeybinds,
+ .processRadialMenuInput = processRadialMenuInput,
+ .radialMenuVisible = radialMenuVisible,
+ .drawRadialMenu = drawRadialMenu,
};
/// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id`
@@ -297,6 +303,22 @@ fn drawSpritesPanel(_: ?*anyopaque) anyerror!void {
try Globals.state.sprites_panel.draw();
}
+fn tickKeybinds(_: *anyopaque) anyerror!void {
+ try KeybindTicks.tick();
+}
+
+fn processRadialMenuInput(_: *anyopaque) void {
+ RadialMenu.processHoldOpenInput();
+}
+
+fn radialMenuVisible(_: *anyopaque) bool {
+ return RadialMenu.visible();
+}
+
+fn drawRadialMenu(_: *anyopaque) anyerror!void {
+ try RadialMenu.draw();
+}
+
/// Pixel-art editing + tool keybinds. The shell registers its own global/region
/// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see
/// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used.
diff --git a/src/plugins/pixelart/src/radial_menu.zig b/src/plugins/pixelart/src/radial_menu.zig
new file mode 100644
index 00000000..103c7e2a
--- /dev/null
+++ b/src/plugins/pixelart/src/radial_menu.zig
@@ -0,0 +1,238 @@
+//! Radial tool menu overlay — opened via Space / hold on empty workspace.
+const std = @import("std");
+const dvui = @import("dvui");
+const icons = @import("icons");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const Tools = pixelart.Tools;
+
+pub fn visible() bool {
+ return Globals.state.tools.radial_menu.visible;
+}
+
+pub fn processHoldOpenInput() void {
+ const rm = &Globals.state.tools.radial_menu;
+ if (!rm.visible or !rm.opened_by_press) {
+ rm.outside_click_press_p = null;
+ return;
+ }
+
+ const dismiss_move_threshold: f32 = dvui.Dragging.threshold;
+
+ for (dvui.events()) |*e| {
+ if (e.evt != .mouse) continue;
+ const me = e.evt.mouse;
+ rm.mouse_position = me.p;
+
+ const primary = me.button.pointer() or me.button.touch();
+ if (!primary) continue;
+
+ switch (me.action) {
+ .press => {
+ if (!rm.containsPhysical(me.p)) {
+ rm.outside_click_press_p = me.p;
+ } else {
+ rm.outside_click_press_p = null;
+ }
+ },
+ .motion => {
+ if (rm.outside_click_press_p) |press_p| {
+ if (me.p.diff(press_p).length() > dismiss_move_threshold) {
+ rm.outside_click_press_p = null;
+ }
+ }
+ },
+ .release => {
+ if (rm.suppress_next_pointer_release) {
+ rm.suppress_next_pointer_release = false;
+ rm.outside_click_press_p = null;
+ continue;
+ }
+ if (rm.outside_click_press_p) |press_p| {
+ const moved = me.p.diff(press_p).length() > dismiss_move_threshold;
+ if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) {
+ rm.close();
+ }
+ rm.outside_click_press_p = null;
+ }
+ },
+ else => {},
+ }
+ }
+}
+
+pub fn draw() !void {
+ var fw: dvui.FloatingWidget = undefined;
+ fw.init(@src(), .{}, .{
+ .rect = .cast(dvui.windowRect()),
+ });
+ defer fw.deinit();
+
+ const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0);
+ const center = fw.data().rectScale().pointFromPhysical(Globals.state.tools.radial_menu.center);
+ const tool_count: usize = std.meta.fields(Tools.Tool).len;
+ const radius: f32 = 50.0;
+ const width: f32 = radius * 2.0;
+ const height: f32 = radius * 2.0;
+ const step: f32 = (2.0 * std.math.pi) / @as(f32, @floatFromInt(tool_count));
+ var angle: f32 = 180.0;
+
+ var outer_anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{});
+ const temp_radius: f32 = 3.0 * radius * (outer_anim.val orelse 1.0);
+ var outer_rect = dvui.Rect.fromPoint(center);
+ outer_rect.w = temp_radius;
+ outer_rect.h = temp_radius;
+ outer_rect.x -= outer_rect.w / 2.0;
+ outer_rect.y -= outer_rect.h / 2.0;
+
+ var box = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .rect = outer_rect,
+ .expand = .none,
+ .background = true,
+ .corner_radius = dvui.Rect.all(100000),
+ .box_shadow = .{
+ .color = .black,
+ .offset = .{ .x = -4.0, .y = 4.0 },
+ .fade = 8.0,
+ .alpha = 0.35,
+ },
+ .color_fill = menu_color.opacity(0.75),
+ .border = dvui.Rect.all(0.0),
+ });
+ box.deinit();
+ outer_anim.deinit();
+
+ const ui_atlas = Globals.state.host.uiAtlas();
+
+ for (0..tool_count) |i| {
+ var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{
+ .id_extra = i,
+ });
+ defer anim.deinit();
+
+ if (anim.val) |val| {
+ angle += ((1 - val) * 100.0) * 0.015;
+ }
+
+ var color = dvui.themeGet().color(.control, .fill_hover);
+ if (Globals.state.colors.file_tree_palette) |*palette| {
+ color = palette.getDVUIColor(i);
+ }
+
+ const x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle) - width / 2.0);
+ const y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle) - height / 2.0);
+ const new_center = center.plus(.{ .x = x, .y = y });
+ var rect = dvui.Rect.fromPoint(new_center);
+ rect.w = 40.0;
+ rect.h = 40.0;
+ rect.x -= rect.w / 2.0;
+ rect.y -= rect.h / 2.0;
+
+ const tool = @as(Tools.Tool, @enumFromInt(i));
+ var button: dvui.ButtonWidget = undefined;
+ button.init(@src(), .{}, .{
+ .rect = rect,
+ .id_extra = i,
+ .corner_radius = dvui.Rect.all(1000.0),
+ .color_fill = if (tool == Globals.state.tools.current) dvui.themeGet().color(.content, .fill) else .transparent,
+ .box_shadow = if (tool == Globals.state.tools.current) .{
+ .color = .black,
+ .offset = .{ .x = -2.5, .y = 2.5 },
+ .fade = 4.0,
+ .alpha = 0.25,
+ .corner_radius = dvui.Rect.all(1000),
+ } else null,
+ .padding = .all(0),
+ .margin = .all(0),
+ });
+
+ Globals.state.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {};
+
+ const selection_sprite = switch (Globals.state.tools.selection_mode) {
+ .box => ui_atlas.sprites[pixelart.atlas.sprites.box_selection_default],
+ .pixel => ui_atlas.sprites[pixelart.atlas.sprites.pixel_selection_default],
+ .color => ui_atlas.sprites[pixelart.atlas.sprites.color_selection_default],
+ };
+
+ const sprite = switch (tool) {
+ .pointer => ui_atlas.sprites[pixelart.atlas.sprites.cursor_default],
+ .pencil => ui_atlas.sprites[pixelart.atlas.sprites.pencil_default],
+ .eraser => ui_atlas.sprites[pixelart.atlas.sprites.eraser_default],
+ .bucket => ui_atlas.sprites[pixelart.atlas.sprites.bucket_default],
+ .selection => selection_sprite,
+ };
+
+ const size: dvui.Size = dvui.imageSize(ui_atlas.source) catch .{ .w = 1, .h = 1 };
+ const atlas_w = if (size.w > 0) size.w else 1;
+ const atlas_h = if (size.h > 0) size.h else 1;
+ const uv = dvui.Rect{
+ .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_w,
+ .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_h,
+ .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_w,
+ .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_h,
+ };
+
+ button.processEvents();
+ button.drawBackground();
+
+ var rs = button.data().contentRectScale();
+ const sw = @as(f32, @floatFromInt(sprite.source[2])) * rs.s;
+ const sh = @as(f32, @floatFromInt(sprite.source[3])) * rs.s;
+ rs.r.x += (rs.r.w - sw) / 2.0;
+ rs.r.y += (rs.r.h - sh) / 2.0;
+ rs.r.w = sw;
+ rs.r.h = sh;
+
+ dvui.renderImage(ui_atlas.source, rs, .{
+ .uv = uv,
+ .fade = 0.0,
+ }) catch {
+ std.log.err("Failed to render image", .{});
+ };
+ angle += step;
+
+ if (button.hovered()) {
+ Globals.state.tools.set(tool);
+ }
+ if (button.clicked()) {
+ Globals.state.tools.set(tool);
+ Globals.state.tools.radial_menu.close();
+ }
+
+ button.deinit();
+ }
+
+ var anim = dvui.animate(@src(), .{ .duration = 100_000, .kind = .alpha, .easing = dvui.easing.linear }, .{
+ .id_extra = tool_count + 1,
+ });
+ defer anim.deinit();
+
+ var rect = dvui.Rect.fromPoint(center);
+ rect.w = 40.0;
+ rect.h = 40.0;
+ rect.x -= rect.w / 2.0;
+ rect.y -= rect.h / 2.0;
+
+ if (Globals.state.host.activeDoc()) |doc| {
+ if (Globals.state.docs.fileById(doc.id)) |file| {
+ if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{
+ .expand = .none,
+ .corner_radius = dvui.Rect.all(1000),
+ .box_shadow = .{
+ .color = .black,
+ .offset = .{ .x = -2.5, .y = 2.5 },
+ .fade = 4.0,
+ .alpha = 0.25,
+ .corner_radius = dvui.Rect.all(1000),
+ },
+ .color_fill = dvui.themeGet().color(.control, .fill_hover),
+ .rect = rect,
+ })) {
+ file.editor.playing = !file.editor.playing;
+ if (Globals.state.tools.radial_menu.opened_by_press) {
+ Globals.state.tools.radial_menu.close();
+ }
+ }
+ }
+ }
+}
diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig
index 2d58d3ab..cfac2907 100644
--- a/src/plugins/workbench/src/FileLoadJob.zig
+++ b/src/plugins/workbench/src/FileLoadJob.zig
@@ -16,6 +16,8 @@
const std = @import("std");
const fizzy = @import("../../../fizzy.zig");
+const pixelart = @import("pixelart");
+const Internal = pixelart.internal;
const dvui = @import("dvui");
const perf = fizzy.perf;
@@ -67,7 +69,7 @@ cancelled: std.atomic.Value(bool) = .init(false),
done: std.atomic.Value(bool) = .init(false),
/// Filled by worker iff load succeeds AND wasn't cancelled. Safe to read after `done.load(.acquire)`.
-result: ?fizzy.Internal.File = null,
+result: ?Internal.File = null,
/// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`.
err: ?anyerror = null,
@@ -117,7 +119,7 @@ pub fn workerMain(job: *FileLoadJob) void {
// Route the actual load through the owning plugin (filled into a stack buffer the
// shell owns; the plugin knows its concrete document type). Mirrors the inline-value
// model below — no heap handoff.
- var file: fizzy.Internal.File = undefined;
+ var file: Internal.File = undefined;
const handled = job.owner.loadDocument(job.path, &file) catch |e| {
job.err = e;
job.phase.store(@intFromEnum(Phase.failed), .release);
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index 87f20b3a..0a489e42 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -3,6 +3,8 @@ const builtin = @import("builtin");
const dvui = @import("dvui");
const sdk = @import("sdk");
+const pixelart = @import("pixelart");
+const Internal = pixelart.internal;
const fizzy = @import("../../../fizzy.zig");
const icons = @import("icons");
@@ -38,13 +40,13 @@ pub fn init(grouping: u64) Workspace {
/// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed
/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown.
pub fn deinit(self: *Workspace) void {
- fizzy.State.removeCanvasPane(fizzy.pixelart, fizzy.app.allocator, self.grouping);
+ pixelart.State.removeCanvasPane(fizzy.editor.pixelart_state, fizzy.app.allocator, self.grouping);
}
/// Recover the typed workspace currently drawing `file` from its opaque slot
/// handle (`File.EditorData.workspace_handle`, set each frame in `drawCanvas`).
/// Returns null before the file has been laid out this session.
-pub fn ofFile(file: *fizzy.Internal.File) ?*Workspace {
+pub fn ofFile(file: *Internal.File) ?*Workspace {
const handle = file.editor.workspace_handle orelse return null;
return @ptrCast(@alignCast(handle));
}
@@ -200,7 +202,7 @@ fn drawTabs(self: *Workspace) void {
for (0..files_len) |i| {
const file = fizzy.editor.fileAt(i) orelse continue;
- const is_fizzy_file = fizzy.Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
+ const is_fizzy_file = Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
if (file.editor.grouping != self.grouping) continue;
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index cff326ac..d6dc5712 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -497,7 +497,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path });
var color = dvui.themeGet().color(.control, .fill);
- if (fizzy.pixelart.colors.palette) |*palette| {
+ if (fizzy.editor.pixelart_state.colors.palette) |*palette| {
color = palette.getDVUIColor(color_id.*);
}
diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig
index bd1d980f..edbc2e35 100644
--- a/src/sdk/DocHandle.zig
+++ b/src/sdk/DocHandle.zig
@@ -1,7 +1,7 @@
//! An opaque handle to an open document. The shell stores these per tab/workspace
//! and never inspects `ptr` — it only routes operations to `owner` (the plugin
//! that opened the document and knows how to render/save/undo it). For pixel art
-//! `ptr` is a `*fizzy.Internal.File`; a text plugin would point it at its own type.
+//! `ptr` is a `*pixelart.internal.File`; a text plugin would point it at its own type.
//!
//! Phase 0: defined but not yet produced/consumed anywhere (see the modular-editor
//! plan). Wired into the open/render/save path in Phase 3.
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index d4f5ea2f..06f0d526 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -57,6 +57,12 @@ pub const VTable = struct {
// ---- shell contributions ----
contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null,
contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null,
+
+ // ---- per-frame shell hooks (global keybinds, overlays) ----
+ tickKeybinds: ?*const fn (state: *anyopaque) anyerror!void = null,
+ processRadialMenuInput: ?*const fn (state: *anyopaque) void = null,
+ radialMenuVisible: ?*const fn (state: *anyopaque) bool = null,
+ drawRadialMenu: ?*const fn (state: *anyopaque) anyerror!void = null,
};
// Thin wrappers so callers don't repeat the optional-vtable dance.
@@ -69,6 +75,22 @@ pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void {
if (self.vtable.contributeKeybinds) |f| try f(self.state, win);
}
+pub fn tickKeybinds(self: Plugin) !void {
+ if (self.vtable.tickKeybinds) |f| try f(self.state);
+}
+
+pub fn processRadialMenuInput(self: Plugin) void {
+ if (self.vtable.processRadialMenuInput) |f| f(self.state);
+}
+
+pub fn radialMenuVisible(self: Plugin) bool {
+ return if (self.vtable.radialMenuVisible) |f| f(self.state) else false;
+}
+
+pub fn drawRadialMenu(self: Plugin) !void {
+ if (self.vtable.drawRadialMenu) |f| try f(self.state);
+}
+
// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ----
/// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin
diff --git a/src/web_main.zig b/src/web_main.zig
index 6534dad9..e810a970 100644
--- a/src/web_main.zig
+++ b/src/web_main.zig
@@ -11,6 +11,8 @@
const std = @import("std");
const dvui = @import("dvui");
const fizzy = @import("fizzy.zig");
+const pixelart = @import("pixelart");
+const Internal = pixelart.internal;
// Wasm-cleanliness probes. Referencing each symbol forces semantic analysis of its
// module graph; any compile error pinpoints what to gate next. Zero-cost at runtime.
@@ -26,25 +28,25 @@ comptime {
_ = fizzy.atlas;
// Algorithms — pure Zig + dvui
- _ = fizzy.algorithms.brezenham;
- _ = fizzy.algorithms.reduce;
+ _ = pixelart.algorithms.brezenham;
+ _ = pixelart.algorithms.reduce;
// Top-level data types (.pixi format on-disk shapes)
- _ = fizzy.Animation;
- _ = fizzy.Atlas;
- _ = fizzy.File;
- _ = fizzy.Layer;
- _ = fizzy.Sprite;
+ _ = pixelart.Animation;
+ _ = pixelart.Atlas;
+ _ = pixelart.File;
+ _ = pixelart.Layer;
+ _ = pixelart.Sprite;
// Internal editor-side data types
- _ = fizzy.Internal.Animation;
- _ = fizzy.Internal.Atlas;
- _ = fizzy.Internal.Buffers;
- _ = fizzy.Internal.File.init;
- _ = fizzy.Internal.History;
- _ = fizzy.Internal.Layer;
- _ = fizzy.Internal.Palette;
- _ = fizzy.Internal.Sprite;
+ _ = Internal.Animation;
+ _ = Internal.Atlas;
+ _ = Internal.Buffers;
+ _ = Internal.File.init;
+ _ = Internal.History;
+ _ = Internal.Layer;
+ _ = Internal.Palette;
+ _ = Internal.Sprite;
// Math + graphics helpers
_ = fizzy.math.checker;
@@ -53,11 +55,11 @@ comptime {
_ = fizzy.image.init;
_ = fizzy.image.pixels;
_ = fizzy.perf.record;
- _ = fizzy.render;
+ _ = pixelart.render;
// Custom dvui wrapper + widgets — types compile even though the widget files
// contain dead `@import("backend")` SDL3 imports at file scope.
- _ = @import("pixelart").widgets.FileWidget;
+ _ = pixelart.widgets.FileWidget;
_ = fizzy.dvui.CanvasWidget;
// The big ones: Editor + App. Type-level reference only — passes because Zig
diff --git a/tests/fizzy_shim.zig b/tests/fizzy_shim.zig
index 13f7a0f6..6fbd6b6c 100644
--- a/tests/fizzy_shim.zig
+++ b/tests/fizzy_shim.zig
@@ -22,6 +22,8 @@ pub const Ctx = struct {
editor: *fizzy.Editor,
pub fn deinit(self: *Ctx, gpa: std.mem.Allocator) void {
+ self.editor.pixelart_state.deinit(gpa);
+ gpa.destroy(self.editor.pixelart_state);
self.editor.arena.deinit();
gpa.destroy(self.editor);
gpa.destroy(self.app);
@@ -51,10 +53,18 @@ pub fn init(gpa: std.mem.Allocator) !Ctx {
// top of that test rather than expanding the shim.
const editor_ptr = try gpa.create(fizzy.Editor);
@memset(@as([*]u8, @ptrCast(editor_ptr))[0..@sizeOf(fizzy.Editor)], 0);
- editor_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 };
- editor_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 };
editor_ptr.arena = std.heap.ArenaAllocator.init(gpa);
+ editor_ptr.host.allocator = gpa;
fizzy.editor = editor_ptr;
+ const pixelart = fizzy.pixelart_mod;
+ const state_ptr = try gpa.create(pixelart.State);
+ pixelart.Globals.gpa = gpa;
+ pixelart.Globals.state = state_ptr;
+ state_ptr.* = pixelart.State.init(gpa, &editor_ptr.host) catch unreachable;
+ editor_ptr.pixelart_state = state_ptr;
+ state_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 };
+ state_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 };
+
return .{ .t = t, .app = app_ptr, .editor = editor_ptr };
}
diff --git a/tests/integration.zig b/tests/integration.zig
index fcbc2361..17f0b086 100644
--- a/tests/integration.zig
+++ b/tests/integration.zig
@@ -12,8 +12,9 @@ const std = @import("std");
const dvui = @import("dvui");
const fizzy = @import("fizzy");
const shim = @import("fizzy_shim.zig");
+const pixelart = fizzy.pixelart_mod;
-const Internal = fizzy.Internal;
+const Internal = pixelart.internal;
/// Create a small in-memory `Internal.File` suitable for tests. The
/// caller must already have a live shim context (so `fizzy.app` /
@@ -206,15 +207,15 @@ test "selectColorFloodFromPoint out-of-bounds is a no-op" {
// -------------------------------------------------------------------
// `.pixi` JSON parser fallbacks. The on-disk format has been bumped
-// three times. `fromPathFizzy` first tries the current `fizzy.File`
+// three times. `fromPathFizzy` first tries the current `pixelart.File`
// shape and, on failure, retries against `FileV3`, `FileV2`, and
// `FileV1`. This test exercises just the JSON layer (no zip, no
// `Internal.File` materialization) by parsing a small in-memory
// fixture for each version. It catches the kind of bug where someone
-// renames or retypes a field on the public `fizzy.File` types and
+// renames or retypes a field on the public `pixelart.File` types and
// silently breaks loading older saves.
// -------------------------------------------------------------------
-test "fizzy.File parses current-format JSON and round-trips" {
+test "pixelart.File parses current-format JSON and round-trips" {
const json =
\\{
\\ "version": { "major": 1, "minor": 0, "patch": 0, "pre": null, "build": null },
@@ -234,7 +235,7 @@ test "fizzy.File parses current-format JSON and round-trips" {
;
const parsed = try std.json.parseFromSlice(
- fizzy.File,
+ pixelart.File,
std.testing.allocator,
json,
.{ .ignore_unknown_fields = true },
@@ -258,7 +259,7 @@ test "fizzy.File parses current-format JSON and round-trips" {
defer std.testing.allocator.free(round_tripped);
const reparsed = try std.json.parseFromSlice(
- fizzy.File,
+ pixelart.File,
std.testing.allocator,
round_tripped,
.{ .ignore_unknown_fields = true },
@@ -275,7 +276,7 @@ test "fizzy.File parses current-format JSON and round-trips" {
try std.testing.expectEqual(parsed.value.animations[0].frames[0].ms, reparsed.value.animations[0].frames[0].ms);
}
-test "fizzy.File.FileV3 fixture parses" {
+test "pixelart.File.FileV3 fixture parses" {
// V3 keeps the columns/rows shape but uses the older `AnimationV2`
// (frame indices + fps) form.
const json =
@@ -295,7 +296,7 @@ test "fizzy.File.FileV3 fixture parses" {
;
const parsed = try std.json.parseFromSlice(
- fizzy.File.FileV3,
+ pixelart.File.FileV3,
std.testing.allocator,
json,
.{ .ignore_unknown_fields = true },
@@ -307,7 +308,7 @@ test "fizzy.File.FileV3 fixture parses" {
try std.testing.expectEqual(@as(f32, 10.0), parsed.value.animations[0].fps);
}
-test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" {
+test "pixelart.File.FileV2 fixture parses (width/height + tile_size shape)" {
const json =
\\{
\\ "version": { "major": 0, "minor": 5, "patch": 0, "pre": null, "build": null },
@@ -325,7 +326,7 @@ test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" {
;
const parsed = try std.json.parseFromSlice(
- fizzy.File.FileV2,
+ pixelart.File.FileV2,
std.testing.allocator,
json,
.{ .ignore_unknown_fields = true },
@@ -336,7 +337,7 @@ test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" {
try std.testing.expectEqual(@as(u32, 8), parsed.value.tile_width);
}
-test "fizzy.File.FileV1 fixture parses (start/length animation shape)" {
+test "pixelart.File.FileV1 fixture parses (start/length animation shape)" {
const json =
\\{
\\ "version": { "major": 0, "minor": 1, "patch": 0, "pre": null, "build": null },
@@ -354,7 +355,7 @@ test "fizzy.File.FileV1 fixture parses (start/length animation shape)" {
;
const parsed = try std.json.parseFromSlice(
- fizzy.File.FileV1,
+ pixelart.File.FileV1,
std.testing.allocator,
json,
.{ .ignore_unknown_fields = true },
@@ -469,7 +470,7 @@ test "Packer.append reduces painted sprite and offsets origin to keep anchor ali
px[3 * 16 + 3] = .{ 255, 0, 0, 255 };
// Cell 1: leave fully transparent so the packer skips the bitmap (image == null).
- var packer = try fizzy.Packer.init(std.testing.allocator);
+ var packer = try pixelart.Packer.init(std.testing.allocator);
defer packer.deinit();
try packer.append(&file);
@@ -517,7 +518,7 @@ test "Packer.append: tighten preserves world-space anchor across cells" {
}
}
- var packer = try fizzy.Packer.init(std.testing.allocator);
+ var packer = try pixelart.Packer.init(std.testing.allocator);
defer packer.deinit();
try packer.append(&file);
@@ -552,7 +553,7 @@ test "Packer.append: tightened bitmap content matches the source pixels" {
px[5 * 8 + 3] = .{ 21, 22, 23, 255 };
px[5 * 8 + 4] = .{ 31, 32, 33, 255 };
- var packer = try fizzy.Packer.init(std.testing.allocator);
+ var packer = try pixelart.Packer.init(std.testing.allocator);
defer packer.deinit();
try packer.append(&file);
@@ -590,7 +591,7 @@ test "Packer.append skips invisible layers" {
.dirty = layer.dirty,
});
- var packer = try fizzy.Packer.init(std.testing.allocator);
+ var packer = try pixelart.Packer.init(std.testing.allocator);
defer packer.deinit();
try packer.append(&file);
@@ -633,7 +634,7 @@ test "Packer.packRects: produced rects fit inside the texture and never overlap"
}
}
- var packer = try fizzy.Packer.init(std.testing.allocator);
+ var packer = try pixelart.Packer.init(std.testing.allocator);
defer packer.deinit();
try packer.append(&file);
@@ -811,7 +812,7 @@ test "fillPoint on temporary layer leaves selected-layer mask cache alone" {
test "Internal.Animation appendFrame, insertFrame, removeFrame" {
const alloc = std.testing.allocator;
- var initial_frames = [_]fizzy.Animation.Frame{.{
+ var initial_frames = [_]pixelart.Animation.Frame{.{
.sprite_index = 0,
.ms = 100,
}};
@@ -819,14 +820,14 @@ test "Internal.Animation appendFrame, insertFrame, removeFrame" {
defer anim.deinit(alloc);
try anim.appendFrame(alloc, .{ .sprite_index = 1, .ms = 50 });
- var expect_two = [_]fizzy.Animation.Frame{
+ var expect_two = [_]pixelart.Animation.Frame{
.{ .sprite_index = 0, .ms = 100 },
.{ .sprite_index = 1, .ms = 50 },
};
try std.testing.expect(anim.eqlFrames(expect_two[0..]));
try anim.insertFrame(alloc, 1, .{ .sprite_index = 9, .ms = 12 });
- var expect_three = [_]fizzy.Animation.Frame{
+ var expect_three = [_]pixelart.Animation.Frame{
.{ .sprite_index = 0, .ms = 100 },
.{ .sprite_index = 9, .ms = 12 },
.{ .sprite_index = 1, .ms = 50 },
@@ -834,7 +835,7 @@ test "Internal.Animation appendFrame, insertFrame, removeFrame" {
try std.testing.expect(anim.eqlFrames(expect_three[0..]));
anim.removeFrame(alloc, 0);
- var expect_after_remove = [_]fizzy.Animation.Frame{
+ var expect_after_remove = [_]pixelart.Animation.Frame{
.{ .sprite_index = 9, .ms = 12 },
.{ .sprite_index = 1, .ms = 50 },
};
@@ -983,7 +984,7 @@ test "Packer.append merges collapsed layer stack before reducing sprites" {
file.layers.get(0).pixels()[0] = .{ 255, 0, 0, 255 };
file.layers.get(1).pixels()[7 * 8 + 7] = .{ 0, 255, 0, 255 };
- var packer = try fizzy.Packer.init(std.testing.allocator);
+ var packer = try pixelart.Packer.init(std.testing.allocator);
defer packer.deinit();
try packer.append(&file);
@@ -1006,10 +1007,10 @@ test "drawPoint with to_change records history; undo restores pixels" {
file.editor.canvas.id = .zero;
- // `drawPoint` reads `fizzy.editor.tools.stroke_size` for stamps smaller than `min_full_stroke_size`;
+ // `drawPoint` reads plugin tools stroke size for stamps smaller than `min_full_stroke_size`;
// the shim zero-fills the editor, so brush size must be set explicitly.
- fizzy.editor.tools.stroke_size = 1;
- fizzy.editor.tools.pencil_stroke_size = 1;
+ fizzy.editor.pixelart_state.tools.stroke_size = 1;
+ fizzy.editor.pixelart_state.tools.pencil_stroke_size = 1;
const idx: usize = 3 * 8 + 4;
From 0656c54af2a5ee35c8b69a7cae6c83d6cf8e7693 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 17:20:35 -0500
Subject: [PATCH 23/49] Phase 4 stage e ctnd
---
HANDOFF.md | 8 +-
src/App.zig | 16 +-
src/editor/Editor.zig | 652 ++-------------------
src/editor/dialogs/UnsavedClose.zig | 4 +-
src/fizzy.zig | 5 +-
src/plugins/pixelart/src/State.zig | 7 +-
src/plugins/pixelart/src/clipboard.zig | 220 +++++++
src/plugins/pixelart/src/docs_registry.zig | 32 +
src/plugins/pixelart/src/pack_project.zig | 236 ++++++++
src/plugins/pixelart/src/plugin.zig | 86 ++-
src/plugins/pixelart/src/transform_op.zig | 123 ++++
src/plugins/workbench/src/Workspace.zig | 2 +-
src/plugins/workbench/src/files.zig | 3 +-
src/sdk/Plugin.zig | 74 +++
tests/integration.zig | 4 +-
15 files changed, 847 insertions(+), 625 deletions(-)
create mode 100644 src/plugins/pixelart/src/clipboard.zig
create mode 100644 src/plugins/pixelart/src/docs_registry.zig
create mode 100644 src/plugins/pixelart/src/pack_project.zig
create mode 100644 src/plugins/pixelart/src/transform_op.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 789551ff..614401de 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -262,11 +262,13 @@ app code until the build module is fully wired.
- **Explorer, UnsavedClose, files, Workspace** — use `fizzy.editor.pixelart_state` or `@import("pixelart")`.
- **`fizzy.zig` hub trimmed** — removed re-export aliases (`Tools`, `Internal`, `render`, `Packer`, on-disk types, …). Shell/workbench/tests/web probes now `@import("pixelart")` (or `fizzy.pixelart_mod` in integration tests). `fizzy.zig` keeps only `pixelart_mod` alias + lifecycle globals (`app`, `editor`, `packer`, `pixelart`).
- **`App.zig`** — wires `pixelart.Globals` directly (not `fizzy.pixelart_mod.Globals`).
+- **Copy/paste + pack/project** — moved to `pixelart/src/clipboard.zig` and `pack_project.zig`; plugin vtable hooks (`copy`, `paste`, `startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`). Shell `Editor` delegates; `setProjectFolder` uses plugin `persistProjectFolder` / `reloadProjectFolder`.
+- **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`.
+- **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only.
**Still remaining:**
-- `fizzy.pixelart` global — fold into `Editor.pixelart_state` + `Globals` only.
-- Shell `Editor` copy/paste/pack/project still touch `editor.pixelart_state` fields directly — route through plugin vtable or EditorAPI.
-- `pixelart.internal.File` in workbench + shell helpers — shrink as doc ownership solidifies.
+- Shell `Editor` still types `*Internal.File` in helpers (`activeFile`, `fileFromDoc`) — shrink as multi-plugin doc types arrive.
+- `pixelart.internal.File` in workbench tab paths — type-agnostic `DocHandle` only at boundary.
- Integration test shim updated for `pixelart.State` settings; `check-integration` still blocked on native `backend_native` SDL import under dvui-testing (pre-existing).
---
diff --git a/src/App.zig b/src/App.zig
index ac32b7b2..ef0076a2 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -168,12 +168,12 @@ pub fn AppInit(win: *dvui.Window) !void {
// Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
- // `state`. Owned here for the app's lifetime; torn down in `AppDeinit`.
- fizzy.pixelart = try allocator.create(pixelart.State);
+ // `state`. Owned on `Editor`; torn down in `AppDeinit`.
+ const pixelart_state = try allocator.create(pixelart.State);
pixelart.Globals.gpa = allocator;
- pixelart.Globals.state = fizzy.pixelart;
- fizzy.pixelart.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable;
- fizzy.editor.pixelart_state = fizzy.pixelart;
+ pixelart.Globals.state = pixelart_state;
+ pixelart_state.* = pixelart.State.init(allocator, &fizzy.editor.host) catch unreachable;
+ fizzy.editor.pixelart_state = pixelart_state;
// Second-stage init that needs the editor at its final heap address (e.g.
// registering the workbench-api service whose `ctx` is this pointer).
@@ -233,12 +233,12 @@ pub fn AppDeinit() void {
// Persist the current windowed frame while the window still exists. No-op off macOS.
fizzy.backend.saveWindowGeometry(fizzy.app.window);
// Persist `.fizproject` while `editor.host` and `editor.folder` are still live.
- pixelart.State.persistProject(fizzy.pixelart);
+ pixelart.State.persistProject(fizzy.editor.pixelart_state);
fizzy.editor.deinit() catch unreachable;
// Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs).
// After the editor so any editor teardown that still reads pixel-art state runs first.
- fizzy.pixelart.deinit(fizzy.app.allocator);
- fizzy.app.allocator.destroy(fizzy.pixelart);
+ fizzy.editor.pixelart_state.deinit(fizzy.app.allocator);
+ fizzy.app.allocator.destroy(fizzy.editor.pixelart_state);
// Tear down the singleton listener after the editor so any callback
// currently in flight finishes before we free state it touches.
singleton.deinit();
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 00913396..121e6095 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -22,7 +22,6 @@ const update_notify = @import("../backend/update_notify.zig");
const App = fizzy.App;
const Editor = @This();
-const Project = pixelart.Project;
pub const Recents = @import("Recents.zig");
pub const Settings = @import("Settings.zig");
pub const Dialogs = @import("dialogs/Dialogs.zig");
@@ -37,7 +36,6 @@ pub const Sidebar = @import("Sidebar.zig");
pub const Infobar = @import("Infobar.zig");
pub const Menu = @import("Menu.zig");
pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig");
-const PackJob = pixelart.PackJob;
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
@@ -58,7 +56,7 @@ atlas: fizzy.core.Atlas,
/// Plugin registry + service locator exposed to plugins
host: Host,
-/// Pixel-art plugin runtime state (owned by App; shell reaches it here instead of `fizzy.pixelart`).
+/// Pixel-art plugin runtime state (owned by App; wired into `Globals.state`).
pixelart_state: *pixelart.State,
/// File-management workbench (per-branch explorer decorations, …)
@@ -442,8 +440,8 @@ pub fn init(
return err;
};
- // Pixel-art tools/colors/palettes now init in `State.init` (App owns the
- // `fizzy.pixelart` instance, created just after this `Editor.init` returns).
+ // Pixel-art tools/colors/palettes now init in `State.init` (App allocates
+ // `editor.pixelart_state` just after this `Editor.init` returns).
try Keybinds.register();
@@ -750,9 +748,17 @@ fn shellIsPackingActive(ctx: *anyopaque) bool {
}
/// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`:
-/// `docs.files` may reallocate and invalidate pointers stored at insert time.
+/// the plugin registry may reallocate and invalidate pointers stored at insert time.
pub fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File {
- return editor.pixelart_state.docs.fileById(doc.id).?;
+ _ = editor;
+ return @ptrCast(@alignCast(doc.owner.documentPtr(doc.id).?));
+}
+
+/// Resolve an open document id to the plugin-owned file, or null when not open.
+pub fn fileById(editor: *Editor, id: u64) ?*Internal.File {
+ const doc = editor.docById(id) orelse return null;
+ const ptr = doc.owner.documentPtr(doc.id) orelse return null;
+ return @ptrCast(@alignCast(ptr));
}
pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle {
@@ -773,8 +779,8 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle {
/// Store a loaded/created document in the plugin registry and register its handle.
pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void {
- try editor.pixelart_state.docs.files.put(fizzy.app.allocator, file.id, file);
- const ptr = editor.pixelart_state.docs.files.getPtr(file.id).?;
+ var file_mut = file;
+ const ptr = try owner.registerOpenDocument(&file_mut);
try editor.open_files.put(fizzy.app.allocator, file.id, .{
// `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`.
.ptr = ptr,
@@ -1541,7 +1547,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
};
if (comptime builtin.target.cpu.arch == .wasm32) {
- runWasmPackWorkers(editor);
+ pixelart.plugin.pluginPtr().runPackWorkers();
}
_ = editor.arena.reset(.retain_capacity);
@@ -1858,7 +1864,7 @@ fn tickPendingSaveCloses(editor: *Editor) void {
var i: usize = 0;
while (i < editor.pending_close_after_save.count()) {
const id = editor.pending_close_after_save.keys()[i];
- if (editor.pixelart_state.docs.fileById(id)) |f| {
+ if (editor.fileById(id)) |f| {
if (f.isSaving()) {
i += 1;
continue;
@@ -1888,7 +1894,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
// Pass 1: kick off any queued saves we haven't started yet.
while (editor.quit_save_all_ids.items.len > 0) {
const id = editor.quit_save_all_ids.items[0];
- const file_ptr = editor.pixelart_state.docs.fileById(id) orelse {
+ const file_ptr = editor.fileById(id) orelse {
_ = editor.quit_save_all_ids.swapRemove(0);
continue;
};
@@ -1937,7 +1943,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
var i: usize = 0;
while (i < editor.quit_saves_in_flight.count()) {
const id = editor.quit_saves_in_flight.keys()[i];
- if (editor.pixelart_state.docs.fileById(id)) |f| {
+ if (editor.fileById(id)) |f| {
if (f.isSaving()) {
i += 1;
continue;
@@ -1981,18 +1987,14 @@ pub fn close(app: *App, editor: *Editor) void {
pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
if (editor.folder) |folder| {
editor.ignore.deinit(fizzy.app.allocator);
- if (editor.pixelart_state.project) |*project| {
- project.save() catch {
- dvui.log.err("Failed to save project", .{});
- };
- }
+ pixelart.plugin.pluginPtr().persistProjectFolder();
fizzy.app.allocator.free(folder);
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files);
- editor.pixelart_state.project = Project.load(fizzy.app.allocator) catch null;
+ pixelart.plugin.pluginPtr().reloadProjectFolder(fizzy.app.allocator);
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
}
@@ -2190,264 +2192,24 @@ pub fn processLoadingJobs(editor: *Editor) void {
}
}
-/// Kick off an async project-pack. Walks the project directory once on the main thread to
-/// gather inputs: open files contribute a thread-isolated snapshot (so unsaved edits make it
-/// into the pack); unopened files just contribute their paths and the worker reads them. Once
-/// inputs are gathered the heavy work — pixel reduction, rect packing, atlas blit — runs on a
-/// worker thread.
-///
-/// Rapid re-triggers (e.g. save-all-then-repack, or rapid button clicks) coalesce: any
-/// in-flight jobs are cancelled before the new one spawns. The cancelled workers continue
-/// running long enough to observe the flag and exit cleanly; their results are discarded by
-/// `processPackJob`. Only the most recently-started job's result is installed.
+/// Kick off an async project-pack via the pixel-art plugin vtable.
pub fn startPackProject(editor: *Editor) !void {
- var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty;
- errdefer {
- for (inputs.items) |*input| input.deinit(fizzy.app.allocator);
- inputs.deinit(fizzy.app.allocator);
- }
-
- if (comptime builtin.target.cpu.arch == .wasm32) {
- // Web: no project folder to walk — pack every open document (fiz, pixi, png,
- // jpg, in-memory untitled, etc.). Saved-path tracking is not available in the
- // browser, so the open tab set is the only source of truth.
- try appendOpenPackInputs(editor, &inputs);
- } else {
- const root = editor.folder orelse return;
- // Snapshot open files first so unsaved edits are included and gather can skip
- // duplicates when it walks the project tree.
- try appendOpenPackInputs(editor, &inputs);
- try gatherPackInputs(editor, &inputs, root);
- }
-
- if (inputs.items.len == 0) {
- const msg = if (comptime builtin.target.cpu.arch == .wasm32)
- "No open files to pack"
- else
- "No .fiz or .pixi files to pack";
- showPackToast(msg, null);
- return;
- }
-
- // `owned_inputs` is nulled out once ownership transfers into the job, so the errdefer
- // below is a no-op on the success path and avoids the double-free of letting both this
- // and `job.destroy()` reclaim the same allocations.
- var owned_inputs: ?[]PackJob.PackInput = try inputs.toOwnedSlice(fizzy.app.allocator);
- errdefer if (owned_inputs) |o| {
- for (o) |*input| input.deinit(fizzy.app.allocator);
- fizzy.app.allocator.free(o);
- };
-
- // Cancel every predecessor BEFORE appending the new job. This avoids a race where a
- // predecessor publishes `done` between append and cancel: `processPackJob` walks the list
- // newest-first and would otherwise see an old non-cancelled ready job and install its
- // (stale) atlas. Cancelled predecessors are skipped during install selection.
- for (editor.pixelart_state.pack_jobs.items) |old| {
- old.cancelled.store(true, .monotonic);
- }
-
- const job = try PackJob.create(fizzy.app.allocator, owned_inputs.?);
- owned_inputs = null;
- errdefer job.destroy();
-
- try editor.pixelart_state.pack_jobs.append(fizzy.app.allocator, job);
- errdefer _ = editor.pixelart_state.pack_jobs.pop();
-
- if (comptime builtin.target.cpu.arch == .wasm32) {
- // Worker runs at end of `tick` (after the explorer draws) so the Pack
- // button can show a spinner for at least one frame before work starts.
- dvui.refresh(dvui.currentWindow(), @src(), null);
- } else {
- const thread = try std.Thread.spawn(.{}, PackJob.workerMain, .{job});
- thread.detach();
- }
+ _ = editor;
+ try pixelart.plugin.pluginPtr().startPackProject();
}
-/// True while a pack is queued, running, or finished but not yet installed into
-/// `fizzy.packer.atlas`. Drives the explorer Pack button spinner.
+/// True while a pack is queued, running, or finished but not yet installed.
pub fn isPackingActive(editor: *const Editor) bool {
- for (editor.pixelart_state.pack_jobs.items) |job| {
- if (job.cancelled.load(.monotonic)) continue;
- if (!job.done.load(.acquire)) return true;
- if (!job.result_consumed) return true;
- }
- return false;
-}
-
-/// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback.
-fn runWasmPackWorkers(editor: *Editor) void {
- for (editor.pixelart_state.pack_jobs.items) |job| {
- if (job.cancelled.load(.monotonic)) continue;
- if (job.done.load(.acquire)) continue;
- PackJob.workerMain(job);
- return;
- }
-}
-
-fn appendOpenPackInputs(editor: *Editor, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void {
- for (editor.open_files.values()) |doc| {
- const open_file = editor.fileFromDoc(doc);
- const snapshot = try PackJob.PackFile.fromOpenFile(fizzy.app.allocator, open_file);
- try inputs.append(fizzy.app.allocator, .{ .open = snapshot });
- }
-}
-
-fn gatherPackInputs(
- editor: *Editor,
- inputs: *std.ArrayListUnmanaged(PackJob.PackInput),
- directory: []const u8,
-) !void {
- const io = dvui.io;
- var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true });
- defer dir.close(io);
-
- var iter = dir.iterate();
- while (try iter.next(io)) |entry| {
- if (entry.kind == .file) {
- const ext = std.fs.path.extension(entry.name);
- if (!Internal.File.isFizzyExtension(ext)) continue;
-
- const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name });
- defer fizzy.app.allocator.free(abs_path);
-
- // Open files were snapshotted in `appendOpenPackInputs` (including unsaved edits).
- if (findOpenFileForPackPath(editor, abs_path) != null) continue;
-
- const owned_path = try fizzy.app.allocator.dupe(u8, abs_path);
- try inputs.append(fizzy.app.allocator, .{ .path = owned_path });
- } else if (entry.kind == .directory) {
- const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name });
- defer fizzy.app.allocator.free(abs_path);
- try gatherPackInputs(editor, inputs, abs_path);
- }
- }
-}
-
-/// Match a project-tree path to an open file (`file.path` may differ in normalization from `join` vs `joinZ`).
-fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*Internal.File {
- if (editor.getFileFromPath(path)) |file| return file;
-
- const basename = std.fs.path.basename(path);
- for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue;
- if (std.mem.eql(u8, file.path, path)) return file;
- if (editor.folder) |folder| {
- const joined = std.fs.path.join(fizzy.app.allocator, &.{ folder, basename }) catch continue;
- defer fizzy.app.allocator.free(joined);
- if (std.mem.eql(u8, file.path, joined)) return file;
- }
- }
- return null;
+ _ = editor;
+ return pixelart.plugin.pluginPtr().isPackingActive();
}
-fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void {
- const anchor = canvas_id orelse blk: {
- if (fizzy.editor.activeWorkspaceCanvasRectPhysical()) |r| {
- if (fizzy.editor.activeFile()) |file| break :blk file.editor.canvas.id;
- _ = r;
- }
- break :blk dvui.currentWindow().data().id;
- };
- const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, fizzy.dvui.toastDisplay, 2_500_000);
- const id = id_mutex.id;
- const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message;
- dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy);
- id_mutex.mutex.unlock(dvui.io);
-}
-
-/// Per-frame sweep called from `tick`. Reaps any pack jobs whose worker has published `done`,
-/// installs the result of the newest non-cancelled job (and only that one), and discards the
-/// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting
-/// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases.
+/// Per-frame pack-job sweep (delegates to the pixel-art plugin).
pub fn processPackJob(editor: *Editor) void {
- if (editor.pixelart_state.pack_jobs.items.len == 0) return;
-
- // Identify the newest (last appended) job that finished with a `.ready` result and was
- // not cancelled. Only its result is installed; older successful results are stale and
- // get discarded along with cancelled / failed ones.
- var install_index: ?usize = null;
- {
- var i = editor.pixelart_state.pack_jobs.items.len;
- while (i > 0) {
- i -= 1;
- const job = editor.pixelart_state.pack_jobs.items[i];
- if (!job.done.load(.acquire)) continue;
- if (job.cancelled.load(.monotonic)) continue;
- if (job.currentPhase() == .ready and job.result_atlas != null) {
- install_index = i;
- break;
- }
- }
- }
-
- if (install_index) |idx| {
- const job = editor.pixelart_state.pack_jobs.items[idx];
- const new_atlas = job.result_atlas.?;
- // Free the previously-installed atlas's allocations so the new one can take its
- // place — matches the synchronous `packAndClear` cleanup ordering.
- if (fizzy.packer.atlas) |*current_atlas| {
- current_atlas.deinitCheckerboardTile();
- for (current_atlas.data.animations) |*anim| fizzy.app.allocator.free(anim.name);
- fizzy.app.allocator.free(current_atlas.data.sprites);
- fizzy.app.allocator.free(current_atlas.data.animations);
- fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source));
-
- current_atlas.source = new_atlas.source;
- current_atlas.data = new_atlas.data;
- current_atlas.initCheckerboardTile();
- } else {
- fizzy.packer.atlas = new_atlas;
- fizzy.packer.atlas.?.initCheckerboardTile();
- }
- fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp();
- job.result_consumed = true;
- editor.host.setActiveSidebarView(pixelart.plugin.view_project);
- const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null;
- showPackToast("Project packed", toast_canvas);
- } else blk: {
- // Newest finished job had no atlas (empty inputs / no packable frames). Tell the user
- // so the Pack button doesn't look like it silently did nothing.
- var i = editor.pixelart_state.pack_jobs.items.len;
- while (i > 0) {
- i -= 1;
- const job = editor.pixelart_state.pack_jobs.items[i];
- if (!job.done.load(.acquire)) continue;
- if (job.cancelled.load(.monotonic)) continue;
- if (job.currentPhase() == .ready and job.result_atlas == null) {
- showPackToast("Nothing to pack in the selected files", null);
- break :blk;
- }
- }
- }
-
- // Reap everything that has published `done`. Successful-but-superseded jobs leave their
- // `result_atlas` un-consumed; `destroy()` frees those allocations for us.
- var write: usize = 0;
- for (editor.pixelart_state.pack_jobs.items) |job| {
- if (!job.done.load(.acquire)) {
- editor.pixelart_state.pack_jobs.items[write] = job;
- write += 1;
- continue;
- }
- const phase = job.currentPhase();
- switch (phase) {
- .ready, .cancelled => {},
- .failed => {
- dvui.log.err("Pack project failed: {any}", .{job.err});
- showPackToast("Pack failed", null);
- },
- else => dvui.log.err("Pack job finished in unexpected phase {s}", .{@tagName(phase)}),
- }
- job.destroy();
- }
- editor.pixelart_state.pack_jobs.shrinkRetainingCapacity(write);
+ _ = editor;
+ pixelart.plugin.pluginPtr().tickPackJobs();
}
-/// Returns the active workspace's canvas content rect (physical pixels) captured from the
-/// previous frame's draw, if available. Falls back to `null` before the first workspace draw.
-/// Used by `drawLoadingOverlay` / `drawSaveToasts` to center their cards over the canvas area
-/// the user is currently looking at, instead of the raw OS window rect.
pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical {
const workspace = editor.workspaces.getPtr(editor.open_workspace_grouping) orelse return null;
return workspace.canvas_rect_physical;
@@ -2652,7 +2414,7 @@ pub fn newFile(editor: *Editor, path: []const u8, options: Internal.File.InitOpt
editor.setActiveFile(editor.open_files.count() - 1);
editor.pending_composite_warmup = true;
- return editor.pixelart_state.docs.fileById(file.id) orelse return error.FailedToCreateFile;
+ return editor.fileById(file.id) orelse return error.FailedToCreateFile;
}
/// Heap-owned path like `untitled-1`, unique among open-document basenames.
@@ -2741,7 +2503,12 @@ pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File {
}
pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File {
- return editor.pixelart_state.docs.fileFromPath(path);
+ for (editor.open_files.values()) |doc| {
+ if (doc.owner.documentByPath(path)) |ptr| {
+ return @ptrCast(@alignCast(ptr));
+ }
+ }
+ return null;
}
pub fn forceCloseFile(editor: *Editor, index: usize) !void {
@@ -2775,216 +2542,13 @@ pub fn cancel(editor: *Editor) !void {
}
pub fn copy(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (file.editor.transform != null) return;
-
- if (editor.pixelart_state.sprite_clipboard) |*clipboard| {
- fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source));
- editor.pixelart_state.sprite_clipboard = null;
- }
-
- file.editor.transform_layer.clear();
-
- var selected_layer = file.layers.get(file.selected_layer_index);
- switch (editor.pixelart_state.tools.current) {
- .selection => {
- // We are in the selection tool, so we should assume that the user has painted a selection
- // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing
- var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (pixel_iterator.next()) |pixel_index| {
- @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]);
- file.editor.transform_layer.mask.set(pixel_index);
- }
- },
- else => {
- if (file.editor.selected_sprites.count() > 0) {
- var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (sprite_iterator.next()) |index| {
- const source_rect = file.spriteRect(index);
- if (selected_layer.pixelsFromRect(
- dvui.currentWindow().arena(),
- source_rect,
- )) |source_pixels| {
- file.editor.transform_layer.blit(
- source_pixels,
- source_rect,
- .{ .transparent = true, .mask = true },
- );
- }
- }
- } else {
- if (file.editor.canvas.hovered) {
- if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| {
- const rect = file.spriteRect(sprite_index);
- if (selected_layer.pixelsFromRect(
- dvui.currentWindow().arena(),
- rect,
- )) |source_pixels| {
- file.editor.transform_layer.blit(
- source_pixels,
- rect,
- .{ .transparent = true, .mask = true },
- );
- }
- }
- } else if (file.selected_animation_index) |animation_index| {
- const animation = file.animations.get(animation_index);
- if (file.selected_animation_frame_index < animation.frames.len) {
- const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index);
- if (selected_layer.pixelsFromRect(
- dvui.currentWindow().arena(),
- rect,
- )) |source_pixels| {
- file.editor.transform_layer.blit(
- source_pixels,
- rect,
- .{ .transparent = true, .mask = true },
- );
- }
- }
- }
- }
- },
- }
-
- const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size());
- if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
- const sprite_tl = file.spritePoint(reduced_data_rect.topLeft());
-
- editor.pixelart_state.sprite_clipboard = .{
- .source = fizzy.image.fromPixelsPMA(
- @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)),
- @intFromFloat(reduced_data_rect.w),
- @intFromFloat(reduced_data_rect.h),
- .ptr,
- ) catch return error.MemoryAllocationFailed,
- .offset = reduced_data_rect.topLeft().diff(sprite_tl),
- };
-
- // Show a toast so its evident a copy action was completed
- {
- const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, fizzy.dvui.toastDisplay, 2_000_000);
- const id = id_mutex.id;
- const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection.";
- dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message);
- id_mutex.mutex.unlock(dvui.io);
- }
- }
- }
+ _ = editor;
+ try pixelart.plugin.pluginPtr().copy();
}
pub fn paste(editor: *Editor) !void {
- if (editor.pixelart_state.sprite_clipboard) |*clipboard| {
- if (editor.activeFile()) |file| {
- const active_layer = file.layers.get(file.selected_layer_index);
-
- var dst_rect: dvui.Rect = .fromSize(fizzy.image.size(clipboard.source));
-
- var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
- while (sprite_iterator.next()) |sprite_index| {
- const sprite_rect = file.spriteRect(sprite_index);
-
- dst_rect.x = sprite_rect.x + clipboard.offset.x;
- dst_rect.y = sprite_rect.y + clipboard.offset.y;
-
- file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
- dvui.log.err("Failed to create target texture", .{});
- return;
- },
- .file_id = file.id,
- .layer_id = active_layer.id,
- .data_points = .{
- dst_rect.topLeft(),
- dst_rect.topRight(),
- dst_rect.bottomRight(),
- dst_rect.bottomLeft(),
- dst_rect.center(),
- dst_rect.center(),
- },
- .source = clipboard.source,
- };
-
- for (file.editor.transform.?.data_points[0..4]) |*point| {
- const d = point.diff(file.editor.transform.?.point(.pivot).*);
- if (d.length() > file.editor.transform.?.radius) {
- file.editor.transform.?.radius = d.length() + 4;
- }
- }
-
- return;
- }
-
- dst_rect.x = clipboard.offset.x;
- dst_rect.y = clipboard.offset.y;
-
- if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| {
- const rect = file.spriteRect(sprite_index);
- dst_rect.x = rect.x + clipboard.offset.x;
- dst_rect.y = rect.y + clipboard.offset.y;
- } else if (file.selected_animation_index) |animation_index| {
- const animation = file.animations.get(animation_index);
-
- if (file.selected_animation_frame_index < animation.frames.len) {
- const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index);
- dst_rect.x = rect.x + clipboard.offset.x;
- dst_rect.y = rect.y + clipboard.offset.y;
-
- file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
- dvui.log.err("Failed to create target texture", .{});
- return;
- },
- .file_id = file.id,
- .layer_id = active_layer.id,
- .data_points = .{
- dst_rect.topLeft(),
- dst_rect.topRight(),
- dst_rect.bottomRight(),
- dst_rect.bottomLeft(),
- dst_rect.center(),
- dst_rect.center(),
- },
- .source = clipboard.source,
- };
-
- for (file.editor.transform.?.data_points[0..4]) |*point| {
- const d = point.diff(file.editor.transform.?.point(.pivot).*);
- if (d.length() > file.editor.transform.?.radius) {
- file.editor.transform.?.radius = d.length() + 4;
- }
- }
-
- return;
- }
- }
-
- file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
- dvui.log.err("Failed to create target texture", .{});
- return;
- },
- .file_id = file.id,
- .layer_id = active_layer.id,
- .data_points = .{
- dst_rect.topLeft(),
- dst_rect.topRight(),
- dst_rect.bottomRight(),
- dst_rect.bottomLeft(),
- dst_rect.center(),
- dst_rect.center(),
- },
- .source = clipboard.source,
- };
-
- for (file.editor.transform.?.data_points[0..4]) |*point| {
- const d = point.diff(file.editor.transform.?.point(.pivot).*);
- if (d.length() > file.editor.transform.?.radius) {
- file.editor.transform.?.radius = d.length() + 4;
- }
- }
- }
- }
+ _ = editor;
+ try pixelart.plugin.pluginPtr().paste();
}
pub fn deleteSelectedContents(editor: *Editor) void {
@@ -2995,122 +2559,8 @@ pub fn deleteSelectedContents(editor: *Editor) void {
/// Begins a transform operation on the currently active file.
pub fn transform(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (file.editor.transform) |*t| {
- t.cancel();
- }
-
- var selected_layer = file.layers.get(file.selected_layer_index);
-
- switch (editor.pixelart_state.tools.current) {
- .selection => {
- file.editor.transform_layer.clear();
- // We are in the selection tool, so we should assume that the user has painted a selection
- // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing
- var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
- while (pixel_iterator.next()) |pixel_index| {
- @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]);
- selected_layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 };
- file.editor.transform_layer.mask.set(pixel_index);
- }
- selected_layer.invalidate();
- },
- else => {
- // Current tool is the pointer, so we potentially have a sprite selection in
- // selected sprites that we need to copy to the selection layer.
- file.editor.transform_layer.clear();
-
- if (file.editor.selected_sprites.count() > 0) {
- var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
-
- while (sprite_iterator.next()) |index| {
- const source_rect = file.spriteRect(index);
- if (selected_layer.pixelsFromRect(
- dvui.currentWindow().arena(),
- source_rect,
- )) |source_pixels| {
- file.editor.transform_layer.blit(
- source_pixels,
- source_rect,
- .{ .transparent = true, .mask = true },
- );
- selected_layer.clearRect(source_rect);
- }
- }
- } else {
- if (file.editor.canvas.hovered) {
- if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| {
- const rect = file.spriteRect(sprite_index);
- if (selected_layer.pixelsFromRect(
- dvui.currentWindow().arena(),
- rect,
- )) |source_pixels| {
- file.editor.transform_layer.blit(
- source_pixels,
- rect,
- .{ .transparent = true, .mask = true },
- );
- selected_layer.clearRect(rect);
- }
- }
- } else if (file.selected_animation_index) |animation_index| {
- const animation = file.animations.get(animation_index);
- if (file.selected_animation_frame_index < animation.frames.len) {
- const source_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index);
- if (selected_layer.pixelsFromRect(
- dvui.currentWindow().arena(),
- source_rect,
- )) |source_pixels| {
- file.editor.transform_layer.blit(
- source_pixels,
- source_rect,
- .{ .transparent = true, .mask = true },
- );
- selected_layer.clearRect(source_rect);
- }
- }
- }
- }
- },
- }
-
- // We now have a transform layer that contains:
- // 1. the unaltered colored pixels of the active transform
- // 2. a mask containing bits for the pixels of the selection being transformed
- const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size());
- if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
- defer file.editor.selection_layer.clearMask();
- file.editor.transform = .{
- .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
- dvui.log.err("Failed to create target texture", .{});
- return;
- },
- .file_id = file.id,
- .layer_id = selected_layer.id,
- .data_points = .{
- reduced_data_rect.topLeft(),
- reduced_data_rect.topRight(),
- reduced_data_rect.bottomRight(),
- reduced_data_rect.bottomLeft(),
- reduced_data_rect.center(),
- reduced_data_rect.center(), // This point constantly moves
- },
- .source = fizzy.image.fromPixelsPMA(
- @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)),
- @intFromFloat(reduced_data_rect.w),
- @intFromFloat(reduced_data_rect.h),
- .ptr,
- ) catch return error.MemoryAllocationFailed,
- };
-
- for (file.editor.transform.?.data_points[0..4]) |*point| {
- const d = point.diff(file.editor.transform.?.point(.pivot).*);
- if (d.length() > file.editor.transform.?.radius) {
- file.editor.transform.?.radius = d.length() + 4;
- }
- }
- }
- }
+ _ = editor;
+ try pixelart.plugin.pluginPtr().transform();
}
/// Performs a save operation on the currently open file.
@@ -3196,7 +2646,7 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void {
if (file_id) |id| {
_ = editor.pending_close_after_save.swapRemove(id);
- if (editor.pixelart_state.docs.fileById(id)) |f| {
+ if (editor.fileById(id)) |f| {
f.resetSaveUIState();
}
} else if (editor.activeFile()) |f| {
@@ -3346,12 +2796,10 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void {
}
pub fn closeFileID(editor: *Editor, id: u64) !void {
- if (editor.open_files.contains(id)) {
- if (editor.pixelart_state.docs.fileById(id)) |file| {
- if (file.dirty()) {
- Dialogs.UnsavedClose.request(id);
- return;
- }
+ if (editor.open_files.get(id)) |doc| {
+ if (doc.owner.isDirty(doc)) {
+ Dialogs.UnsavedClose.request(id);
+ return;
}
try editor.rawCloseFileID(id);
}
@@ -3367,11 +2815,11 @@ pub fn closeFile(editor: *Editor, index: usize) !void {
/// the matching `DocHandle` from `open_files`.
fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void {
if (doc.owner.closeDocument(doc)) {
- _ = editor.pixelart_state.docs.files.swapRemove(doc.id);
+ doc.owner.unregisterDocument(doc.id);
return;
}
editor.fileFromDoc(doc).deinit();
- _ = editor.pixelart_state.docs.files.swapRemove(doc.id);
+ doc.owner.unregisterDocument(doc.id);
}
pub fn rawCloseFile(editor: *Editor, index: usize) !void {
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index 87a0d436..b7720980 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -23,7 +23,7 @@ pub fn request(file_id: u64) void {
}
fn fileBasename(file_id: u64) []const u8 {
- const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return "?";
+ const file = fizzy.editor.fileById(file_id) orelse return "?";
return std.fs.path.basename(file.path);
}
@@ -113,7 +113,7 @@ fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void {
}
fn onSaveAndClose(file_id: u64) !void {
- const file = fizzy.editor.pixelart_state.docs.fileById(file_id) orelse return;
+ const file = fizzy.editor.fileById(file_id) orelse return;
if (!Internal.File.hasRecognizedSaveExtension(file.path)) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
fizzy.editor.setActiveFile(idx);
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 61ae745c..63fc8f0b 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -30,16 +30,13 @@ pub const Fling = core.Fling;
//pub const Popups = @import("editor/popups/Popups.zig");
pub const Sidebar = @import("editor/Sidebar.zig");
-/// Pixel-art plugin module. Shell code should `@import("pixelart")` directly;
-/// this alias exists only for `App.zig` lifecycle wiring (can't name it `pixelart`
-/// — that name is the runtime `*State` global below).
+/// Pixel-art plugin module. Shell code should `@import("pixelart")` directly.
pub const pixelart_mod = @import("pixelart");
// Global pointers
pub var app: *App = undefined;
pub var editor: *Editor = undefined;
pub var packer: *pixelart_mod.Packer = undefined;
-pub var pixelart: *pixelart_mod.State = undefined;
/// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web
/// builds, where `builtin.os.tag` is always `.freestanding`.
diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig
index f76dad21..79d6290b 100644
--- a/src/plugins/pixelart/src/State.zig
+++ b/src/plugins/pixelart/src/State.zig
@@ -5,7 +5,7 @@
//! project's pack config, the sprite clipboard, and the background pack-job queue.
//!
//! Each plugin has a `State.zig` holding its live state. The shell still reaches
-//! this through `fizzy.pixelart` during migration; plugin code uses `Globals.state`.
+//! plugin code uses `Globals.state`.
const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
@@ -111,6 +111,11 @@ pub fn persistProject(st: *State) void {
}
}
+/// Load `.fizproject` for the shell's currently-open project folder.
+pub fn reloadProjectForFolder(st: *State, allocator: std.mem.Allocator) void {
+ st.project = Project.load(allocator) catch null;
+}
+
pub fn deinit(st: *State, allocator: std.mem.Allocator) void {
for (st.pack_jobs.items) |job| {
// Detached workers still reference each job. Signal cancellation and leak the structs
diff --git a/src/plugins/pixelart/src/clipboard.zig b/src/plugins/pixelart/src/clipboard.zig
new file mode 100644
index 00000000..3cab67d6
--- /dev/null
+++ b/src/plugins/pixelart/src/clipboard.zig
@@ -0,0 +1,220 @@
+//! Sprite copy/paste for the pixel-art plugin. Invoked from the plugin vtable;
+//! the shell routes `EditorAPI.copy` / `paste` here instead of owning the logic.
+const std = @import("std");
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const Internal = pixelart.internal;
+
+fn activeFile(st: *State) ?*Internal.File {
+ const doc = st.host.activeDoc() orelse return null;
+ return st.docs.fileById(doc.id);
+}
+
+pub fn copy(st: *State) !void {
+ const file = activeFile(st) orelse return;
+ if (file.editor.transform != null) return;
+
+ if (st.sprite_clipboard) |*clipboard| {
+ Globals.allocator().free(pixelart.image.bytes(clipboard.source));
+ st.sprite_clipboard = null;
+ }
+
+ file.editor.transform_layer.clear();
+
+ var selected_layer = file.layers.get(file.selected_layer_index);
+ switch (st.tools.current) {
+ .selection => {
+ var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
+ while (pixel_iterator.next()) |pixel_index| {
+ @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]);
+ file.editor.transform_layer.mask.set(pixel_index);
+ }
+ },
+ else => {
+ if (file.editor.selected_sprites.count() > 0) {
+ var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
+ while (sprite_iterator.next()) |index| {
+ const source_rect = file.spriteRect(index);
+ if (selected_layer.pixelsFromRect(
+ dvui.currentWindow().arena(),
+ source_rect,
+ )) |source_pixels| {
+ file.editor.transform_layer.blit(
+ source_pixels,
+ source_rect,
+ .{ .transparent = true, .mask = true },
+ );
+ }
+ }
+ } else {
+ if (file.editor.canvas.hovered) {
+ if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| {
+ const rect = file.spriteRect(sprite_index);
+ if (selected_layer.pixelsFromRect(
+ dvui.currentWindow().arena(),
+ rect,
+ )) |source_pixels| {
+ file.editor.transform_layer.blit(
+ source_pixels,
+ rect,
+ .{ .transparent = true, .mask = true },
+ );
+ }
+ }
+ } else if (file.selected_animation_index) |animation_index| {
+ const animation = file.animations.get(animation_index);
+ if (file.selected_animation_frame_index < animation.frames.len) {
+ const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index);
+ if (selected_layer.pixelsFromRect(
+ dvui.currentWindow().arena(),
+ rect,
+ )) |source_pixels| {
+ file.editor.transform_layer.blit(
+ source_pixels,
+ rect,
+ .{ .transparent = true, .mask = true },
+ );
+ }
+ }
+ }
+ }
+ },
+ }
+
+ const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size());
+ if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
+ const sprite_tl = file.spritePoint(reduced_data_rect.topLeft());
+ const gpa = Globals.allocator();
+
+ st.sprite_clipboard = .{
+ .source = pixelart.image.fromPixelsPMA(
+ @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)),
+ @intFromFloat(reduced_data_rect.w),
+ @intFromFloat(reduced_data_rect.h),
+ .ptr,
+ ) catch return error.MemoryAllocationFailed,
+ .offset = reduced_data_rect.topLeft().diff(sprite_tl),
+ };
+
+ const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, pixelart.core.dvui.toastDisplay, 2_000_000);
+ const id = id_mutex.id;
+ const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection.";
+ dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message);
+ id_mutex.mutex.unlock(dvui.io);
+ }
+}
+
+pub fn paste(st: *State) !void {
+ if (st.sprite_clipboard) |*clipboard| {
+ const file = activeFile(st) orelse return;
+ const active_layer = file.layers.get(file.selected_layer_index);
+
+ var dst_rect: dvui.Rect = .fromSize(pixelart.image.size(clipboard.source));
+
+ var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
+ while (sprite_iterator.next()) |sprite_index| {
+ const sprite_rect = file.spriteRect(sprite_index);
+
+ dst_rect.x = sprite_rect.x + clipboard.offset.x;
+ dst_rect.y = sprite_rect.y + clipboard.offset.y;
+
+ file.editor.transform = .{
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ dvui.log.err("Failed to create target texture", .{});
+ return;
+ },
+ .file_id = file.id,
+ .layer_id = active_layer.id,
+ .data_points = .{
+ dst_rect.topLeft(),
+ dst_rect.topRight(),
+ dst_rect.bottomRight(),
+ dst_rect.bottomLeft(),
+ dst_rect.center(),
+ dst_rect.center(),
+ },
+ .source = clipboard.source,
+ };
+
+ for (file.editor.transform.?.data_points[0..4]) |*point| {
+ const d = point.diff(file.editor.transform.?.point(.pivot).*);
+ if (d.length() > file.editor.transform.?.radius) {
+ file.editor.transform.?.radius = d.length() + 4;
+ }
+ }
+
+ return;
+ }
+
+ dst_rect.x = clipboard.offset.x;
+ dst_rect.y = clipboard.offset.y;
+
+ if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| {
+ const rect = file.spriteRect(sprite_index);
+ dst_rect.x = rect.x + clipboard.offset.x;
+ dst_rect.y = rect.y + clipboard.offset.y;
+ } else if (file.selected_animation_index) |animation_index| {
+ const animation = file.animations.get(animation_index);
+
+ if (file.selected_animation_frame_index < animation.frames.len) {
+ const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index);
+ dst_rect.x = rect.x + clipboard.offset.x;
+ dst_rect.y = rect.y + clipboard.offset.y;
+
+ file.editor.transform = .{
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ dvui.log.err("Failed to create target texture", .{});
+ return;
+ },
+ .file_id = file.id,
+ .layer_id = active_layer.id,
+ .data_points = .{
+ dst_rect.topLeft(),
+ dst_rect.topRight(),
+ dst_rect.bottomRight(),
+ dst_rect.bottomLeft(),
+ dst_rect.center(),
+ dst_rect.center(),
+ },
+ .source = clipboard.source,
+ };
+
+ for (file.editor.transform.?.data_points[0..4]) |*point| {
+ const d = point.diff(file.editor.transform.?.point(.pivot).*);
+ if (d.length() > file.editor.transform.?.radius) {
+ file.editor.transform.?.radius = d.length() + 4;
+ }
+ }
+
+ return;
+ }
+ }
+
+ file.editor.transform = .{
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ dvui.log.err("Failed to create target texture", .{});
+ return;
+ },
+ .file_id = file.id,
+ .layer_id = active_layer.id,
+ .data_points = .{
+ dst_rect.topLeft(),
+ dst_rect.topRight(),
+ dst_rect.bottomRight(),
+ dst_rect.bottomLeft(),
+ dst_rect.center(),
+ dst_rect.center(),
+ },
+ .source = clipboard.source,
+ };
+
+ for (file.editor.transform.?.data_points[0..4]) |*point| {
+ const d = point.diff(file.editor.transform.?.point(.pivot).*);
+ if (d.length() > file.editor.transform.?.radius) {
+ file.editor.transform.?.radius = d.length() + 4;
+ }
+ }
+ }
+}
diff --git a/src/plugins/pixelart/src/docs_registry.zig b/src/plugins/pixelart/src/docs_registry.zig
new file mode 100644
index 00000000..b6744e28
--- /dev/null
+++ b/src/plugins/pixelart/src/docs_registry.zig
@@ -0,0 +1,32 @@
+//! Open-document registry bridge: the shell stores `DocHandle`s; this owns `Internal.File`.
+const std = @import("std");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const Internal = pixelart.internal;
+
+pub fn registerOpenDocument(st: *State, file: *Internal.File) !*Internal.File {
+ const gpa = Globals.allocator();
+ try st.docs.files.put(gpa, file.id, file.*);
+ return st.docs.files.getPtr(file.id).?;
+}
+
+pub fn documentPtr(st: *State, id: u64) ?*Internal.File {
+ return st.docs.fileById(id);
+}
+
+pub fn documentByPath(st: *State, path: []const u8) ?*Internal.File {
+ return st.docs.fileFromPath(path);
+}
+
+pub fn unregisterDocument(st: *State, id: u64) void {
+ _ = st.docs.files.swapRemove(id);
+}
+
+pub fn persistProjectFolder(st: *State) void {
+ st.persistProject();
+}
+
+pub fn reloadProjectFolder(st: *State, allocator: std.mem.Allocator) void {
+ st.reloadProjectForFolder(allocator);
+}
diff --git a/src/plugins/pixelart/src/pack_project.zig b/src/plugins/pixelart/src/pack_project.zig
new file mode 100644
index 00000000..303c1c64
--- /dev/null
+++ b/src/plugins/pixelart/src/pack_project.zig
@@ -0,0 +1,236 @@
+//! Async project packing for the pixel-art plugin. Invoked from the plugin vtable;
+//! the shell routes `EditorAPI.startPackProject` / `isPackingActive` here.
+const std = @import("std");
+const builtin = @import("builtin");
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const PackJob = @import("PackJob.zig");
+const Internal = pixelart.internal;
+
+fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void {
+ const anchor = canvas_id orelse blk: {
+ if (Globals.state.host.activeDoc()) |doc| {
+ if (Globals.state.docs.fileById(doc.id)) |file| break :blk file.editor.canvas.id;
+ }
+ break :blk dvui.currentWindow().data().id;
+ };
+ const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, pixelart.core.dvui.toastDisplay, 2_500_000);
+ const id = id_mutex.id;
+ const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message;
+ dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy);
+ id_mutex.mutex.unlock(dvui.io);
+}
+
+fn appendOpenPackInputs(st: *State, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void {
+ const gpa = Globals.allocator();
+ const host = st.host;
+ var i: usize = 0;
+ while (i < host.openDocCount()) : (i += 1) {
+ const doc = host.docByIndex(i) orelse continue;
+ const open_file = st.docs.fileById(doc.id) orelse continue;
+ const snapshot = try PackJob.PackFile.fromOpenFile(gpa, open_file);
+ try inputs.append(gpa, .{ .open = snapshot });
+ }
+}
+
+fn findOpenFileForPackPath(st: *State, path: []const u8) ?*Internal.File {
+ if (st.docs.fileFromPath(path)) |file| return file;
+
+ const basename = std.fs.path.basename(path);
+ const gpa = Globals.allocator();
+ const host = st.host;
+ var i: usize = 0;
+ while (i < host.openDocCount()) : (i += 1) {
+ const doc = host.docByIndex(i) orelse continue;
+ const file = st.docs.fileById(doc.id) orelse continue;
+ if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue;
+ if (std.mem.eql(u8, file.path, path)) return file;
+ if (host.folder()) |folder| {
+ const joined = std.fs.path.join(gpa, &.{ folder, basename }) catch continue;
+ defer gpa.free(joined);
+ if (std.mem.eql(u8, file.path, joined)) return file;
+ }
+ }
+ return null;
+}
+
+fn gatherPackInputs(
+ st: *State,
+ inputs: *std.ArrayListUnmanaged(PackJob.PackInput),
+ directory: []const u8,
+) !void {
+ const gpa = Globals.allocator();
+ const io = dvui.io;
+ var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true });
+ defer dir.close(io);
+
+ var iter = dir.iterate();
+ while (try iter.next(io)) |entry| {
+ if (entry.kind == .file) {
+ const ext = std.fs.path.extension(entry.name);
+ if (!Internal.File.isFizzyExtension(ext)) continue;
+
+ const abs_path = try std.fs.path.join(gpa, &.{ directory, entry.name });
+ defer gpa.free(abs_path);
+
+ if (findOpenFileForPackPath(st, abs_path) != null) continue;
+
+ const owned_path = try gpa.dupe(u8, abs_path);
+ try inputs.append(gpa, .{ .path = owned_path });
+ } else if (entry.kind == .directory) {
+ const abs_path = try std.fs.path.join(gpa, &.{ directory, entry.name });
+ defer gpa.free(abs_path);
+ try gatherPackInputs(st, inputs, abs_path);
+ }
+ }
+}
+
+pub fn start(st: *State) !void {
+ const gpa = Globals.allocator();
+ var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty;
+ errdefer {
+ for (inputs.items) |*input| input.deinit(gpa);
+ inputs.deinit(gpa);
+ }
+
+ if (comptime builtin.target.cpu.arch == .wasm32) {
+ try appendOpenPackInputs(st, &inputs);
+ } else {
+ const root = st.host.folder() orelse return;
+ try appendOpenPackInputs(st, &inputs);
+ try gatherPackInputs(st, &inputs, root);
+ }
+
+ if (inputs.items.len == 0) {
+ const msg = if (comptime builtin.target.cpu.arch == .wasm32)
+ "No open files to pack"
+ else
+ "No .fiz or .pixi files to pack";
+ showPackToast(msg, null);
+ return;
+ }
+
+ var owned_inputs: ?[]PackJob.PackInput = try inputs.toOwnedSlice(gpa);
+ errdefer if (owned_inputs) |o| {
+ for (o) |*input| input.deinit(gpa);
+ gpa.free(o);
+ };
+
+ for (st.pack_jobs.items) |old| {
+ old.cancelled.store(true, .monotonic);
+ }
+
+ const job = try PackJob.create(gpa, owned_inputs.?);
+ owned_inputs = null;
+ errdefer job.destroy();
+
+ try st.pack_jobs.append(gpa, job);
+ errdefer _ = st.pack_jobs.pop();
+
+ if (comptime builtin.target.cpu.arch == .wasm32) {
+ dvui.refresh(dvui.currentWindow(), @src(), null);
+ } else {
+ const thread = try std.Thread.spawn(.{}, PackJob.workerMain, .{job});
+ thread.detach();
+ }
+}
+
+pub fn isActive(st: *const State) bool {
+ for (st.pack_jobs.items) |job| {
+ if (job.cancelled.load(.monotonic)) continue;
+ if (!job.done.load(.acquire)) return true;
+ if (!job.result_consumed) return true;
+ }
+ return false;
+}
+
+pub fn runWasmWorkers(st: *State) void {
+ if (comptime builtin.target.cpu.arch != .wasm32) return;
+ for (st.pack_jobs.items) |job| {
+ if (job.cancelled.load(.monotonic)) continue;
+ if (job.done.load(.acquire)) continue;
+ PackJob.workerMain(job);
+ return;
+ }
+}
+
+pub fn tick(st: *State) void {
+ if (st.pack_jobs.items.len == 0) return;
+
+ const gpa = Globals.allocator();
+ var install_index: ?usize = null;
+ {
+ var i = st.pack_jobs.items.len;
+ while (i > 0) {
+ i -= 1;
+ const job = st.pack_jobs.items[i];
+ if (!job.done.load(.acquire)) continue;
+ if (job.cancelled.load(.monotonic)) continue;
+ if (job.currentPhase() == .ready and job.result_atlas != null) {
+ install_index = i;
+ break;
+ }
+ }
+ }
+
+ if (install_index) |idx| {
+ const job = st.pack_jobs.items[idx];
+ const new_atlas = job.result_atlas.?;
+ if (Globals.packer.atlas) |*current_atlas| {
+ current_atlas.deinitCheckerboardTile();
+ for (current_atlas.data.animations) |*anim| gpa.free(anim.name);
+ gpa.free(current_atlas.data.sprites);
+ gpa.free(current_atlas.data.animations);
+ gpa.free(pixelart.image.bytes(current_atlas.source));
+
+ current_atlas.source = new_atlas.source;
+ current_atlas.data = new_atlas.data;
+ current_atlas.initCheckerboardTile();
+ } else {
+ Globals.packer.atlas = new_atlas;
+ Globals.packer.atlas.?.initCheckerboardTile();
+ }
+ Globals.packer.last_packed_at_ns = pixelart.perf.nanoTimestamp();
+ job.result_consumed = true;
+ st.host.setActiveSidebarView("pixelart.project");
+ const toast_canvas: ?dvui.Id = if (st.host.activeDoc()) |doc|
+ if (st.docs.fileById(doc.id)) |file| file.editor.canvas.id else null
+ else
+ null;
+ showPackToast("Project packed", toast_canvas);
+ } else blk: {
+ var i = st.pack_jobs.items.len;
+ while (i > 0) {
+ i -= 1;
+ const job = st.pack_jobs.items[i];
+ if (!job.done.load(.acquire)) continue;
+ if (job.cancelled.load(.monotonic)) continue;
+ if (job.currentPhase() == .ready and job.result_atlas == null) {
+ showPackToast("Nothing to pack in the selected files", null);
+ break :blk;
+ }
+ }
+ }
+
+ var write: usize = 0;
+ for (st.pack_jobs.items) |job| {
+ if (!job.done.load(.acquire)) {
+ st.pack_jobs.items[write] = job;
+ write += 1;
+ continue;
+ }
+ const phase = job.currentPhase();
+ switch (phase) {
+ .ready, .cancelled => {},
+ .failed => {
+ dvui.log.err("Pack project failed: {any}", .{job.err});
+ showPackToast("Pack failed", null);
+ },
+ else => dvui.log.err("Pack job finished in unexpected phase {s}", .{@tagName(phase)}),
+ }
+ job.destroy();
+ }
+ st.pack_jobs.shrinkRetainingCapacity(write);
+}
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index 73fd96b0..c122aece 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -15,6 +15,10 @@ const ImageWidget = @import("widgets/ImageWidget.zig");
const PixelArtSettings = @import("Settings.zig");
const KeybindTicks = @import("keybind_ticks.zig");
const RadialMenu = @import("radial_menu.zig");
+const Clipboard = @import("clipboard.zig");
+const PackProject = @import("pack_project.zig");
+const TransformOp = @import("transform_op.zig");
+const DocsRegistry = @import("docs_registry.zig");
const DocHandle = sdk.DocHandle;
const Internal = pixelart.internal;
@@ -42,11 +46,24 @@ const vtable: sdk.Plugin.VTable = .{
.closeDocument = closeDocument,
.undo = undo,
.redo = redo,
+ .registerOpenDocument = registerOpenDocument,
+ .documentPtr = documentPtr,
+ .documentByPath = documentByPath,
+ .unregisterDocument = unregisterDocument,
.drawDocument = drawDocument,
.tickKeybinds = tickKeybinds,
.processRadialMenuInput = processRadialMenuInput,
.radialMenuVisible = radialMenuVisible,
.drawRadialMenu = drawRadialMenu,
+ .transform = pluginTransform,
+ .copy = pluginCopy,
+ .paste = pluginPaste,
+ .startPackProject = pluginStartPackProject,
+ .isPackingActive = pluginIsPackingActive,
+ .tickPackJobs = pluginTickPackJobs,
+ .runPackWorkers = pluginRunPackWorkers,
+ .persistProjectFolder = pluginPersistProjectFolder,
+ .reloadProjectFolder = pluginReloadProjectFolder,
};
/// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id`
@@ -319,7 +336,74 @@ fn drawRadialMenu(_: *anyopaque) anyerror!void {
try RadialMenu.draw();
}
-/// Pixel-art editing + tool keybinds. The shell registers its own global/region
+fn pluginCopy(state: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ try Clipboard.copy(st);
+}
+
+fn pluginTransform(state: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ try TransformOp.begin(st);
+}
+
+fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ const internal_file: *Internal.File = @ptrCast(@alignCast(file));
+ const ptr = try DocsRegistry.registerOpenDocument(st, internal_file);
+ return ptr;
+}
+
+fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocsRegistry.documentPtr(st, id);
+}
+
+fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocsRegistry.documentByPath(st, path);
+}
+
+fn unregisterDocument(state: *anyopaque, id: u64) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocsRegistry.unregisterDocument(st, id);
+}
+
+fn pluginPersistProjectFolder(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocsRegistry.persistProjectFolder(st);
+}
+
+fn pluginReloadProjectFolder(state: *anyopaque, allocator: std.mem.Allocator) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocsRegistry.reloadProjectFolder(st, allocator);
+}
+
+fn pluginPaste(state: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ try Clipboard.paste(st);
+}
+
+fn pluginStartPackProject(state: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ try PackProject.start(st);
+}
+
+fn pluginIsPackingActive(state: *const anyopaque) bool {
+ const st: *const State = @ptrCast(@alignCast(state));
+ return PackProject.isActive(st);
+}
+
+fn pluginTickPackJobs(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ PackProject.tick(st);
+}
+
+fn pluginRunPackWorkers(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ PackProject.runWasmWorkers(st);
+}
+
+/// Pixel-art editing + tool keybinds.
/// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see
/// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used.
fn contributeKeybinds(state: *anyopaque, win: *dvui.Window) anyerror!void {
diff --git a/src/plugins/pixelart/src/transform_op.zig b/src/plugins/pixelart/src/transform_op.zig
new file mode 100644
index 00000000..ad03b262
--- /dev/null
+++ b/src/plugins/pixelart/src/transform_op.zig
@@ -0,0 +1,123 @@
+//! Begin a transform on the active document (selection → transform handles).
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const Internal = pixelart.internal;
+
+fn activeFile(st: *State) ?*Internal.File {
+ const doc = st.host.activeDoc() orelse return null;
+ return st.docs.fileById(doc.id);
+}
+
+pub fn begin(st: *State) !void {
+ const file = activeFile(st) orelse return;
+ if (file.editor.transform) |*t| {
+ t.cancel();
+ }
+
+ var selected_layer = file.layers.get(file.selected_layer_index);
+
+ switch (st.tools.current) {
+ .selection => {
+ file.editor.transform_layer.clear();
+ var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward });
+ while (pixel_iterator.next()) |pixel_index| {
+ @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]);
+ selected_layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 };
+ file.editor.transform_layer.mask.set(pixel_index);
+ }
+ selected_layer.invalidate();
+ },
+ else => {
+ file.editor.transform_layer.clear();
+
+ if (file.editor.selected_sprites.count() > 0) {
+ var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward });
+
+ while (sprite_iterator.next()) |index| {
+ const source_rect = file.spriteRect(index);
+ if (selected_layer.pixelsFromRect(
+ dvui.currentWindow().arena(),
+ source_rect,
+ )) |source_pixels| {
+ file.editor.transform_layer.blit(
+ source_pixels,
+ source_rect,
+ .{ .transparent = true, .mask = true },
+ );
+ selected_layer.clearRect(source_rect);
+ }
+ }
+ } else {
+ if (file.editor.canvas.hovered) {
+ if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| {
+ const rect = file.spriteRect(sprite_index);
+ if (selected_layer.pixelsFromRect(
+ dvui.currentWindow().arena(),
+ rect,
+ )) |source_pixels| {
+ file.editor.transform_layer.blit(
+ source_pixels,
+ rect,
+ .{ .transparent = true, .mask = true },
+ );
+ selected_layer.clearRect(rect);
+ }
+ }
+ } else if (file.selected_animation_index) |animation_index| {
+ const animation = file.animations.get(animation_index);
+ if (file.selected_animation_frame_index < animation.frames.len) {
+ const source_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index);
+ if (selected_layer.pixelsFromRect(
+ dvui.currentWindow().arena(),
+ source_rect,
+ )) |source_pixels| {
+ file.editor.transform_layer.blit(
+ source_pixels,
+ source_rect,
+ .{ .transparent = true, .mask = true },
+ );
+ selected_layer.clearRect(source_rect);
+ }
+ }
+ }
+ }
+ },
+ }
+
+ const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size());
+ if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| {
+ defer file.editor.selection_layer.clearMask();
+ const gpa = Globals.allocator();
+ file.editor.transform = .{
+ .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixelart.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch {
+ dvui.log.err("Failed to create target texture", .{});
+ return;
+ },
+ .file_id = file.id,
+ .layer_id = selected_layer.id,
+ .data_points = .{
+ reduced_data_rect.topLeft(),
+ reduced_data_rect.topRight(),
+ reduced_data_rect.bottomRight(),
+ reduced_data_rect.bottomLeft(),
+ reduced_data_rect.center(),
+ reduced_data_rect.center(),
+ },
+ .source = pixelart.image.fromPixelsPMA(
+ @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)),
+ @intFromFloat(reduced_data_rect.w),
+ @intFromFloat(reduced_data_rect.h),
+ .ptr,
+ ) catch return error.MemoryAllocationFailed,
+ };
+
+ for (file.editor.transform.?.data_points[0..4]) |*point| {
+ const d = point.diff(file.editor.transform.?.point(.pivot).*);
+ if (d.length() > file.editor.transform.?.radius) {
+ file.editor.transform.?.radius = d.length() + 4;
+ }
+ }
+ }
+}
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index 0a489e42..5df09127 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -40,7 +40,7 @@ pub fn init(grouping: u64) Workspace {
/// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed
/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown.
pub fn deinit(self: *Workspace) void {
- pixelart.State.removeCanvasPane(fizzy.editor.pixelart_state, fizzy.app.allocator, self.grouping);
+ pixelart.State.removeCanvasPane(pixelart.Globals.state, fizzy.app.allocator, self.grouping);
}
/// Recover the typed workspace currently drawing `file` from its opaque slot
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index d6dc5712..9b55ab9a 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const fizzy = @import("../../../fizzy.zig");
+const pixelart = @import("pixelart");
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const builtin = @import("builtin");
@@ -497,7 +498,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path });
var color = dvui.themeGet().color(.control, .fill);
- if (fizzy.editor.pixelart_state.colors.palette) |*palette| {
+ if (pixelart.Globals.state.colors.palette) |*palette| {
color = palette.getDVUIColor(color_id.*);
}
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index 06f0d526..c918af2f 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -46,6 +46,17 @@ pub const VTable = struct {
undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+ /// Register a loaded/created document in the plugin's open-doc map. `file` points at
+ /// the plugin's document type (for pixel art, `*Internal.File` on the caller's stack).
+ /// Returns the stable registry pointer for `DocHandle.ptr`.
+ registerOpenDocument: ?*const fn (state: *anyopaque, file: *anyopaque) anyerror!*anyopaque = null,
+ /// Resolve a document id to the plugin's registry pointer, or null when not open.
+ documentPtr: ?*const fn (state: *anyopaque, id: u64) ?*anyopaque = null,
+ /// Lookup an open document by absolute path.
+ documentByPath: ?*const fn (state: *anyopaque, path: []const u8) ?*anyopaque = null,
+ /// Drop the registry entry after `closeDocument` has torn down resources.
+ unregisterDocument: ?*const fn (state: *anyopaque, id: u64) void = null,
+
// ---- render hooks (the plugin draws its own dvui UI into the host window) ----
/// Draw the plugin's explorer/sidebar pane (left region).
drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null,
@@ -63,6 +74,17 @@ pub const VTable = struct {
processRadialMenuInput: ?*const fn (state: *anyopaque) void = null,
radialMenuVisible: ?*const fn (state: *anyopaque) bool = null,
drawRadialMenu: ?*const fn (state: *anyopaque) anyerror!void = null,
+
+ // ---- editing + project pack (pixel-art today; future plugins opt in) ----
+ transform: ?*const fn (state: *anyopaque) anyerror!void = null,
+ copy: ?*const fn (state: *anyopaque) anyerror!void = null,
+ paste: ?*const fn (state: *anyopaque) anyerror!void = null,
+ startPackProject: ?*const fn (state: *anyopaque) anyerror!void = null,
+ isPackingActive: ?*const fn (state: *const anyopaque) bool = null,
+ tickPackJobs: ?*const fn (state: *anyopaque) void = null,
+ runPackWorkers: ?*const fn (state: *anyopaque) void = null,
+ persistProjectFolder: ?*const fn (state: *anyopaque) void = null,
+ reloadProjectFolder: ?*const fn (state: *anyopaque, allocator: std.mem.Allocator) void = null,
};
// Thin wrappers so callers don't repeat the optional-vtable dance.
@@ -91,6 +113,58 @@ pub fn drawRadialMenu(self: Plugin) !void {
if (self.vtable.drawRadialMenu) |f| try f(self.state);
}
+pub fn copy(self: Plugin) !void {
+ if (self.vtable.copy) |f| try f(self.state);
+}
+
+pub fn paste(self: Plugin) !void {
+ if (self.vtable.paste) |f| try f(self.state);
+}
+
+pub fn startPackProject(self: Plugin) !void {
+ if (self.vtable.startPackProject) |f| try f(self.state);
+}
+
+pub fn isPackingActive(self: Plugin) bool {
+ return if (self.vtable.isPackingActive) |f| f(self.state) else false;
+}
+
+pub fn tickPackJobs(self: Plugin) void {
+ if (self.vtable.tickPackJobs) |f| f(self.state);
+}
+
+pub fn runPackWorkers(self: Plugin) void {
+ if (self.vtable.runPackWorkers) |f| f(self.state);
+}
+
+pub fn transform(self: Plugin) !void {
+ if (self.vtable.transform) |f| try f(self.state);
+}
+
+pub fn registerOpenDocument(self: Plugin, file: *anyopaque) !*anyopaque {
+ return if (self.vtable.registerOpenDocument) |f| try f(self.state, file) else error.Unsupported;
+}
+
+pub fn documentPtr(self: Plugin, id: u64) ?*anyopaque {
+ return if (self.vtable.documentPtr) |f| f(self.state, id) else null;
+}
+
+pub fn documentByPath(self: Plugin, path: []const u8) ?*anyopaque {
+ return if (self.vtable.documentByPath) |f| f(self.state, path) else null;
+}
+
+pub fn unregisterDocument(self: Plugin, id: u64) void {
+ if (self.vtable.unregisterDocument) |f| f(self.state, id);
+}
+
+pub fn persistProjectFolder(self: Plugin) void {
+ if (self.vtable.persistProjectFolder) |f| f(self.state);
+}
+
+pub fn reloadProjectFolder(self: Plugin, allocator: std.mem.Allocator) void {
+ if (self.vtable.reloadProjectFolder) |f| f(self.state, allocator);
+}
+
// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ----
/// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin
diff --git a/tests/integration.zig b/tests/integration.zig
index 17f0b086..cbf904c2 100644
--- a/tests/integration.zig
+++ b/tests/integration.zig
@@ -1009,8 +1009,8 @@ test "drawPoint with to_change records history; undo restores pixels" {
// `drawPoint` reads plugin tools stroke size for stamps smaller than `min_full_stroke_size`;
// the shim zero-fills the editor, so brush size must be set explicitly.
- fizzy.editor.pixelart_state.tools.stroke_size = 1;
- fizzy.editor.pixelart_state.tools.pencil_stroke_size = 1;
+ pixelart.Globals.state.tools.stroke_size = 1;
+ pixelart.Globals.state.tools.pencil_stroke_size = 1;
const idx: usize = 3 * 8 + 4;
From e05fb1c07e3b459e846257be0a279a7e0e855480 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Thu, 18 Jun 2026 17:27:12 -0500
Subject: [PATCH 24/49] Phase 4 stage e final
---
HANDOFF.md | 7 +-
src/editor/Editor.zig | 47 +++++++---
src/editor/Menu.zig | 3 +-
src/editor/dialogs/AppQuitUnsaved.zig | 5 +-
src/editor/dialogs/UnsavedClose.zig | 25 ++----
src/plugins/pixelart/src/doc_bridge.zig | 78 ++++++++++++++++
src/plugins/pixelart/src/plugin.zig | 67 ++++++++++++++
src/plugins/workbench/src/Workbench.zig | 10 +--
src/plugins/workbench/src/Workspace.zig | 113 ++++++++++--------------
src/plugins/workbench/src/files.zig | 38 ++++----
src/sdk/Plugin.zig | 57 ++++++++++++
11 files changed, 319 insertions(+), 131 deletions(-)
create mode 100644 src/plugins/pixelart/src/doc_bridge.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 614401de..919529cd 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -265,11 +265,12 @@ app code until the build module is fully wired.
- **Copy/paste + pack/project** — moved to `pixelart/src/clipboard.zig` and `pack_project.zig`; plugin vtable hooks (`copy`, `paste`, `startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`). Shell `Editor` delegates; `setProjectFolder` uses plugin `persistProjectFolder` / `reloadProjectFolder`.
- **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`.
- **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only.
+- **DocHandle at workbench boundary** — `doc_bridge.zig` + plugin vtable metadata hooks (`bindDocumentToPane`, `documentGrouping`, `documentPath`, `setDocumentPath`, save/dirty indicators, …). `Workspace.zig` + `files.zig` use `DocHandle` + `doc.owner` only (no `Internal.File`). Shell helpers `docFromPath`, `docPath`, `setDocGrouping`, `bindDocToPane`; `fileFromDoc`/`fileById` are shell-internal.
**Still remaining:**
-- Shell `Editor` still types `*Internal.File` in helpers (`activeFile`, `fileFromDoc`) — shrink as multi-plugin doc types arrive.
-- `pixelart.internal.File` in workbench tab paths — type-agnostic `DocHandle` only at boundary.
-- Integration test shim updated for `pixelart.State` settings; `check-integration` still blocked on native `backend_native` SDL import under dvui-testing (pre-existing).
+- Shell `Editor` still types `*Internal.File` in internal save/new-file paths (`newFile`, `openFileFromBytes`, save queue).
+- `FileLoadJob` staging buffer still uses `Internal.File` (loader contract).
+- Menu/Infobar still use `activeFile()` for pixel-art-specific UI (undo stacks, save enabled).
---
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 121e6095..5c8e1435 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -747,15 +747,14 @@ fn shellIsPackingActive(ctx: *anyopaque) bool {
return shellCtx(ctx).isPackingActive();
}
-/// Resolve a shell `DocHandle` to the plugin-owned file. Uses `doc.id`, not `doc.ptr`:
-/// the plugin registry may reallocate and invalidate pointers stored at insert time.
-pub fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File {
+/// Resolve a shell `DocHandle` to the plugin-owned file (shell-internal; workbench uses `DocHandle` + owner hooks).
+fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File {
_ = editor;
return @ptrCast(@alignCast(doc.owner.documentPtr(doc.id).?));
}
-/// Resolve an open document id to the plugin-owned file, or null when not open.
-pub fn fileById(editor: *Editor, id: u64) ?*Internal.File {
+/// Resolve an open document id to the plugin-owned file (shell-internal).
+fn fileById(editor: *Editor, id: u64) ?*Internal.File {
const doc = editor.docById(id) orelse return null;
const ptr = doc.owner.documentPtr(doc.id) orelse return null;
return @ptrCast(@alignCast(ptr));
@@ -777,6 +776,30 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle {
return null;
}
+/// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`).
+pub fn docGrouping(_: *Editor, doc: sdk.DocHandle) u64 {
+ return doc.owner.documentGrouping(doc);
+}
+
+pub fn setDocGrouping(_: *Editor, doc: sdk.DocHandle, grouping: u64) void {
+ doc.owner.setDocumentGrouping(doc, grouping);
+}
+
+pub fn docPath(_: *Editor, doc: sdk.DocHandle) []const u8 {
+ return doc.owner.documentPath(doc);
+}
+
+pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle {
+ for (editor.open_files.values()) |doc| {
+ if (doc.owner.documentByPath(path) != null) return doc;
+ }
+ return null;
+}
+
+pub fn bindDocToPane(_: *Editor, doc: sdk.DocHandle, canvas_id: dvui.Id, workspace: *anyopaque, center: bool) void {
+ doc.owner.bindDocumentToPane(doc, canvas_id, workspace, center);
+}
+
/// Store a loaded/created document in the plugin registry and register its handle.
pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void {
var file_mut = file;
@@ -2015,9 +2038,9 @@ pub fn saving(editor: *Editor) bool {
/// worker hasn't landed it yet and there is no valid `open_files` index to act on. The async
/// load will auto-focus once the worker completes (see `processLoadingJobs`).
pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u64) !?usize {
- if (editor.getFileFromPath(path)) |file| {
- const idx = editor.open_files.getIndex(file.id) orelse return error.Unexpected;
- editor.fileAt(idx).?.editor.grouping = grouping;
+ if (editor.docFromPath(path)) |doc| {
+ const idx = editor.open_files.getIndex(doc.id) orelse return error.Unexpected;
+ editor.setDocGrouping(doc, grouping);
editor.setActiveFile(idx);
return idx;
}
@@ -2503,12 +2526,8 @@ pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File {
}
pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File {
- for (editor.open_files.values()) |doc| {
- if (doc.owner.documentByPath(path)) |ptr| {
- return @ptrCast(@alignCast(ptr));
- }
- }
- return null;
+ const doc = editor.docFromPath(path) orelse return null;
+ return editor.fileFromDoc(doc);
}
pub fn forceCloseFile(editor: *Editor, index: usize) !void {
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index cdc13904..18e88878 100644
--- a/src/editor/Menu.zig
+++ b/src/editor/Menu.zig
@@ -160,8 +160,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
// extension. Worker queue handles them serially; UI stays responsive.
const any_dirty = blk: {
for (fizzy.editor.open_files.values()) |doc| {
- const f = fizzy.editor.fileFromDoc(doc);
- if (f.dirty() and Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true;
+ if (doc.owner.isDirty(doc) and Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) break :blk true;
}
break :blk false;
};
diff --git a/src/editor/dialogs/AppQuitUnsaved.zig b/src/editor/dialogs/AppQuitUnsaved.zig
index b2e05023..48aafd04 100644
--- a/src/editor/dialogs/AppQuitUnsaved.zig
+++ b/src/editor/dialogs/AppQuitUnsaved.zig
@@ -32,7 +32,7 @@ pub fn request() void {
fn dirtyCount() usize {
var n: usize = 0;
for (fizzy.editor.open_files.values()) |doc| {
- if (fizzy.editor.fileFromDoc(doc).dirty()) n += 1;
+ if (doc.owner.isDirty(doc)) n += 1;
}
return n;
}
@@ -113,8 +113,7 @@ fn onSaveAllAndQuit() !void {
fizzy.editor.quit_save_all_ids.clearRetainingCapacity();
for (fizzy.editor.open_files.values()) |doc| {
- const f = fizzy.editor.fileFromDoc(doc);
- if (f.dirty()) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, f.id);
+ if (doc.owner.isDirty(doc)) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, doc.id);
}
if (fizzy.editor.quit_save_all_ids.items.len == 0) {
fizzy.editor.pending_app_close = true;
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index b7720980..d6403478 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -23,8 +23,8 @@ pub fn request(file_id: u64) void {
}
fn fileBasename(file_id: u64) []const u8 {
- const file = fizzy.editor.fileById(file_id) orelse return "?";
- return std.fs.path.basename(file.path);
+ const doc = fizzy.editor.docById(file_id) orelse return "?";
+ return std.fs.path.basename(fizzy.editor.docPath(doc));
}
fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool {
@@ -95,12 +95,8 @@ fn onCancel() void {
fizzy.dvui.closeFloatingDialogAnchored();
}
-/// Start an async save for the file (`.fizzy` runs on a worker, PNG/JPG runs sync
-/// on the GUI thread) and queue the close for once `File.isSaving()` clears.
-/// `Editor.tickPendingSaveCloses` does the actual close on the next frame after
-/// the worker settles, so the GUI thread never blocks on the save.
-fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void {
- if (file.isSaving()) return;
+fn beginSaveAndClose(doc: fizzy.sdk.DocHandle, file_id: u64) !void {
+ if (doc.owner.isDocumentSaving(doc)) return;
if (comptime @import("builtin").target.cpu.arch == .wasm32) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
fizzy.editor.setActiveFile(idx);
@@ -108,13 +104,13 @@ fn beginSaveAndClose(file: *Internal.File, file_id: u64) !void {
fizzy.editor.requestWebSaveDialog(.save);
return;
}
- try file.saveAsync();
+ try doc.owner.saveDocumentAsync(doc);
try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {});
}
fn onSaveAndClose(file_id: u64) !void {
- const file = fizzy.editor.fileById(file_id) orelse return;
- if (!Internal.File.hasRecognizedSaveExtension(file.path)) {
+ const doc = fizzy.editor.docById(file_id) orelse return;
+ if (!Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
fizzy.editor.setActiveFile(idx);
fizzy.editor.pending_close_file_id = file_id;
@@ -122,16 +118,13 @@ fn onSaveAndClose(file_id: u64) !void {
fizzy.editor.requestSaveAs();
return;
}
- if (file.shouldConfirmFlatRasterSave()) {
+ if (doc.owner.shouldConfirmFlatRasterSave(doc)) {
FlatRasterSaveWarning.pending_from_save_all_quit = false;
fizzy.dvui.closeFloatingDialogAnchored();
FlatRasterSaveWarning.request(file_id, .save_and_close);
return;
}
- beginSaveAndClose(file, file_id) catch |err| {
- dvui.log.err("Save and Close failed: {s}", .{@errorName(err)});
- return;
- };
+ try beginSaveAndClose(doc, file_id);
fizzy.dvui.closeFloatingDialogAnchored();
}
diff --git a/src/plugins/pixelart/src/doc_bridge.zig b/src/plugins/pixelart/src/doc_bridge.zig
new file mode 100644
index 00000000..a515e8ad
--- /dev/null
+++ b/src/plugins/pixelart/src/doc_bridge.zig
@@ -0,0 +1,78 @@
+//! Document metadata + pane-binding hooks for shell/workbench routing without
+//! typing `Internal.File` at the SDK boundary.
+const std = @import("std");
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const Internal = pixelart.internal;
+const DocHandle = pixelart.sdk.DocHandle;
+
+fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
+ return st.docs.fileById(doc.id);
+}
+
+pub fn bindDocumentToPane(
+ st: *State,
+ doc: DocHandle,
+ canvas_id: dvui.Id,
+ workspace_handle: *anyopaque,
+ center: bool,
+) void {
+ const file = docFile(st, doc) orelse return;
+ file.editor.canvas.id = canvas_id;
+ file.editor.workspace_handle = workspace_handle;
+ file.editor.center = center;
+}
+
+pub fn documentGrouping(st: *State, doc: DocHandle) u64 {
+ const file = docFile(st, doc) orelse return 0;
+ return file.editor.grouping;
+}
+
+pub fn setDocumentGrouping(st: *State, doc: DocHandle, grouping: u64) void {
+ const file = docFile(st, doc) orelse return;
+ file.editor.grouping = grouping;
+}
+
+pub fn documentPath(st: *State, doc: DocHandle) []const u8 {
+ const file = docFile(st, doc) orelse return "";
+ return file.path;
+}
+
+pub fn setDocumentPath(st: *State, doc: DocHandle, path: []const u8) !void {
+ const file = docFile(st, doc) orelse return error.DocumentNotFound;
+ const gpa = Globals.allocator();
+ gpa.free(file.path);
+ file.path = try gpa.dupe(u8, path);
+}
+
+pub fn documentHasNativeExtension(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
+}
+
+pub fn showsSaveStatusIndicator(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return file.showsSaveStatusIndicator();
+}
+
+pub fn isDocumentSaving(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return file.isSaving();
+}
+
+pub fn shouldConfirmFlatRasterSave(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return file.shouldConfirmFlatRasterSave();
+}
+
+pub fn saveDocumentAsync(st: *State, doc: DocHandle) !void {
+ const file = docFile(st, doc) orelse return error.DocumentNotFound;
+ try file.saveAsync();
+}
+
+pub fn timeSinceSaveCompleteNs(st: *State, doc: DocHandle) ?i128 {
+ const file = docFile(st, doc) orelse return null;
+ return file.timeSinceSaveComplete();
+}
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index c122aece..b6a840ad 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -19,6 +19,7 @@ const Clipboard = @import("clipboard.zig");
const PackProject = @import("pack_project.zig");
const TransformOp = @import("transform_op.zig");
const DocsRegistry = @import("docs_registry.zig");
+const DocBridge = @import("doc_bridge.zig");
const DocHandle = sdk.DocHandle;
const Internal = pixelart.internal;
@@ -50,6 +51,17 @@ const vtable: sdk.Plugin.VTable = .{
.documentPtr = documentPtr,
.documentByPath = documentByPath,
.unregisterDocument = unregisterDocument,
+ .bindDocumentToPane = bindDocumentToPane,
+ .documentGrouping = documentGrouping,
+ .setDocumentGrouping = setDocumentGrouping,
+ .documentPath = documentPath,
+ .setDocumentPath = setDocumentPath,
+ .documentHasNativeExtension = documentHasNativeExtension,
+ .showsSaveStatusIndicator = showsSaveStatusIndicator,
+ .isDocumentSaving = isDocumentSaving,
+ .shouldConfirmFlatRasterSave = shouldConfirmFlatRasterSave,
+ .saveDocumentAsync = saveDocumentAsync,
+ .timeSinceSaveCompleteNs = timeSinceSaveCompleteNs,
.drawDocument = drawDocument,
.tickKeybinds = tickKeybinds,
.processRadialMenuInput = processRadialMenuInput,
@@ -368,6 +380,61 @@ fn unregisterDocument(state: *anyopaque, id: u64) void {
DocsRegistry.unregisterDocument(st, id);
}
+fn bindDocumentToPane(state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocBridge.bindDocumentToPane(st, doc, canvas_id, workspace_handle, center);
+}
+
+fn documentGrouping(state: *anyopaque, doc: DocHandle) u64 {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.documentGrouping(st, doc);
+}
+
+fn setDocumentGrouping(state: *anyopaque, doc: DocHandle, grouping: u64) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocBridge.setDocumentGrouping(st, doc, grouping);
+}
+
+fn documentPath(state: *anyopaque, doc: DocHandle) []const u8 {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.documentPath(st, doc);
+}
+
+fn setDocumentPath(state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.setDocumentPath(st, doc, path);
+}
+
+fn documentHasNativeExtension(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.documentHasNativeExtension(st, doc);
+}
+
+fn showsSaveStatusIndicator(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.showsSaveStatusIndicator(st, doc);
+}
+
+fn isDocumentSaving(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.isDocumentSaving(st, doc);
+}
+
+fn shouldConfirmFlatRasterSave(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.shouldConfirmFlatRasterSave(st, doc);
+}
+
+fn saveDocumentAsync(state: *anyopaque, doc: DocHandle) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.saveDocumentAsync(st, doc);
+}
+
+fn timeSinceSaveCompleteNs(state: *anyopaque, doc: DocHandle) ?i128 {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.timeSinceSaveCompleteNs(st, doc);
+}
+
fn pluginPersistProjectFolder(state: *anyopaque) void {
const st: *State = @ptrCast(@alignCast(state));
DocsRegistry.persistProjectFolder(st);
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
index ea1a6f11..f535b9b5 100644
--- a/src/plugins/workbench/src/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -65,8 +65,8 @@ pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize
/// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the
/// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent.
fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void {
- const file = fizzy.editor.getFileFromPath(path) orelse return;
- if (!file.dirty()) return;
+ const doc = fizzy.editor.docFromPath(path) orelse return;
+ if (!doc.owner.isDirty(doc)) return;
dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{
.stroke_color = dvui.themeGet().color(.window, .text),
}, .{
@@ -213,15 +213,15 @@ fn svcSave(ctx: *anyopaque) anyerror!void {
return editorOf(ctx).save();
}
fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool {
- return editorOf(ctx).getFileFromPath(path) != null;
+ return editorOf(ctx).docFromPath(path) != null;
}
fn svcOpenCount(ctx: *anyopaque) usize {
return editorOf(ctx).open_files.count();
}
fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 {
const editor = editorOf(ctx);
- if (index >= editor.open_files.count()) return null;
- return if (editor.fileAt(index)) |file| file.path else null;
+ const doc = editor.docAt(index) orelse return null;
+ return editor.docPath(doc);
}
fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void {
return files.createFilePath(path);
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index 5df09127..c196f430 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -4,7 +4,6 @@ const builtin = @import("builtin");
const dvui = @import("dvui");
const sdk = @import("sdk");
const pixelart = @import("pixelart");
-const Internal = pixelart.internal;
const fizzy = @import("../../../fizzy.zig");
const icons = @import("icons");
@@ -43,14 +42,6 @@ pub fn deinit(self: *Workspace) void {
pixelart.State.removeCanvasPane(pixelart.Globals.state, fizzy.app.allocator, self.grouping);
}
-/// Recover the typed workspace currently drawing `file` from its opaque slot
-/// handle (`File.EditorData.workspace_handle`, set each frame in `drawCanvas`).
-/// Returns null before the file has been laid out this session.
-pub fn ofFile(file: *Internal.File) ?*Workspace {
- const handle = file.editor.workspace_handle orelse return null;
- return @ptrCast(@alignCast(handle));
-}
-
const handle_size = 10;
const handle_dist = 60;
@@ -170,30 +161,28 @@ fn drawTabs(self: *Workspace) void {
const active_in_this_group = blk: {
if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false;
if (self.open_file_index >= files_len) break :blk false;
- const active_file = fizzy.editor.fileAt(self.open_file_index) orelse break :blk false;
- if (active_file.editor.grouping != self.grouping) break :blk false;
+ const active_doc = fizzy.editor.docAt(self.open_file_index) orelse break :blk false;
+ if (fizzy.editor.docGrouping(active_doc) != self.grouping) break :blk false;
break :blk true;
};
if (active_in_this_group) {
const active_index = self.open_file_index;
- // Scan left from the active tab to find the previous tab in this grouping.
var j: usize = active_index;
while (j > 0) {
j -= 1;
- const tab_file = fizzy.editor.fileAt(j) orelse continue;
- if (tab_file.editor.grouping == self.grouping) {
+ const tab_doc = fizzy.editor.docAt(j) orelse continue;
+ if (fizzy.editor.docGrouping(tab_doc) == self.grouping) {
prev_same_group_index = j;
break;
}
}
- // Scan right from the active tab to find the next tab in this grouping.
j = active_index + 1;
while (j < files_len) : (j += 1) {
- const tab_file = fizzy.editor.fileAt(j) orelse continue;
- if (tab_file.editor.grouping == self.grouping) {
+ const tab_doc = fizzy.editor.docAt(j) orelse continue;
+ if (fizzy.editor.docGrouping(tab_doc) == self.grouping) {
next_same_group_index = j;
break;
}
@@ -201,10 +190,10 @@ fn drawTabs(self: *Workspace) void {
}
for (0..files_len) |i| {
- const file = fizzy.editor.fileAt(i) orelse continue;
- const is_fizzy_file = Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
+ const doc = fizzy.editor.docAt(i) orelse continue;
+ const is_fizzy_file = doc.owner.documentHasNativeExtension(doc);
- if (file.editor.grouping != self.grouping) continue;
+ if (fizzy.editor.docGrouping(doc) != self.grouping) continue;
var reorderable = tabs.reorderable(@src(), .{}, .{
.expand = .vertical,
@@ -291,7 +280,7 @@ fn drawTabs(self: *Workspace) void {
});
}
- dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{
+ dvui.label(@src(), "{s}", .{std.fs.path.basename(fizzy.editor.docPath(doc))}, .{
.color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
.padding = dvui.Rect.all(4),
.gravity_y = 0.5,
@@ -313,13 +302,13 @@ fn drawTabs(self: *Workspace) void {
// button so the layout doesn't shift when saving starts/ends. `editor.saving`
// can be written by a background save worker (`saveZip`), so we read it with an
// atomic load — the write side uses an atomic store in matching `save*` paths.
- const save_flash_elapsed = file.timeSinceSaveComplete();
+ const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc);
const save_in_check_phase = if (save_flash_elapsed) |elapsed|
fizzy.dvui.bubbleSpinnerSaveInCheckPhase(elapsed)
else
false;
- const save_blocks_tab_close = file.isSaving() or
- (file.showsSaveStatusIndicator() and !save_in_check_phase);
+ const save_blocks_tab_close = doc.owner.isDocumentSaving(doc) or
+ (doc.owner.showsSaveStatusIndicator(doc) and !save_in_check_phase);
if (save_blocks_tab_close) {
fizzy.dvui.bubbleSpinner(@src(), .{
@@ -369,12 +358,12 @@ fn drawTabs(self: *Workspace) void {
}
if (tab_close_button.clicked()) {
- fizzy.editor.closeFileID(file.id) catch |err| {
+ fizzy.editor.closeFileID(doc.id) catch |err| {
dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
};
break;
}
- } else if (selected and !file.dirty()) {
+ } else if (selected and !doc.owner.isDirty(doc)) {
const tab_text = dvui.themeGet().color(.window, .text);
var ghost_close: dvui.ButtonWidget = undefined;
ghost_close.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{
@@ -414,12 +403,12 @@ fn drawTabs(self: *Workspace) void {
});
if (ghost_close.clicked()) {
- fizzy.editor.closeFileID(file.id) catch |err| {
+ fizzy.editor.closeFileID(doc.id) catch |err| {
dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
};
break;
}
- } else if (file.dirty()) {
+ } else if (doc.owner.isDirty(doc)) {
dvui.icon(@src(), "dirty_icon", icons.tvg.lucide.@"circle-small", .{
.stroke_color = dvui.themeGet().color(.window, .text),
}, .{
@@ -499,18 +488,18 @@ pub fn processTabsDrag(self: *Workspace) void {
std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping;
+ fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping);
fizzy.editor.setActiveFile(insert_before);
} else {
if (insert_before > 0) {
std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]);
- fizzy.editor.fileAt(insert_before - 1).?.editor.grouping = self.grouping;
+ fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before - 1).?, self.grouping);
fizzy.editor.setActiveFile(insert_before - 1);
} else {
std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.fileAt(insert_before).?.editor.grouping = self.grouping;
+ fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping);
fizzy.editor.setActiveFile(insert_before);
}
}
@@ -528,13 +517,12 @@ pub fn processTabsDrag(self: *Workspace) void {
/// Repoint `open_file_index` on workspaces that were showing the dragged tab as active.
fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void {
- const dragged_file = editor.fileAt(drag_index) orelse return;
+ const dragged_doc = editor.docAt(drag_index) orelse return;
if (tab_bar_workspace) |workspace| {
- if (workspace.open_file_index == editor.open_files.getIndex(dragged_file.id)) {
+ if (workspace.open_file_index == editor.open_files.getIndex(dragged_doc.id)) {
for (editor.open_files.values()) |doc| {
- const f = editor.fileFromDoc(doc);
- if (f.editor.grouping == workspace.grouping and f.id != dragged_file.id) {
- workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
+ if (editor.docGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) {
+ workspace.open_file_index = editor.open_files.getIndex(doc.id) orelse 0;
break;
}
}
@@ -543,9 +531,8 @@ fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace
for (editor.workspaces.values()) |*w| {
if (w.open_file_index == drag_index) {
for (editor.open_files.values()) |doc| {
- const f = editor.fileFromDoc(doc);
- if (f.editor.grouping == w.grouping and f.id != dragged_file.id) {
- w.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
+ if (editor.docGrouping(doc) == w.grouping and doc.id != dragged_doc.id) {
+ w.open_file_index = editor.open_files.getIndex(doc.id) orelse 0;
break;
}
}
@@ -565,8 +552,8 @@ const WorkspaceTabDragSrc = union(enum) {
if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } };
}
if (editor.tab_drag_from_tree_path) |p| {
- if (editor.getFileFromPath(p)) |f| {
- const idx = editor.open_files.getIndex(f.id) orelse return .none;
+ if (editor.docFromPath(p)) |doc| {
+ const idx = editor.open_files.getIndex(doc.id) orelse return .none;
return .{ .tree_open = idx };
}
return .{ .tree_closed = p };
@@ -617,9 +604,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
- dragged_file.editor.grouping = fizzy.editor.newGroupingID();
- fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
+ const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ const new_g = fizzy.editor.newGroupingID();
+ fizzy.editor.setDocGrouping(dragged_doc, new_g);
+ fizzy.editor.open_workspace_grouping = new_g;
}
} else if (data.rectScale().r.contains(e.evt.mouse.p)) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
@@ -636,10 +624,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
- dragged_file.editor.grouping = self.grouping;
- fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
- self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0;
+ const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ fizzy.editor.setDocGrouping(dragged_doc, self.grouping);
+ fizzy.editor.open_workspace_grouping = self.grouping;
+ self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0;
}
}
},
@@ -662,9 +650,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
- dragged_file.editor.grouping = fizzy.editor.newGroupingID();
- fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
+ const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ const new_g = fizzy.editor.newGroupingID();
+ fizzy.editor.setDocGrouping(dragged_doc, new_g);
+ fizzy.editor.open_workspace_grouping = new_g;
}
} else if (data.rectScale().r.contains(e.evt.mouse.p)) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
@@ -680,10 +669,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- const dragged_file = fizzy.editor.fileAt(drag_index) orelse continue;
- dragged_file.editor.grouping = self.grouping;
- fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping;
- self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0;
+ const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ fizzy.editor.setDocGrouping(dragged_doc, self.grouping);
+ fizzy.editor.open_workspace_grouping = self.grouping;
+ self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0;
}
}
},
@@ -779,17 +768,9 @@ pub fn drawCanvas(self: *Workspace) !void {
self.open_file_index = fizzy.editor.open_files.values().len - 1;
}
- if (fizzy.editor.fileAt(self.open_file_index)) |file| {
- // The workbench owns only the content region (this container) + tab/split frame;
- // bind it to the document and route the entire in-region render to the owning
- // plugin (pixel art draws its rulers, overlays, and editing widget itself).
- file.editor.canvas.id = canvas_vbox.data().id;
- file.editor.workspace_handle = self;
- file.editor.center = self.center;
-
- if (fizzy.editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
- _ = try plugin.drawDocument(.{ .ptr = file, .owner = plugin, .id = file.id });
- }
+ if (fizzy.editor.docAt(self.open_file_index)) |doc| {
+ fizzy.editor.bindDocToPane(doc, canvas_vbox.data().id, self, self.center);
+ _ = try doc.owner.drawDocument(doc);
}
} else {
var box = workspaceEmptyStateCard(content_color, self.grouping);
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index 9b55ab9a..b670a99c 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -796,22 +796,16 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
editableLabel(
inner_id_extra.*,
if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", fizzy.editor.folder.?, abs_path) catch entry.name else entry.name,
- if (fizzy.editor.getFileFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
+ if (fizzy.editor.docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
entry.kind,
abs_path,
) catch {
dvui.log.err("Failed to draw editable label", .{});
};
- if (fizzy.editor.getFileFromPath(abs_path)) |file| {
- // Save spinner takes priority over the dirty dot: while a file is
- // mid-save it's no longer "dirty waiting to be saved", it's "saving
- // right now", and the user needs that distinction at a glance when
- // multiple files are flushing in parallel. `isSaving` reads via an
- // atomic load so the background `saveZip` worker can flip the flag
- // safely from another thread.
- const save_flash_elapsed = file.timeSinceSaveComplete();
- if (file.showsSaveStatusIndicator()) {
+ if (fizzy.editor.docFromPath(abs_path)) |doc| {
+ const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc);
+ if (doc.owner.showsSaveStatusIndicator(doc)) {
fizzy.dvui.bubbleSpinner(@src(), .{
.id_extra = inner_id_extra.* +% 4001,
.expand = .none,
@@ -822,7 +816,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
}, .{
.complete_elapsed_ns = save_flash_elapsed,
});
- } else if (file.dirty()) {
+ } else if (doc.owner.isDirty(doc)) {
_ = dvui.icon(
@src(),
"DirtyIcon",
@@ -1236,9 +1230,8 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m
return false;
};
- if (fizzy.editor.getFileFromPath(source_path)) |file| {
- fizzy.app.allocator.free(file.path);
- file.path = fizzy.app.allocator.dupe(u8, new_path) catch {
+ if (fizzy.editor.docFromPath(source_path)) |doc| {
+ doc.owner.setDocumentPath(doc, new_path) catch {
dvui.log.err("Failed to duplicate path: {s}", .{new_path});
return error.FailedToDuplicatePath;
};
@@ -1260,20 +1253,21 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File
std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
for (fizzy.editor.open_files.values()) |doc| {
- const file = fizzy.editor.fileFromDoc(doc);
- if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) {
- const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path";
- fizzy.app.allocator.free(file.path);
- file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name });
+ const path = fizzy.editor.docPath(doc);
+ if (std.mem.containsAtLeast(u8, path, 1, full_path)) {
+ const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path";
+ const new_full = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name });
+ doc.owner.setDocumentPath(doc, new_full) catch {
+ dvui.log.err("Failed to update open document path", .{});
+ };
}
}
},
.file => {
std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
- if (fizzy.editor.getFileFromPath(full_path)) |file| {
- fizzy.app.allocator.free(file.path);
- file.path = fizzy.app.allocator.dupe(u8, new_path) catch {
+ if (fizzy.editor.docFromPath(full_path)) |doc| {
+ doc.owner.setDocumentPath(doc, new_path) catch {
dvui.log.err("Failed to duplicate path: {s}", .{new_path});
return error.FailedToDuplicatePath;
};
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index c918af2f..95d29605 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -57,6 +57,19 @@ pub const VTable = struct {
/// Drop the registry entry after `closeDocument` has torn down resources.
unregisterDocument: ?*const fn (state: *anyopaque, id: u64) void = null,
+ /// Bind a document to a workbench pane before `drawDocument` (canvas id, workspace handle, center flag).
+ bindDocumentToPane: ?*const fn (state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void = null,
+ documentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle) u64 = null,
+ setDocumentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle, grouping: u64) void = null,
+ documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null,
+ setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null,
+ documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ showsSaveStatusIndicator: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ isDocumentSaving: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ shouldConfirmFlatRasterSave: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ saveDocumentAsync: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+ timeSinceSaveCompleteNs: ?*const fn (state: *anyopaque, doc: DocHandle) ?i128 = null,
+
// ---- render hooks (the plugin draws its own dvui UI into the host window) ----
/// Draw the plugin's explorer/sidebar pane (left region).
drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null,
@@ -165,6 +178,50 @@ pub fn reloadProjectFolder(self: Plugin, allocator: std.mem.Allocator) void {
if (self.vtable.reloadProjectFolder) |f| f(self.state, allocator);
}
+pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void {
+ if (self.vtable.bindDocumentToPane) |f| f(self.state, doc, canvas_id, workspace_handle, center);
+}
+
+pub fn documentGrouping(self: Plugin, doc: DocHandle) u64 {
+ return if (self.vtable.documentGrouping) |f| f(self.state, doc) else 0;
+}
+
+pub fn setDocumentGrouping(self: Plugin, doc: DocHandle, grouping: u64) void {
+ if (self.vtable.setDocumentGrouping) |f| f(self.state, doc, grouping);
+}
+
+pub fn documentPath(self: Plugin, doc: DocHandle) []const u8 {
+ return if (self.vtable.documentPath) |f| f(self.state, doc) else "";
+}
+
+pub fn setDocumentPath(self: Plugin, doc: DocHandle, path: []const u8) !void {
+ if (self.vtable.setDocumentPath) |f| try f(self.state, doc, path);
+}
+
+pub fn documentHasNativeExtension(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.documentHasNativeExtension) |f| f(self.state, doc) else false;
+}
+
+pub fn showsSaveStatusIndicator(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.showsSaveStatusIndicator) |f| f(self.state, doc) else false;
+}
+
+pub fn isDocumentSaving(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.isDocumentSaving) |f| f(self.state, doc) else false;
+}
+
+pub fn shouldConfirmFlatRasterSave(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.shouldConfirmFlatRasterSave) |f| f(self.state, doc) else false;
+}
+
+pub fn saveDocumentAsync(self: Plugin, doc: DocHandle) !void {
+ if (self.vtable.saveDocumentAsync) |f| try f(self.state, doc);
+}
+
+pub fn timeSinceSaveCompleteNs(self: Plugin, doc: DocHandle) ?i128 {
+ return if (self.vtable.timeSinceSaveCompleteNs) |f| f(self.state, doc) else null;
+}
+
// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ----
/// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin
From 09d2a9d2b2cd60a2bc798472cb5ed3051044f682 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 07:52:10 -0500
Subject: [PATCH 25/49] Phase 4 post stage e
---
HANDOFF.md | 68 ++-
src/editor/Editor.zig | 519 +++++++-------------
src/editor/Infobar.zig | 60 +--
src/editor/Keybinds.zig | 2 +-
src/editor/Menu.zig | 38 +-
src/editor/WebFileIo.zig | 19 +-
src/editor/dialogs/UnsavedClose.zig | 3 +-
src/editor/panel/Panel.zig | 2 -
src/plugins/pixelart/src/doc_bridge.zig | 15 +
src/plugins/pixelart/src/doc_lifecycle.zig | 161 ++++++
src/plugins/pixelart/src/infobar_status.zig | 83 ++++
src/plugins/pixelart/src/plugin.zig | 153 ++++++
src/plugins/workbench/src/FileLoadJob.zig | 59 +--
src/sdk/Plugin.zig | 138 ++++++
14 files changed, 810 insertions(+), 510 deletions(-)
create mode 100644 src/plugins/pixelart/src/doc_lifecycle.zig
create mode 100644 src/plugins/pixelart/src/infobar_status.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 919529cd..7af1f424 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -14,10 +14,18 @@ lift, sprites bottom-panel lift.
**In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection,
Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired.
-**Next:** Stage E — trim `fizzy.zig` re-exports; route copy/paste/pack through plugin vtable.
+**Stage E — polish complete** (see "Stage E polish — DONE" below): shell no longer imports
+`pixelart.internal`; `pixelart_state` field access fully routed to lifecycle + vtable;
+`Plugin.beginFrame` hook removes the last shell→`pixelart.render` poke; dead imports pruned.
+**Sprite/atlas → `core` big rock: DONE** (verified — generic atlas type + sprite-draw
+primitive + sprite-id index all in `core`; neither shell nor plugin reaches the other's atlas).
-> **Read this first if you're a fresh agent:** start at "Stage D — remaining work"
-> below. All three build configs are green right now.
+**Next:** the only remaining shell→plugin concrete reaches are `pixelart.dialogs.*` +
+`pixelart.explorer.project` — needs a generic dialog-registry vtable to lift (deferred).
+Then: wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor` (logo atlas draw).
+
+> **Read this first if you're a fresh agent:** Stage D/E are done bar the dialog-registry
+> lift. All three build configs are green right now.
All three build configs are green:
@@ -266,25 +274,51 @@ app code until the build module is fully wired.
- **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`.
- **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only.
- **DocHandle at workbench boundary** — `doc_bridge.zig` + plugin vtable metadata hooks (`bindDocumentToPane`, `documentGrouping`, `documentPath`, `setDocumentPath`, save/dirty indicators, …). `Workspace.zig` + `files.zig` use `DocHandle` + `doc.owner` only (no `Internal.File`). Shell helpers `docFromPath`, `docPath`, `setDocGrouping`, `bindDocToPane`; `fileFromDoc`/`fileById` are shell-internal.
-
-**Still remaining:**
-- Shell `Editor` still types `*Internal.File` in internal save/new-file paths (`newFile`, `openFileFromBytes`, save queue).
-- `FileLoadJob` staging buffer still uses `Internal.File` (loader contract).
-- Menu/Infobar still use `activeFile()` for pixel-art-specific UI (undo stacks, save enabled).
+- **Menu/Infobar off `activeFile()`** — `Menu.zig` + `Infobar.zig` route through `activeDoc()` + plugin hooks (`canUndo`/`canRedo`, `documentHasRecognizedSaveExtension`, `drawDocumentInfobar`). Active-doc infobar UI moved to `pixelart/src/infobar_status.zig`. Shell save/keybind paths (`save`, `saveAll`, quit-save-all, `UnsavedClose`) use `DocHandle` + owner hooks.
+- **Shell `Internal.File` removed** — `Editor.zig` no longer types `*Internal.File` (removed `activeFile`, `fileFromDoc`, `fileById`, `getFile`, …). Document create/load/save-as routed through plugin vtable + `doc_lifecycle.zig` (`createDocument`, `saveDocumentAs`, `documentDefaultSaveAsFilename`, frame ticks, accept/cancel/delete). `insertOpenDoc` takes `*anyopaque` + id; `newFile` returns `DocHandle`; `openFileFromBytes` returns doc id. `FileLoadJob` uses opaque staging buffer via `Plugin.allocDocumentBuffer`. Save-queue worker owned by plugin (`initPlugin`/`deinit`).
+
+**Stage E polish — DONE:**
+- ✅ Removed dead `Editor.closeReference` (referenced a non-existent `open_references`
+ field + `Internal.Reference` type; survived only via Zig lazy analysis). With it gone,
+ the `const Internal = pixelart.internal;` import is dropped — **shell no longer imports
+ `pixelart.internal` at all.**
+- ✅ `editor.pixelart_state` direct field access already routed away: `pixelart_state`
+ now appears only as the `Editor` field declaration + `App.zig` lifecycle
+ (create/init/persist/deinit/destroy). No shell member access remains.
+- ✅ **`Plugin.beginFrame` vtable hook** — shell no longer pokes `pixelart.render.frame_index`
+ directly. `Editor.frame` now calls `plugin.beginFrame()` for every registered plugin; the
+ pixel-art impl advances its own composite-cache frame clock. **No `pixelart.render` in shell.**
+- ✅ Removed dead `pixelart`/`Packer` imports from `editor/panel/Panel.zig`.
+
+**Shell → plugin surface now (grep `pixelart\.X` in `src/editor` + `src/plugins/workbench`):**
+`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.dialogs` ×6,
+`pixelart.State` ×2, `pixelart.Globals` ×2, `pixelart.explorer` ×1,
+`"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a symbol ref).
+The remaining real reaches are `pixelart.dialogs.*` (NewFile/Export/GridLayout/
+FlatRasterSaveWarning/DimensionsLabel re-exported by `editor/dialogs/Dialogs.zig` +
+`UnsavedClose.zig`) and `pixelart.explorer.project` — concrete pixel-art UI the shell still
+constructs directly. Lifting these needs a generic dialog-registry vtable; deferred, not blocking.
---
-## Next big rock: sprite / atlas → `core` (parallel track)
+## Next big rock: sprite / atlas → `core` — DONE
-Resolves `editor.atlas` coupling and the shell reaching into the plugin for UI icons.
+End-state achieved. Verified this session:
-- Shell only needs a static atlas sprite draw (logo/icons) — `workbench/files.zig`,
- `workbench/Workspace.zig`.
-- **`core`:** generic atlas type + "draw sprite N" primitive.
-- **Plugin:** `renderSprite`, composites, reflections, `water_surface`.
-- End-state: **shell → core**, **plugin → core**, neither depends on the other.
+- **`core.Atlas`** (`src/core/Atlas.zig`) — generic atlas type, `loadSpritesFromBytes`.
+- **`core.atlas`** (`src/core/generated/atlas.zig`) — generated sprite-id index
+ (`sprites.logo_default`, …). `fizzy.atlas = core.atlas`.
+- **`core.Sprite.draw`** — the "draw sprite N" primitive.
+- **Shell** holds its own static atlas instance (`editor.atlas`, loaded via
+ `core.Atlas.loadSpritesFromBytes`) for logo/icons and exposes it to plugins as
+ `EditorAPI.UiSprite`. Draws via `core.Sprite.draw`.
+- **Plugin** consumes `core.Atlas`/`core.Sprite` for its own rendering (composites,
+ reflections, `water_surface`) and builds its own packed `internal/Atlas.zig` at pack time.
+- **Neither side reaches the other's atlas** — `grep 'editor.atlas|fizzy.atlas' src/plugins/pixelart/src` → 0.
-(User signed off; sequenced after settings, can proceed alongside late Stage D.)
+Residual: `workbench/files.zig` + `workbench/Workspace.zig` draw the logo via
+`fizzy.editor.atlas` — that's the workbench plugin still routing through `fizzy.editor`
+(a separate "workbench off the app hub" concern), not a sprite/atlas-in-core gap.
---
@@ -313,7 +347,7 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
| `src/plugins/workbench/workbench.zig` | Workbench intra-plugin hub |
| `src/plugins/workbench/src/` | Workbench implementation tree |
| `src/sdk/EditorAPI.zig`, `Host.zig` | Full shell API surface |
-| `src/editor/Editor.zig` | Shell; still uses `fizzy.pixelart.*` and `Internal.File` helpers |
+| `src/editor/Editor.zig` | Shell; `DocHandle`-only at UI boundary; no `Internal.File` |
| `src/fizzy.zig` | App hub; mid-migration to `pixelart_mod` re-exports |
| `process_assets.zig` | Build-time asset atlas generator (repo root, beside `build.zig`) |
| `src/backend/` | Platform backend: native/web stubs, singleton, auto-update, objc, MSVC shim |
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 5c8e1435..eece5348 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -15,7 +15,6 @@ const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf
const fizzy = @import("../fizzy.zig");
const pixelart = @import("pixelart");
-const Internal = pixelart.internal;
const dvui = @import("dvui");
const update_notify = @import("../backend/update_notify.zig");
@@ -84,8 +83,8 @@ themes: std.ArrayList(dvui.Theme) = .empty,
open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty,
-/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread runs
-/// `Internal.File.fromPath` off the main thread; the main thread polls via `processLoadingJobs`
+/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread loads
+/// the document bytes off the main thread; the main thread polls via `processLoadingJobs`
/// and moves completed results into `open_files`. The map owns its key strings via each job's
/// `path` allocation; the StringHashMap stores key slices that point into job memory.
loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty,
@@ -270,10 +269,7 @@ pub fn init(
Settings.loadPluginStore(app.allocator, settings_path, &editor.host.plugin_settings);
}
- // Start the long-lived save-queue worker. All .fiz async saves get
- // serialized through this single thread (see `File.SaveQueue`); concurrent
- // worker spawns were causing one save to wedge under contention.
- try Internal.File.initSaveQueue();
+ // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`).
{ // Setup themes
var fizzy_dark = dvui.themeGet();
@@ -482,6 +478,7 @@ pub fn postInit(editor: *Editor) !void {
try @import("../plugins/workbench/src/plugin.zig").register(&editor.host);
const pixelart_plugin = pixelart.plugin;
try pixelart_plugin.register(&editor.host);
+ try pixelart_plugin.pluginPtr().initPlugin();
// Shell built-in: Settings (owner = null; not a plugin).
try editor.host.registerSidebarView(.{
@@ -694,15 +691,7 @@ fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 {
return shellCtx(ctx).allocNextUntitledPath();
}
fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle {
- const editor = shellCtx(ctx);
- const file = try editor.newFile(path, .{
- .columns = grid.columns,
- .rows = grid.rows,
- .column_width = grid.column_width,
- .row_height = grid.row_height,
- });
- const owner = pixelart.plugin.pluginPtr();
- return .{ .ptr = file, .owner = owner, .id = file.id };
+ return shellCtx(ctx).newFile(path, grid);
}
fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void {
const Files = fizzy.Explorer.files;
@@ -747,19 +736,15 @@ fn shellIsPackingActive(ctx: *anyopaque) bool {
return shellCtx(ctx).isPackingActive();
}
-/// Resolve a shell `DocHandle` to the plugin-owned file (shell-internal; workbench uses `DocHandle` + owner hooks).
-fn fileFromDoc(editor: *Editor, doc: sdk.DocHandle) *Internal.File {
- _ = editor;
- return @ptrCast(@alignCast(doc.owner.documentPtr(doc.id).?));
-}
-
-/// Resolve an open document id to the plugin-owned file (shell-internal).
-fn fileById(editor: *Editor, id: u64) ?*Internal.File {
- const doc = editor.docById(id) orelse return null;
- const ptr = doc.owner.documentPtr(doc.id) orelse return null;
- return @ptrCast(@alignCast(ptr));
+/// Store a loaded/created document in the plugin registry and register its handle.
+pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void {
+ const ptr = try owner.registerOpenDocument(doc_buf);
+ try editor.open_files.put(fizzy.app.allocator, id, .{
+ .ptr = ptr,
+ .owner = owner,
+ .id = id,
+ });
}
-
pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle {
if (index >= editor.open_files.values().len) return null;
return editor.open_files.values()[index];
@@ -800,18 +785,6 @@ pub fn bindDocToPane(_: *Editor, doc: sdk.DocHandle, canvas_id: dvui.Id, workspa
doc.owner.bindDocumentToPane(doc, canvas_id, workspace, center);
}
-/// Store a loaded/created document in the plugin registry and register its handle.
-pub fn insertOpenDoc(editor: *Editor, file: Internal.File, owner: *sdk.Plugin) !void {
- var file_mut = file;
- const ptr = try owner.registerOpenDocument(&file_mut);
- try editor.open_files.put(fizzy.app.allocator, file.id, .{
- // `ptr` is a hint only; consumers must resolve via `fileFromDoc` / `doc.id`.
- .ptr = ptr,
- .owner = owner,
- .id = file.id,
- });
-}
-
/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes).
fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void {
const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" });
@@ -938,8 +911,8 @@ pub fn markSettingsDirty(editor: *Editor) void {
}
fn activelyDrawing(editor: *Editor) bool {
- for (editor.open_files.values()) |doc| {
- if (editor.fileFromDoc(doc).editor.active_drawing) return true;
+ for (editor.host.plugins.items) |plugin| {
+ if (plugin.isAnyDocumentActivelyDrawing()) return true;
}
return false;
}
@@ -1033,10 +1006,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
// Drain any "Save and Close" requests whose async save has settled.
editor.tickPendingSaveCloses();
var needs_save_status_anim_tick = false;
- for (editor.open_files.values()) |doc| {
- const f = editor.fileFromDoc(doc);
- f.tickSaveDoneFlash();
- if (f.showsSaveStatusIndicator()) needs_save_status_anim_tick = true;
+ for (editor.host.plugins.items) |plugin| {
+ if (plugin.tickOpenDocuments()) needs_save_status_anim_tick = true;
}
// Re-poll the quit walker while saves are in flight on worker threads.
if (editor.quit_saves_in_flight.count() > 0) editor.pending_quit_continue = true;
@@ -1060,7 +1031,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
var dirty_n: usize = 0;
for (editor.open_files.values()) |doc| {
- if (editor.fileFromDoc(doc).dirty()) dirty_n += 1;
+ if (doc.owner.isDirty(doc)) dirty_n += 1;
}
if (dirty_n == 0) continue;
@@ -1078,7 +1049,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
editor.setTitlebarColor();
editor.setWindowStyle();
- pixelart.render.frame_index +%= 1;
+ for (editor.host.plugins.items) |plugin| plugin.beginFrame();
if (fizzy.perf.record) fizzy.perf.beginFrame();
defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog();
@@ -1098,31 +1069,14 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
if (editor.pending_composite_warmup) {
editor.pending_composite_warmup = false;
- if (editor.activeFile()) |file| {
- const w = file.width();
- const h = file.height();
- if (w > 0 and h > 0) {
- const area = @as(u64, w) * @as(u64, h);
- // Skip tiny canvases; large docs benefit most from moving split-target work off the first stroke.
- if (area >= 512 * 512) {
- pixelart.render.warmupDrawingComposites(file) catch |err| {
- dvui.log.err("Composite warmup failed: {any}", .{err});
- };
- }
- }
- }
+ for (editor.host.plugins.items) |plugin| plugin.warmupActiveDocumentComposites();
}
{
var any_drawing = false;
- fizzy.perf.draw_stroke_buf_count = 0; // no active stroke → 0; else first active file's map size
- for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (file.editor.active_drawing) {
- any_drawing = true;
- fizzy.perf.draw_stroke_buf_count = file.buffers.stroke.pixels.count();
- break;
- }
+ fizzy.perf.draw_stroke_buf_count = 0;
+ for (editor.host.plugins.items) |plugin| {
+ if (plugin.isAnyDocumentActivelyDrawing()) any_drawing = true;
}
fizzy.perf.drawFrameBegin(any_drawing);
}
@@ -1309,38 +1263,13 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
);
defer base_box.deinit();
- // Advance the animation frame if we are in play mode
- if (editor.activeFile()) |file| {
- if (file.editor.playing) {
- if (file.selected_animation_index) |index| {
- const animation = file.animations.get(index);
-
- if (animation.frames.len > 0) {
- if (dvui.timerDoneOrNone(base_box.data().id)) {
- if (file.selected_animation_frame_index >= animation.frames.len - 1) {
- file.selected_animation_frame_index = 0;
- } else {
- file.selected_animation_frame_index += 1;
- }
- const millis_per_frame = animation.frames[file.selected_animation_frame_index].ms;
-
- dvui.timer(base_box.data().id, @intCast(millis_per_frame * 1000));
- }
- }
- }
- }
+ for (editor.host.plugins.items) |plugin| {
+ plugin.tickActiveDocumentPlayback(base_box.data().id);
}
// Always reset the peek layer index back, but we need to do this outside of the file widget so
// other editor windows can use it
- defer for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (file.editor.isolate_layer) {
- file.peek_layer_index = file.selected_layer_index;
- } else {
- file.peek_layer_index = null;
- }
- };
+ defer for (editor.host.plugins.items) |plugin| plugin.resetDocumentPeekLayers();
// Sidebar area
// Since sidebar is drawn before the explorer, and we want to allow expanding the explorer
@@ -1482,7 +1411,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
defer editor.panel.paned.deinit();
if (!editor.panel.paned.dragging) {
- if (editor.activeFile()) |_| {
+ if (editor.activeDoc() != null) {
if ((editor.panel.paned.split_ratio.* == 1.0 and !editor.panel.paned.collapsed()) and fizzy.editor.settings.panel_ratio > 0.0) {
editor.panel.paned.animateSplit(1.0 - fizzy.editor.settings.panel_ratio, dvui.easing.outQuint);
}
@@ -1653,42 +1582,42 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA
editor.requestSaveAs();
},
.copy => {
- if (editor.activeFile() != null) {
+ if (editor.activeDoc() != null) {
editor.copy() catch {
std.log.err("Failed to copy", .{});
};
}
},
.paste => {
- if (editor.activeFile() != null) {
+ if (editor.activeDoc() != null) {
editor.paste() catch {
std.log.err("Failed to paste", .{});
};
}
},
.undo => {
- if (editor.activeFile()) |file| {
- file.history.undoRedo(file, .undo) catch {
+ if (editor.activeDoc()) |doc| {
+ doc.owner.undo(doc) catch {
std.log.err("Failed to undo", .{});
};
}
},
.redo => {
- if (editor.activeFile()) |file| {
- file.history.undoRedo(file, .redo) catch {
+ if (editor.activeDoc()) |doc| {
+ doc.owner.redo(doc) catch {
std.log.err("Failed to redo", .{});
};
}
},
.transform => {
- if (editor.activeFile() != null) {
+ if (editor.activeDoc() != null) {
editor.transform() catch {
std.log.err("Failed to transform", .{});
};
}
},
.grid_layout => {
- if (editor.activeFile() != null) {
+ if (editor.activeDoc() != null) {
editor.requestGridLayoutDialog();
}
},
@@ -1734,17 +1663,16 @@ pub fn rebuildWorkspaces(editor: *Editor) !void {
// Create workspaces for each grouping ID
for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (!editor.workspaces.contains(file.editor.grouping)) {
- var workspace: fizzy.Editor.Workspace = .init(file.editor.grouping);
+ const grouping = editor.docGrouping(doc);
+ if (!editor.workspaces.contains(grouping)) {
+ var workspace: fizzy.Editor.Workspace = .init(grouping);
for (editor.open_files.values()) |d| {
- const f = editor.fileFromDoc(d);
- if (f.editor.grouping == file.editor.grouping) {
- workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0;
+ if (editor.docGrouping(d) == grouping) {
+ workspace.open_file_index = editor.open_files.getIndex(d.id) orelse 0;
}
}
- editor.workspaces.put(fizzy.app.allocator, file.editor.grouping, workspace) catch |err| {
+ editor.workspaces.put(fizzy.app.allocator, grouping, workspace) catch |err| {
std.log.err("Failed to create workspace: {s}", .{@errorName(err)});
return err;
};
@@ -1759,8 +1687,7 @@ pub fn rebuildWorkspaces(editor: *Editor) !void {
var contains: bool = false;
for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (file.editor.grouping == workspace.grouping) {
+ if (editor.docGrouping(doc) == workspace.grouping) {
contains = true;
break;
}
@@ -1784,8 +1711,8 @@ pub fn rebuildWorkspaces(editor: *Editor) !void {
// Ensure the selected file for each workspace is still valid
for (editor.workspaces.values()) |*workspace| {
- if (editor.getFile(workspace.open_file_index)) |file| {
- if (file.editor.grouping == workspace.grouping) {
+ if (editor.docAt(workspace.open_file_index)) |doc| {
+ if (editor.docGrouping(doc) == workspace.grouping) {
continue;
}
}
@@ -1793,9 +1720,8 @@ pub fn rebuildWorkspaces(editor: *Editor) !void {
var i: usize = editor.open_files.count();
while (i > 0) {
i -= 1;
-
- if (editor.getFile(i)) |file| {
- if (file.editor.grouping == workspace.grouping) {
+ if (editor.docAt(i)) |d| {
+ if (editor.docGrouping(d) == workspace.grouping) {
workspace.open_file_index = i;
break;
}
@@ -1887,8 +1813,8 @@ fn tickPendingSaveCloses(editor: *Editor) void {
var i: usize = 0;
while (i < editor.pending_close_after_save.count()) {
const id = editor.pending_close_after_save.keys()[i];
- if (editor.fileById(id)) |f| {
- if (f.isSaving()) {
+ if (editor.docById(id)) |doc| {
+ if (doc.owner.isDocumentSaving(doc)) {
i += 1;
continue;
}
@@ -1917,16 +1843,16 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
// Pass 1: kick off any queued saves we haven't started yet.
while (editor.quit_save_all_ids.items.len > 0) {
const id = editor.quit_save_all_ids.items[0];
- const file_ptr = editor.fileById(id) orelse {
+ const doc = editor.docById(id) orelse {
_ = editor.quit_save_all_ids.swapRemove(0);
continue;
};
- if (!file_ptr.dirty()) {
+ if (!doc.owner.isDirty(doc)) {
_ = editor.quit_save_all_ids.swapRemove(0);
continue;
}
- if (!Internal.File.hasRecognizedSaveExtension(file_ptr.path)) {
+ if (!doc.owner.documentHasRecognizedSaveExtension(doc)) {
// Save As dialog needs a single active file — bail out of the parallel
// kickoff for this one and let the existing Save As + pending_close_file_id
// flow handle it. Next frame, pending_quit_continue will re-enter us.
@@ -1936,7 +1862,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
editor.requestSaveAs();
return;
}
- if (file_ptr.shouldConfirmFlatRasterSave()) {
+ if (doc.owner.shouldConfirmFlatRasterSave(doc)) {
// Flat-raster prompt is a modal dialog — same reason as Save As, do
// it serially and rejoin afterwards.
if (editor.open_files.getIndex(id)) |idx| editor.setActiveFile(idx);
@@ -1946,7 +1872,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
}
// Async-safe path: kick off, move to in-flight, drop from queue.
- file_ptr.saveAsync() catch |err| {
+ doc.owner.saveDocumentAsync(doc) catch |err| {
dvui.log.err("Save all quit kickoff: {s}", .{@errorName(err)});
editor.abortSaveAllQuit();
return;
@@ -1966,8 +1892,8 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
var i: usize = 0;
while (i < editor.quit_saves_in_flight.count()) {
const id = editor.quit_saves_in_flight.keys()[i];
- if (editor.fileById(id)) |f| {
- if (f.isSaving()) {
+ if (editor.docById(id)) |doc| {
+ if (doc.owner.isDocumentSaving(doc)) {
i += 1;
continue;
}
@@ -1998,7 +1924,7 @@ pub fn close(app: *App, editor: *Editor) void {
}
var dirty_n: usize = 0;
for (editor.open_files.values()) |doc| {
- if (editor.fileFromDoc(doc).dirty()) dirty_n += 1;
+ if (doc.owner.isDirty(doc)) dirty_n += 1;
}
if (dirty_n > 0) {
Dialogs.AppQuitUnsaved.request();
@@ -2023,7 +1949,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
pub fn saving(editor: *Editor) bool {
for (editor.open_files.values()) |doc| {
- if (editor.fileFromDoc(doc).saving) return true;
+ if (doc.owner.isDocumentSaving(doc)) return true;
}
return false;
}
@@ -2064,8 +1990,7 @@ pub fn clearFileTreeTabDragDropState(editor: *Editor) void {
pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool {
// Already open? Just focus it.
for (editor.open_files.values(), 0..) |doc, i| {
- const file = editor.fileFromDoc(doc);
- if (std.mem.eql(u8, file.path, path)) {
+ if (std.mem.eql(u8, editor.docPath(doc), path)) {
editor.setActiveFile(i);
return false;
}
@@ -2111,17 +2036,14 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool {
return true;
}
-/// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`).
-pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !Internal.File {
- for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (std.mem.eql(u8, file.path, path)) {
- if (editor.open_files.getIndex(file.id)) |idx| {
- editor.setActiveFile(idx);
- }
- fizzy.app.allocator.free(path);
- return error.AlreadyOpen;
+/// Synchronous open from browser file-picker bytes. Registers the document and returns its id.
+pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !u64 {
+ if (editor.docFromPath(path)) |existing| {
+ if (editor.open_files.getIndex(existing.id)) |idx| {
+ editor.setActiveFile(idx);
}
+ fizzy.app.allocator.free(path);
+ return error.AlreadyOpen;
}
const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse {
@@ -2129,8 +2051,10 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin
return error.InvalidExtension;
};
- var file: Internal.File = undefined;
- const handled = owner.loadDocumentFromBytes(path, bytes, &file) catch |err| {
+ const staging = try owner.allocDocumentBuffer(fizzy.app.allocator);
+ defer fizzy.app.allocator.free(staging.backing);
+
+ const handled = owner.loadDocumentFromBytes(path, bytes, staging.buf.ptr) catch |err| {
fizzy.app.allocator.free(path);
return err;
};
@@ -2138,8 +2062,11 @@ pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, groupin
fizzy.app.allocator.free(path);
return error.InvalidFile;
}
- file.editor.grouping = grouping;
- return file;
+
+ owner.setDocumentGroupingOnBuffer(staging.buf.ptr, grouping);
+ const id = owner.documentIdFromBuffer(staging.buf.ptr);
+ try editor.insertOpenDoc(staging.buf.ptr, owner, id);
+ return id;
}
/// Per-frame sweep called from `tick`. Moves completed load jobs into `open_files`, cleans up
@@ -2164,47 +2091,33 @@ pub fn processLoadingJobs(editor: *Editor) void {
const phase = job.currentPhase();
switch (phase) {
.ready => {
- if (job.result) |result| {
- var file = result;
- file.editor.grouping = job.target_grouping;
-
- const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse {
- dvui.log.err("No plugin for loaded file: {s}", .{job.path});
- var f = file;
- f.deinit();
- job.destroy();
- continue;
- };
-
- editor.insertOpenDoc(file, owner) catch {
- dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path});
- // We still own `file` here — clean it up.
- var f = file;
- f.deinit();
- job.destroy();
- continue;
- };
+ const owner = job.owner;
+ owner.setDocumentGroupingOnBuffer(job.doc_buf.ptr, job.target_grouping);
+ const id = owner.documentIdFromBuffer(job.doc_buf.ptr);
+
+ editor.insertOpenDoc(job.doc_buf.ptr, owner, id) catch {
+ dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path});
+ owner.deinitDocumentBuffer(job.doc_buf.ptr);
+ job.destroy();
+ continue;
+ };
- // Focus this file iff it's the most recently requested load. Multiple
- // simultaneous loads only auto-focus the latest; others land silently.
- const should_focus = editor.last_load_request_path != null and
- std.mem.eql(u8, editor.last_load_request_path.?, job.path);
- if (should_focus) {
- if (editor.open_files.getIndex(file.id)) |idx| {
- editor.setActiveFile(idx);
- editor.last_load_request_path = null;
- }
- editor.pending_composite_warmup = true;
+ const should_focus = editor.last_load_request_path != null and
+ std.mem.eql(u8, editor.last_load_request_path.?, job.path);
+ if (should_focus) {
+ if (editor.open_files.getIndex(id)) |idx| {
+ editor.setActiveFile(idx);
+ editor.last_load_request_path = null;
}
- } else {
- dvui.log.err("Load job reported ready but result was null: {s}", .{job.path});
+ editor.pending_composite_warmup = true;
}
},
.failed => {
dvui.log.err("Failed to open file: {s} ({any})", .{ job.path, job.err });
+ job.owner.deinitDocumentBuffer(job.doc_buf.ptr);
},
.cancelled => {
- // No-op: result already discarded by the worker.
+ job.owner.deinitDocumentBuffer(job.doc_buf.ptr);
},
else => {
dvui.log.err("Load job finished in unexpected phase {s}: {s}", .{ @tagName(phase), job.path });
@@ -2423,29 +2336,34 @@ pub fn requestCompositeWarmup(editor: *Editor) void {
editor.pending_composite_warmup = true;
}
-pub fn newFile(editor: *Editor, path: []const u8, options: Internal.File.InitOptions) !*Internal.File {
- if (editor.getFileFromPath(path)) |_| {
+pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) !sdk.DocHandle {
+ if (editor.docFromPath(path) != null) {
return error.FileAlreadyExists;
}
- const file = Internal.File.init(path, options) catch {
+ const owner = pixelart.plugin.pluginPtr();
+ const staging = try owner.allocDocumentBuffer(fizzy.app.allocator);
+ defer fizzy.app.allocator.free(staging.backing);
+
+ owner.createDocument(path, grid, staging.buf.ptr) catch {
+ owner.deinitDocumentBuffer(staging.buf.ptr);
dvui.log.err("Failed to create file: {s}", .{path});
return error.FailedToCreateFile;
};
- try editor.insertOpenDoc(file, pixelart.plugin.pluginPtr());
+ const id = owner.documentIdFromBuffer(staging.buf.ptr);
+ try editor.insertOpenDoc(staging.buf.ptr, owner, id);
editor.setActiveFile(editor.open_files.count() - 1);
editor.pending_composite_warmup = true;
- return editor.fileById(file.id) orelse return error.FailedToCreateFile;
+ return editor.docById(id) orelse return error.FailedToCreateFile;
}
/// Heap-owned path like `untitled-1`, unique among open-document basenames.
pub fn allocNextUntitledPath(editor: *Editor) ![]u8 {
var max_n: u32 = 0;
for (editor.open_files.values()) |doc| {
- const f = editor.fileFromDoc(doc);
- const base = std.fs.path.basename(f.path);
+ const base = std.fs.path.basename(editor.docPath(doc));
if (std.mem.startsWith(u8, base, "untitled-")) {
const suffix = base["untitled-".len..];
const n = std.fmt.parseUnsigned(u32, suffix, 10) catch continue;
@@ -2463,9 +2381,8 @@ pub fn allocNextUntitledPath(editor: *Editor) ![]u8 {
/// The dialog rebinds the active file via the `_grid_layout_file_id` data slot so the form and
/// preview can survive frames where `fizzy.editor.activeFile()` momentarily returns null.
pub fn requestGridLayoutDialog(editor: *Editor) void {
- const file = editor.activeFile() orelse return;
-
- Dialogs.GridLayout.presetFromFile(file);
+ const doc = editor.activeDoc() orelse return;
+ doc.owner.prepareGridLayoutDialog(doc);
var mutex = fizzy.dvui.dialog(@src(), .{
.displayFn = Dialogs.GridLayout.dialog,
@@ -2478,7 +2395,7 @@ pub fn requestGridLayoutDialog(editor: *Editor) void {
.header_kind = .info,
.default = .ok,
});
- dvui.dataSet(null, mutex.id, "_grid_layout_file_id", file.id);
+ dvui.dataSet(null, mutex.id, "_grid_layout_file_id", doc.id);
// Let `GridLayout.windowFn` run `autoSize` only until the open animation finishes; otherwise
// `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks).
dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false);
@@ -2501,8 +2418,8 @@ pub fn requestNewFileDialog(_: *Editor) void {
}
pub fn setActiveFile(editor: *Editor, index: usize) void {
- const file = editor.fileAt(index) orelse return;
- const grouping = file.editor.grouping;
+ const doc = editor.docAt(index) orelse return;
+ const grouping = editor.docGrouping(doc);
if (editor.workspaces.getPtr(grouping)) |workspace| {
editor.open_workspace_grouping = grouping;
@@ -2510,54 +2427,20 @@ pub fn setActiveFile(editor: *Editor, index: usize) void {
}
}
-/// Returns the actively focused file, through workspace grouping.
-pub fn activeFile(editor: *Editor) ?*Internal.File {
- const doc = editor.activeDoc() orelse return null;
- return editor.fileFromDoc(doc);
-}
-
-pub fn getFile(editor: *Editor, index: usize) ?*Internal.File {
- return editor.fileAt(index);
-}
-
-pub fn fileAt(editor: *Editor, index: usize) ?*Internal.File {
- const doc = editor.docAt(index) orelse return null;
- return editor.fileFromDoc(doc);
-}
-
-pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*Internal.File {
- const doc = editor.docFromPath(path) orelse return null;
- return editor.fileFromDoc(doc);
-}
-
pub fn forceCloseFile(editor: *Editor, index: usize) !void {
- if (editor.getFile(index) != null) {
+ if (editor.docAt(index) != null) {
return editor.rawCloseFile(index);
}
}
pub fn accept(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (file.editor.transform) |*t| {
- t.accept();
- }
- }
+ _ = editor;
+ pixelart.plugin.pluginPtr().acceptEdit();
}
pub fn cancel(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (file.editor.transform) |*t| {
- t.cancel();
- }
-
- if (file.editor.selected_sprites.count() > 0) {
- file.clearSelectedSprites();
- }
-
- if (file.selected_animation_index != null) {
- file.selected_animation_index = null;
- }
- }
+ _ = editor;
+ pixelart.plugin.pluginPtr().cancelEdit();
}
pub fn copy(editor: *Editor) !void {
@@ -2571,9 +2454,8 @@ pub fn paste(editor: *Editor) !void {
}
pub fn deleteSelectedContents(editor: *Editor) void {
- if (editor.activeFile()) |file| {
- file.deleteSelectedContents();
- }
+ _ = editor;
+ pixelart.plugin.pluginPtr().deleteSelection();
}
/// Begins a transform operation on the currently active file.
@@ -2585,29 +2467,27 @@ pub fn transform(editor: *Editor) !void {
/// Performs a save operation on the currently open file.
/// Paths without a recognized on-disk extension (e.g. in-memory `untitled-n`) open Save As instead.
pub fn save(editor: *Editor) !void {
- const file = editor.activeFile() orelse return;
- if (!Internal.File.hasRecognizedSaveExtension(file.path)) {
+ const doc = editor.activeDoc() orelse return;
+ if (!doc.owner.documentHasRecognizedSaveExtension(doc)) {
editor.requestSaveAs();
return;
}
- if (file.shouldConfirmFlatRasterSave()) {
- Dialogs.FlatRasterSaveWarning.request(file.id, .editor_save);
+ if (doc.owner.shouldConfirmFlatRasterSave(doc)) {
+ Dialogs.FlatRasterSaveWarning.request(doc.id, .editor_save);
return;
}
if (comptime builtin.target.cpu.arch == .wasm32) {
editor.requestWebSaveDialog(.save);
return;
}
- if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
- try plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id });
- }
+ try doc.owner.saveDocument(doc);
}
/// Browser: pick download filename/extension before encoding (`processPendingSaveAs`).
pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void {
if (comptime builtin.target.cpu.arch != .wasm32) return;
- const file = editor.activeFile() orelse return;
- Dialogs.WebSaveAs.request(std.fs.path.basename(file.path), kind);
+ const doc = editor.activeDoc() orelse return;
+ Dialogs.WebSaveAs.request(std.fs.path.basename(editor.docPath(doc)), kind);
}
/// Kick off an async save for every dirty file with a recognized extension.
@@ -2617,13 +2497,11 @@ pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void
/// Files that are already saving are also skipped (their `saveAsync` no-ops).
pub fn saveAll(editor: *Editor) !void {
for (editor.open_files.values()) |doc| {
- const file = editor.fileFromDoc(doc);
- if (!file.dirty()) continue;
- if (!Internal.File.hasRecognizedSaveExtension(file.path)) continue;
- if (file.shouldConfirmFlatRasterSave()) continue;
- const plugin = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse continue;
- plugin.saveDocument(.{ .ptr = file, .owner = plugin, .id = file.id }) catch |err| {
- dvui.log.err("Save All: file {s} failed: {s}", .{ file.path, @errorName(err) });
+ if (!doc.owner.isDirty(doc)) continue;
+ if (!doc.owner.documentHasRecognizedSaveExtension(doc)) continue;
+ if (doc.owner.shouldConfirmFlatRasterSave(doc)) continue;
+ doc.owner.saveDocument(doc) catch |err| {
+ dvui.log.err("Save All: file {s} failed: {s}", .{ editor.docPath(doc), @errorName(err) });
};
}
}
@@ -2636,13 +2514,13 @@ const save_as_dialog_filters: [3]fizzy.backend.DialogFileFilter = .{
/// Opens a Save As dialog: `.fiz` (all layers; `.pixi` also accepted for legacy) or flat `.png` / `.jpg` / `.jpeg` (visible layers composited).
pub fn requestSaveAs(_: *Editor) void {
- const active = fizzy.editor.activeFile() orelse return;
- const def = Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch {
+ const doc = fizzy.editor.activeDoc() orelse return;
+ const def = doc.owner.documentDefaultSaveAsFilename(doc, fizzy.app.allocator) catch {
std.log.err("Failed to build default save-as name", .{});
return;
};
defer fizzy.app.allocator.free(def);
- const current_file_dir: ?[]const u8 = std.fs.path.dirname(active.path);
+ const current_file_dir: ?[]const u8 = std.fs.path.dirname(fizzy.editor.docPath(doc));
fizzy.backend.showSaveFileDialog(saveAsDialogCallback, &save_as_dialog_filters, def, current_file_dir);
}
@@ -2660,16 +2538,16 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void {
}
}
- const file_id = editor.pending_close_file_id orelse if (editor.activeFile()) |f| f.id else null;
+ const file_id = editor.pending_close_file_id orelse if (editor.activeDoc()) |doc| doc.id else null;
editor.pending_close_file_id = null;
if (file_id) |id| {
_ = editor.pending_close_after_save.swapRemove(id);
- if (editor.fileById(id)) |f| {
- f.resetSaveUIState();
+ if (editor.docById(id)) |doc| {
+ doc.owner.resetDocumentSaveUIState(doc);
}
- } else if (editor.activeFile()) |f| {
- f.resetSaveUIState();
+ } else if (editor.activeDoc()) |doc| {
+ doc.owner.resetDocumentSaveUIState(doc);
}
if (editor.quit_save_all_ids.items.len > 0 or editor.quit_in_progress) {
@@ -2697,85 +2575,40 @@ pub fn saveAsDialogCallback(paths: ?[][:0]const u8) void {
}
fn processPendingSaveAs(editor: *Editor) void {
- if (comptime builtin.target.cpu.arch == .wasm32) {
- const path = blk: {
- if (editor.pending_save_as_path) |p| break :blk p;
+ const path = blk: {
+ if (editor.pending_save_as_path) |p| break :blk p;
+ if (comptime builtin.target.cpu.arch == .wasm32) {
const WebFileIo = @import("WebFileIo.zig");
if (WebFileIo.pending_save_filename) |p| break :blk p;
- return;
- };
- const owned_by_editor = editor.pending_save_as_path != null;
- editor.pending_save_as_path = null;
+ }
+ return;
+ };
+ const owned_by_editor = editor.pending_save_as_path != null;
+ editor.pending_save_as_path = null;
+ if (comptime builtin.target.cpu.arch == .wasm32) {
if (!owned_by_editor) {
const WebFileIo = @import("WebFileIo.zig");
WebFileIo.pending_save_filename = null;
}
- defer fizzy.app.allocator.free(path);
-
- const file = editor.activeFile() orelse return;
- const ext = std.fs.path.extension(path);
- const saved: bool = blk: {
- if (Internal.File.isFizzyExtension(ext)) {
- file.saveAsFizzy(path, dvui.currentWindow()) catch |err| {
- dvui.log.err("Save As: {any}", .{err});
- break :blk false;
- };
- } else if (std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
- file.saveAsFlattened(path, dvui.currentWindow()) catch |err| {
- dvui.log.err("Save As: {any}", .{err});
- break :blk false;
- };
- } else {
- dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{ext});
- break :blk false;
- }
- break :blk true;
- };
- if (!saved) return;
- if (editor.pending_close_file_id) |cid| {
- if (file.id == cid) {
- editor.pending_close_file_id = null;
- editor.rawCloseFileID(cid) catch |err| {
- dvui.log.err("Failed to close file after Save As: {s}", .{@errorName(err)});
- };
- }
- }
- return;
}
- const path = editor.pending_save_as_path orelse return;
- editor.pending_save_as_path = null;
defer fizzy.app.allocator.free(path);
- const ext = std.fs.path.extension(path);
- const file = editor.activeFile() orelse {
+ const doc = editor.activeDoc() orelse {
editor.pending_close_file_id = null;
return;
};
- const saved: bool = blk: {
- if (Internal.File.isFizzyExtension(ext)) {
- file.saveAsFizzy(path, dvui.currentWindow()) catch |err| {
- dvui.log.err("Save As: {any}", .{err});
- break :blk false;
- };
- } else if (std.mem.eql(u8, ext, ".png") or
- std.mem.eql(u8, ext, ".jpg") or
- std.mem.eql(u8, ext, ".jpeg"))
- {
- file.saveAsFlattened(path, dvui.currentWindow()) catch |err| {
- dvui.log.err("Save As: {any}", .{err});
- break :blk false;
- };
+ doc.owner.saveDocumentAs(doc, path, dvui.currentWindow()) catch |err| {
+ if (err == error.UnsupportedSaveExtension) {
+ dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{std.fs.path.extension(path)});
} else {
- dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{ext});
- break :blk false;
+ dvui.log.err("Save As: {any}", .{err});
}
- break :blk true;
+ return;
};
- if (!saved) return;
if (editor.pending_close_file_id) |cid| {
- if (file.id == cid) {
+ if (doc.id == cid) {
editor.pending_close_file_id = null;
editor.rawCloseFileID(cid) catch |err| {
dvui.log.err("Failed to close file after Save As: {s}", .{@errorName(err)});
@@ -2791,19 +2624,13 @@ fn processPendingSaveAs(editor: *Editor) void {
}
pub fn undo(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
- try plugin.undo(.{ .ptr = file, .owner = plugin, .id = file.id });
- }
- }
+ const doc = editor.activeDoc() orelse return;
+ try doc.owner.undo(doc);
}
pub fn redo(editor: *Editor) !void {
- if (editor.activeFile()) |file| {
- if (editor.host.pluginForExtension(std.fs.path.extension(file.path))) |plugin| {
- try plugin.redo(.{ .ptr = file, .owner = plugin, .id = file.id });
- }
- }
+ const doc = editor.activeDoc() orelse return;
+ try doc.owner.redo(doc);
}
pub fn openInFileBrowser(_: *Editor, path: []const u8) !void {
@@ -2832,24 +2659,19 @@ pub fn closeFile(editor: *Editor, index: usize) !void {
/// Tear down a document via its owning plugin, falling back to a direct `deinit`.
/// Removes the entry from the plugin's document registry; the shell still removes
/// the matching `DocHandle` from `open_files`.
-fn closeDocumentResources(editor: *Editor, doc: sdk.DocHandle) void {
- if (doc.owner.closeDocument(doc)) {
- doc.owner.unregisterDocument(doc.id);
- return;
- }
- editor.fileFromDoc(doc).deinit();
+fn closeDocumentResources(_: *Editor, doc: sdk.DocHandle) void {
+ _ = doc.owner.closeDocument(doc);
doc.owner.unregisterDocument(doc.id);
}
pub fn rawCloseFile(editor: *Editor, index: usize) !void {
const doc = editor.docAt(index) orelse return;
- const file = editor.fileFromDoc(doc);
+ const grouping = editor.docGrouping(doc);
- if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| {
+ if (editor.workspaces.getPtr(grouping)) |workspace| {
if (workspace.open_file_index == index) {
for (editor.open_files.values(), 0..) |d, i| {
- const f = editor.fileFromDoc(d);
- if (f.editor.grouping == workspace.grouping and f.id != file.id) {
+ if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) {
workspace.open_file_index = i;
break;
}
@@ -2863,13 +2685,12 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void {
pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
const doc = editor.open_files.get(id) orelse return;
- const file = editor.fileFromDoc(doc);
+ const grouping = editor.docGrouping(doc);
- if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| {
- if (workspace.open_file_index == editor.open_files.getIndex(file.id)) {
+ if (editor.workspaces.getPtr(grouping)) |workspace| {
+ if (workspace.open_file_index == editor.open_files.getIndex(doc.id)) {
for (editor.open_files.values(), 0..) |d, i| {
- const f = editor.fileFromDoc(d);
- if (f.editor.grouping == workspace.grouping and f.id != file.id) {
+ if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) {
workspace.open_file_index = i;
break;
}
@@ -2881,16 +2702,10 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
_ = editor.open_files.orderedRemove(id);
}
-pub fn closeReference(editor: *Editor, index: usize) !void {
- editor.open_reference_index = 0;
- var reference: Internal.Reference = editor.open_references.orderedRemove(index);
- reference.deinit();
-}
-
pub fn deinit(editor: *Editor) !void {
// Drain & join the save-queue worker before tearing anything else down. Any
// queued jobs need to finish writing or be dropped before File data is freed.
- Internal.File.deinitSaveQueue();
+ for (editor.host.plugins.items) |plugin| plugin.deinit();
// Signal cancel to any in-flight load workers. They check the flag after `fromPath` returns
// and discard the result; we can't synchronously join them without blocking quit, so we
// accept a brief window where a worker may still be running with a discardable result.
diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig
index 0d0beb1a..9e728177 100644
--- a/src/editor/Infobar.zig
+++ b/src/editor/Infobar.zig
@@ -23,7 +23,6 @@ pub fn deinit() void {
pub fn draw(_: Infobar) !void {
const font = dvui.Font.theme(.body).larger(-1.0);
- const font_mono = dvui.Font.theme(.mono).larger(-3.0);
var scrollarea = dvui.scrollArea(@src(), .{}, .{
.expand = .horizontal,
@@ -106,60 +105,9 @@ pub fn draw(_: Infobar) !void {
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
- if (fizzy.editor.activeFile()) |file| {
- dvui.icon(
- @src(),
- "file_icon",
- icons.tvg.lucide.file,
- .{ .stroke_color = dvui.themeGet().color(.window, .text) },
- .{ .gravity_y = 0.5 },
- );
- dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ .font = font, .gravity_y = 0.5 });
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
-
- dvui.icon(
- @src(),
- "width_icon",
- icons.tvg.lucide.@"ruler-dimension-line",
- .{ .stroke_color = dvui.themeGet().color(.window, .text) },
- .{ .gravity_y = 0.5 },
- );
-
- fizzy.Editor.Dialogs.drawDimensionsLabel(@src(), file.width(), file.height(), font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } });
-
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
-
- dvui.icon(
- @src(),
- "sprite_icon",
- dvui.entypo.grid,
- .{ .fill_color = dvui.themeGet().color(.window, .text) },
- .{ .gravity_y = 0.5 },
- );
-
- fizzy.Editor.Dialogs.drawDimensionsLabel(@src(), file.column_width, file.row_height, font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } });
-
- //dvui.label(@src(), "{d}x{d} - {d}x{d}", .{ file.width(), file.height(), file.column_width, file.row_height }, .{ .font = font, .gravity_y = 0.5 });
-
- const mouse_pt = dvui.currentWindow().mouse_pt;
- const data_pt = file.editor.canvas.dataFromScreenPoint(mouse_pt);
-
- const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) });
-
- if (file_rect.contains(data_pt)) {
- _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
-
- dvui.icon(
- @src(),
- "mouse_icon",
- icons.tvg.lucide.@"mouse-pointer",
- .{ .stroke_color = dvui.themeGet().color(.window, .text) },
- .{ .gravity_y = 0.5 },
- );
-
- const sprite_pt = file.spritePoint(data_pt);
- dvui.label(@src(), "{d:0.0},{d:0.0} - {d:0.0},{d:0.0}", .{ @floor(data_pt.x), @floor(data_pt.y), @floor(sprite_pt.x / @as(f32, @floatFromInt(file.column_width))), @floor(sprite_pt.y / @as(f32, @floatFromInt(file.row_height))) }, .{ .gravity_y = 0.5, .font = font_mono });
- }
+ if (fizzy.editor.activeDoc()) |doc| {
+ doc.owner.drawDocumentInfobar(doc) catch {
+ dvui.log.err("Failed to draw document infobar", .{});
+ };
}
}
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index f7289a5a..f8a41f9c 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -151,7 +151,7 @@ pub fn tick() !void {
}
if (ke.matchBind("grid_layout") and ke.action == .down) {
- if (fizzy.editor.activeFile() != null) {
+ if (fizzy.editor.activeDoc() != null) {
fizzy.editor.requestGridLayoutDialog();
}
}
diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig
index 18e88878..722816f3 100644
--- a/src/editor/Menu.zig
+++ b/src/editor/Menu.zig
@@ -1,7 +1,5 @@
const std = @import("std");
const fizzy = @import("../fizzy.zig");
-const pixelart = @import("pixelart");
-const Internal = pixelart.internal;
const dvui = @import("dvui");
const Editor = fizzy.Editor;
const settings = fizzy.settings;
@@ -135,8 +133,8 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
_ = dvui.separator(@src(), .{ .expand = .horizontal });
- if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeFile()) |file|
- (file.dirty() or !Internal.File.hasRecognizedSaveExtension(file.path))
+ if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeDoc()) |doc|
+ (doc.owner.isDirty(doc) or !doc.owner.documentHasRecognizedSaveExtension(doc))
else
false, .{}, .{
.expand = .horizontal,
@@ -148,7 +146,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
fw.close();
}
- if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeFile() != null, .{}, .{
+ if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeDoc() != null, .{}, .{
.expand = .horizontal,
.color_text = dvui.themeGet().color(.window, .text),
}) != null) {
@@ -160,7 +158,7 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void {
// extension. Worker queue handles them serially; UI stays responsive.
const any_dirty = blk: {
for (fizzy.editor.open_files.values()) |doc| {
- if (doc.owner.isDirty(doc) and Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) break :blk true;
+ if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) break :blk true;
}
break :blk false;
};
@@ -203,11 +201,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
@src(),
"Copy",
dvui.currentWindow().keybinds.get("copy") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDoc() != null,
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != null) {
+ if (fizzy.editor.activeDoc() != null) {
fizzy.editor.copy() catch {
std.log.err("Failed to copy", .{});
};
@@ -219,11 +217,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
@src(),
"Paste",
dvui.currentWindow().keybinds.get("paste") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDoc() != null,
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != null) {
+ if (fizzy.editor.activeDoc() != null) {
fizzy.editor.paste() catch {
std.log.err("Failed to paste", .{});
};
@@ -237,12 +235,12 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
@src(),
"Undo",
dvui.currentWindow().keybinds.get("undo") orelse .{},
- if (fizzy.editor.activeFile()) |file| if (file.history.undo_stack.items.len > 0) true else false else false,
+ if (fizzy.editor.activeDoc()) |doc| doc.owner.canUndo(doc) else false,
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile()) |file| {
- file.history.undoRedo(file, .undo) catch {
+ if (fizzy.editor.activeDoc()) |doc| {
+ doc.owner.undo(doc) catch {
std.log.err("Failed to undo", .{});
};
}
@@ -252,12 +250,12 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
@src(),
"Redo",
dvui.currentWindow().keybinds.get("redo") orelse .{},
- if (fizzy.editor.activeFile()) |file| if (file.history.redo_stack.items.len > 0) true else false else false,
+ if (fizzy.editor.activeDoc()) |doc| doc.owner.canRedo(doc) else false,
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile()) |file| {
- file.history.undoRedo(file, .redo) catch {
+ if (fizzy.editor.activeDoc()) |doc| {
+ doc.owner.redo(doc) catch {
std.log.err("Failed to redo", .{});
};
}
@@ -269,11 +267,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
@src(),
"Transform",
dvui.currentWindow().keybinds.get("transform") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDoc() != null,
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != null) {
+ if (fizzy.editor.activeDoc() != null) {
fizzy.editor.transform() catch {
std.log.err("Failed to transform", .{});
};
@@ -287,11 +285,11 @@ pub fn drawEditMenu(_: ?*anyopaque) anyerror!void {
@src(),
"Grid Layout…",
dvui.currentWindow().keybinds.get("grid_layout") orelse .{},
- if (fizzy.editor.activeFile() != null) true else false,
+ fizzy.editor.activeDoc() != null,
.{},
.{ .expand = .horizontal },
) != null) {
- if (fizzy.editor.activeFile() != null) {
+ if (fizzy.editor.activeDoc() != null) {
fizzy.editor.requestGridLayoutDialog();
fw.close();
}
diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig
index 8acf190a..a7d7992e 100644
--- a/src/editor/WebFileIo.zig
+++ b/src/editor/WebFileIo.zig
@@ -79,25 +79,12 @@ pub fn pollOpenPicker(editor: *fizzy.Editor) void {
defer fizzy.app.allocator.free(bytes);
const path_owned = fizzy.app.allocator.dupe(u8, wasm_file.name) catch continue;
- if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |file| {
- const owner = editor.host.pluginForExtension(std.fs.path.extension(file.path)) orelse {
- var f = file;
- f.deinit();
- fizzy.app.allocator.free(path_owned);
- continue;
- };
- editor.insertOpenDoc(file, owner) catch {
- var f = file;
- f.deinit();
- fizzy.app.allocator.free(path_owned);
- };
- if (editor.open_files.getIndex(file.id)) |idx| {
+ if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |doc_id| {
+ if (editor.open_files.getIndex(doc_id)) |idx| {
editor.setActiveFile(idx);
editor.pending_composite_warmup = true;
}
- } else |_| {
- fizzy.app.allocator.free(path_owned);
- }
+ } else |_| {}
}
open_callback = null;
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index d6403478..7aa149b7 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -1,7 +1,6 @@
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
const pixelart = @import("pixelart");
-const Internal = pixelart.internal;
const dvui = @import("dvui");
const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning;
@@ -110,7 +109,7 @@ fn beginSaveAndClose(doc: fizzy.sdk.DocHandle, file_id: u64) !void {
fn onSaveAndClose(file_id: u64) !void {
const doc = fizzy.editor.docById(file_id) orelse return;
- if (!Internal.File.hasRecognizedSaveExtension(fizzy.editor.docPath(doc))) {
+ if (!doc.owner.documentHasRecognizedSaveExtension(doc)) {
const idx = fizzy.editor.open_files.getIndex(file_id) orelse return;
fizzy.editor.setActiveFile(idx);
fizzy.editor.pending_close_file_id = file_id;
diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig
index fc4684e1..15ffdb82 100644
--- a/src/editor/panel/Panel.zig
+++ b/src/editor/panel/Panel.zig
@@ -2,13 +2,11 @@ const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
-const pixelart = @import("pixelart");
const fizzy = @import("../../fizzy.zig");
const Core = @import("mach").Core;
const App = fizzy.App;
const Editor = fizzy.Editor;
-const Packer = pixelart.Packer;
pub const Panel = @This();
diff --git a/src/plugins/pixelart/src/doc_bridge.zig b/src/plugins/pixelart/src/doc_bridge.zig
index a515e8ad..b9d48914 100644
--- a/src/plugins/pixelart/src/doc_bridge.zig
+++ b/src/plugins/pixelart/src/doc_bridge.zig
@@ -52,6 +52,21 @@ pub fn documentHasNativeExtension(st: *State, doc: DocHandle) bool {
return Internal.File.isFizzyExtension(std.fs.path.extension(file.path));
}
+pub fn documentHasRecognizedSaveExtension(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return Internal.File.hasRecognizedSaveExtension(file.path);
+}
+
+pub fn canUndo(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return file.history.undo_stack.items.len > 0;
+}
+
+pub fn canRedo(st: *State, doc: DocHandle) bool {
+ const file = docFile(st, doc) orelse return false;
+ return file.history.redo_stack.items.len > 0;
+}
+
pub fn showsSaveStatusIndicator(st: *State, doc: DocHandle) bool {
const file = docFile(st, doc) orelse return false;
return file.showsSaveStatusIndicator();
diff --git a/src/plugins/pixelart/src/doc_lifecycle.zig b/src/plugins/pixelart/src/doc_lifecycle.zig
new file mode 100644
index 00000000..69f1b3f5
--- /dev/null
+++ b/src/plugins/pixelart/src/doc_lifecycle.zig
@@ -0,0 +1,161 @@
+//! Document create/load buffer contract + shell frame hooks without typing
+//! `Internal.File` at the SDK boundary.
+const std = @import("std");
+const dvui = @import("dvui");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const Internal = pixelart.internal;
+const DocHandle = pixelart.sdk.DocHandle;
+const NewDocGrid = pixelart.sdk.EditorAPI.NewDocGrid;
+const GridLayout = @import("dialogs/GridLayout.zig");
+
+fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
+ return st.docs.fileById(doc.id);
+}
+
+fn activeFile(st: *State) ?*Internal.File {
+ const doc = st.host.activeDoc() orelse return null;
+ return docFile(st, doc);
+}
+
+pub fn documentStackSize(_: *State) usize {
+ return @sizeOf(Internal.File);
+}
+
+pub fn documentStackAlign(_: *State) usize {
+ return @alignOf(Internal.File);
+}
+
+pub fn documentIdFromBuffer(_: *State, doc: *anyopaque) u64 {
+ const file: *Internal.File = @ptrCast(@alignCast(doc));
+ return file.id;
+}
+
+pub fn deinitDocumentBuffer(_: *State, doc: *anyopaque) void {
+ const file: *Internal.File = @ptrCast(@alignCast(doc));
+ file.deinit();
+}
+
+pub fn setDocumentGroupingOnBuffer(_: *State, doc: *anyopaque, grouping: u64) void {
+ const file: *Internal.File = @ptrCast(@alignCast(doc));
+ file.editor.grouping = grouping;
+}
+
+pub fn createDocument(_: *State, path: []const u8, grid: NewDocGrid, out_doc: *anyopaque) !void {
+ const file: *Internal.File = @ptrCast(@alignCast(out_doc));
+ file.* = try Internal.File.init(path, .{
+ .columns = grid.columns,
+ .rows = grid.rows,
+ .column_width = grid.column_width,
+ .row_height = grid.row_height,
+ });
+}
+
+pub fn documentDefaultSaveAsFilename(st: *State, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 {
+ const file = docFile(st, doc) orelse return error.DocumentNotFound;
+ return Internal.File.defaultSaveAsFilename(allocator, file.path);
+}
+
+pub fn saveDocumentAs(st: *State, doc: DocHandle, path: []const u8, window: *dvui.Window) !void {
+ const file = docFile(st, doc) orelse return error.DocumentNotFound;
+ const ext = std.fs.path.extension(path);
+ if (Internal.File.isFizzyExtension(ext)) {
+ try file.saveAsFizzy(path, window);
+ } else if (std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) {
+ try file.saveAsFlattened(path, window);
+ } else {
+ return error.UnsupportedSaveExtension;
+ }
+}
+
+pub fn resetDocumentSaveUIState(st: *State, doc: DocHandle) void {
+ const file = docFile(st, doc) orelse return;
+ file.resetSaveUIState();
+}
+
+pub fn prepareGridLayoutDialog(st: *State, doc: DocHandle) void {
+ const file = docFile(st, doc) orelse return;
+ GridLayout.presetFromFile(file);
+}
+
+pub fn tickOpenDocuments(st: *State) bool {
+ var needs_save_status_anim_tick = false;
+ for (st.docs.files.values()) |*file| {
+ file.tickSaveDoneFlash();
+ if (file.showsSaveStatusIndicator()) needs_save_status_anim_tick = true;
+ }
+ return needs_save_status_anim_tick;
+}
+
+pub fn resetDocumentPeekLayers(st: *State) void {
+ for (st.docs.files.values()) |*file| {
+ if (file.editor.isolate_layer) {
+ file.peek_layer_index = file.selected_layer_index;
+ } else {
+ file.peek_layer_index = null;
+ }
+ }
+}
+
+pub fn tickActiveDocumentPlayback(st: *State, timer_host_id: dvui.Id) void {
+ const file = activeFile(st) orelse return;
+ if (!file.editor.playing) return;
+ if (file.selected_animation_index) |index| {
+ const animation = file.animations.get(index);
+ if (animation.frames.len == 0) return;
+ if (dvui.timerDoneOrNone(timer_host_id)) {
+ if (file.selected_animation_frame_index >= animation.frames.len - 1) {
+ file.selected_animation_frame_index = 0;
+ } else {
+ file.selected_animation_frame_index += 1;
+ }
+ const millis_per_frame = animation.frames[file.selected_animation_frame_index].ms;
+ dvui.timer(timer_host_id, @intCast(millis_per_frame * 1000));
+ }
+ }
+}
+
+pub fn warmupActiveDocumentComposites(st: *State) void {
+ const file = activeFile(st) orelse return;
+ const w = file.width();
+ const h = file.height();
+ if (w == 0 or h == 0) return;
+ const area = @as(u64, w) * @as(u64, h);
+ if (area < 512 * 512) return;
+ pixelart.render.warmupDrawingComposites(file) catch |err| {
+ dvui.log.err("Composite warmup failed: {any}", .{err});
+ };
+}
+
+pub fn isAnyDocumentActivelyDrawing(st: *State) bool {
+ for (st.docs.files.values()) |*file| {
+ if (file.editor.active_drawing) return true;
+ }
+ return false;
+}
+
+pub fn acceptEdit(st: *State) void {
+ const file = activeFile(st) orelse return;
+ if (file.editor.transform) |*t| t.accept();
+}
+
+pub fn cancelEdit(st: *State) void {
+ const file = activeFile(st) orelse return;
+ if (file.editor.transform) |*t| t.cancel();
+ if (file.editor.selected_sprites.count() > 0) file.clearSelectedSprites();
+ if (file.selected_animation_index != null) file.selected_animation_index = null;
+}
+
+pub fn deleteSelection(st: *State) void {
+ const file = activeFile(st) orelse return;
+ file.deleteSelectedContents();
+}
+
+pub fn initPlugin(_: *State) !void {
+ try Internal.File.initSaveQueue();
+}
+
+pub fn deinitPlugin(_: *State) void {
+ Internal.File.deinitSaveQueue();
+}
diff --git a/src/plugins/pixelart/src/infobar_status.zig b/src/plugins/pixelart/src/infobar_status.zig
new file mode 100644
index 00000000..2476d10d
--- /dev/null
+++ b/src/plugins/pixelart/src/infobar_status.zig
@@ -0,0 +1,83 @@
+//! Active-document infobar status (path, dimensions, cursor) for the shell infobar.
+const std = @import("std");
+const dvui = @import("dvui");
+const icons = @import("icons");
+const pixelart = @import("../pixelart.zig");
+const Globals = pixelart.Globals;
+const State = pixelart.State;
+const Internal = pixelart.internal;
+const DocHandle = pixelart.sdk.DocHandle;
+const DimensionsLabel = @import("dialogs/dimensions_label.zig");
+
+fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
+ return st.docs.fileById(doc.id);
+}
+
+pub fn drawDocumentInfobar(st: *State, doc: DocHandle) !void {
+ const file = docFile(st, doc) orelse return;
+ const font = dvui.Font.theme(.body).larger(-1.0);
+ const font_mono = dvui.Font.theme(.mono).larger(-3.0);
+
+ dvui.icon(
+ @src(),
+ "file_icon",
+ icons.tvg.lucide.file,
+ .{ .stroke_color = dvui.themeGet().color(.window, .text) },
+ .{ .gravity_y = 0.5 },
+ );
+ dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ .font = font, .gravity_y = 0.5 });
+
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
+
+ dvui.icon(
+ @src(),
+ "width_icon",
+ icons.tvg.lucide.@"ruler-dimension-line",
+ .{ .stroke_color = dvui.themeGet().color(.window, .text) },
+ .{ .gravity_y = 0.5 },
+ );
+
+ DimensionsLabel.drawDimensionsLabel(@src(), file.width(), file.height(), font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } });
+
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
+
+ dvui.icon(
+ @src(),
+ "sprite_icon",
+ dvui.entypo.grid,
+ .{ .fill_color = dvui.themeGet().color(.window, .text) },
+ .{ .gravity_y = 0.5 },
+ );
+
+ DimensionsLabel.drawDimensionsLabel(@src(), file.column_width, file.row_height, font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } });
+
+ const mouse_pt = dvui.currentWindow().mouse_pt;
+ const data_pt = file.editor.canvas.dataFromScreenPoint(mouse_pt);
+
+ const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) });
+
+ if (file_rect.contains(data_pt)) {
+ _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } });
+
+ dvui.icon(
+ @src(),
+ "mouse_icon",
+ icons.tvg.lucide.@"mouse-pointer",
+ .{ .stroke_color = dvui.themeGet().color(.window, .text) },
+ .{ .gravity_y = 0.5 },
+ );
+
+ const sprite_pt = file.spritePoint(data_pt);
+ dvui.label(
+ @src(),
+ "{d:0.0},{d:0.0} - {d:0.0},{d:0.0}",
+ .{
+ @floor(data_pt.x),
+ @floor(data_pt.y),
+ @floor(sprite_pt.x / @as(f32, @floatFromInt(file.column_width))),
+ @floor(sprite_pt.y / @as(f32, @floatFromInt(file.row_height))),
+ },
+ .{ .gravity_y = 0.5, .font = font_mono },
+ );
+ }
+}
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index b6a840ad..d0ecfade 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -20,6 +20,8 @@ const PackProject = @import("pack_project.zig");
const TransformOp = @import("transform_op.zig");
const DocsRegistry = @import("docs_registry.zig");
const DocBridge = @import("doc_bridge.zig");
+const DocLifecycle = @import("doc_lifecycle.zig");
+const InfobarStatus = @import("infobar_status.zig");
const DocHandle = sdk.DocHandle;
const Internal = pixelart.internal;
@@ -38,15 +40,25 @@ var plugin: sdk.Plugin = .{
};
const vtable: sdk.Plugin.VTable = .{
+ .deinit = pluginDeinit,
+ .initPlugin = pluginInit,
.fileTypePriority = fileTypePriority,
.contributeKeybinds = contributeKeybinds,
.loadDocument = loadDocument,
.loadDocumentFromBytes = loadDocumentFromBytes,
+ .documentStackSize = documentStackSize,
+ .documentStackAlign = documentStackAlign,
+ .documentIdFromBuffer = documentIdFromBuffer,
+ .deinitDocumentBuffer = deinitDocumentBuffer,
+ .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer,
+ .createDocument = createDocument,
.isDirty = isDirty,
.saveDocument = saveDocument,
.closeDocument = closeDocument,
.undo = undo,
.redo = redo,
+ .canUndo = canUndo,
+ .canRedo = canRedo,
.registerOpenDocument = registerOpenDocument,
.documentPtr = documentPtr,
.documentByPath = documentByPath,
@@ -57,19 +69,34 @@ const vtable: sdk.Plugin.VTable = .{
.documentPath = documentPath,
.setDocumentPath = setDocumentPath,
.documentHasNativeExtension = documentHasNativeExtension,
+ .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension,
.showsSaveStatusIndicator = showsSaveStatusIndicator,
.isDocumentSaving = isDocumentSaving,
.shouldConfirmFlatRasterSave = shouldConfirmFlatRasterSave,
.saveDocumentAsync = saveDocumentAsync,
.timeSinceSaveCompleteNs = timeSinceSaveCompleteNs,
+ .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename,
+ .saveDocumentAs = saveDocumentAs,
+ .resetDocumentSaveUIState = resetDocumentSaveUIState,
+ .prepareGridLayoutDialog = prepareGridLayoutDialog,
.drawDocument = drawDocument,
+ .drawDocumentInfobar = drawDocumentInfobar,
+ .beginFrame = beginFrame,
.tickKeybinds = tickKeybinds,
+ .tickOpenDocuments = tickOpenDocuments,
+ .tickActiveDocumentPlayback = tickActiveDocumentPlayback,
+ .resetDocumentPeekLayers = resetDocumentPeekLayers,
+ .warmupActiveDocumentComposites = warmupActiveDocumentComposites,
+ .isAnyDocumentActivelyDrawing = isAnyDocumentActivelyDrawing,
.processRadialMenuInput = processRadialMenuInput,
.radialMenuVisible = radialMenuVisible,
.drawRadialMenu = drawRadialMenu,
.transform = pluginTransform,
.copy = pluginCopy,
.paste = pluginPaste,
+ .acceptEdit = pluginAcceptEdit,
+ .cancelEdit = pluginCancelEdit,
+ .deleteSelection = pluginDeleteSelection,
.startPackProject = pluginStartPackProject,
.isPackingActive = pluginIsPackingActive,
.tickPackJobs = pluginTickPackJobs,
@@ -262,6 +289,11 @@ fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void {
}
}
+fn drawDocumentInfobar(state: *anyopaque, doc: DocHandle) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ return InfobarStatus.drawDocumentInfobar(st, doc);
+}
+
fn undo(_: *anyopaque, doc: DocHandle) anyerror!void {
const file = docFile(doc);
try file.history.undoRedo(file, .undo);
@@ -272,6 +304,16 @@ fn redo(_: *anyopaque, doc: DocHandle) anyerror!void {
try file.history.undoRedo(file, .redo);
}
+fn canUndo(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.canUndo(st, doc);
+}
+
+fn canRedo(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.canRedo(st, doc);
+}
+
pub fn register(host: *sdk.Host) !void {
// Adopt the app-owned pixel-art state as this plugin's `state`. Wire Globals
// here too so plugin code and the shell share one injection site (App also sets
@@ -410,6 +452,11 @@ fn documentHasNativeExtension(state: *anyopaque, doc: DocHandle) bool {
return DocBridge.documentHasNativeExtension(st, doc);
}
+fn documentHasRecognizedSaveExtension(state: *anyopaque, doc: DocHandle) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocBridge.documentHasRecognizedSaveExtension(st, doc);
+}
+
fn showsSaveStatusIndicator(state: *anyopaque, doc: DocHandle) bool {
const st: *State = @ptrCast(@alignCast(state));
return DocBridge.showsSaveStatusIndicator(st, doc);
@@ -435,6 +482,112 @@ fn timeSinceSaveCompleteNs(state: *anyopaque, doc: DocHandle) ?i128 {
return DocBridge.timeSinceSaveCompleteNs(st, doc);
}
+fn pluginDeinit(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.deinitPlugin(st);
+}
+
+fn pluginInit(state: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.initPlugin(st);
+}
+
+fn documentStackSize(state: *anyopaque) usize {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.documentStackSize(st);
+}
+
+fn documentStackAlign(state: *anyopaque) usize {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.documentStackAlign(st);
+}
+
+fn documentIdFromBuffer(state: *anyopaque, doc: *anyopaque) u64 {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.documentIdFromBuffer(st, doc);
+}
+
+fn deinitDocumentBuffer(state: *anyopaque, doc: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.deinitDocumentBuffer(st, doc);
+}
+
+fn setDocumentGroupingOnBuffer(state: *anyopaque, doc: *anyopaque, grouping: u64) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.setDocumentGroupingOnBuffer(st, doc, grouping);
+}
+
+fn createDocument(state: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid, out_doc: *anyopaque) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.createDocument(st, path, grid, out_doc);
+}
+
+fn documentDefaultSaveAsFilename(state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.documentDefaultSaveAsFilename(st, doc, allocator);
+}
+
+fn saveDocumentAs(state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.saveDocumentAs(st, doc, path, window);
+}
+
+fn resetDocumentSaveUIState(state: *anyopaque, doc: DocHandle) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.resetDocumentSaveUIState(st, doc);
+}
+
+fn prepareGridLayoutDialog(state: *anyopaque, doc: DocHandle) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.prepareGridLayoutDialog(st, doc);
+}
+
+fn beginFrame(state: *anyopaque) void {
+ _ = state;
+ // Advance the per-frame render clock used as a composite-cache invalidation key.
+ pixelart.render.frame_index +%= 1;
+}
+
+fn tickOpenDocuments(state: *anyopaque) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.tickOpenDocuments(st);
+}
+
+fn tickActiveDocumentPlayback(state: *anyopaque, timer_host_id: dvui.Id) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.tickActiveDocumentPlayback(st, timer_host_id);
+}
+
+fn resetDocumentPeekLayers(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.resetDocumentPeekLayers(st);
+}
+
+fn warmupActiveDocumentComposites(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.warmupActiveDocumentComposites(st);
+}
+
+fn isAnyDocumentActivelyDrawing(state: *anyopaque) bool {
+ const st: *State = @ptrCast(@alignCast(state));
+ return DocLifecycle.isAnyDocumentActivelyDrawing(st);
+}
+
+fn pluginAcceptEdit(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.acceptEdit(st);
+}
+
+fn pluginCancelEdit(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.cancelEdit(st);
+}
+
+fn pluginDeleteSelection(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ DocLifecycle.deleteSelection(st);
+}
+
fn pluginPersistProjectFolder(state: *anyopaque) void {
const st: *State = @ptrCast(@alignCast(state));
DocsRegistry.persistProjectFolder(st);
diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig
index cfac2907..164e05c3 100644
--- a/src/plugins/workbench/src/FileLoadJob.zig
+++ b/src/plugins/workbench/src/FileLoadJob.zig
@@ -9,15 +9,13 @@
//!
//! Ownership / threading model:
//! - `path` is owned by the job, freed in `destroy()`.
-//! - `result` is written by the worker, read by the main thread only after `done.load(.acquire)`.
+//! - `doc_buf` is written by the worker, read by the main thread only after `done.load(.acquire)`.
//! - `phase` / `cancelled` are written by either side, read by either side.
//! - The job pointer itself is owned by `Editor.loading_jobs`. Worker holds a borrowed pointer
-//! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields.
+//! but only writes through atomic fields + the worker-only `doc_buf`/`err` fields.
const std = @import("std");
const fizzy = @import("../../../fizzy.zig");
-const pixelart = @import("pixelart");
-const Internal = pixelart.internal;
const dvui = @import("dvui");
const perf = fizzy.perf;
@@ -37,48 +35,36 @@ allocator: std.mem.Allocator,
path: []u8,
/// Plugin that owns this file's extension (resolved on the main thread before spawn).
-/// The worker routes the load through `owner.loadDocument` instead of hardcoding the
-/// pixel-art loader, so open is decoupled from any one editor plugin.
owner: *fizzy.sdk.Plugin,
/// Workspace grouping the file should land in once loaded.
target_grouping: u64,
-/// Captured at create time on the GUI thread. The worker uses this to wake the main loop
-/// (`dvui.refresh(window, ...)`) the instant the load finishes, so small files don't sit
-/// completed-but-unconsumed waiting for an unrelated input event to tick the editor.
window: *dvui.Window,
-
-/// Monotonic timestamp (boot clock, nanos) captured on the main thread at job creation.
-/// Compared against the main thread's current `perf.nanoTimestamp` to gate the 150ms toast
-/// threshold. Only read on the main thread.
started_at_ns: i128,
-/// Atomic phase, written by worker, read by main. Cast through `Phase`.
phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)),
-
-/// Optional progress hint, written by worker. `den == 0` means indeterminate.
progress_num: std.atomic.Value(u32) = .init(0),
progress_den: std.atomic.Value(u32) = .init(0),
-
-/// Main thread sets true on close-while-loading / quit. Worker checks after `fromPath` returns
-/// and discards the result instead of publishing.
cancelled: std.atomic.Value(bool) = .init(false),
-
-/// Worker → main publish flag. `release` on write, `acquire` on read.
done: std.atomic.Value(bool) = .init(false),
-/// Filled by worker iff load succeeds AND wasn't cancelled. Safe to read after `done.load(.acquire)`.
-result: ?Internal.File = null,
+/// Plugin-document staging buffer (size/align from `owner.documentStackSize/Align`).
+doc_slab: []u8,
+doc_buf: []u8,
-/// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`.
err: ?anyerror = null,
pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.Plugin, target_grouping: u64) !*FileLoadJob {
const path_copy = try allocator.dupe(u8, path);
errdefer allocator.free(path_copy);
+ const staging = try owner.allocDocumentBuffer(allocator);
+ errdefer allocator.free(staging.backing);
+
const job = try allocator.create(FileLoadJob);
+ errdefer allocator.destroy(job);
+
job.* = .{
.allocator = allocator,
.path = path_copy,
@@ -86,6 +72,8 @@ pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.
.target_grouping = target_grouping,
.window = dvui.currentWindow(),
.started_at_ns = perf.nanoTimestamp(),
+ .doc_slab = staging.backing,
+ .doc_buf = staging.buf,
};
return job;
}
@@ -93,19 +81,13 @@ pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.
pub fn destroy(job: *FileLoadJob) void {
const a = job.allocator;
a.free(job.path);
+ a.free(job.doc_slab);
a.destroy(job);
}
-/// Worker entry point. Spawn with `std.Thread.spawn(.{}, FileLoadJob.workerMain, .{job})`.
pub fn workerMain(job: *FileLoadJob) void {
defer {
- // Publish before waking the GUI thread so `done.load(.acquire)` on the consumer side
- // sees `result` / `err` / `phase` already in place.
job.done.store(true, .release);
- // Wake the GUI thread from this thread. `dvui.refresh` with a non-null Window pointer
- // is the documented thread-safe entry — it goes through the backend to interrupt the
- // event-driven idle loop, so the editor processes our completion immediately instead
- // of waiting for the next unrelated input event.
dvui.refresh(job.window, @src(), null);
}
@@ -116,11 +98,7 @@ pub fn workerMain(job: *FileLoadJob) void {
job.phase.store(@intFromEnum(Phase.reading), .release);
- // Route the actual load through the owning plugin (filled into a stack buffer the
- // shell owns; the plugin knows its concrete document type). Mirrors the inline-value
- // model below — no heap handoff.
- var file: Internal.File = undefined;
- const handled = job.owner.loadDocument(job.path, &file) catch |e| {
+ const handled = job.owner.loadDocument(job.path, job.doc_buf.ptr) catch |e| {
job.err = e;
job.phase.store(@intFromEnum(Phase.failed), .release);
return;
@@ -131,22 +109,15 @@ pub fn workerMain(job: *FileLoadJob) void {
return;
}
- // Cancellation check post-load: if the user closed the tab / quit while we were loading,
- // discard the file rather than publishing it.
if (job.cancelled.load(.monotonic)) {
- var f = file;
- f.deinit();
+ job.owner.deinitDocumentBuffer(job.doc_buf.ptr);
job.phase.store(@intFromEnum(Phase.cancelled), .release);
return;
}
- job.result = file;
job.phase.store(@intFromEnum(Phase.ready), .release);
}
-/// True iff at least `threshold_ms` of wall-clock time has elapsed since job creation. Used
-/// to delay the toast appearance so sub-threshold loads don't flash a UI element. Must be
-/// called from the main thread (uses `dvui.io` via `perf.nanoTimestamp`).
pub fn elapsedExceeds(job: *const FileLoadJob, threshold_ms: i64) bool {
const elapsed_ns = perf.nanoTimestamp() - job.started_at_ns;
return @divTrunc(elapsed_ns, std.time.ns_per_ms) >= threshold_ms;
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index 95d29605..3f0e8de7 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -10,6 +10,7 @@
const std = @import("std");
const dvui = @import("dvui");
const DocHandle = @import("DocHandle.zig");
+const EditorAPI = @import("EditorAPI.zig");
pub const Plugin = @This();
@@ -25,6 +26,8 @@ display_name: []const u8,
pub const VTable = struct {
/// Tear down `state`. Called when the plugin is unregistered / app shuts down.
deinit: ?*const fn (state: *anyopaque) void = null,
+ /// One-time plugin setup (e.g. background worker threads).
+ initPlugin: ?*const fn (state: *anyopaque) anyerror!void = null,
/// Priority for opening files with extension `ext` (including the dot, e.g.
/// ".fiz"); lower value wins. `null` = this plugin does not handle `ext`.
@@ -40,11 +43,20 @@ pub const VTable = struct {
/// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used
/// for extension detection + display name. Synchronous (web has no load worker).
loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null,
+ /// Size of the plugin's document type for stack/heap staging buffers (`loadDocument`, etc.).
+ documentStackSize: ?*const fn (state: *anyopaque) usize = null,
+ documentStackAlign: ?*const fn (state: *anyopaque) usize = null,
+ documentIdFromBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque) u64 = null,
+ deinitDocumentBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque) void = null,
+ setDocumentGroupingOnBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque, grouping: u64) void = null,
+ createDocument: ?*const fn (state: *anyopaque, path: []const u8, grid: EditorAPI.NewDocGrid, out_doc: *anyopaque) anyerror!void = null,
saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
+ canUndo: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ canRedo: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
/// Register a loaded/created document in the plugin's open-doc map. `file` points at
/// the plugin's document type (for pixel art, `*Internal.File` on the caller's stack).
@@ -64,11 +76,17 @@ pub const VTable = struct {
documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null,
setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null,
documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
+ /// True when `saveDocument` can write the document without Save As (e.g. `.fiz` or flat image).
+ documentHasRecognizedSaveExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
showsSaveStatusIndicator: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
isDocumentSaving: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
shouldConfirmFlatRasterSave: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
saveDocumentAsync: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
timeSinceSaveCompleteNs: ?*const fn (state: *anyopaque, doc: DocHandle) ?i128 = null,
+ documentDefaultSaveAsFilename: ?*const fn (state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 = null,
+ saveDocumentAs: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void = null,
+ resetDocumentSaveUIState: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
+ prepareGridLayoutDialog: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
// ---- render hooks (the plugin draws its own dvui UI into the host window) ----
/// Draw the plugin's explorer/sidebar pane (left region).
@@ -77,13 +95,23 @@ pub const VTable = struct {
drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
/// Draw the plugin's bottom panel content.
drawBottomPanel: ?*const fn (state: *anyopaque) anyerror!void = null,
+ /// Draw active-document status into the shell infobar (dimensions, cursor, etc.).
+ drawDocumentInfobar: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
// ---- shell contributions ----
contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null,
contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null,
// ---- per-frame shell hooks (global keybinds, overlays) ----
+ /// Called once at the top of every shell frame, before any document drawing. Plugins
+ /// use this to advance their internal frame clock / invalidate per-frame caches.
+ beginFrame: ?*const fn (state: *anyopaque) void = null,
tickKeybinds: ?*const fn (state: *anyopaque) anyerror!void = null,
+ tickOpenDocuments: ?*const fn (state: *anyopaque) bool = null,
+ tickActiveDocumentPlayback: ?*const fn (state: *anyopaque, timer_host_id: dvui.Id) void = null,
+ resetDocumentPeekLayers: ?*const fn (state: *anyopaque) void = null,
+ warmupActiveDocumentComposites: ?*const fn (state: *anyopaque) void = null,
+ isAnyDocumentActivelyDrawing: ?*const fn (state: *anyopaque) bool = null,
processRadialMenuInput: ?*const fn (state: *anyopaque) void = null,
radialMenuVisible: ?*const fn (state: *anyopaque) bool = null,
drawRadialMenu: ?*const fn (state: *anyopaque) anyerror!void = null,
@@ -92,6 +120,9 @@ pub const VTable = struct {
transform: ?*const fn (state: *anyopaque) anyerror!void = null,
copy: ?*const fn (state: *anyopaque) anyerror!void = null,
paste: ?*const fn (state: *anyopaque) anyerror!void = null,
+ acceptEdit: ?*const fn (state: *anyopaque) void = null,
+ cancelEdit: ?*const fn (state: *anyopaque) void = null,
+ deleteSelection: ?*const fn (state: *anyopaque) void = null,
startPackProject: ?*const fn (state: *anyopaque) anyerror!void = null,
isPackingActive: ?*const fn (state: *const anyopaque) bool = null,
tickPackJobs: ?*const fn (state: *anyopaque) void = null,
@@ -202,6 +233,10 @@ pub fn documentHasNativeExtension(self: Plugin, doc: DocHandle) bool {
return if (self.vtable.documentHasNativeExtension) |f| f(self.state, doc) else false;
}
+pub fn documentHasRecognizedSaveExtension(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.documentHasRecognizedSaveExtension) |f| f(self.state, doc) else false;
+}
+
pub fn showsSaveStatusIndicator(self: Plugin, doc: DocHandle) bool {
return if (self.vtable.showsSaveStatusIndicator) |f| f(self.state, doc) else false;
}
@@ -270,6 +305,14 @@ pub fn redo(self: Plugin, doc: DocHandle) !void {
if (self.vtable.redo) |f| try f(self.state, doc);
}
+pub fn canUndo(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.canUndo) |f| f(self.state, doc) else false;
+}
+
+pub fn canRedo(self: Plugin, doc: DocHandle) bool {
+ return if (self.vtable.canRedo) |f| f(self.state, doc) else false;
+}
+
// ---- render hook wrappers ----
/// Draw an open document into the current dvui parent (the workbench sets up the
@@ -282,6 +325,101 @@ pub fn drawDocument(self: Plugin, doc: DocHandle) !bool {
return false;
}
+pub fn drawDocumentInfobar(self: Plugin, doc: DocHandle) !void {
+ if (self.vtable.drawDocumentInfobar) |f| try f(self.state, doc);
+}
+
pub fn deinit(self: Plugin) void {
if (self.vtable.deinit) |f| f(self.state);
}
+
+pub fn initPlugin(self: Plugin) !void {
+ if (self.vtable.initPlugin) |f| try f(self.state);
+}
+
+pub fn documentStackSize(self: Plugin) usize {
+ return if (self.vtable.documentStackSize) |f| f(self.state) else 0;
+}
+
+pub fn documentStackAlign(self: Plugin) usize {
+ return if (self.vtable.documentStackAlign) |f| f(self.state) else 1;
+}
+
+pub fn documentIdFromBuffer(self: Plugin, doc: *anyopaque) u64 {
+ return if (self.vtable.documentIdFromBuffer) |f| f(self.state, doc) else 0;
+}
+
+pub fn deinitDocumentBuffer(self: Plugin, doc: *anyopaque) void {
+ if (self.vtable.deinitDocumentBuffer) |f| f(self.state, doc);
+}
+
+pub fn setDocumentGroupingOnBuffer(self: Plugin, doc: *anyopaque, grouping: u64) void {
+ if (self.vtable.setDocumentGroupingOnBuffer) |f| f(self.state, doc, grouping);
+}
+
+pub fn createDocument(self: Plugin, path: []const u8, grid: EditorAPI.NewDocGrid, out_doc: *anyopaque) !void {
+ if (self.vtable.createDocument) |f| try f(self.state, path, grid, out_doc) else return error.Unsupported;
+}
+
+pub fn documentDefaultSaveAsFilename(self: Plugin, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 {
+ return if (self.vtable.documentDefaultSaveAsFilename) |f| try f(self.state, doc, allocator) else error.Unsupported;
+}
+
+pub fn saveDocumentAs(self: Plugin, doc: DocHandle, path: []const u8, window: *dvui.Window) !void {
+ if (self.vtable.saveDocumentAs) |f| try f(self.state, doc, path, window) else return error.Unsupported;
+}
+
+pub fn resetDocumentSaveUIState(self: Plugin, doc: DocHandle) void {
+ if (self.vtable.resetDocumentSaveUIState) |f| f(self.state, doc);
+}
+
+pub fn prepareGridLayoutDialog(self: Plugin, doc: DocHandle) void {
+ if (self.vtable.prepareGridLayoutDialog) |f| f(self.state, doc);
+}
+
+pub fn beginFrame(self: Plugin) void {
+ if (self.vtable.beginFrame) |f| f(self.state);
+}
+
+pub fn tickOpenDocuments(self: Plugin) bool {
+ return if (self.vtable.tickOpenDocuments) |f| f(self.state) else false;
+}
+
+pub fn tickActiveDocumentPlayback(self: Plugin, timer_host_id: dvui.Id) void {
+ if (self.vtable.tickActiveDocumentPlayback) |f| f(self.state, timer_host_id);
+}
+
+pub fn resetDocumentPeekLayers(self: Plugin) void {
+ if (self.vtable.resetDocumentPeekLayers) |f| f(self.state);
+}
+
+pub fn warmupActiveDocumentComposites(self: Plugin) void {
+ if (self.vtable.warmupActiveDocumentComposites) |f| f(self.state);
+}
+
+pub fn isAnyDocumentActivelyDrawing(self: Plugin) bool {
+ return if (self.vtable.isAnyDocumentActivelyDrawing) |f| f(self.state) else false;
+}
+
+pub fn acceptEdit(self: Plugin) void {
+ if (self.vtable.acceptEdit) |f| f(self.state);
+}
+
+pub fn cancelEdit(self: Plugin) void {
+ if (self.vtable.cancelEdit) |f| f(self.state);
+}
+
+pub fn deleteSelection(self: Plugin) void {
+ if (self.vtable.deleteSelection) |f| f(self.state);
+}
+
+/// Allocate a buffer suitable for staging `loadDocument` / `createDocument`. Caller frees `backing`.
+pub fn allocDocumentBuffer(self: Plugin, allocator: std.mem.Allocator) !struct { backing: []u8, buf: []u8 } {
+ const size = self.documentStackSize();
+ const align_req = self.documentStackAlign();
+ if (size == 0 or align_req == 0) return error.Unsupported;
+ const pad = align_req - 1;
+ const backing = try allocator.alloc(u8, size + pad);
+ const offset = std.mem.alignForward(usize, @intFromPtr(backing.ptr), align_req) - @intFromPtr(backing.ptr);
+ return .{ .backing = backing, .buf = backing[offset..][0..size] };
+}
From a2a0e2200660ceac7612790b9802656332ef8246 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 08:10:27 -0500
Subject: [PATCH 26/49] dialogs and project
---
HANDOFF.md | 78 ++++++++++++++++---
src/editor/Editor.zig | 57 +++-----------
src/editor/dialogs/Dialogs.zig | 20 +----
src/editor/dialogs/UnsavedClose.zig | 5 +-
src/editor/explorer/Explorer.zig | 4 +-
src/plugins/pixelart/src/CanvasData.zig | 5 +-
.../src/dialogs/FlatRasterSaveWarning.zig | 26 +++----
.../pixelart/src/dialogs/GridLayout.zig | 27 +++++++
src/plugins/pixelart/src/dialogs/NewFile.zig | 21 +++++
src/plugins/pixelart/src/doc_lifecycle.zig | 6 --
src/plugins/pixelart/src/plugin.zig | 20 ++++-
src/plugins/workbench/src/files.zig | 32 +-------
src/sdk/EditorAPI.zig | 5 --
src/sdk/Host.zig | 17 +++-
src/sdk/Plugin.zig | 40 ++++++++--
15 files changed, 210 insertions(+), 153 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index 7af1f424..6da744d8 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -20,12 +20,15 @@ Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart
**Sprite/atlas → `core` big rock: DONE** (verified — generic atlas type + sprite-draw
primitive + sprite-id index all in `core`; neither shell nor plugin reaches the other's atlas).
-**Next:** the only remaining shell→plugin concrete reaches are `pixelart.dialogs.*` +
-`pixelart.explorer.project` — needs a generic dialog-registry vtable to lift (deferred).
-Then: wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor` (logo atlas draw).
+**Dialog-registry lift — DONE** (see "Multi-plugin readiness"): the shell no longer names any
+pixel-art dialog. `pixelart.dialogs` is gone from `src/editor` + `src/plugins/workbench`.
-> **Read this first if you're a fresh agent:** Stage D/E are done bar the dialog-registry
-> lift. All three build configs are green right now.
+**Next:** wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor`
+(logo atlas draw, `fizzy.editor.host.requestNewDocument`, etc.).
+
+> **Read this first if you're a fresh agent:** Stage D/E + the dialog-registry lift are done.
+> Shell→pixelart surface is now only `pixelart.plugin` (vtable) + `State`/`Globals` (lifecycle).
+> All three build configs are green right now.
All three build configs are green:
@@ -289,15 +292,66 @@ app code until the build module is fully wired.
directly. `Editor.frame` now calls `plugin.beginFrame()` for every registered plugin; the
pixel-art impl advances its own composite-cache frame clock. **No `pixelart.render` in shell.**
- ✅ Removed dead `pixelart`/`Packer` imports from `editor/panel/Panel.zig`.
+- ✅ Removed dead `pixelart.explorer.project` re-export from `editor/explorer/Explorer.zig`
+ (the project view is contributed via `Host.registerSidebarView`, not the shell hub).
+- ✅ Removed dead `Plugin.drawBottomPanel` / `drawExplorerPane` vtable hooks — superseded by
+ the `registerSidebarView` / `registerBottomView` registries (see "Multi-plugin readiness").
+
+- ✅ **Dialog-registry lift** (see "Multi-plugin readiness"): all pixel-art dialogs lifted off
+ the shell hub onto plugin vtable hooks. `editor/dialogs/Dialogs.zig` no longer imports
+ `pixelart`; owns only shell-level dialogs (UnsavedClose, AppQuitUnsaved, AboutFizzy, Web*).
**Shell → plugin surface now (grep `pixelart\.X` in `src/editor` + `src/plugins/workbench`):**
-`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.dialogs` ×6,
-`pixelart.State` ×2, `pixelart.Globals` ×2, `pixelart.explorer` ×1,
-`"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a symbol ref).
-The remaining real reaches are `pixelart.dialogs.*` (NewFile/Export/GridLayout/
-FlatRasterSaveWarning/DimensionsLabel re-exported by `editor/dialogs/Dialogs.zig` +
-`UnsavedClose.zig`) and `pixelart.explorer.project` — concrete pixel-art UI the shell still
-constructs directly. Lifting these needs a generic dialog-registry vtable; deferred, not blocking.
+`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.State` ×2,
+`pixelart.Globals` ×2, `"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a
+symbol ref). **No concrete pixel-art type (dialogs/render/explorer/Packer) is named in the
+shell anymore** — only the plugin vtable boundary + lifecycle.
+
+---
+
+## Multi-plugin readiness (context for the upcoming **textedit** plugin)
+
+> Direction (user, 2026-06-19): a textedit plugin will render `.txt`/`.atlas`/`.json` etc.,
+> coexisting in tabs/splits beside pixel-art docs. The bottom panel should likewise host
+> per-plugin tabs (a console plugin one day). **This is NOT current scope** — captured here
+> so the decoupling doesn't bake in single-plugin assumptions.
+
+**Audit result (this session): the architecture is already positioned for all of it.**
+
+| Concern | Mechanism today | textedit slots in by |
+|---------|-----------------|----------------------|
+| Which plugin owns an opened file | `Host.pluginForExtension(ext)` picks lowest `fileTypePriority` across **all** plugins (`Host.zig`) | registering `.txt/.atlas/.json` with a priority |
+| Per-document ops (save/dirty/undo/path/grouping/…) | all route through `DocHandle.owner` vtable (opaque handle; shell never inspects `ptr`) | implementing the doc vtable hooks |
+| Rendering a doc into a tab/split | `Workspace.zig` calls `doc.owner.drawDocument(doc)` — type-agnostic | implementing `drawDocument` |
+| Sidebar/explorer panes | `Host.registerSidebarView(.{id,owner,title,draw[,draw_workspace]})`; shell renders the set (`Sidebar.zig`) | calling `registerSidebarView` |
+| **Bottom panel tabs** | `Host.registerBottomView(.{id,owner,title,draw})`; `Panel.zig` draws a **tab strip when >1 view** + active-view get/set on `Host` | calling `registerBottomView` (a console is just another bottom view) |
+| Menus | `Host.registerMenu` + `contributeMenu` | registering its menus |
+
+So tabs/splits and multi-plugin bottom panels are **already** registry-driven, not
+pixelart-hardcoded. No corner-painting risk found.
+
+**Dialogs — lifted (was the one single-plugin seam, now DONE).** All pixel-art dialog launches
+moved out of the shell hub onto the plugin; the shell never names a plugin dialog:
+
+- **Doc-scoped dialogs** route through `DocHandle.owner` vtable hooks (added to `sdk/Plugin.zig`):
+ - `requestGridLayoutDialog(doc)` — shell `Editor.requestGridLayoutDialog` resolves the active
+ doc and dispatches; launch + `presetFromFile` now live in `dialogs/GridLayout.request`.
+ Removed the old `prepareGridLayoutDialog` hook and the `EditorAPI.requestGridLayoutDialog`
+ round-trip (plugin `CanvasData` calls `GridLayout.request` directly now).
+ - `requestFlatRasterSaveWarning(doc, mode, from_save_all_quit)` — `mode` is the new SDK enum
+ `Plugin.FlatRasterSaveMode {editor_save, save_and_close}`. The save/quit flag is now captured
+ per-dialog in a `_flat_raster_from_quit` data slot instead of an externally-reset module var,
+ so `Editor.abortSaveAllQuit` no longer pokes dialog state.
+- **Type-selecting dialog** (not doc-scoped): `Host.requestNewDocument(parent_path, id_extra)`
+ dispatches to the first plugin advertising `requestNewDocumentDialog` (vtable). Shell
+ `Editor.requestNewFileDialog` and `workbench/files.zig` "New File…" call the Host method;
+ launch lives in `dialogs/NewFile.request`.
+ **TODO(multi-plugin):** with textedit registered, "New File" is ambiguous — turn this into a
+ typed `New > ` chooser (each editor plugin contributes a new-doc kind) instead of
+ first-provider dispatch. The seam (shell decoupled from the dialog impl) is already in place.
+
+Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.drawDimensionsLabel`
+(both had zero shell callers).
---
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index eece5348..83108d85 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -572,7 +572,6 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.transform = shellTransform,
.save = shellSave,
.requestCompositeWarmup = shellRequestCompositeWarmup,
- .requestGridLayoutDialog = shellRequestGridLayoutDialog,
.allocUntitledPath = shellAllocUntitledPath,
.createDocument = shellCreateDocument,
.setExplorerNewFilePath = shellSetExplorerNewFilePath,
@@ -684,9 +683,6 @@ fn shellSave(ctx: *anyopaque) anyerror!void {
fn shellRequestCompositeWarmup(ctx: *anyopaque) void {
shellCtx(ctx).requestCompositeWarmup();
}
-fn shellRequestGridLayoutDialog(ctx: *anyopaque) void {
- shellCtx(ctx).requestGridLayoutDialog();
-}
fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 {
return shellCtx(ctx).allocNextUntitledPath();
}
@@ -1792,7 +1788,6 @@ pub fn drawWorkspaces(editor: *Editor, index: usize) !dvui.App.Result {
}
pub fn abortSaveAllQuit(editor: *Editor) void {
- Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = false;
editor.quit_save_all_ids.clearAndFree(fizzy.app.allocator);
editor.quit_saves_in_flight.clearRetainingCapacity();
editor.quit_in_progress = false;
@@ -1866,8 +1861,7 @@ pub fn advanceSaveAllQuit(editor: *Editor) void {
// Flat-raster prompt is a modal dialog — same reason as Save As, do
// it serially and rejoin afterwards.
if (editor.open_files.getIndex(id)) |idx| editor.setActiveFile(idx);
- Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = true;
- Dialogs.FlatRasterSaveWarning.request(id, .save_and_close);
+ doc.owner.requestFlatRasterSaveWarning(doc, .save_and_close, true);
return;
}
@@ -2375,46 +2369,17 @@ pub fn allocNextUntitledPath(editor: *Editor) ![]u8 {
return std.fmt.allocPrint(fizzy.app.allocator, "untitled-{d}", .{max_n + 1});
}
-/// Opens the Grid Layout dialog for the active file. Uses a custom `windowFn` that matches
-/// `dialogWindow`'s open animation while capping the window to half the main window size; the
-/// dialog can still be resized afterward.
-/// The dialog rebinds the active file via the `_grid_layout_file_id` data slot so the form and
-/// preview can survive frames where `fizzy.editor.activeFile()` momentarily returns null.
+/// Opens the active document owner's grid-layout dialog. The shell only resolves the active
+/// document and dispatches to `doc.owner`; the dialog itself is owned by the plugin.
pub fn requestGridLayoutDialog(editor: *Editor) void {
const doc = editor.activeDoc() orelse return;
- doc.owner.prepareGridLayoutDialog(doc);
-
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = Dialogs.GridLayout.dialog,
- .callafterFn = Dialogs.GridLayout.callAfter,
- .windowFn = Dialogs.GridLayout.windowFn,
- .title = "Grid Layout...",
- .ok_label = "Apply",
- .cancel_label = "Cancel",
- .resizeable = true,
- .header_kind = .info,
- .default = .ok,
- });
- dvui.dataSet(null, mutex.id, "_grid_layout_file_id", doc.id);
- // Let `GridLayout.windowFn` run `autoSize` only until the open animation finishes; otherwise
- // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks).
- dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false);
- mutex.mutex.unlock(dvui.io);
-}
-
-/// Opens the New File dimensions dialog; on confirm, creates an in-memory `untitled-n` document (or on-disk from explorer when `_parent_path` is set).
-pub fn requestNewFileDialog(_: *Editor) void {
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = Dialogs.NewFile.dialog,
- .callafterFn = Dialogs.NewFile.callAfter,
- .title = "New File...",
- .ok_label = "Create",
- .cancel_label = "Cancel",
- .resizeable = false,
- .header_kind = .info,
- .default = .ok,
- });
- mutex.mutex.unlock(dvui.io);
+ doc.owner.requestGridLayoutDialog(doc);
+}
+
+/// Opens the New File dialog via the plugin that provides one (dispatched by `Host`); on confirm
+/// the owner creates an in-memory `untitled-n` document (or on-disk when a parent folder is set).
+pub fn requestNewFileDialog(editor: *Editor) void {
+ editor.host.requestNewDocument(null, 0);
}
pub fn setActiveFile(editor: *Editor, index: usize) void {
@@ -2473,7 +2438,7 @@ pub fn save(editor: *Editor) !void {
return;
}
if (doc.owner.shouldConfirmFlatRasterSave(doc)) {
- Dialogs.FlatRasterSaveWarning.request(doc.id, .editor_save);
+ doc.owner.requestFlatRasterSaveWarning(doc, .editor_save, false);
return;
}
if (comptime builtin.target.cpu.arch == .wasm32) {
diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig
index b851d228..629a9c79 100644
--- a/src/editor/dialogs/Dialogs.zig
+++ b/src/editor/dialogs/Dialogs.zig
@@ -1,16 +1,13 @@
-const std = @import("std");
const builtin = @import("builtin");
-const pixelart = @import("pixelart");
const dvui = @import("dvui");
const Dialogs = @This();
-pub const NewFile = pixelart.dialogs.NewFile;
-pub const Export = pixelart.dialogs.Export;
+// Plugin-owned dialogs (New File, Grid Layout, Export, Flat-raster save warning) are no longer
+// re-exported here. The shell triggers them through plugin vtable hooks / `Host.requestNewDocument`
+// so it never names a plugin's dialog implementation. This hub owns only shell-level dialogs.
pub const UnsavedClose = @import("UnsavedClose.zig");
pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig");
-pub const GridLayout = pixelart.dialogs.GridLayout;
-pub const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning;
pub const AboutFizzy = @import("AboutFizzy.zig");
pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32)
@import("WebFolderUnavailable.zig")
@@ -31,14 +28,3 @@ else
return false;
}
};
-
-pub fn drawDimensionsLabel(
- src: std.builtin.SourceLocation,
- width: u32,
- height: u32,
- font: dvui.Font,
- unit: []const u8,
- opts: dvui.Options,
-) void {
- pixelart.dialogs.DimensionsLabel.drawDimensionsLabel(src, width, height, font, unit, opts);
-}
diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig
index 7aa149b7..8c3d12ba 100644
--- a/src/editor/dialogs/UnsavedClose.zig
+++ b/src/editor/dialogs/UnsavedClose.zig
@@ -1,8 +1,6 @@
const std = @import("std");
const fizzy = @import("../../fizzy.zig");
-const pixelart = @import("pixelart");
const dvui = @import("dvui");
-const FlatRasterSaveWarning = pixelart.dialogs.FlatRasterSaveWarning;
pub fn request(file_id: u64) void {
var mutex = fizzy.dvui.dialog(@src(), .{
@@ -118,9 +116,8 @@ fn onSaveAndClose(file_id: u64) !void {
return;
}
if (doc.owner.shouldConfirmFlatRasterSave(doc)) {
- FlatRasterSaveWarning.pending_from_save_all_quit = false;
fizzy.dvui.closeFloatingDialogAnchored();
- FlatRasterSaveWarning.request(file_id, .save_and_close);
+ doc.owner.requestFlatRasterSaveWarning(doc, .save_and_close, false);
return;
}
try beginSaveAndClose(doc, file_id);
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 7af9aed0..7469271e 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -7,7 +7,6 @@ const icons = @import("icons");
const Core = @import("mach").Core;
const App = fizzy.App;
const Editor = fizzy.Editor;
-const pixelart = @import("pixelart");
const nfd = @import("nfd");
@@ -16,7 +15,8 @@ pub const Explorer = @This();
pub const files = @import("../../plugins/workbench/src/files.zig");
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
-pub const project = pixelart.explorer.project;
+// The pixel-art project view is contributed by the plugin via `Host.registerSidebarView`,
+// not re-exported here.
pub const settings = @import("settings.zig");
paned: *fizzy.dvui.PanedWidget = undefined,
diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig
index cdb1d48b..2c13e4a9 100644
--- a/src/plugins/pixelart/src/CanvasData.zig
+++ b/src/plugins/pixelart/src/CanvasData.zig
@@ -14,6 +14,7 @@ const dvui = @import("dvui");
const icons = @import("icons");
const FileWidget = @import("widgets/FileWidget.zig");
const Export = @import("dialogs/Export.zig");
+const GridLayout = @import("dialogs/GridLayout.zig");
const pixelart = @import("../pixelart.zig");
const Globals = pixelart.Globals;
@@ -1054,7 +1055,9 @@ pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void {
.transform => Globals.state.host.transform() catch {
dvui.log.err("Failed to start transform", .{});
},
- .grid_layout => Globals.state.host.requestGridLayoutDialog(),
+ .grid_layout => {
+ if (Globals.state.host.activeDoc()) |doc| GridLayout.request(doc.id);
+ },
}
}
}
diff --git a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig
index d301db5b..213c350b 100644
--- a/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig
+++ b/src/plugins/pixelart/src/dialogs/FlatRasterSaveWarning.zig
@@ -3,21 +3,15 @@ const dvui = @import("dvui");
const pixelart = @import("../../pixelart.zig");
const Globals = pixelart.Globals;
-/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save.
-pub var pending_from_save_all_quit: bool = false;
+pub const Mode = pixelart.sdk.Plugin.FlatRasterSaveMode;
pub var pending_mode: Mode = .editor_save;
-pub const Mode = enum {
- editor_save,
- save_and_close,
-};
-
-pub fn request(file_id: u64, mode: Mode) void {
+/// Open the flat-raster save confirmation for `file_id`. `from_save_all_quit` (whether this
+/// request was issued during the shell's quit walk) is captured per-dialog in a data slot so
+/// no externally-mutated module flag has to be reset when the quit walk aborts.
+pub fn request(file_id: u64, mode: Mode, from_save_all_quit: bool) void {
pending_mode = mode;
- if (mode == .editor_save) {
- pending_from_save_all_quit = false;
- }
var mutex = pixelart.core.dvui.dialog(@src(), .{
.displayFn = dialog,
.callafterFn = callAfter,
@@ -31,6 +25,7 @@ pub fn request(file_id: u64, mode: Mode) void {
.header_kind = .warning,
});
dvui.dataSet(null, mutex.id, "_flat_raster_file_id", file_id);
+ dvui.dataSet(null, mutex.id, "_flat_raster_from_quit", from_save_all_quit);
mutex.mutex.unlock(dvui.io);
}
@@ -62,6 +57,7 @@ fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style:
pub fn dialog(id: dvui.Id) anyerror!bool {
const file_id = dvui.dataGet(null, id, "_flat_raster_file_id", u64) orelse return false;
+ const from_quit = dvui.dataGet(null, id, "_flat_raster_from_quit", bool) orelse false;
const file = fileRef(file_id) orelse return false;
const ext_raw = std.fs.path.extension(file.path);
@@ -103,7 +99,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool {
}
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } });
if (dialogButton(@src(), ext_disp, .control, 2, 1)) {
- try onChooseFlatRaster(file_id);
+ try onChooseFlatRaster(file_id, from_quit);
}
_ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } });
if (dialogButton(@src(), "Cancel", .control, 3, 2)) {
@@ -123,7 +119,7 @@ fn onChooseFizzy(file_id: u64) !void {
Globals.state.host.requestSaveAs();
}
-fn onChooseFlatRaster(file_id: u64) !void {
+fn onChooseFlatRaster(file_id: u64, from_save_all_quit: bool) !void {
const f = fileRef(file_id) orelse return;
switch (pending_mode) {
.editor_save => {
@@ -144,10 +140,10 @@ fn onChooseFlatRaster(file_id: u64) !void {
// otherwise this is a single-doc save-and-close.
f.saveAsync() catch |err| {
dvui.log.err("Save failed: {s}", .{@errorName(err)});
- if (pending_from_save_all_quit) Globals.state.host.abortSaveAllQuit();
+ if (from_save_all_quit) Globals.state.host.abortSaveAllQuit();
return;
};
- if (pending_from_save_all_quit) {
+ if (from_save_all_quit) {
Globals.state.host.trackQuitSaveInFlight(file_id) catch |err| {
dvui.log.err("Save all quit track: {s}", .{@errorName(err)});
Globals.state.host.abortSaveAllQuit();
diff --git a/src/plugins/pixelart/src/dialogs/GridLayout.zig b/src/plugins/pixelart/src/dialogs/GridLayout.zig
index 9f021824..b45f942c 100644
--- a/src/plugins/pixelart/src/dialogs/GridLayout.zig
+++ b/src/plugins/pixelart/src/dialogs/GridLayout.zig
@@ -85,6 +85,33 @@ const anchors: [9]pixelart.math.layout_anchor.LayoutAnchor = .{
const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", "SE" };
+/// Open the Grid Layout dialog for the document `file_id`. Seeds the form from the file's
+/// current grid, then launches the floating dialog. Uses a custom `windowFn` that matches
+/// `dialogWindow`'s open animation while capping the window to half the main window size.
+/// The `_grid_layout_file_id` slot rebinds the active file so the form/preview survive frames
+/// where the active document momentarily resolves null.
+pub fn request(file_id: u64) void {
+ const file = Globals.state.docs.fileById(file_id) orelse return;
+ presetFromFile(file);
+
+ var mutex = pixelart.core.dvui.dialog(@src(), .{
+ .displayFn = dialog,
+ .callafterFn = callAfter,
+ .windowFn = windowFn,
+ .title = "Grid Layout...",
+ .ok_label = "Apply",
+ .cancel_label = "Cancel",
+ .resizeable = true,
+ .header_kind = .info,
+ .default = .ok,
+ });
+ dvui.dataSet(null, mutex.id, "_grid_layout_file_id", file_id);
+ // Let `windowFn` run `autoSize` only until the open animation finishes; otherwise
+ // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks).
+ dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false);
+ mutex.mutex.unlock(dvui.io);
+}
+
/// Seed both mode forms with the active file's current grid so the dialog opens "no-op" by default.
pub fn presetFromFile(file: *pixelart.internal.File) void {
resize_form = .{
diff --git a/src/plugins/pixelart/src/dialogs/NewFile.zig b/src/plugins/pixelart/src/dialogs/NewFile.zig
index c9950e30..6a221ab8 100644
--- a/src/plugins/pixelart/src/dialogs/NewFile.zig
+++ b/src/plugins/pixelart/src/dialogs/NewFile.zig
@@ -18,6 +18,27 @@ pub var row_height: u32 = 32;
pub const max_size: [2]u32 = .{ 4096, 4096 };
pub const min_size: [2]u32 = .{ 1, 1 };
+/// Open the "New File" dimensions dialog. When `parent_path` is set the new document is created
+/// on disk inside that folder (explorer-initiated); otherwise an in-memory `untitled-n` is made.
+/// `id_extra` disambiguates dialogs launched from distinct explorer rows.
+pub fn request(parent_path: ?[]const u8, id_extra: usize) void {
+ var mutex = pixelart.core.dvui.dialog(@src(), .{
+ .displayFn = dialog,
+ .callafterFn = callAfter,
+ .title = "New File...",
+ .ok_label = "Create",
+ .cancel_label = "Cancel",
+ .resizeable = false,
+ .header_kind = .info,
+ .default = .ok,
+ .id_extra = id_extra,
+ });
+ // `dataSetSlice` copies the bytes into dvui's per-widget store, so the borrowed slice
+ // only needs to be valid for this call.
+ if (parent_path) |p| dvui.dataSetSlice(null, mutex.id, "_parent_path", p);
+ mutex.mutex.unlock(dvui.io);
+}
+
pub fn dialog(id: dvui.Id) anyerror!bool {
const entry_font = dvui.Font.theme(.mono).larger(-2);
diff --git a/src/plugins/pixelart/src/doc_lifecycle.zig b/src/plugins/pixelart/src/doc_lifecycle.zig
index 69f1b3f5..b84071a1 100644
--- a/src/plugins/pixelart/src/doc_lifecycle.zig
+++ b/src/plugins/pixelart/src/doc_lifecycle.zig
@@ -8,7 +8,6 @@ const State = pixelart.State;
const Internal = pixelart.internal;
const DocHandle = pixelart.sdk.DocHandle;
const NewDocGrid = pixelart.sdk.EditorAPI.NewDocGrid;
-const GridLayout = @import("dialogs/GridLayout.zig");
fn docFile(st: *State, doc: DocHandle) ?*Internal.File {
return st.docs.fileById(doc.id);
@@ -74,11 +73,6 @@ pub fn resetDocumentSaveUIState(st: *State, doc: DocHandle) void {
file.resetSaveUIState();
}
-pub fn prepareGridLayoutDialog(st: *State, doc: DocHandle) void {
- const file = docFile(st, doc) orelse return;
- GridLayout.presetFromFile(file);
-}
-
pub fn tickOpenDocuments(st: *State) bool {
var needs_save_status_anim_tick = false;
for (st.docs.files.values()) |*file| {
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index d0ecfade..158689bb 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -22,6 +22,9 @@ const DocsRegistry = @import("docs_registry.zig");
const DocBridge = @import("doc_bridge.zig");
const DocLifecycle = @import("doc_lifecycle.zig");
const InfobarStatus = @import("infobar_status.zig");
+const GridLayout = @import("dialogs/GridLayout.zig");
+const FlatRasterSaveWarning = @import("dialogs/FlatRasterSaveWarning.zig");
+const NewFile = @import("dialogs/NewFile.zig");
const DocHandle = sdk.DocHandle;
const Internal = pixelart.internal;
@@ -78,7 +81,9 @@ const vtable: sdk.Plugin.VTable = .{
.documentDefaultSaveAsFilename = documentDefaultSaveAsFilename,
.saveDocumentAs = saveDocumentAs,
.resetDocumentSaveUIState = resetDocumentSaveUIState,
- .prepareGridLayoutDialog = prepareGridLayoutDialog,
+ .requestNewDocumentDialog = requestNewDocumentDialog,
+ .requestGridLayoutDialog = requestGridLayoutDialog,
+ .requestFlatRasterSaveWarning = requestFlatRasterSaveWarning,
.drawDocument = drawDocument,
.drawDocumentInfobar = drawDocumentInfobar,
.beginFrame = beginFrame,
@@ -537,9 +542,16 @@ fn resetDocumentSaveUIState(state: *anyopaque, doc: DocHandle) void {
DocLifecycle.resetDocumentSaveUIState(st, doc);
}
-fn prepareGridLayoutDialog(state: *anyopaque, doc: DocHandle) void {
- const st: *State = @ptrCast(@alignCast(state));
- DocLifecycle.prepareGridLayoutDialog(st, doc);
+fn requestNewDocumentDialog(_: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void {
+ NewFile.request(parent_path, id_extra);
+}
+
+fn requestGridLayoutDialog(_: *anyopaque, doc: DocHandle) void {
+ GridLayout.request(doc.id);
+}
+
+fn requestFlatRasterSaveWarning(_: *anyopaque, doc: DocHandle, mode: sdk.Plugin.FlatRasterSaveMode, from_save_all_quit: bool) void {
+ FlatRasterSaveWarning.request(doc.id, mode, from_save_all_quit);
}
fn beginFrame(state: *anyopaque) void {
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index b670a99c..117c9e30 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -288,20 +288,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u
if ((dvui.menuItemLabel(@src(), "New File...", .{}, .{ .expand = .horizontal })) != null) {
defer fw2.close();
- const parent_owned = try dvui.currentWindow().arena().dupe(u8, project_path);
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = fizzy.Editor.Dialogs.NewFile.dialog,
- .callafterFn = fizzy.Editor.Dialogs.NewFile.callAfter,
- .title = "New File...",
- .ok_label = "Create",
- .cancel_label = "Cancel",
- .resizeable = false,
- .header_kind = .info,
- .default = .ok,
- .id_extra = root_branch_id.asUsize(),
- });
- dvui.dataSetSlice(null, mutex.id, "_parent_path", parent_owned);
- mutex.mutex.unlock(dvui.io);
+ fizzy.editor.host.requestNewDocument(project_path, root_branch_id.asUsize());
}
if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) {
@@ -696,22 +683,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
defer fw2.close();
const parent_dir: []const u8 = if (entry.kind == .directory) abs_path else directory;
- const parent_owned = try dvui.currentWindow().arena().dupe(u8, parent_dir);
- // Create a generic dialog that contains typical okay and cancel buttons and header
- // The displayFn will be called during the drawing of the dialog, prior to ok and cancel buttons
- var mutex = fizzy.dvui.dialog(@src(), .{
- .displayFn = fizzy.Editor.Dialogs.NewFile.dialog,
- .callafterFn = fizzy.Editor.Dialogs.NewFile.callAfter,
- .title = "New File...",
- .ok_label = "Create",
- .cancel_label = "Cancel",
- .resizeable = false,
- .header_kind = .info,
- .default = .ok,
- .id_extra = branch_id.asUsize(),
- });
- dvui.dataSetSlice(null, mutex.id, "_parent_path", parent_owned);
- mutex.mutex.unlock(dvui.io);
+ fizzy.editor.host.requestNewDocument(parent_dir, branch_id.asUsize());
}
if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) {
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index 882589bb..83ed48f5 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -109,7 +109,6 @@ pub const VTable = struct {
transform: *const fn (ctx: *anyopaque) anyerror!void,
save: *const fn (ctx: *anyopaque) anyerror!void,
requestCompositeWarmup: *const fn (ctx: *anyopaque) void,
- requestGridLayoutDialog: *const fn (ctx: *anyopaque) void,
// ---- new document ----
/// Heap-owned unique basename like `untitled-1`; caller frees with the app allocator.
@@ -244,10 +243,6 @@ pub fn requestCompositeWarmup(self: EditorAPI) void {
self.vtable.requestCompositeWarmup(self.ctx);
}
-pub fn requestGridLayoutDialog(self: EditorAPI) void {
- self.vtable.requestGridLayoutDialog(self.ctx);
-}
-
pub fn allocUntitledPath(self: EditorAPI) ![]u8 {
return self.vtable.allocUntitledPath(self.ctx);
}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 392278e5..fb43b5f6 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -212,9 +212,6 @@ pub fn requestCompositeWarmup(self: *Host) void {
if (self.shell_api) |a| a.requestCompositeWarmup();
}
-pub fn requestGridLayoutDialog(self: *Host) void {
- if (self.shell_api) |a| a.requestGridLayoutDialog();
-}
pub fn allocUntitledPath(self: *Host) ![]u8 {
return if (self.shell_api) |a| try a.allocUntitledPath() else error.ShellNotInstalled;
@@ -398,3 +395,17 @@ pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin {
}
return best;
}
+
+/// Open a "new document" dialog. `parent_path` (when set) targets an on-disk folder; `id_extra`
+/// disambiguates launches from distinct explorer rows. Dispatches to the first plugin that
+/// provides a new-document dialog.
+/// TODO(multi-plugin): with >1 editor plugin, present a typed "New > " chooser instead of
+/// picking the first provider.
+pub fn requestNewDocument(self: *Host, parent_path: ?[]const u8, id_extra: usize) void {
+ for (self.plugins.items) |plugin| {
+ if (plugin.vtable.requestNewDocumentDialog) |f| {
+ f(plugin.state, parent_path, id_extra);
+ return;
+ }
+ }
+}
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index 3f0e8de7..4e75836d 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -23,6 +23,11 @@ id: []const u8,
/// User-facing name shown in UI.
display_name: []const u8,
+/// Context for an owner's "save would flatten lossy data" confirmation
+/// (`requestFlatRasterSaveWarning`). `editor_save` is a plain in-place save; `save_and_close`
+/// is part of a close/quit flow and resumes the shell close walk once the save settles.
+pub const FlatRasterSaveMode = enum { editor_save, save_and_close };
+
pub const VTable = struct {
/// Tear down `state`. Called when the plugin is unregistered / app shuts down.
deinit: ?*const fn (state: *anyopaque) void = null,
@@ -86,15 +91,26 @@ pub const VTable = struct {
documentDefaultSaveAsFilename: ?*const fn (state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 = null,
saveDocumentAs: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void = null,
resetDocumentSaveUIState: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
- prepareGridLayoutDialog: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
+ /// Open the owner's "new document" dialog. Not doc-scoped — the host dispatches to a plugin
+ /// that provides one (see `Host.requestNewDocument`). `parent_path` (when set) creates the
+ /// document on disk in that folder; `id_extra` disambiguates per-explorer-row launches.
+ /// TODO(multi-plugin): with >1 editor plugin this becomes a typed "New > " chooser.
+ requestNewDocumentDialog: ?*const fn (state: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void = null,
+ /// Open the owner's grid-layout dialog for `doc` (pixel-art specific; the shell only
+ /// resolves the active doc and dispatches here so it never names the plugin's dialog).
+ requestGridLayoutDialog: ?*const fn (state: *anyopaque, doc: DocHandle) void = null,
+ /// Open the owner's "save would flatten lossy data" confirmation for `doc`. The shell calls
+ /// this when `shouldConfirmFlatRasterSave(doc)` is true; the dialog drives the save through
+ /// the shell save/close API. `from_save_all_quit` marks requests issued during the quit walk.
+ requestFlatRasterSaveWarning: ?*const fn (state: *anyopaque, doc: DocHandle, mode: FlatRasterSaveMode, from_save_all_quit: bool) void = null,
// ---- render hooks (the plugin draws its own dvui UI into the host window) ----
- /// Draw the plugin's explorer/sidebar pane (left region).
- drawExplorerPane: ?*const fn (state: *anyopaque) anyerror!void = null,
- /// Draw an open document (center/workspace region).
+ // Sidebar/explorer panes and bottom-panel tabs are NOT vtable hooks — plugins
+ // contribute them as named, owned views via `Host.registerSidebarView` /
+ // `Host.registerBottomView`, which the shell renders as tab strips when more than
+ // one is registered. Only per-document rendering routes through the vtable below.
+ /// Draw an open document (center/workspace region), dispatched via `DocHandle.owner`.
drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
- /// Draw the plugin's bottom panel content.
- drawBottomPanel: ?*const fn (state: *anyopaque) anyerror!void = null,
/// Draw active-document status into the shell infobar (dimensions, cursor, etc.).
drawDocumentInfobar: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null,
@@ -373,8 +389,16 @@ pub fn resetDocumentSaveUIState(self: Plugin, doc: DocHandle) void {
if (self.vtable.resetDocumentSaveUIState) |f| f(self.state, doc);
}
-pub fn prepareGridLayoutDialog(self: Plugin, doc: DocHandle) void {
- if (self.vtable.prepareGridLayoutDialog) |f| f(self.state, doc);
+pub fn requestFlatRasterSaveWarning(self: Plugin, doc: DocHandle, mode: FlatRasterSaveMode, from_save_all_quit: bool) void {
+ if (self.vtable.requestFlatRasterSaveWarning) |f| f(self.state, doc, mode, from_save_all_quit);
+}
+
+pub fn requestNewDocumentDialog(self: Plugin, parent_path: ?[]const u8, id_extra: usize) void {
+ if (self.vtable.requestNewDocumentDialog) |f| f(self.state, parent_path, id_extra);
+}
+
+pub fn requestGridLayoutDialog(self: Plugin, doc: DocHandle) void {
+ if (self.vtable.requestGridLayoutDialog) |f| f(self.state, doc);
}
pub fn beginFrame(self: Plugin) void {
From 5a067c353d94536d689cd5bceff845fac43811de Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 09:25:32 -0500
Subject: [PATCH 27/49] lift workbench
---
HANDOFF.md | 31 ++++++++++
src/App.zig | 7 +++
src/editor/Editor.zig | 6 ++
src/plugins/workbench/src/Globals.zig | 15 +++++
src/plugins/workbench/src/Workspace.zig | 78 ++++++++++++-------------
src/plugins/workbench/src/files.zig | 11 ++--
src/sdk/EditorAPI.zig | 7 +++
src/sdk/Host.zig | 4 ++
8 files changed, 113 insertions(+), 46 deletions(-)
create mode 100644 src/plugins/workbench/src/Globals.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 6da744d8..f5bc41de 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -355,6 +355,37 @@ Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.draw
---
+## Stage W — workbench lift (IN PROGRESS, user signed off 2026-06-19)
+
+Workbench is the last "half-shell" plugin: 225 `fizzy` refs (163 `fizzy.editor`) across
+`files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`, `plugin.zig`. Unlike pixelart
+it has **no state-injection yet** — `plugin.state = undefined`, draw hooks call
+`fizzy.editor.*` directly, and the `Workbench` struct instance lives on `Editor`. Tab order *is*
+the order of `Editor.open_files`, which workbench mutates in place (`std.mem.swap` on
+values/keys at `Workspace.zig:467+`) — that's the deep coupling.
+
+**Plan (mirrors pixelart Stage C–E), each stage builds all 3 configs green:**
+
+- **W1 — host-injection seam + doc-collection routing — DONE.** Added
+ `workbench/src/Globals.zig` (`host: *sdk.Host`, `gpa`), injected in `App.zig` (path import
+ until W5). Added `EditorAPI.swapDocs(a,b)` primitive (+ Host forwarder + shell impl) — the
+ only mutation of open-doc *order* plugins do; replaces workbench's in-place `std.mem.swap`
+ on `open_files`. Converted in `Workspace.zig` + `files.zig`: `open_files.count/.values().len`
+ → `Globals.host.openDocCount()`, `open_files.values()[i]`/`docAt` → `docByIndex`,
+ `open_files.getIndex` → `docIndex`, `setActiveFile` → `setActiveDocIndex`,
+ `fizzy.editor.host` → `Globals.host`. **Workbench `fizzy.editor` refs: 163 → 106.**
+- **W2 — workspace/grouping ownership.** Move `workspaces`, `open_workspace_grouping`,
+ grouping-id counters (`newGroupingID`/`currentGroupingID`), and file-tree tab drag-drop
+ state (`tab_drag_from_tree_path`/`file_tree_data_id`/`clearFileTreeTabDragDropState`, today
+ shared with shell `Explorer`/`Editor`) onto the `Workbench` struct; shell routes through it.
+- **W3 — remaining `fizzy.editor.*` (doc ops, folder/settings/recents/atlas) → EditorAPI/Host.**
+ Add missing EditorAPI surface as needed (`folder`, `setProjectFolder`, `openFilePath`, …).
+- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then
+ **W5 — `b.addModule("workbench")`** + `@import("workbench")`, drop the shell path imports
+ (`Editor.zig` re-exports of `Workspace`/`FileLoadJob`/`Workbench`) and the `fizzy` import.
+
+---
+
## Next big rock: sprite / atlas → `core` — DONE
End-state achieved. Verified this session:
diff --git a/src/App.zig b/src/App.zig
index ef0076a2..eb9c0eb5 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -9,6 +9,8 @@ const icon = assets.files.@"icon.png";
const fizzy = @import("fizzy.zig");
const pixelart = @import("pixelart");
+// Path import until workbench becomes a build module (Stage W5); see HANDOFF "Stage W".
+const WorkbenchGlobals = @import("plugins/workbench/src/Globals.zig");
const auto_update = @import("backend/auto_update.zig");
const update_notify = @import("backend/update_notify.zig");
const singleton = @import("backend/singleton.zig");
@@ -166,6 +168,11 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.editor = try allocator.create(Editor);
fizzy.editor.* = Editor.init(fizzy.app) catch unreachable;
+ // Workbench plugin runtime injection (Stage W): host + allocator, so workbench code
+ // reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals.
+ WorkbenchGlobals.gpa = allocator;
+ WorkbenchGlobals.host = &fizzy.editor.host;
+
// Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
// `state`. Owned on `Editor`; torn down in `AppDeinit`.
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 83108d85..12bed749 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -564,6 +564,7 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.docIndex = shellDocIndex,
.openDocCount = shellOpenDocCount,
.setActiveDocIndex = shellSetActiveDocIndex,
+ .swapDocs = shellSwapDocs,
.allocDocId = shellAllocDocId,
.accept = shellAccept,
.cancel = shellCancel,
@@ -659,6 +660,11 @@ fn shellOpenDocCount(ctx: *anyopaque) usize {
fn shellSetActiveDocIndex(ctx: *anyopaque, index: usize) void {
shellCtx(ctx).setActiveFile(index);
}
+fn shellSwapDocs(ctx: *anyopaque, a: usize, b: usize) void {
+ const editor = shellCtx(ctx);
+ std.mem.swap(sdk.DocHandle, &editor.open_files.values()[a], &editor.open_files.values()[b]);
+ std.mem.swap(u64, &editor.open_files.keys()[a], &editor.open_files.keys()[b]);
+}
fn shellAllocDocId(ctx: *anyopaque) u64 {
return shellCtx(ctx).newFileID();
}
diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig
new file mode 100644
index 00000000..1bf8393f
--- /dev/null
+++ b/src/plugins/workbench/src/Globals.zig
@@ -0,0 +1,15 @@
+//! Runtime injection points for the workbench plugin (Stage W).
+//!
+//! The shell sets these once during `App` startup so workbench code can reach the
+//! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`.
+//! Mirrors `plugins/pixelart/src/Globals.zig`.
+const std = @import("std");
+const workbench = @import("../workbench.zig");
+const sdk = workbench.sdk;
+
+pub var gpa: std.mem.Allocator = undefined;
+pub var host: *sdk.Host = undefined;
+
+pub fn allocator() std.mem.Allocator {
+ return gpa;
+}
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index c196f430..b74e5ee9 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -5,6 +5,7 @@ const dvui = @import("dvui");
const sdk = @import("sdk");
const pixelart = @import("pixelart");
const fizzy = @import("../../../fizzy.zig");
+const Globals = @import("Globals.zig");
const icons = @import("icons");
const App = fizzy.App;
@@ -88,7 +89,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
// A sidebar view may optionally take over this workspace pane's content region (e.g. pixel
// art's "Project" view renders the packed atlas here instead of document tabs+canvas). The
// workbench owns only the pane frame; it hands the active view the opaque workspace handle.
- const active = fizzy.editor.host.activeSidebarView();
+ const active = Globals.host.activeSidebarView();
if (active != null and active.?.draw_workspace != null) {
var pane_view: sdk.WorkbenchPaneView = .{
.grouping = self.grouping,
@@ -116,7 +117,7 @@ pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.B
}
fn drawTabs(self: *Workspace) void {
- if (fizzy.editor.open_files.values().len == 0) return;
+ if (Globals.host.openDocCount() == 0) return;
// Handle dragging of tabs between workspace reorderables (tab bars)
defer self.processTabsDrag();
@@ -152,7 +153,7 @@ fn drawTabs(self: *Workspace) void {
});
defer tabs_hbox.deinit();
- const files_len = fizzy.editor.open_files.count();
+ const files_len = Globals.host.openDocCount();
// Find the neighbouring tabs (within this workspace grouping) of the active tab.
var prev_same_group_index: ?usize = null;
@@ -161,7 +162,7 @@ fn drawTabs(self: *Workspace) void {
const active_in_this_group = blk: {
if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false;
if (self.open_file_index >= files_len) break :blk false;
- const active_doc = fizzy.editor.docAt(self.open_file_index) orelse break :blk false;
+ const active_doc = Globals.host.docByIndex(self.open_file_index) orelse break :blk false;
if (fizzy.editor.docGrouping(active_doc) != self.grouping) break :blk false;
break :blk true;
};
@@ -172,7 +173,7 @@ fn drawTabs(self: *Workspace) void {
var j: usize = active_index;
while (j > 0) {
j -= 1;
- const tab_doc = fizzy.editor.docAt(j) orelse continue;
+ const tab_doc = Globals.host.docByIndex(j) orelse continue;
if (fizzy.editor.docGrouping(tab_doc) == self.grouping) {
prev_same_group_index = j;
break;
@@ -181,7 +182,7 @@ fn drawTabs(self: *Workspace) void {
j = active_index + 1;
while (j < files_len) : (j += 1) {
- const tab_doc = fizzy.editor.docAt(j) orelse continue;
+ const tab_doc = Globals.host.docByIndex(j) orelse continue;
if (fizzy.editor.docGrouping(tab_doc) == self.grouping) {
next_same_group_index = j;
break;
@@ -190,7 +191,7 @@ fn drawTabs(self: *Workspace) void {
}
for (0..files_len) |i| {
- const doc = fizzy.editor.docAt(i) orelse continue;
+ const doc = Globals.host.docByIndex(i) orelse continue;
const is_fizzy_file = doc.owner.documentHasNativeExtension(doc);
if (fizzy.editor.docGrouping(doc) != self.grouping) continue;
@@ -427,7 +428,7 @@ fn drawTabs(self: *Workspace) void {
switch (e.evt) {
.mouse => |me| {
if (me.action == .press and me.button.pointer()) {
- fizzy.editor.setActiveFile(i);
+ Globals.host.setActiveDocIndex(i);
dvui.refresh(null, @src(), hbox.data().id);
e.handle(@src(), hbox.data());
@@ -452,7 +453,7 @@ fn drawTabs(self: *Workspace) void {
}
}
if (tabs.finalSlot()) {
- self.tabs_insert_before_index = fizzy.editor.open_files.values().len;
+ self.tabs_insert_before_index = Globals.host.openDocCount();
}
}
}
@@ -462,20 +463,17 @@ pub fn processTabsDrag(self: *Workspace) void {
if (self.tabs_insert_before_index) |insert_before| {
if (self.tabs_removed_index) |removed| { // Dragging from this workspace
- if (removed > fizzy.editor.open_files.count()) return;
+ if (removed > Globals.host.openDocCount()) return;
if (removed > insert_before) {
- std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
- std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.setActiveFile(insert_before);
+ Globals.host.swapDocs(removed, insert_before);
+ Globals.host.setActiveDocIndex(insert_before);
} else {
if (insert_before > 0) {
- std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
- std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]);
- fizzy.editor.setActiveFile(insert_before - 1);
+ Globals.host.swapDocs(removed, insert_before - 1);
+ Globals.host.setActiveDocIndex(insert_before - 1);
} else {
- std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
- std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.setActiveFile(insert_before);
+ Globals.host.swapDocs(removed, insert_before);
+ Globals.host.setActiveDocIndex(insert_before);
}
}
@@ -485,22 +483,18 @@ pub fn processTabsDrag(self: *Workspace) void {
for (fizzy.editor.workspaces.values()) |*workspace| {
if (workspace.tabs_removed_index) |removed| {
if (removed > insert_before) {
- std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
- std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
-
- fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping);
- fizzy.editor.setActiveFile(insert_before);
+ Globals.host.swapDocs(removed, insert_before);
+ fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping);
+ Globals.host.setActiveDocIndex(insert_before);
} else {
if (insert_before > 0) {
- std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]);
- std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]);
- fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before - 1).?, self.grouping);
- fizzy.editor.setActiveFile(insert_before - 1);
+ Globals.host.swapDocs(removed, insert_before - 1);
+ fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before - 1).?, self.grouping);
+ Globals.host.setActiveDocIndex(insert_before - 1);
} else {
- std.mem.swap(fizzy.sdk.DocHandle, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]);
- std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]);
- fizzy.editor.setDocGrouping(fizzy.editor.docAt(insert_before).?, self.grouping);
- fizzy.editor.setActiveFile(insert_before);
+ Globals.host.swapDocs(removed, insert_before);
+ fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping);
+ Globals.host.setActiveDocIndex(insert_before);
}
}
@@ -604,7 +598,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
const new_g = fizzy.editor.newGroupingID();
fizzy.editor.setDocGrouping(dragged_doc, new_g);
fizzy.editor.open_workspace_grouping = new_g;
@@ -624,10 +618,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
- const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
fizzy.editor.setDocGrouping(dragged_doc, self.grouping);
fizzy.editor.open_workspace_grouping = self.grouping;
- self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0;
+ self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0;
}
}
},
@@ -650,7 +644,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
const new_g = fizzy.editor.newGroupingID();
fizzy.editor.setDocGrouping(dragged_doc, new_g);
fizzy.editor.open_workspace_grouping = new_g;
@@ -669,10 +663,10 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
fizzy.editor.clearFileTreeTabDragDropState();
repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
- const dragged_doc = fizzy.editor.docAt(drag_index) orelse continue;
+ const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
fizzy.editor.setDocGrouping(dragged_doc, self.grouping);
fizzy.editor.open_workspace_grouping = self.grouping;
- self.open_file_index = fizzy.editor.open_files.getIndex(dragged_doc.id) orelse 0;
+ self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0;
}
}
},
@@ -753,7 +747,7 @@ pub fn drawCanvas(self: *Workspace) !void {
else => {},
}
- const has_files = fizzy.editor.open_files.values().len > 0;
+ const has_files = Globals.host.openDocCount() > 0;
var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, self.grouping);
defer {
@@ -764,11 +758,11 @@ pub fn drawCanvas(self: *Workspace) !void {
defer self.processTabDrag(canvas_vbox.data());
if (has_files) {
- if (self.open_file_index >= fizzy.editor.open_files.values().len) {
- self.open_file_index = fizzy.editor.open_files.values().len - 1;
+ if (self.open_file_index >= Globals.host.openDocCount()) {
+ self.open_file_index = Globals.host.openDocCount() - 1;
}
- if (fizzy.editor.docAt(self.open_file_index)) |doc| {
+ if (Globals.host.docByIndex(self.open_file_index)) |doc| {
fizzy.editor.bindDocToPane(doc, canvas_vbox.data().id, self, self.center);
_ = try doc.owner.drawDocument(doc);
}
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index 117c9e30..50bbf690 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const fizzy = @import("../../../fizzy.zig");
+const Globals = @import("Globals.zig");
const pixelart = @import("pixelart");
const dvui = @import("dvui");
const Editor = fizzy.Editor;
@@ -288,7 +289,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u
if ((dvui.menuItemLabel(@src(), "New File...", .{}, .{ .expand = .horizontal })) != null) {
defer fw2.close();
- fizzy.editor.host.requestNewDocument(project_path, root_branch_id.asUsize());
+ Globals.host.requestNewDocument(project_path, root_branch_id.asUsize());
}
if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) {
@@ -654,7 +655,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
var have_grouping = false;
for (to_open) |p| {
if (!have_grouping) {
- side_grouping = if (fizzy.editor.open_files.count() == 0)
+ side_grouping = if (Globals.host.openDocCount() == 0)
fizzy.editor.currentGroupingID()
else
fizzy.editor.newGroupingID();
@@ -683,7 +684,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
defer fw2.close();
const parent_dir: []const u8 = if (entry.kind == .directory) abs_path else directory;
- fizzy.editor.host.requestNewDocument(parent_dir, branch_id.asUsize());
+ Globals.host.requestNewDocument(parent_dir, branch_id.asUsize());
}
if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) {
@@ -1224,7 +1225,9 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File
.directory => {
std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
- for (fizzy.editor.open_files.values()) |doc| {
+ var di: usize = 0;
+ while (di < Globals.host.openDocCount()) : (di += 1) {
+ const doc = Globals.host.docByIndex(di) orelse continue;
const path = fizzy.editor.docPath(doc);
if (std.mem.containsAtLeast(u8, path, 1, full_path)) {
const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path";
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index 83ed48f5..7c047b2b 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -98,6 +98,9 @@ pub const VTable = struct {
openDocCount: *const fn (ctx: *anyopaque) usize,
/// Focus the document at `index` (updates workspace tab selection).
setActiveDocIndex: *const fn (ctx: *anyopaque, index: usize) void,
+ /// Swap the open documents at indices `a` and `b` (used by tab drag-reorder). The shell
+ /// owns the open-document collection; this is the only mutation of its order plugins do.
+ swapDocs: *const fn (ctx: *anyopaque, a: usize, b: usize) void,
/// Allocate the next shell document id (monotonic).
allocDocId: *const fn (ctx: *anyopaque) u64,
@@ -211,6 +214,10 @@ pub fn setActiveDocIndex(self: EditorAPI, index: usize) void {
self.vtable.setActiveDocIndex(self.ctx, index);
}
+pub fn swapDocs(self: EditorAPI, a: usize, b: usize) void {
+ self.vtable.swapDocs(self.ctx, a, b);
+}
+
pub fn allocDocId(self: EditorAPI) u64 {
return self.vtable.allocDocId(self.ctx);
}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index fb43b5f6..94b79ab9 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -180,6 +180,10 @@ pub fn setActiveDocIndex(self: *Host, index: usize) void {
if (self.shell_api) |a| a.setActiveDocIndex(index);
}
+pub fn swapDocs(self: *Host, a_index: usize, b_index: usize) void {
+ if (self.shell_api) |a| a.swapDocs(a_index, b_index);
+}
+
pub fn allocDocId(self: *Host) u64 {
return if (self.shell_api) |a| a.allocDocId() else 0;
}
From e28095324b26e075153c7753a13d5c20ca94eb14 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 10:18:43 -0500
Subject: [PATCH 28/49] Stage W1-2
---
HANDOFF.md | 11 +-
src/App.zig | 1 +
src/backend/singleton_native.zig | 2 +-
src/editor/Editor.zig | 197 ++----------------
src/editor/Keybinds.zig | 2 +-
src/editor/WebFileIo.zig | 2 +-
src/editor/explorer/Explorer.zig | 7 +-
src/plugins/pixelart/src/plugin.zig | 6 +
src/plugins/workbench/src/Globals.zig | 6 +-
src/plugins/workbench/src/Workbench.zig | 70 ++++++-
src/plugins/workbench/src/Workspace.zig | 137 ++++++------
src/plugins/workbench/src/files.zig | 18 +-
.../workbench/src/workbench_layout.zig | 138 ++++++++++++
src/sdk/Plugin.zig | 5 +
14 files changed, 341 insertions(+), 261 deletions(-)
create mode 100644 src/plugins/workbench/src/workbench_layout.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index f5bc41de..b5696d3b 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -374,10 +374,13 @@ values/keys at `Workspace.zig:467+`) — that's the deep coupling.
→ `Globals.host.openDocCount()`, `open_files.values()[i]`/`docAt` → `docByIndex`,
`open_files.getIndex` → `docIndex`, `setActiveFile` → `setActiveDocIndex`,
`fizzy.editor.host` → `Globals.host`. **Workbench `fizzy.editor` refs: 163 → 106.**
-- **W2 — workspace/grouping ownership.** Move `workspaces`, `open_workspace_grouping`,
- grouping-id counters (`newGroupingID`/`currentGroupingID`), and file-tree tab drag-drop
- state (`tab_drag_from_tree_path`/`file_tree_data_id`/`clearFileTreeTabDragDropState`, today
- shared with shell `Explorer`/`Editor`) onto the `Workbench` struct; shell routes through it.
+- **W2 — workspace/grouping ownership — DONE.** Moved `workspaces`, `open_workspace_grouping`,
+ `grouping_id_counter`, `tab_drag_from_tree_path`, `file_tree_data_id` onto `Workbench`;
+ added `Globals.workbench`, `workbench_layout.zig` (`rebuildWorkspaces`/`drawWorkspaces`),
+ and `Plugin.removeCanvasPane` (pixelart implements; `Workspace.deinit` iterates host plugins).
+ Shell `Editor` delegates `activeDoc`/`setActiveFile`/`rebuildWorkspaces`/`drawWorkspaces`/
+ grouping helpers through `editor.workbench`. Workbench plugin code uses `Globals.workbench`
+ for workspace state; `setDocGrouping` → `doc.owner.setDocumentGrouping` in tab-drag paths.
- **W3 — remaining `fizzy.editor.*` (doc ops, folder/settings/recents/atlas) → EditorAPI/Host.**
Add missing EditorAPI surface as needed (`folder`, `setProjectFolder`, `openFilePath`, …).
- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then
diff --git a/src/App.zig b/src/App.zig
index eb9c0eb5..a2265166 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -172,6 +172,7 @@ pub fn AppInit(win: *dvui.Window) !void {
// reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals.
WorkbenchGlobals.gpa = allocator;
WorkbenchGlobals.host = &fizzy.editor.host;
+ WorkbenchGlobals.workbench = &fizzy.editor.workbench;
// Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
diff --git a/src/backend/singleton_native.zig b/src/backend/singleton_native.zig
index dd453999..749cd16e 100644
--- a/src/backend/singleton_native.zig
+++ b/src/backend/singleton_native.zig
@@ -197,7 +197,7 @@ fn dispatchPath(path: []const u8) !void {
return err;
};
file.close(io);
- _ = try fizzy.editor.openFilePath(path, fizzy.editor.open_workspace_grouping);
+ _ = try fizzy.editor.openFilePath(path, fizzy.editor.workbench.open_workspace_grouping);
}
/// Walk upward from `file_path`'s parent directory, returning the first
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 12bed749..60b6eaca 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -69,8 +69,7 @@ panel: *Panel,
last_titlebar_color: dvui.Color,
-/// Workspaces stored by their grouping ID
-workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty,
+/// Workspaces stored by their grouping ID (owned by `workbench`, Stage W2).
sidebar: Sidebar,
infobar: Infobar,
@@ -94,16 +93,6 @@ loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty,
/// loads only auto-focus the most recently requested one.
last_load_request_path: ?[]const u8 = null,
-// The actively focused workspace grouping ID
-// This will contain tabs for all open files with a matching grouping ID
-open_workspace_grouping: u64 = 0,
-
-/// Files tree cross-workspace drag (`tab_drag`): heap copy of absolute path. See `files.zig`.
-tab_drag_from_tree_path: ?[]u8 = null,
-/// `drawFiles` data id for `removed_path`; clear after drop on workspace canvas.
-file_tree_data_id: ?dvui.Id = null,
-
-grouping_id_counter: u64 = 0,
file_id_counter: u64 = 0,
window_opacity: f32 = 1.0,
@@ -430,11 +419,7 @@ pub fn init(
editor.explorer.* = .init();
editor.panel.* = .init();
editor.open_files = .empty;
- editor.workspaces = .empty;
- editor.workspaces.put(fizzy.app.allocator, 0, .init(0)) catch |err| {
- std.log.err("Failed to create workspace: {s}", .{@errorName(err)});
- return err;
- };
+ try editor.workbench.initDefaultWorkspace();
// Pixel-art tools/colors/palettes now init in `State.init` (App allocates
// `editor.pixelart_state` just after this `Editor.init` returns).
@@ -757,10 +742,7 @@ pub fn docById(editor: *Editor, id: u64) ?sdk.DocHandle {
}
pub fn activeDoc(editor: *Editor) ?sdk.DocHandle {
- if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| {
- return editor.docAt(workspace.open_file_index);
- }
- return null;
+ return editor.workbench.activeDoc();
}
/// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`).
@@ -894,12 +876,11 @@ pub fn applyHoldMenuDuration(editor: *Editor) void {
}
pub fn currentGroupingID(editor: *Editor) u64 {
- return editor.open_workspace_grouping;
+ return editor.workbench.currentGroupingID();
}
pub fn newGroupingID(editor: *Editor) u64 {
- editor.grouping_id_counter += 1;
- return editor.grouping_id_counter;
+ return editor.workbench.newGroupingID();
}
pub fn newFileID(editor: *Editor) u64 {
@@ -1452,7 +1433,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
} else {
// Explorer peek/collapse hides the workspace subtree, so `drawWorkspaces` does not
// run and `workspace.center` would otherwise stay latched from a prior panel animation.
- for (editor.workspaces.values()) |*ws| {
+ for (editor.workbench.workspaces.values()) |*ws| {
ws.center = false;
}
}
@@ -1561,7 +1542,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA
.filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" },
})) |files| {
for (files) |file| {
- _ = editor.openFilePath(file, editor.open_workspace_grouping) catch {
+ _ = editor.openFilePath(file, editor.workbench.open_workspace_grouping) catch {
std.log.err("Failed to open file: {s}", .{file});
};
}
@@ -1662,135 +1643,16 @@ pub fn setWindowStyle(_: *Editor) void {
}
pub fn rebuildWorkspaces(editor: *Editor) !void {
-
- // Create workspaces for each grouping ID
- for (editor.open_files.values()) |doc| {
- const grouping = editor.docGrouping(doc);
- if (!editor.workspaces.contains(grouping)) {
- var workspace: fizzy.Editor.Workspace = .init(grouping);
- for (editor.open_files.values()) |d| {
- if (editor.docGrouping(d) == grouping) {
- workspace.open_file_index = editor.open_files.getIndex(d.id) orelse 0;
- }
- }
-
- editor.workspaces.put(fizzy.app.allocator, grouping, workspace) catch |err| {
- std.log.err("Failed to create workspace: {s}", .{@errorName(err)});
- return err;
- };
- }
- }
-
- // Remove workspaces that are no longer needed
- for (editor.workspaces.values()) |*workspace| {
- if (editor.workspaces.count() == 1) {
- break;
- }
-
- var contains: bool = false;
- for (editor.open_files.values()) |doc| {
- if (editor.docGrouping(doc) == workspace.grouping) {
- contains = true;
- break;
- }
- }
-
- if (!contains) {
- if (editor.open_workspace_grouping == workspace.grouping) {
- for (editor.workspaces.values()) |*w| {
- if (w.grouping != workspace.grouping) {
- editor.open_workspace_grouping = w.grouping;
- break;
- }
- }
- }
-
- workspace.deinit();
- _ = editor.workspaces.orderedRemove(workspace.grouping);
- break;
- }
- }
-
- // Ensure the selected file for each workspace is still valid
- for (editor.workspaces.values()) |*workspace| {
- if (editor.docAt(workspace.open_file_index)) |doc| {
- if (editor.docGrouping(doc) == workspace.grouping) {
- continue;
- }
- }
-
- var i: usize = editor.open_files.count();
- while (i > 0) {
- i -= 1;
- if (editor.docAt(i)) |d| {
- if (editor.docGrouping(d) == workspace.grouping) {
- workspace.open_file_index = i;
- break;
- }
- }
- }
- }
+ try editor.workbench.rebuildWorkspaces();
}
pub fn drawWorkspaces(editor: *Editor, index: usize) !dvui.App.Result {
- if (index >= editor.workspaces.count()) return .ok;
-
- var s = fizzy.dvui.paned(@src(), .{
- .direction = .horizontal,
- .collapsed_size = if (index == editor.workspaces.count() - 1) std.math.floatMax(f32) else 0,
- .handle_size = handle_size,
- .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist },
- }, .{
- .expand = .both,
- .background = false,
- });
- defer s.deinit();
-
- const dragging = editor.panel.paned.dragging or s.dragging;
-
- if (!dragging) {
- const should_center = (s.animating and s.split_ratio.* < 1.0) or
- (editor.panel.paned.animating and editor.panel.paned.split_ratio.* < 1.0);
- if (index + 1 < editor.workspaces.count()) {
- editor.workspaces.values()[index + 1].center = should_center;
- } else if (editor.workspaces.count() == 1) {
- editor.workspaces.values()[index].center = should_center;
- }
- }
-
- // Ens
- if (s.collapsing and s.split_ratio.* < 0.5) {
- s.animateSplit(1.0, dvui.easing.outBack);
- }
-
- if (!s.dragging and !s.animating and !s.collapsing and !s.collapsed_state) {
- if (index == editor.workspaces.count() - 1) {
- if (s.split_ratio.* != 1.0) {
- s.animateSplit(1.0, dvui.easing.outBack);
- }
- } else {
- if (dvui.firstFrame(s.wd.id)) {
- s.split_ratio.* = 1.0;
- s.animateSplit(0.5, dvui.easing.outBack);
- }
- }
- }
-
- if (s.showFirst()) {
- const result = try editor.workspaces.values()[index].draw();
- if (result != .ok) {
- return result;
- }
- }
-
- if (s.showSecond()) {
- const result = try drawWorkspaces(editor, index + 1);
- if (result != .ok) {
- return result;
- }
- }
-
- return .ok;
+ const panel = editor.panel.paned;
+ return editor.workbench.drawWorkspaces(.{
+ .dragging = panel.dragging,
+ .animating = panel.animating,
+ .split_ratio = panel.split_ratio,
+ }, index);
}
pub fn abortSaveAllQuit(editor: *Editor) void {
@@ -1976,11 +1838,8 @@ pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u6
/// After a workspace drop from the Files tree or when `tab_drag` ends; frees path and clears tree reorder stash.
pub fn clearFileTreeTabDragDropState(editor: *Editor) void {
- if (editor.tab_drag_from_tree_path) |p| {
- fizzy.app.allocator.free(p);
- editor.tab_drag_from_tree_path = null;
- }
- if (editor.file_tree_data_id) |id| {
+ editor.workbench.clearFileTreeTabDragDropState();
+ if (editor.workbench.file_tree_data_id) |id| {
dvui.dataRemove(null, id, "removed_path");
}
// `file_tree_data_id` is reassigned each `drawFiles` frame; do not clear the id here so
@@ -2147,8 +2006,7 @@ pub fn processPackJob(editor: *Editor) void {
}
pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical {
- const workspace = editor.workspaces.getPtr(editor.open_workspace_grouping) orelse return null;
- return workspace.canvas_rect_physical;
+ return editor.workbench.activeWorkspaceCanvasRectPhysical();
}
/// Cancel every in-flight load. Workers exit at the next cancellation checkpoint (after
@@ -2389,13 +2247,7 @@ pub fn requestNewFileDialog(editor: *Editor) void {
}
pub fn setActiveFile(editor: *Editor, index: usize) void {
- const doc = editor.docAt(index) orelse return;
- const grouping = editor.docGrouping(doc);
-
- if (editor.workspaces.getPtr(grouping)) |workspace| {
- editor.open_workspace_grouping = grouping;
- workspace.open_file_index = index;
- }
+ editor.workbench.setActiveDocIndex(index);
}
pub fn forceCloseFile(editor: *Editor, index: usize) !void {
@@ -2639,7 +2491,7 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void {
const doc = editor.docAt(index) orelse return;
const grouping = editor.docGrouping(doc);
- if (editor.workspaces.getPtr(grouping)) |workspace| {
+ if (editor.workbench.workspaces.getPtr(grouping)) |workspace| {
if (workspace.open_file_index == index) {
for (editor.open_files.values(), 0..) |d, i| {
if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) {
@@ -2658,7 +2510,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
const doc = editor.open_files.get(id) orelse return;
const grouping = editor.docGrouping(doc);
- if (editor.workspaces.getPtr(grouping)) |workspace| {
+ if (editor.workbench.workspaces.getPtr(grouping)) |workspace| {
if (workspace.open_file_index == editor.open_files.getIndex(doc.id)) {
for (editor.open_files.values(), 0..) |d, i| {
if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) {
@@ -2695,10 +2547,7 @@ pub fn deinit(editor: *Editor) !void {
editor.loading_jobs.deinit(fizzy.app.allocator);
}
- if (editor.tab_drag_from_tree_path) |p| {
- fizzy.app.allocator.free(p);
- editor.tab_drag_from_tree_path = null;
- }
+ editor.workbench.clearFileTreeTabDragDropState();
if (editor.pending_save_as_path) |p| {
fizzy.app.allocator.free(p);
@@ -2726,9 +2575,7 @@ pub fn deinit(editor: *Editor) !void {
editor.explorer.deinit();
- for (editor.workspaces.values()) |*workspace| workspace.deinit();
- editor.workspaces.deinit(fizzy.app.allocator);
-
+ editor.workbench.deinitWorkspaces();
editor.host.deinit();
editor.workbench.deinit();
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index f8a41f9c..64824f99 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -63,7 +63,7 @@ pub fn tick() !void {
.{ .title = "Open Files...", .filter_description = ".fiz, .pixi, .png, .jpg, .jpeg", .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" } },
)) |files| {
for (files) |file| {
- _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch {
+ _ = fizzy.editor.openFilePath(file, fizzy.editor.workbench.open_workspace_grouping) catch {
std.log.err("Failed to open file: {s}", .{file});
};
}
diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig
index a7d7992e..eaac597c 100644
--- a/src/editor/WebFileIo.zig
+++ b/src/editor/WebFileIo.zig
@@ -46,7 +46,7 @@ pub fn showOpenFileDialog(
) void {
if (comptime builtin.target.cpu.arch != .wasm32) return;
open_callback = cb;
- open_grouping = fizzy.editor.open_workspace_grouping;
+ open_grouping = fizzy.editor.workbench.open_workspace_grouping;
open_picker_id = dvui.Id.extendId(null, @src(), 0);
dvui.dialogWasmFileOpenMultiple(open_picker_id.?, .{ .accept = open_accept });
}
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 7469271e..93359cb2 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -108,11 +108,8 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
});
if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/src/plugin.zig").view_files)) {
- fizzy.editor.file_tree_data_id = null;
- if (fizzy.editor.tab_drag_from_tree_path) |p| {
- fizzy.app.allocator.free(p);
- fizzy.editor.tab_drag_from_tree_path = null;
- }
+ fizzy.editor.workbench.file_tree_data_id = null;
+ fizzy.editor.workbench.clearFileTreeTabDragDropState();
}
if (fizzy.editor.host.activeSidebarView()) |view| {
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index 158689bb..196e4d98 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -69,6 +69,7 @@ const vtable: sdk.Plugin.VTable = .{
.bindDocumentToPane = bindDocumentToPane,
.documentGrouping = documentGrouping,
.setDocumentGrouping = setDocumentGrouping,
+ .removeCanvasPane = removeCanvasPane,
.documentPath = documentPath,
.setDocumentPath = setDocumentPath,
.documentHasNativeExtension = documentHasNativeExtension,
@@ -442,6 +443,11 @@ fn setDocumentGrouping(state: *anyopaque, doc: DocHandle, grouping: u64) void {
DocBridge.setDocumentGrouping(st, doc, grouping);
}
+fn removeCanvasPane(state: *anyopaque, grouping: u64, allocator: std.mem.Allocator) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ State.removeCanvasPane(st, allocator, grouping);
+}
+
fn documentPath(state: *anyopaque, doc: DocHandle) []const u8 {
const st: *State = @ptrCast(@alignCast(state));
return DocBridge.documentPath(st, doc);
diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig
index 1bf8393f..8ec402f9 100644
--- a/src/plugins/workbench/src/Globals.zig
+++ b/src/plugins/workbench/src/Globals.zig
@@ -4,11 +4,13 @@
//! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`.
//! Mirrors `plugins/pixelart/src/Globals.zig`.
const std = @import("std");
-const workbench = @import("../workbench.zig");
-const sdk = workbench.sdk;
+const wb_mod = @import("../workbench.zig");
+const sdk = wb_mod.sdk;
+const Workbench = @import("Workbench.zig");
pub var gpa: std.mem.Allocator = undefined;
pub var host: *sdk.Host = undefined;
+pub var workbench: *Workbench = undefined;
pub fn allocator() std.mem.Allocator {
return gpa;
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
index f535b9b5..fdceacee 100644
--- a/src/plugins/workbench/src/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -13,6 +13,10 @@ const dvui = @import("dvui");
const icons = @import("icons");
const fizzy = @import("../../../fizzy.zig");
const files = @import("files.zig");
+const Workspace = @import("Workspace.zig");
+const Globals = @import("Globals.zig");
+const workbench_layout = @import("workbench_layout.zig");
+const sdk = @import("sdk");
pub const Workbench = @This();
@@ -27,6 +31,13 @@ pub const BranchDecorator = struct {
allocator: std.mem.Allocator,
decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty,
+/// Workspaces keyed by tab-grouping id (Stage W2: owned here, not on the shell Editor).
+workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty,
+open_workspace_grouping: u64 = 0,
+grouping_id_counter: u64 = 0,
+tab_drag_from_tree_path: ?[]u8 = null,
+file_tree_data_id: ?dvui.Id = null,
+
/// The `workbench-api` service instance handed to plugins. Its `ctx` must be the
/// editor's FINAL heap address, so it's filled in by `initService` from
/// `Editor.postInit` (after `Editor.init`'s by-value result is copied to the heap),
@@ -41,6 +52,61 @@ pub fn deinit(self: *Workbench) void {
self.decorators.deinit(self.allocator);
}
+pub fn initDefaultWorkspace(self: *Workbench) !void {
+ self.workspaces = .empty;
+ try self.workspaces.put(self.allocator, 0, Workspace.init(0));
+}
+
+pub fn deinitWorkspaces(self: *Workbench) void {
+ for (self.workspaces.values()) |*workspace| workspace.deinit();
+ self.workspaces.deinit(self.allocator);
+}
+
+pub fn currentGroupingID(self: *Workbench) u64 {
+ return self.open_workspace_grouping;
+}
+
+pub fn newGroupingID(self: *Workbench) u64 {
+ self.grouping_id_counter += 1;
+ return self.grouping_id_counter;
+}
+
+pub fn clearFileTreeTabDragDropState(self: *Workbench) void {
+ if (self.tab_drag_from_tree_path) |p| {
+ self.allocator.free(p);
+ self.tab_drag_from_tree_path = null;
+ }
+}
+
+pub fn rebuildWorkspaces(self: *Workbench) !void {
+ return workbench_layout.rebuildWorkspaces(self);
+}
+
+pub fn drawWorkspaces(self: *Workbench, panel: workbench_layout.PanelPanedState, index: usize) !dvui.App.Result {
+ return workbench_layout.drawWorkspaces(self, panel, index);
+}
+
+pub fn activeDoc(self: *Workbench) ?sdk.DocHandle {
+ if (self.workspaces.get(self.open_workspace_grouping)) |workspace| {
+ return Globals.host.docByIndex(workspace.open_file_index);
+ }
+ return null;
+}
+
+pub fn setActiveDocIndex(self: *Workbench, index: usize) void {
+ const doc = Globals.host.docByIndex(index) orelse return;
+ const grouping = doc.owner.documentGrouping(doc);
+ if (self.workspaces.getPtr(grouping)) |workspace| {
+ self.open_workspace_grouping = grouping;
+ workspace.open_file_index = index;
+ }
+}
+
+pub fn activeWorkspaceCanvasRectPhysical(self: *Workbench) ?dvui.Rect.Physical {
+ const workspace = self.workspaces.getPtr(self.open_workspace_grouping) orelse return null;
+ return workspace.canvas_rect_physical;
+}
+
/// Build the `workbench-api` service. `editor_ctx` is the host's heap `*Editor`,
/// passed opaquely so the API has no compile-time dependency back on the editor.
pub fn initService(self: *Workbench, editor_ctx: *anyopaque) void {
@@ -201,10 +267,10 @@ fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool {
return editorOf(ctx).openFilePath(path, grouping);
}
fn svcCurrentGrouping(ctx: *anyopaque) u64 {
- return editorOf(ctx).currentGroupingID();
+ return editorOf(ctx).workbench.currentGroupingID();
}
fn svcNewGrouping(ctx: *anyopaque) u64 {
- return editorOf(ctx).newGroupingID();
+ return editorOf(ctx).workbench.newGroupingID();
}
fn svcClose(ctx: *anyopaque, id: u64) anyerror!void {
return editorOf(ctx).closeFileID(id);
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index b74e5ee9..ff4e9609 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -40,7 +40,9 @@ pub fn init(grouping: u64) Workspace {
/// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed
/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown.
pub fn deinit(self: *Workspace) void {
- pixelart.State.removeCanvasPane(pixelart.Globals.state, fizzy.app.allocator, self.grouping);
+ for (Globals.host.plugins.items) |plugin| {
+ plugin.removeCanvasPane(self.grouping, Globals.allocator());
+ }
}
const handle_size = 10;
@@ -81,7 +83,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result {
if (e.evt == .mouse) {
if (e.evt.mouse.action == .press or (e.evt.mouse.action == .position and e.evt.mouse.mod.matchBind("ctrl/cmd"))) {
- fizzy.editor.open_workspace_grouping = self.grouping;
+ Globals.workbench.open_workspace_grouping = self.grouping;
}
}
}
@@ -160,10 +162,10 @@ fn drawTabs(self: *Workspace) void {
var next_same_group_index: ?usize = null;
const active_in_this_group = blk: {
- if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false;
+ if (Globals.workbench.open_workspace_grouping != self.grouping) break :blk false;
if (self.open_file_index >= files_len) break :blk false;
const active_doc = Globals.host.docByIndex(self.open_file_index) orelse break :blk false;
- if (fizzy.editor.docGrouping(active_doc) != self.grouping) break :blk false;
+ if (active_doc.owner.documentGrouping(active_doc) != self.grouping) break :blk false;
break :blk true;
};
@@ -174,7 +176,7 @@ fn drawTabs(self: *Workspace) void {
while (j > 0) {
j -= 1;
const tab_doc = Globals.host.docByIndex(j) orelse continue;
- if (fizzy.editor.docGrouping(tab_doc) == self.grouping) {
+ if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) {
prev_same_group_index = j;
break;
}
@@ -183,7 +185,7 @@ fn drawTabs(self: *Workspace) void {
j = active_index + 1;
while (j < files_len) : (j += 1) {
const tab_doc = Globals.host.docByIndex(j) orelse continue;
- if (fizzy.editor.docGrouping(tab_doc) == self.grouping) {
+ if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) {
next_same_group_index = j;
break;
}
@@ -194,7 +196,7 @@ fn drawTabs(self: *Workspace) void {
const doc = Globals.host.docByIndex(i) orelse continue;
const is_fizzy_file = doc.owner.documentHasNativeExtension(doc);
- if (fizzy.editor.docGrouping(doc) != self.grouping) continue;
+ if (doc.owner.documentGrouping(doc) != self.grouping) continue;
var reorderable = tabs.reorderable(@src(), .{}, .{
.expand = .vertical,
@@ -204,7 +206,7 @@ fn drawTabs(self: *Workspace) void {
});
defer reorderable.deinit();
- const selected = self.open_file_index == i and fizzy.editor.open_workspace_grouping == self.grouping;
+ const selected = self.open_file_index == i and Globals.workbench.open_workspace_grouping == self.grouping;
var anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{});
defer anim.deinit();
@@ -480,20 +482,26 @@ pub fn processTabsDrag(self: *Workspace) void {
self.tabs_removed_index = null;
self.tabs_insert_before_index = null;
} else { // Dragging from another workspace
- for (fizzy.editor.workspaces.values()) |*workspace| {
+ for (Globals.workbench.workspaces.values()) |*workspace| {
if (workspace.tabs_removed_index) |removed| {
if (removed > insert_before) {
Globals.host.swapDocs(removed, insert_before);
- fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping);
+ if (Globals.host.docByIndex(insert_before)) |d| {
+ d.owner.setDocumentGrouping(d, self.grouping);
+ }
Globals.host.setActiveDocIndex(insert_before);
} else {
if (insert_before > 0) {
Globals.host.swapDocs(removed, insert_before - 1);
- fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before - 1).?, self.grouping);
+ if (Globals.host.docByIndex(insert_before - 1)) |d| {
+ d.owner.setDocumentGrouping(d, self.grouping);
+ }
Globals.host.setActiveDocIndex(insert_before - 1);
} else {
Globals.host.swapDocs(removed, insert_before);
- fizzy.editor.setDocGrouping(Globals.host.docByIndex(insert_before).?, self.grouping);
+ if (Globals.host.docByIndex(insert_before)) |d| {
+ d.owner.setDocumentGrouping(d, self.grouping);
+ }
Globals.host.setActiveDocIndex(insert_before);
}
}
@@ -510,23 +518,27 @@ pub fn processTabsDrag(self: *Workspace) void {
}
/// Repoint `open_file_index` on workspaces that were showing the dragged tab as active.
-fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void {
- const dragged_doc = editor.docAt(drag_index) orelse return;
+fn repointWorkspacesAfterTabDrag(tab_bar_workspace: ?*Workspace, drag_index: usize) void {
+ const dragged_doc = Globals.host.docByIndex(drag_index) orelse return;
if (tab_bar_workspace) |workspace| {
- if (workspace.open_file_index == editor.open_files.getIndex(dragged_doc.id)) {
- for (editor.open_files.values()) |doc| {
- if (editor.docGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) {
- workspace.open_file_index = editor.open_files.getIndex(doc.id) orelse 0;
+ if (workspace.open_file_index == Globals.host.docIndex(dragged_doc.id)) {
+ var i: usize = 0;
+ while (i < Globals.host.openDocCount()) : (i += 1) {
+ const doc = Globals.host.docByIndex(i).?;
+ if (doc.owner.documentGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) {
+ workspace.open_file_index = i;
break;
}
}
}
} else {
- for (editor.workspaces.values()) |*w| {
+ for (Globals.workbench.workspaces.values()) |*w| {
if (w.open_file_index == drag_index) {
- for (editor.open_files.values()) |doc| {
- if (editor.docGrouping(doc) == w.grouping and doc.id != dragged_doc.id) {
- w.open_file_index = editor.open_files.getIndex(doc.id) orelse 0;
+ var i: usize = 0;
+ while (i < Globals.host.openDocCount()) : (i += 1) {
+ const doc = Globals.host.docByIndex(i).?;
+ if (doc.owner.documentGrouping(doc) == w.grouping and doc.id != dragged_doc.id) {
+ w.open_file_index = i;
break;
}
}
@@ -541,14 +553,17 @@ const WorkspaceTabDragSrc = union(enum) {
tree_closed: []const u8,
none,
- fn resolve(editor: *Editor) WorkspaceTabDragSrc {
- for (editor.workspaces.values()) |*w| {
+ fn resolve() WorkspaceTabDragSrc {
+ for (Globals.workbench.workspaces.values()) |*w| {
if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } };
}
- if (editor.tab_drag_from_tree_path) |p| {
- if (editor.docFromPath(p)) |doc| {
- const idx = editor.open_files.getIndex(doc.id) orelse return .none;
- return .{ .tree_open = idx };
+ if (Globals.workbench.tab_drag_from_tree_path) |p| {
+ var i: usize = 0;
+ while (i < Globals.host.openDocCount()) : (i += 1) {
+ const doc = Globals.host.docByIndex(i).?;
+ if (doc.owner.documentByPath(p) != null) {
+ return .{ .tree_open = i };
+ }
}
return .{ .tree_closed = p };
}
@@ -560,11 +575,11 @@ const WorkspaceTabDragSrc = union(enum) {
/// Also handles the same `tab_drag` from the Files tree (see `files.zig` + DVUI reorder_tree cross-widget pattern).
pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
if (!dvui.dragName("tab_drag")) {
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
return;
}
- const drag_src = WorkspaceTabDragSrc.resolve(fizzy.editor);
+ const drag_src = WorkspaceTabDragSrc.resolve();
switch (drag_src) {
.none => return,
else => {},
@@ -583,7 +598,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
right_side.w /= 2;
right_side.x += right_side.w;
- if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) {
+ if (right_side.contains(e.evt.mouse.p) and Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{
.color = dvui.themeGet().color(.highlight, .fill).opacity(0.5),
@@ -595,13 +610,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
e.handle(@src(), data);
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
- repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
+ repointWorkspacesAfterTabDrag(workspace, drag_index);
const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
- const new_g = fizzy.editor.newGroupingID();
- fizzy.editor.setDocGrouping(dragged_doc, new_g);
- fizzy.editor.open_workspace_grouping = new_g;
+ const new_g = Globals.workbench.newGroupingID();
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g);
+ Globals.workbench.open_workspace_grouping = new_g;
}
} else if (data.rectScale().r.contains(e.evt.mouse.p)) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
@@ -615,12 +630,12 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
e.handle(@src(), data);
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
- repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index);
+ repointWorkspacesAfterTabDrag(workspace, drag_index);
const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
- fizzy.editor.setDocGrouping(dragged_doc, self.grouping);
- fizzy.editor.open_workspace_grouping = self.grouping;
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping);
+ Globals.workbench.open_workspace_grouping = self.grouping;
self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0;
}
}
@@ -630,7 +645,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
right_side.w /= 2;
right_side.x += right_side.w;
- if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) {
+ if (right_side.contains(e.evt.mouse.p) and Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{
.color = dvui.themeGet().color(.highlight, .fill).opacity(0.5),
@@ -641,13 +656,13 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
e.handle(@src(), data);
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
- repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
+ repointWorkspacesAfterTabDrag(null, drag_index);
const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
- const new_g = fizzy.editor.newGroupingID();
- fizzy.editor.setDocGrouping(dragged_doc, new_g);
- fizzy.editor.open_workspace_grouping = new_g;
+ const new_g = Globals.workbench.newGroupingID();
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g);
+ Globals.workbench.open_workspace_grouping = new_g;
}
} else if (data.rectScale().r.contains(e.evt.mouse.p)) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
@@ -660,12 +675,12 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
e.handle(@src(), data);
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
- repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index);
+ repointWorkspacesAfterTabDrag(null, drag_index);
const dragged_doc = Globals.host.docByIndex(drag_index) orelse continue;
- fizzy.editor.setDocGrouping(dragged_doc, self.grouping);
- fizzy.editor.open_workspace_grouping = self.grouping;
+ dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping);
+ Globals.workbench.open_workspace_grouping = self.grouping;
self.open_file_index = Globals.host.docIndex(dragged_doc.id) orelse 0;
}
}
@@ -675,7 +690,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
right_side.w /= 2;
right_side.x += right_side.w;
- if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) {
+ if (right_side.contains(e.evt.mouse.p) and Globals.workbench.workspaces.keys()[Globals.workbench.workspaces.keys().len - 1] == self.grouping) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{
.color = dvui.themeGet().color(.highlight, .fill).opacity(0.5),
@@ -686,23 +701,23 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
e.handle(@src(), data);
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
- const new_g = fizzy.editor.newGroupingID();
+ const new_g = Globals.workbench.newGroupingID();
const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, new_g) catch {
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
continue :events_loop;
};
if (maybe_idx) |idx| {
// File was already open and moved between groupings — repoint the
// workspaces that were showing it, and focus the new pane now.
- repointWorkspacesAfterTabDrag(fizzy.editor, null, idx);
- fizzy.editor.open_workspace_grouping = new_g;
+ repointWorkspacesAfterTabDrag(null, idx);
+ Globals.workbench.open_workspace_grouping = new_g;
}
// Else: async load — leave `open_workspace_grouping` alone. Switching
// to the not-yet-extant workspace would make `activeFile()` null and
// collapse the bottom panel mid-load; `processLoadingJobs` will focus
// the new pane once the worker lands the file, matching the
// "Open to the side" menu action.
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
}
} else if (data.rectScale().r.contains(e.evt.mouse.p)) {
if (e.evt == .mouse and e.evt.mouse.action == .position) {
@@ -716,17 +731,17 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, self.grouping) catch {
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
continue :events_loop;
};
if (maybe_idx) |idx| {
- repointWorkspacesAfterTabDrag(fizzy.editor, null, idx);
+ repointWorkspacesAfterTabDrag(null, idx);
self.open_file_index = idx;
}
// Else: async load into this workspace's existing grouping. The
// worker's `processLoadingJobs` focus handler will set the active
// file once it lands.
- fizzy.editor.clearFileTreeTabDragDropState();
+ Globals.workbench.clearFileTreeTabDragDropState();
}
}
},
@@ -936,7 +951,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
// .filters = &.{ "*.pixi", "*.png" },
// })) |files| {
// for (files) |file| {
- // _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch {
+ // _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch {
// std.log.err("Failed to open file: {s}", .{file});
// };
// }
@@ -1064,7 +1079,7 @@ pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void {
pub fn openFilesCallback(files: ?[][:0]const u8) void {
if (files) |f| {
for (f) |file| {
- _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch {
+ _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch {
dvui.log.err("Failed to open file: {s}", .{file});
};
}
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index 50bbf690..f626b8b8 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -83,7 +83,7 @@ pub fn draw() !void {
if (fizzy.editor.folder) |path| {
try drawFiles(path, tree);
} else {
- fizzy.editor.file_tree_data_id = null;
+ Globals.workbench.file_tree_data_id = null;
dvui.labelNoFmt(
@src(),
"Open a project folder to begin.",
@@ -143,7 +143,7 @@ fn drawWeb() !void {
pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void {
const unique_id = dvui.parentGet().extendId(@src(), 0);
- fizzy.editor.file_tree_data_id = unique_id;
+ Globals.workbench.file_tree_data_id = unique_id;
var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal });
dvui.icon(
@@ -580,13 +580,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
dvui.dataSetSlice(null, inner_unique_id, "removed_path", abs_path);
if (entry.kind == .file and tree.id_branch == inner_id_extra.*) {
- if (fizzy.editor.tab_drag_from_tree_path) |old| {
+ if (Globals.workbench.tab_drag_from_tree_path) |old| {
if (!std.mem.eql(u8, old, abs_path)) {
fizzy.app.allocator.free(old);
- fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
+ Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
}
} else {
- fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
+ Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
}
}
}
@@ -635,7 +635,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
break :blk &[_][]const u8{};
};
for (to_open) |p| {
- _ = fizzy.editor.openFilePath(p, fizzy.editor.currentGroupingID()) catch |e| {
+ _ = fizzy.editor.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| {
dvui.log.err("Failed to open file: {any} ({s})", .{ e, p });
};
}
@@ -656,9 +656,9 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
for (to_open) |p| {
if (!have_grouping) {
side_grouping = if (Globals.host.openDocCount() == 0)
- fizzy.editor.currentGroupingID()
+ Globals.workbench.currentGroupingID()
else
- fizzy.editor.newGroupingID();
+ Globals.workbench.newGroupingID();
have_grouping = true;
}
_ = fizzy.editor.openFilePath(p, side_grouping) catch {
@@ -810,7 +810,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
if (mode == .replace) {
switch (ext) {
.fizzy, .png, .jpg => {
- _ = fizzy.editor.openFilePath(abs_path, fizzy.editor.currentGroupingID()) catch |err| {
+ _ = fizzy.editor.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| {
dvui.log.err("{any}: {s}", .{ err, abs_path });
};
},
diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig
new file mode 100644
index 00000000..dd2b5ad6
--- /dev/null
+++ b/src/plugins/workbench/src/workbench_layout.zig
@@ -0,0 +1,138 @@
+//! Workspace map maintenance + recursive split drawing (Stage W2).
+const std = @import("std");
+const dvui = @import("dvui");
+const sdk = @import("sdk");
+const fizzy = @import("../../../fizzy.zig");
+const Globals = @import("Globals.zig");
+const Workbench = @import("Workbench.zig");
+const Workspace = @import("Workspace.zig");
+
+const handle_size = 10;
+const handle_dist = 60;
+
+pub fn rebuildWorkspaces(wb: *Workbench) !void {
+ const host = Globals.host;
+
+ var i: usize = 0;
+ while (i < host.openDocCount()) : (i += 1) {
+ const doc = host.docByIndex(i) orelse continue;
+ const grouping = doc.owner.documentGrouping(doc);
+ if (!wb.workspaces.contains(grouping)) {
+ var workspace: Workspace = .init(grouping);
+ var j: usize = 0;
+ while (j < host.openDocCount()) : (j += 1) {
+ const d = host.docByIndex(j) orelse continue;
+ if (d.owner.documentGrouping(d) == grouping) {
+ workspace.open_file_index = host.docIndex(d.id) orelse 0;
+ }
+ }
+ try wb.workspaces.put(Globals.allocator(), grouping, workspace);
+ }
+ }
+
+ for (wb.workspaces.values()) |*workspace| {
+ if (wb.workspaces.count() == 1) break;
+
+ var contains = false;
+ var k: usize = 0;
+ while (k < host.openDocCount()) : (k += 1) {
+ const doc = host.docByIndex(k) orelse continue;
+ if (doc.owner.documentGrouping(doc) == workspace.grouping) {
+ contains = true;
+ break;
+ }
+ }
+
+ if (!contains) {
+ if (wb.open_workspace_grouping == workspace.grouping) {
+ for (wb.workspaces.values()) |*w| {
+ if (w.grouping != workspace.grouping) {
+ wb.open_workspace_grouping = w.grouping;
+ break;
+ }
+ }
+ }
+ workspace.deinit();
+ _ = wb.workspaces.orderedRemove(workspace.grouping);
+ break;
+ }
+ }
+
+ for (wb.workspaces.values()) |*workspace| {
+ if (host.docByIndex(workspace.open_file_index)) |doc| {
+ if (doc.owner.documentGrouping(doc) == workspace.grouping) continue;
+ }
+ var idx: usize = host.openDocCount();
+ while (idx > 0) {
+ idx -= 1;
+ if (host.docByIndex(idx)) |d| {
+ if (d.owner.documentGrouping(d) == workspace.grouping) {
+ workspace.open_file_index = idx;
+ break;
+ }
+ }
+ }
+ }
+}
+
+pub const PanelPanedState = struct {
+ dragging: bool,
+ animating: bool,
+ split_ratio: *f32,
+};
+
+pub fn drawWorkspaces(wb: *Workbench, panel: PanelPanedState, index: usize) !dvui.App.Result {
+ if (index >= wb.workspaces.count()) return .ok;
+
+ var s = fizzy.dvui.paned(@src(), .{
+ .direction = .horizontal,
+ .collapsed_size = if (index == wb.workspaces.count() - 1) std.math.floatMax(f32) else 0,
+ .handle_size = handle_size,
+ .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist },
+ }, .{
+ .expand = .both,
+ .background = false,
+ });
+ defer s.deinit();
+
+ const dragging = panel.dragging or s.dragging;
+
+ if (!dragging) {
+ const should_center = (s.animating and s.split_ratio.* < 1.0) or
+ (panel.animating and panel.split_ratio.* < 1.0);
+ if (index + 1 < wb.workspaces.count()) {
+ wb.workspaces.values()[index + 1].center = should_center;
+ } else if (wb.workspaces.count() == 1) {
+ wb.workspaces.values()[index].center = should_center;
+ }
+ }
+
+ if (s.collapsing and s.split_ratio.* < 0.5) {
+ s.animateSplit(1.0, dvui.easing.outBack);
+ }
+
+ if (!s.dragging and !s.animating and !s.collapsing and !s.collapsed_state) {
+ if (index == wb.workspaces.count() - 1) {
+ if (s.split_ratio.* != 1.0) {
+ s.animateSplit(1.0, dvui.easing.outBack);
+ }
+ } else {
+ if (dvui.firstFrame(s.wd.id)) {
+ s.split_ratio.* = 1.0;
+ s.animateSplit(0.5, dvui.easing.outBack);
+ }
+ }
+ }
+
+ if (s.showFirst()) {
+ const result = try wb.workspaces.values()[index].draw();
+ if (result != .ok) return result;
+ }
+
+ if (s.showSecond()) {
+ const result = try drawWorkspaces(wb, panel, index + 1);
+ if (result != .ok) return result;
+ }
+
+ return .ok;
+}
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index 4e75836d..a2cf5eaf 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -78,6 +78,7 @@ pub const VTable = struct {
bindDocumentToPane: ?*const fn (state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void = null,
documentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle) u64 = null,
setDocumentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle, grouping: u64) void = null,
+ removeCanvasPane: ?*const fn (state: *anyopaque, grouping: u64, allocator: std.mem.Allocator) void = null,
documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null,
setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null,
documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null,
@@ -237,6 +238,10 @@ pub fn setDocumentGrouping(self: Plugin, doc: DocHandle, grouping: u64) void {
if (self.vtable.setDocumentGrouping) |f| f(self.state, doc, grouping);
}
+pub fn removeCanvasPane(self: Plugin, grouping: u64, allocator: std.mem.Allocator) void {
+ if (self.vtable.removeCanvasPane) |f| f(self.state, grouping, allocator);
+}
+
pub fn documentPath(self: Plugin, doc: DocHandle) []const u8 {
return if (self.vtable.documentPath) |f| f(self.state, doc) else "";
}
From 40d044a4535af21eaa88fff2a27122578d3d715b Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 10:27:33 -0500
Subject: [PATCH 29/49] stage w3
---
HANDOFF.md | 10 ++-
src/editor/Editor.zig | 78 ++++++++++++++++++++
src/plugins/workbench/src/Workbench.zig | 2 +-
src/plugins/workbench/src/Workspace.zig | 35 ++++-----
src/plugins/workbench/src/files.zig | 71 +++++++++---------
src/plugins/workbench/src/plugin.zig | 3 +-
src/sdk/EditorAPI.zig | 95 +++++++++++++++++++++++++
src/sdk/Host.zig | 62 ++++++++++++++++
8 files changed, 299 insertions(+), 57 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index b5696d3b..38646b80 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -381,8 +381,14 @@ values/keys at `Workspace.zig:467+`) — that's the deep coupling.
Shell `Editor` delegates `activeDoc`/`setActiveFile`/`rebuildWorkspaces`/`drawWorkspaces`/
grouping helpers through `editor.workbench`. Workbench plugin code uses `Globals.workbench`
for workspace state; `setDocGrouping` → `doc.owner.setDocumentGrouping` in tab-drag paths.
-- **W3 — remaining `fizzy.editor.*` (doc ops, folder/settings/recents/atlas) → EditorAPI/Host.**
- Add missing EditorAPI surface as needed (`folder`, `setProjectFolder`, `openFilePath`, …).
+- **W3 — remaining `fizzy.editor.*` → EditorAPI/Host — DONE.** Extended `EditorAPI`/`Host`
+ with doc/file ops (`docFromPath`, `openFilePath`, `openOrFocusFileAtGrouping`,
+ `closeDocById`), project folder (`setProjectFolder`, `closeProjectFolder`, `isPathIgnored`,
+ `recentFolderCount`/`recentFolderAt`, `openInFileBrowser`), explorer state
+ (`explorerViewportWidth`, `explorerBranchIsOpen`, `setExplorerBranchOpen`), and
+ `drawWorkspaces`. Workbench `files.zig`/`Workspace.zig`/`Workbench.zig`/`plugin.zig`
+ now route through `Globals.host` + `Globals.workbench`; zero runtime `fizzy.editor`
+ refs remain in workbench draw paths (comments only).
- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then
**W5 — `b.addModule("workbench")`** + `@import("workbench")`, drop the shell path imports
(`Editor.zig` re-exports of `Workspace`/`FileLoadJob`/`Workbench`) and the `fizzy` import.
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 60b6eaca..83891efe 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -551,6 +551,20 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.setActiveDocIndex = shellSetActiveDocIndex,
.swapDocs = shellSwapDocs,
.allocDocId = shellAllocDocId,
+ .explorerViewportWidth = shellExplorerViewportWidth,
+ .docFromPath = shellDocFromPath,
+ .openFilePath = shellOpenFilePath,
+ .openOrFocusFileAtGrouping = shellOpenOrFocusFileAtGrouping,
+ .closeDocById = shellCloseDocById,
+ .setProjectFolder = shellSetProjectFolder,
+ .closeProjectFolder = shellCloseProjectFolder,
+ .recentFolderCount = shellRecentFolderCount,
+ .recentFolderAt = shellRecentFolderAt,
+ .openInFileBrowser = shellOpenInFileBrowser,
+ .isPathIgnored = shellIsPathIgnored,
+ .explorerBranchIsOpen = shellExplorerBranchIsOpen,
+ .setExplorerBranchOpen = shellSetExplorerBranchOpen,
+ .drawWorkspaces = shellDrawWorkspaces,
.accept = shellAccept,
.cancel = shellCancel,
.copy = shellCopy,
@@ -653,6 +667,61 @@ fn shellSwapDocs(ctx: *anyopaque, a: usize, b: usize) void {
fn shellAllocDocId(ctx: *anyopaque) u64 {
return shellCtx(ctx).newFileID();
}
+fn shellExplorerViewportWidth(ctx: *anyopaque) f32 {
+ return shellCtx(ctx).explorer.scroll_info.viewport.w;
+}
+fn shellDocFromPath(ctx: *anyopaque, path: []const u8) ?sdk.DocHandle {
+ return shellCtx(ctx).docFromPath(path);
+}
+fn shellOpenFilePath(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool {
+ return shellCtx(ctx).openFilePath(path, grouping);
+}
+fn shellOpenOrFocusFileAtGrouping(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!?usize {
+ return shellCtx(ctx).openOrFocusFileAtGrouping(path, grouping);
+}
+fn shellCloseDocById(ctx: *anyopaque, id: u64) anyerror!void {
+ return shellCtx(ctx).closeFileID(id);
+}
+fn shellSetProjectFolder(ctx: *anyopaque, path: []const u8) anyerror!void {
+ return shellCtx(ctx).setProjectFolder(path);
+}
+fn shellCloseProjectFolder(ctx: *anyopaque) void {
+ shellCtx(ctx).closeProjectFolder();
+}
+fn shellRecentFolderCount(ctx: *anyopaque) usize {
+ return shellCtx(ctx).recents.folders.items.len;
+}
+fn shellRecentFolderAt(ctx: *anyopaque, index: usize) ?[]const u8 {
+ const editor = shellCtx(ctx);
+ if (index >= editor.recents.folders.items.len) return null;
+ return editor.recents.folders.items[index];
+}
+fn shellOpenInFileBrowser(ctx: *anyopaque, path: []const u8) anyerror!void {
+ return shellCtx(ctx).openInFileBrowser(path);
+}
+fn shellIsPathIgnored(
+ ctx: *anyopaque,
+ project_root: []const u8,
+ abs_path: []const u8,
+ name: []const u8,
+ kind: std.Io.File.Kind,
+) bool {
+ return shellCtx(ctx).ignore.isIgnored(project_root, abs_path, name, kind);
+}
+fn shellExplorerBranchIsOpen(ctx: *anyopaque, branch_id: dvui.Id) bool {
+ return shellCtx(ctx).explorer.open_branches.contains(branch_id);
+}
+fn shellSetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) void {
+ const editor = shellCtx(ctx);
+ if (open) {
+ editor.explorer.open_branches.put(branch_id, {}) catch {};
+ } else {
+ _ = editor.explorer.open_branches.remove(branch_id);
+ }
+}
+fn shellDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result {
+ return drawWorkspaces(shellCtx(ctx), index);
+}
fn shellAccept(ctx: *anyopaque) anyerror!void {
return shellCtx(ctx).accept();
}
@@ -1809,6 +1878,15 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
}
+pub fn closeProjectFolder(editor: *Editor) void {
+ if (editor.folder) |folder| {
+ editor.ignore.deinit(fizzy.app.allocator);
+ pixelart.plugin.pluginPtr().persistProjectFolder();
+ fizzy.app.allocator.free(folder);
+ editor.folder = null;
+ }
+}
+
pub fn saving(editor: *Editor) bool {
for (editor.open_files.values()) |doc| {
if (doc.owner.isDocumentSaving(doc)) return true;
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
index fdceacee..c5b88234 100644
--- a/src/plugins/workbench/src/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -131,7 +131,7 @@ pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize
/// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the
/// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent.
fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void {
- const doc = fizzy.editor.docFromPath(path) orelse return;
+ const doc = Globals.host.docFromPath(path) orelse return;
if (!doc.owner.isDirty(doc)) return;
dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{
.stroke_color = dvui.themeGet().color(.window, .text),
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index ff4e9609..7d069a8d 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -215,7 +215,7 @@ fn drawTabs(self: *Workspace) void {
hbox.init(@src(), .{ .dir = .horizontal }, .{
.expand = .none,
.border = .all(0),
- .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(fizzy.editor.settings.content_opacity),
+ .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(Globals.host.contentOpacity()),
.background = true,
.id_extra = i,
.padding = dvui.Rect.all(2),
@@ -270,7 +270,10 @@ fn drawTabs(self: *Workspace) void {
}
if (is_fizzy_file) {
- _ = fizzy.core.Sprite.draw(fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default], @src(), fizzy.editor.atlas.source, 2.0, .{
+ const ui_atlas = Globals.host.uiAtlas();
+ const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default];
+ const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
+ _ = fizzy.core.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{
.gravity_y = 0.5,
.padding = dvui.Rect.all(4),
});
@@ -283,7 +286,7 @@ fn drawTabs(self: *Workspace) void {
});
}
- dvui.label(@src(), "{s}", .{std.fs.path.basename(fizzy.editor.docPath(doc))}, .{
+ dvui.label(@src(), "{s}", .{std.fs.path.basename(doc.owner.documentPath(doc))}, .{
.color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
.padding = dvui.Rect.all(4),
.gravity_y = 0.5,
@@ -361,7 +364,7 @@ fn drawTabs(self: *Workspace) void {
}
if (tab_close_button.clicked()) {
- fizzy.editor.closeFileID(doc.id) catch |err| {
+ Globals.host.closeDocById(doc.id) catch |err| {
dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
};
break;
@@ -406,7 +409,7 @@ fn drawTabs(self: *Workspace) void {
});
if (ghost_close.clicked()) {
- fizzy.editor.closeFileID(doc.id) catch |err| {
+ Globals.host.closeDocById(doc.id) catch |err| {
dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) });
};
break;
@@ -702,7 +705,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
const new_g = Globals.workbench.newGroupingID();
- const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, new_g) catch {
+ const maybe_idx = Globals.host.openOrFocusFileAtGrouping(path, new_g) catch {
Globals.workbench.clearFileTreeTabDragDropState();
continue :events_loop;
};
@@ -730,7 +733,7 @@ pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void {
e.handle(@src(), data);
dvui.dragEnd();
dvui.refresh(null, @src(), data.id);
- const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, self.grouping) catch {
+ const maybe_idx = Globals.host.openOrFocusFileAtGrouping(path, self.grouping) catch {
Globals.workbench.clearFileTreeTabDragDropState();
continue :events_loop;
};
@@ -754,10 +757,10 @@ pub fn drawCanvas(self: *Workspace) !void {
switch (builtin.os.tag) {
.macos => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
+ content_color = if (!Globals.host.isMaximized()) content_color.opacity(Globals.host.contentOpacity()) else content_color;
},
.windows => {
- content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color;
+ content_color = if (!Globals.host.isMaximized()) content_color.opacity(Globals.host.contentOpacity()) else content_color;
},
else => {},
}
@@ -778,7 +781,7 @@ pub fn drawCanvas(self: *Workspace) !void {
}
if (Globals.host.docByIndex(self.open_file_index)) |doc| {
- fizzy.editor.bindDocToPane(doc, canvas_vbox.data().id, self, self.center);
+ doc.owner.bindDocumentToPane(doc, canvas_vbox.data().id, self, self.center);
_ = try doc.owner.drawDocument(doc);
}
} else {
@@ -890,7 +893,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
);
if (button.clicked()) {
- fizzy.editor.requestNewFileDialog();
+ Globals.host.requestNewDocument(null, 0);
}
}
{
@@ -982,7 +985,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
});
defer scroll_area.deinit();
- var i: usize = fizzy.editor.recents.folders.items.len;
+ var i: usize = Globals.host.recentFolderCount();
while (i > 0) : (i -= 1) {
var anim = dvui.animate(@src(), .{
.kind = .horizontal,
@@ -994,7 +997,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
});
defer anim.deinit();
- const folder = fizzy.editor.recents.folders.items[i - 1];
+ const folder = Globals.host.recentFolderAt(i - 1) orelse continue;
if (dvui.button(@src(), folder, .{
.draw_focus = false,
}, .{
@@ -1008,7 +1011,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
.color_fill_press = dvui.themeGet().color(.window, .fill_press),
.color_text = dvui.themeGet().color(.control, .text).opacity(0.5),
})) {
- try fizzy.editor.setProjectFolder(folder);
+ try Globals.host.setProjectFolder(folder);
}
}
}
@@ -1070,7 +1073,7 @@ pub fn drawBubble(rect: dvui.Rect, rs: dvui.RectScale, color: [4]u8, _: usize) !
// This should never be able to return more than one folder
pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void {
if (folder) |f| {
- fizzy.editor.setProjectFolder(f[0]) catch {
+ Globals.host.setProjectFolder(f[0]) catch {
dvui.log.err("Failed to set project folder: {s}", .{f[0]});
};
}
@@ -1079,7 +1082,7 @@ pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void {
pub fn openFilesCallback(files: ?[][:0]const u8) void {
if (files) |f| {
for (f) |file| {
- _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch {
+ _ = Globals.host.openFilePath(file, Globals.workbench.open_workspace_grouping) catch {
dvui.log.err("Failed to open file: {s}", .{file});
};
}
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index f626b8b8..16f6d4ab 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -80,7 +80,7 @@ pub fn draw() !void {
// Safe as long as `selected_paths` isn't mutated between now and `tree.deinit`.
tree.selected_branch_ids = selectionBranchIdsForMultiDrag(dvui.currentWindow().arena()) catch selected_paths.keys();
- if (fizzy.editor.folder) |path| {
+ if (Globals.host.folder()) |path| {
try drawFiles(path, tree);
} else {
Globals.workbench.file_tree_data_id = null;
@@ -93,7 +93,7 @@ pub fn draw() !void {
if (dvui.button(@src(), "Open Folder", .{ .draw_focus = false }, .{ .expand = .horizontal, .style = .highlight })) {
if (try dvui.dialogNativeFolderSelect(dvui.currentWindow().arena(), .{ .title = "Open Project Folder" })) |folder| {
- try fizzy.editor.setProjectFolder(folder);
+ try Globals.host.setProjectFolder(folder);
}
}
}
@@ -103,7 +103,7 @@ fn drawWeb() !void {
var tree = fizzy.dvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both });
defer tree.deinit();
- const viewport_w = fizzy.editor.explorer.scroll_info.viewport.w;
+ const viewport_w = Globals.host.explorerViewportWidth();
const wrap_w: f32 = if (viewport_w > 0) viewport_w else 200;
{
@@ -267,11 +267,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u
if ((dvui.menuItemLabel(@src(), "Close", .{}, .{
.expand = .horizontal,
})) != null) {
- if (fizzy.editor.folder) |f| {
- fizzy.editor.ignore.deinit(fizzy.app.allocator);
- fizzy.app.allocator.free(f);
- fizzy.editor.folder = null;
- }
+ Globals.host.closeProjectFolder();
fw2.close();
}
@@ -279,7 +275,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u
_ = dvui.separator(@src(), .{ .expand = .horizontal });
if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) {
- fizzy.editor.openInFileBrowser(project_path) catch {
+ Globals.host.openInFileBrowser(project_path) catch {
dvui.log.err("Failed to open file browser", .{});
};
@@ -417,7 +413,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind
.expand = .horizontal,
.gravity_y = 0.5,
});
- fizzy.editor.workbench.drawBranchDecorations(full_path, id_extra);
+ Globals.workbench.drawBranchDecorations(full_path, id_extra);
} else {
dvui.label(@src(), "{s}", .{label}, .{
.color_text = color,
@@ -467,8 +463,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
&.{ directory, entry.name },
);
- if (fizzy.editor.folder) |proj_root| {
- if (fizzy.editor.ignore.isIgnored(proj_root, abs_path, entry.name, entry.kind)) {
+ if (Globals.host.folder()) |proj_root| {
+ if (Globals.host.isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) {
continue;
}
}
@@ -500,7 +496,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
var expanded = false;
const expanded_indent: f32 = 14.0;
- if (fizzy.editor.explorer.open_branches.get(branch_id) != null) {
+ if (Globals.host.explorerBranchIsOpen(branch_id)) {
expanded = true;
}
@@ -599,7 +595,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
if (branch.dropInto() and entry.kind == .directory) {
try applyFileMove(inner_unique_id, tree, abs_path);
// Expand the folder so the dropped item is visible
- fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {};
+ Globals.host.setExplorerBranchOpen(branch_id, true);
}
{ // Add right click context menu for item options
@@ -635,7 +631,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
break :blk &[_][]const u8{};
};
for (to_open) |p| {
- _ = fizzy.editor.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| {
+ _ = Globals.host.openFilePath(p, Globals.workbench.currentGroupingID()) catch |e| {
dvui.log.err("Failed to open file: {any} ({s})", .{ e, p });
};
}
@@ -661,7 +657,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
Globals.workbench.newGroupingID();
have_grouping = true;
}
- _ = fizzy.editor.openFilePath(p, side_grouping) catch {
+ _ = Globals.host.openFilePath(p, side_grouping) catch {
dvui.log.err("Failed to open file: {s}", .{p});
};
}
@@ -673,7 +669,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
}
if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) {
- fizzy.editor.openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch {
+ Globals.host.openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch {
dvui.log.err("Failed to open file browser", .{});
};
@@ -745,13 +741,16 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color;
if (ext == .fizzy) {
- _ = fizzy.core.Sprite.draw(
- fizzy.editor.atlas.sprites[fizzy.atlas.sprites.logo_default],
- @src(),
- fizzy.editor.atlas.source,
- 2.0,
- .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false },
- );
+ const ui_atlas = Globals.host.uiAtlas();
+ const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default];
+ const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
+ _ = fizzy.core.Sprite.draw(
+ logo_sprite,
+ @src(),
+ ui_atlas.source,
+ 2.0,
+ .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false },
+ );
} else {
dvui.icon(
@src(),
@@ -768,15 +767,15 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
editableLabel(
inner_id_extra.*,
- if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", fizzy.editor.folder.?, abs_path) catch entry.name else entry.name,
- if (fizzy.editor.docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
+ if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", Globals.host.folder().?, abs_path) catch entry.name else entry.name,
+ if (Globals.host.docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text),
entry.kind,
abs_path,
) catch {
dvui.log.err("Failed to draw editable label", .{});
};
- if (fizzy.editor.docFromPath(abs_path)) |doc| {
+ if (Globals.host.docFromPath(abs_path)) |doc| {
const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc);
if (doc.owner.showsSaveStatusIndicator(doc)) {
fizzy.dvui.bubbleSpinner(@src(), .{
@@ -810,7 +809,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
if (mode == .replace) {
switch (ext) {
.fizzy, .png, .jpg => {
- _ = fizzy.editor.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| {
+ _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| {
dvui.log.err("{any}: {s}", .{ err, abs_path });
};
},
@@ -880,9 +879,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
// .alpha = 0.15 * t,
// },
})) {
- fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {
- dvui.log.debug("Failed to track branch state!", .{});
- };
+ Globals.host.setExplorerBranchOpen(branch_id, true);
try search(
abs_path,
tree,
@@ -893,13 +890,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
branch,
);
} else {
- if (fizzy.editor.explorer.open_branches.contains(branch_id)) {
- _ = fizzy.editor.explorer.open_branches.remove(branch_id);
+ if (Globals.host.explorerBranchIsOpen(branch_id)) {
+ Globals.host.setExplorerBranchOpen(branch_id, false);
}
}
// Keep open_branches in sync so hover-expand and drop-into expand persist next frame
if (branch.expanded) {
- fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {};
+ Globals.host.setExplorerBranchOpen(branch_id, true);
}
color_id.* = color_id.* + 1;
},
@@ -1203,7 +1200,7 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m
return false;
};
- if (fizzy.editor.docFromPath(source_path)) |doc| {
+ if (Globals.host.docFromPath(source_path)) |doc| {
doc.owner.setDocumentPath(doc, new_path) catch {
dvui.log.err("Failed to duplicate path: {s}", .{new_path});
return error.FailedToDuplicatePath;
@@ -1228,7 +1225,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File
var di: usize = 0;
while (di < Globals.host.openDocCount()) : (di += 1) {
const doc = Globals.host.docByIndex(di) orelse continue;
- const path = fizzy.editor.docPath(doc);
+ const path = doc.owner.documentPath(doc);
if (std.mem.containsAtLeast(u8, path, 1, full_path)) {
const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path";
const new_full = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name });
@@ -1241,7 +1238,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File
.file => {
std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) });
- if (fizzy.editor.docFromPath(full_path)) |doc| {
+ if (Globals.host.docFromPath(full_path)) |doc| {
doc.owner.setDocumentPath(doc, new_path) catch {
dvui.log.err("Failed to duplicate path: {s}", .{new_path});
return error.FailedToDuplicatePath;
diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig
index 334cf8df..fff1c9a7 100644
--- a/src/plugins/workbench/src/plugin.zig
+++ b/src/plugins/workbench/src/plugin.zig
@@ -6,6 +6,7 @@ const std = @import("std");
const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
const sdk = fizzy.sdk;
+const Globals = @import("Globals.zig");
const files = @import("files.zig");
/// Stable contribution ids (plugin-namespaced) referenced across modules.
@@ -45,7 +46,7 @@ fn drawFiles(_: ?*anyopaque) anyerror!void {
}
fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result {
- return fizzy.editor.drawWorkspaces(0);
+ return Globals.host.drawWorkspaces(0);
}
/// File-management keybinds (open / save). The shell registers its own
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index 7c047b2b..f24ae2b7 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -104,6 +104,39 @@ pub const VTable = struct {
/// Allocate the next shell document id (monotonic).
allocDocId: *const fn (ctx: *anyopaque) u64,
+ /// Explorer scroll viewport width (0 when unavailable).
+ explorerViewportWidth: *const fn (ctx: *anyopaque) f32,
+ /// Lookup an open document by absolute path.
+ docFromPath: *const fn (ctx: *anyopaque, path: []const u8) ?DocHandle,
+ /// Open `path` in `grouping` (async load when needed). Returns true when a new load started.
+ openFilePath: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool,
+ /// Focus an open doc or queue load; returns index when already open, null when loading.
+ openOrFocusFileAtGrouping: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!?usize,
+ /// Close document `id` (may prompt when dirty).
+ closeDocById: *const fn (ctx: *anyopaque, id: u64) anyerror!void,
+ /// Open/switch the project root folder.
+ setProjectFolder: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
+ /// Close the current project folder (no-op when none open).
+ closeProjectFolder: *const fn (ctx: *anyopaque) void,
+ /// Recent project folders (most recent last).
+ recentFolderCount: *const fn (ctx: *anyopaque) usize,
+ recentFolderAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8,
+ /// Reveal `path` in the OS file browser.
+ openInFileBrowser: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void,
+ /// True when `abs_path` is ignored by `.fizignore`/`.gitignore` at `project_root`.
+ isPathIgnored: *const fn (
+ ctx: *anyopaque,
+ project_root: []const u8,
+ abs_path: []const u8,
+ name: []const u8,
+ kind: std.Io.File.Kind,
+ ) bool,
+ /// Explorer tree branch expanded state.
+ explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool,
+ setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void,
+ /// Draw workspace panes (center region); `index` is the root pane (usually 0).
+ drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result,
+
// ---- document editing (active file) ----
accept: *const fn (ctx: *anyopaque) anyerror!void,
cancel: *const fn (ctx: *anyopaque) anyerror!void,
@@ -222,6 +255,68 @@ pub fn allocDocId(self: EditorAPI) u64 {
return self.vtable.allocDocId(self.ctx);
}
+pub fn explorerViewportWidth(self: EditorAPI) f32 {
+ return self.vtable.explorerViewportWidth(self.ctx);
+}
+
+pub fn docFromPath(self: EditorAPI, path: []const u8) ?DocHandle {
+ return self.vtable.docFromPath(self.ctx, path);
+}
+
+pub fn openFilePath(self: EditorAPI, path: []const u8, grouping: u64) !bool {
+ return self.vtable.openFilePath(self.ctx, path, grouping);
+}
+
+pub fn openOrFocusFileAtGrouping(self: EditorAPI, path: []const u8, grouping: u64) !?usize {
+ return self.vtable.openOrFocusFileAtGrouping(self.ctx, path, grouping);
+}
+
+pub fn closeDocById(self: EditorAPI, id: u64) !void {
+ return self.vtable.closeDocById(self.ctx, id);
+}
+
+pub fn setProjectFolder(self: EditorAPI, path: []const u8) !void {
+ return self.vtable.setProjectFolder(self.ctx, path);
+}
+
+pub fn closeProjectFolder(self: EditorAPI) void {
+ self.vtable.closeProjectFolder(self.ctx);
+}
+
+pub fn recentFolderCount(self: EditorAPI) usize {
+ return self.vtable.recentFolderCount(self.ctx);
+}
+
+pub fn recentFolderAt(self: EditorAPI, index: usize) ?[]const u8 {
+ return self.vtable.recentFolderAt(self.ctx, index);
+}
+
+pub fn openInFileBrowser(self: EditorAPI, path: []const u8) !void {
+ return self.vtable.openInFileBrowser(self.ctx, path);
+}
+
+pub fn isPathIgnored(
+ self: EditorAPI,
+ project_root: []const u8,
+ abs_path: []const u8,
+ name: []const u8,
+ kind: std.Io.File.Kind,
+) bool {
+ return self.vtable.isPathIgnored(self.ctx, project_root, abs_path, name, kind);
+}
+
+pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool {
+ return self.vtable.explorerBranchIsOpen(self.ctx, branch_id);
+}
+
+pub fn setExplorerBranchOpen(self: EditorAPI, branch_id: dvui.Id, open: bool) void {
+ self.vtable.setExplorerBranchOpen(self.ctx, branch_id, open);
+}
+
+pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result {
+ return self.vtable.drawWorkspaces(self.ctx, index);
+}
+
pub fn accept(self: EditorAPI) !void {
return self.vtable.accept(self.ctx);
}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 94b79ab9..d1dca264 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -188,6 +188,68 @@ pub fn allocDocId(self: *Host) u64 {
return if (self.shell_api) |a| a.allocDocId() else 0;
}
+pub fn explorerViewportWidth(self: *Host) f32 {
+ return if (self.shell_api) |a| a.explorerViewportWidth() else 0;
+}
+
+pub fn docFromPath(self: *Host, path: []const u8) ?DocHandle {
+ return if (self.shell_api) |a| a.docFromPath(path) else null;
+}
+
+pub fn openFilePath(self: *Host, path: []const u8, grouping: u64) !bool {
+ return if (self.shell_api) |a| try a.openFilePath(path, grouping) else false;
+}
+
+pub fn openOrFocusFileAtGrouping(self: *Host, path: []const u8, grouping: u64) !?usize {
+ return if (self.shell_api) |a| try a.openOrFocusFileAtGrouping(path, grouping) else null;
+}
+
+pub fn closeDocById(self: *Host, id: u64) !void {
+ if (self.shell_api) |a| return a.closeDocById(id);
+}
+
+pub fn setProjectFolder(self: *Host, path: []const u8) !void {
+ return if (self.shell_api) |a| try a.setProjectFolder(path) else error.ShellNotInstalled;
+}
+
+pub fn closeProjectFolder(self: *Host) void {
+ if (self.shell_api) |a| a.closeProjectFolder();
+}
+
+pub fn recentFolderCount(self: *Host) usize {
+ return if (self.shell_api) |a| a.recentFolderCount() else 0;
+}
+
+pub fn recentFolderAt(self: *Host, index: usize) ?[]const u8 {
+ return if (self.shell_api) |a| a.recentFolderAt(index) else null;
+}
+
+pub fn openInFileBrowser(self: *Host, path: []const u8) !void {
+ return if (self.shell_api) |a| try a.openInFileBrowser(path) else error.ShellNotInstalled;
+}
+
+pub fn isPathIgnored(
+ self: *Host,
+ project_root: []const u8,
+ abs_path: []const u8,
+ name: []const u8,
+ kind: std.Io.File.Kind,
+) bool {
+ return if (self.shell_api) |a| a.isPathIgnored(project_root, abs_path, name, kind) else false;
+}
+
+pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool {
+ return if (self.shell_api) |a| a.explorerBranchIsOpen(branch_id) else false;
+}
+
+pub fn setExplorerBranchOpen(self: *Host, branch_id: dvui.Id, open: bool) void {
+ if (self.shell_api) |a| a.setExplorerBranchOpen(branch_id, open);
+}
+
+pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result {
+ return if (self.shell_api) |a| try a.drawWorkspaces(index) else .ok;
+}
+
pub fn accept(self: *Host) !void {
if (self.shell_api) |a| return a.accept();
}
From dd53062890772b5d684ecd6dbf39d3aedf9025ca Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 10:30:33 -0500
Subject: [PATCH 30/49] stage w4
---
HANDOFF.md | 11 ++-
build.zig | 66 ++++++++++++++++-
src/App.zig | 4 +-
src/editor/Editor.zig | 31 ++++++--
src/editor/explorer/Explorer.zig | 5 +-
src/plugins/workbench/module.zig | 1 +
src/plugins/workbench/src/FileLoadJob.zig | 11 +--
src/plugins/workbench/src/Workbench.zig | 37 +++++-----
src/plugins/workbench/src/Workspace.zig | 63 ++++++++--------
src/plugins/workbench/src/files.zig | 73 +++++++++----------
src/plugins/workbench/src/plugin.zig | 11 +--
.../workbench/src/workbench_layout.zig | 5 +-
src/plugins/workbench/workbench.zig | 9 +++
src/sdk/EditorAPI.zig | 27 +++++++
src/sdk/Host.zig | 14 ++++
15 files changed, 245 insertions(+), 123 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index 38646b80..939cd1cd 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -389,9 +389,14 @@ values/keys at `Workspace.zig:467+`) — that's the deep coupling.
`drawWorkspaces`. Workbench `files.zig`/`Workspace.zig`/`Workbench.zig`/`plugin.zig`
now route through `Globals.host` + `Globals.workbench`; zero runtime `fizzy.editor`
refs remain in workbench draw paths (comments only).
-- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core**; then
- **W5 — `b.addModule("workbench")`** + `@import("workbench")`, drop the shell path imports
- (`Editor.zig` re-exports of `Workspace`/`FileLoadJob`/`Workbench`) and the `fizzy` import.
+- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core — DONE.**
+ Workbench hub (`workbench.zig`) re-exports `wdvui` (= `core.dvui`), `math`, `atlas`,
+ `platform`, `Sprite`, `perf`. Plugin sources use `Globals.allocator()` instead of
+ `fizzy.app`; native open dialogs via `host.showOpenFolderDialog`/`showOpenFileDialog`.
+ `workbench-api` service ctx is `*Host` (no `fizzy.Editor` in workbench).
+- **W5 — `b.addModule("workbench")` + shell `@import("workbench")` — DONE.**
+ `wireWorkbenchModule` in `build.zig` (native, web, test). `Editor.zig`/`App.zig`/
+ `Explorer.zig` import the module; path imports removed.
---
diff --git a/build.zig b/build.zig
index 79bd5e78..dabd2f9c 100644
--- a/build.zig
+++ b/build.zig
@@ -412,7 +412,7 @@ pub fn build(b: *std.Build) !void {
});
web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module);
- wirePixelartModule(b, web_target, optimize, .{
+ const pixelart_module_web = wirePixelartModule(b, web_target, optimize, .{
.dvui = dvui_web_dep.module("dvui_web"),
.core = core_module_web,
.sdk = sdk_module_web,
@@ -423,6 +423,14 @@ pub fn build(b: *std.Build) !void {
.icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
.backend = null,
}, web_exe.root_module);
+ wireWorkbenchModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .pixelart = pixelart_module_web,
+ .backend = null,
+ }, web_exe.root_module);
const web_install_dir: std.Build.InstallDir = .{ .custom = "web" };
const install_wasm = b.addInstallArtifact(web_exe, .{
@@ -860,7 +868,7 @@ pub fn build(b: *std.Build) !void {
}
fizzy_test_module.addImport("core", core_module_test);
const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
- wirePixelartModule(b, target, optimize, .{
+ const pixelart_module_test = wirePixelartModule(b, target, optimize, .{
.dvui = dvui_testing_dep.module("dvui_testing"),
.core = core_module_test,
.sdk = sdk_module_test,
@@ -871,6 +879,14 @@ pub fn build(b: *std.Build) !void {
.icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
.backend = dvui_testing_dep.module("testing"),
}, fizzy_test_module);
+ wireWorkbenchModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
+ .pixelart = pixelart_module_test,
+ .backend = dvui_testing_dep.module("testing"),
+ }, fizzy_test_module);
if (target.result.os.tag == .macos) {
if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
@@ -1202,7 +1218,7 @@ fn addFizzyExecutableForTarget(
core_module.addImport("icons", dep.module("icons"));
icons_module = dep.module("icons");
}
- wirePixelartModule(b, resolved_target, optimize, .{
+ const pixelart_module = wirePixelartModule(b, resolved_target, optimize, .{
.dvui = dvui_dep.module("dvui_sdl3"),
.core = core_module,
.sdk = sdk_module,
@@ -1213,6 +1229,14 @@ fn addFizzyExecutableForTarget(
.icons = icons_module,
.backend = dvui_dep.module("sdl3"),
}, exe.root_module);
+ wireWorkbenchModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .icons = icons_module,
+ .pixelart = pixelart_module,
+ .backend = dvui_dep.module("sdl3"),
+ }, exe.root_module);
const singleton_app_dep = b.dependency("dvui_singleton_app", .{
.target = resolved_target,
@@ -1306,6 +1330,39 @@ const PixelartModuleDeps = struct {
backend: ?*std.Build.Module,
};
+const WorkbenchModuleDeps = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+ icons: ?*std.Build.Module,
+ pixelart: *std.Build.Module,
+ backend: ?*std.Build.Module,
+};
+
+/// Workbench plugin (`src/plugins/workbench/module.zig`).
+fn wireWorkbenchModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ deps: WorkbenchModuleDeps,
+ consumer: *std.Build.Module,
+) void {
+ const workbench_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/plugins/workbench/module.zig"),
+ .link_libc = target.result.cpu.arch != .wasm32,
+ .single_threaded = target.result.cpu.arch == .wasm32,
+ });
+ workbench_module.addImport("dvui", deps.dvui);
+ workbench_module.addImport("core", deps.core);
+ workbench_module.addImport("sdk", deps.sdk);
+ workbench_module.addImport("pixelart", deps.pixelart);
+ if (deps.icons) |icons| workbench_module.addImport("icons", icons);
+ if (deps.backend) |backend| workbench_module.addImport("backend", backend);
+ consumer.addImport("workbench", workbench_module);
+}
+
/// Pixel-art plugin (`src/plugins/pixelart/module.zig`).
fn wirePixelartModule(
b: *std.Build,
@@ -1313,7 +1370,7 @@ fn wirePixelartModule(
optimize: std.builtin.OptimizeMode,
deps: PixelartModuleDeps,
consumer: *std.Build.Module,
-) void {
+) *std.Build.Module {
const pixelart_module = b.createModule(.{
.target = target,
.optimize = optimize,
@@ -1331,6 +1388,7 @@ fn wirePixelartModule(
if (deps.icons) |icons| pixelart_module.addImport("icons", icons);
if (deps.backend) |backend| pixelart_module.addImport("backend", backend);
consumer.addImport("pixelart", pixelart_module);
+ return pixelart_module;
}
inline fn thisDir() []const u8 {
diff --git a/src/App.zig b/src/App.zig
index a2265166..f957caab 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -8,9 +8,9 @@ const assets = @import("assets");
const icon = assets.files.@"icon.png";
const fizzy = @import("fizzy.zig");
+const workbench = @import("workbench");
const pixelart = @import("pixelart");
-// Path import until workbench becomes a build module (Stage W5); see HANDOFF "Stage W".
-const WorkbenchGlobals = @import("plugins/workbench/src/Globals.zig");
+const WorkbenchGlobals = workbench.Globals;
const auto_update = @import("backend/auto_update.zig");
const update_notify = @import("backend/update_notify.zig");
const singleton = @import("backend/singleton.zig");
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 83891efe..c1fb56c1 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -27,21 +27,23 @@ pub const Dialogs = @import("dialogs/Dialogs.zig");
pub const Keybinds = @import("Keybinds.zig");
-pub const Workspace = @import("../plugins/workbench/src/Workspace.zig");
+const workbench_mod = @import("workbench");
+
+pub const Workspace = workbench_mod.Workspace;
pub const Explorer = @import("explorer/Explorer.zig");
pub const IgnoreRules = @import("explorer/IgnoreRules.zig");
pub const Panel = @import("panel/Panel.zig");
pub const Sidebar = @import("Sidebar.zig");
pub const Infobar = @import("Infobar.zig");
pub const Menu = @import("Menu.zig");
-pub const FileLoadJob = @import("../plugins/workbench/src/FileLoadJob.zig");
+pub const FileLoadJob = workbench_mod.FileLoadJob;
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
/// Workbench (Phase 1): file-management home — currently the per-branch
/// decoration registry for the explorer; grows to own files + tabs/splits.
-pub const Workbench = @import("../plugins/workbench/src/Workbench.zig");
+pub const Workbench = workbench_mod.Workbench;
/// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels.
/// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame
@@ -460,7 +462,7 @@ pub fn postInit(editor: *Editor) !void {
// near-empty shell's content: it iterates the Host registries rather than
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
// editor tick already runs on wasm. Order = sidebar order.
- try @import("../plugins/workbench/src/plugin.zig").register(&editor.host);
+ try workbench_mod.plugin.register(&editor.host);
const pixelart_plugin = pixelart.plugin;
try pixelart_plugin.register(&editor.host);
try pixelart_plugin.pluginPtr().initPlugin();
@@ -495,7 +497,7 @@ const pixelart_plugin = pixelart.plugin;
// wasm analysis entirely (the codebase's dead-branch convention; see
// `web_main.zig`).
if (comptime builtin.target.cpu.arch != .wasm32) {
- editor.workbench.initService(editor);
+ editor.workbench.initService(&editor.host);
try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api);
}
}
@@ -565,6 +567,8 @@ const shell_api_vtable: sdk.EditorAPI.VTable = .{
.explorerBranchIsOpen = shellExplorerBranchIsOpen,
.setExplorerBranchOpen = shellSetExplorerBranchOpen,
.drawWorkspaces = shellDrawWorkspaces,
+ .showOpenFolderDialog = shellShowOpenFolderDialog,
+ .showOpenFileDialog = shellShowOpenFileDialog,
.accept = shellAccept,
.cancel = shellCancel,
.copy = shellCopy,
@@ -722,6 +726,21 @@ fn shellSetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) v
fn shellDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result {
return drawWorkspaces(shellCtx(ctx), index);
}
+fn shellShowOpenFolderDialog(ctx: *anyopaque, cb: sdk.EditorAPI.OpenPathsCallback, default_folder: ?[]const u8) void {
+ _ = ctx;
+ fizzy.backend.showOpenFolderDialog(cb, default_folder);
+}
+fn shellShowOpenFileDialog(
+ ctx: *anyopaque,
+ cb: sdk.EditorAPI.OpenPathsCallback,
+ filters: []const sdk.EditorAPI.SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+) void {
+ _ = ctx;
+ const native_filters: [*]const fizzy.backend.DialogFileFilter = @ptrCast(filters.ptr);
+ fizzy.backend.showOpenFileDialog(cb, native_filters[0..filters.len], default_filename, default_folder);
+}
fn shellAccept(ctx: *anyopaque) anyerror!void {
return shellCtx(ctx).accept();
}
@@ -1872,7 +1891,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
- editor.host.setActiveSidebarView(@import("../plugins/workbench/src/plugin.zig").view_files);
+ editor.host.setActiveSidebarView(workbench_mod.plugin.view_files);
pixelart.plugin.pluginPtr().reloadProjectFolder(fizzy.app.allocator);
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 93359cb2..ae597de7 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const dvui = @import("dvui");
const fizzy = @import("../../fizzy.zig");
+const workbench = @import("workbench");
const icons = @import("icons");
const Core = @import("mach").Core;
@@ -12,7 +13,7 @@ const nfd = @import("nfd");
pub const Explorer = @This();
-pub const files = @import("../../plugins/workbench/src/files.zig");
+pub const files = workbench.files;
// pub const animations = @import("animations.zig");
// pub const keyframe_animations = @import("keyframe_animations.zig");
// The pixel-art project view is contributed by the plugin via `Host.registerSidebarView`,
@@ -107,7 +108,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
.background = false,
});
- if (!fizzy.editor.host.isActiveSidebarView(@import("../../plugins/workbench/src/plugin.zig").view_files)) {
+ if (!fizzy.editor.host.isActiveSidebarView(workbench.plugin.view_files)) {
fizzy.editor.workbench.file_tree_data_id = null;
fizzy.editor.workbench.clearFileTreeTabDragDropState();
}
diff --git a/src/plugins/workbench/module.zig b/src/plugins/workbench/module.zig
index f5996363..d29a7613 100644
--- a/src/plugins/workbench/module.zig
+++ b/src/plugins/workbench/module.zig
@@ -9,3 +9,4 @@ pub const files = @import("src/files.zig");
pub const Workspace = @import("src/Workspace.zig");
pub const Workbench = @import("src/Workbench.zig");
pub const FileLoadJob = @import("src/FileLoadJob.zig");
+pub const Globals = @import("src/Globals.zig");
diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig
index 164e05c3..eee291d7 100644
--- a/src/plugins/workbench/src/FileLoadJob.zig
+++ b/src/plugins/workbench/src/FileLoadJob.zig
@@ -15,9 +15,10 @@
//! but only writes through atomic fields + the worker-only `doc_buf`/`err` fields.
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
-const dvui = @import("dvui");
-const perf = fizzy.perf;
+const wb = @import("../workbench.zig");
+const dvui = wb.dvui;
+const perf = wb.perf;
+const sdk = wb.sdk;
const FileLoadJob = @This();
@@ -35,7 +36,7 @@ allocator: std.mem.Allocator,
path: []u8,
/// Plugin that owns this file's extension (resolved on the main thread before spawn).
-owner: *fizzy.sdk.Plugin,
+owner: *sdk.Plugin,
/// Workspace grouping the file should land in once loaded.
target_grouping: u64,
@@ -55,7 +56,7 @@ doc_buf: []u8,
err: ?anyerror = null,
-pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *fizzy.sdk.Plugin, target_grouping: u64) !*FileLoadJob {
+pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *sdk.Plugin, target_grouping: u64) !*FileLoadJob {
const path_copy = try allocator.dupe(u8, path);
errdefer allocator.free(path_copy);
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
index c5b88234..4a402049 100644
--- a/src/plugins/workbench/src/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -11,7 +11,6 @@
const std = @import("std");
const dvui = @import("dvui");
const icons = @import("icons");
-const fizzy = @import("../../../fizzy.zig");
const files = @import("files.zig");
const Workspace = @import("Workspace.zig");
const Globals = @import("Globals.zig");
@@ -107,10 +106,9 @@ pub fn activeWorkspaceCanvasRectPhysical(self: *Workbench) ?dvui.Rect.Physical {
return workspace.canvas_rect_physical;
}
-/// Build the `workbench-api` service. `editor_ctx` is the host's heap `*Editor`,
-/// passed opaquely so the API has no compile-time dependency back on the editor.
-pub fn initService(self: *Workbench, editor_ctx: *anyopaque) void {
- self.api = .{ .ctx = editor_ctx, .vtable = &service_vtable };
+/// Build the `workbench-api` service. `host_ctx` is the shell `*Host`.
+pub fn initService(self: *Workbench, host_ctx: *sdk.Host) void {
+ self.api = .{ .ctx = host_ctx, .vtable = &service_vtable };
}
/// Register the decorations the shell ships with. Called once after the editor is
@@ -259,35 +257,34 @@ const service_vtable: Api.VTable = .{
.registerBranchDecorator = svcRegisterBranchDecorator,
};
-inline fn editorOf(ctx: *anyopaque) *fizzy.Editor {
+inline fn hostOf(ctx: *anyopaque) *sdk.Host {
return @ptrCast(@alignCast(ctx));
}
fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool {
- return editorOf(ctx).openFilePath(path, grouping);
+ return hostOf(ctx).openFilePath(path, grouping);
}
-fn svcCurrentGrouping(ctx: *anyopaque) u64 {
- return editorOf(ctx).workbench.currentGroupingID();
+fn svcCurrentGrouping(_: *anyopaque) u64 {
+ return Globals.workbench.currentGroupingID();
}
-fn svcNewGrouping(ctx: *anyopaque) u64 {
- return editorOf(ctx).workbench.newGroupingID();
+fn svcNewGrouping(_: *anyopaque) u64 {
+ return Globals.workbench.newGroupingID();
}
fn svcClose(ctx: *anyopaque, id: u64) anyerror!void {
- return editorOf(ctx).closeFileID(id);
+ return hostOf(ctx).closeDocById(id);
}
fn svcSave(ctx: *anyopaque) anyerror!void {
- return editorOf(ctx).save();
+ return hostOf(ctx).save();
}
fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool {
- return editorOf(ctx).docFromPath(path) != null;
+ return hostOf(ctx).docFromPath(path) != null;
}
fn svcOpenCount(ctx: *anyopaque) usize {
- return editorOf(ctx).open_files.count();
+ return hostOf(ctx).openDocCount();
}
fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 {
- const editor = editorOf(ctx);
- const doc = editor.docAt(index) orelse return null;
- return editor.docPath(doc);
+ const doc = hostOf(ctx).docByIndex(index) orelse return null;
+ return doc.owner.documentPath(doc);
}
fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void {
return files.createFilePath(path);
@@ -304,6 +301,6 @@ fn svcDelete(_: *anyopaque, path: []const u8) void {
fn svcMove(_: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool {
return files.moveOnePath(path, target_dir, dvui.currentWindow().arena());
}
-fn svcRegisterBranchDecorator(ctx: *anyopaque, decorator: BranchDecorator) anyerror!void {
- return editorOf(ctx).workbench.registerBranchDecorator(decorator);
+fn svcRegisterBranchDecorator(_: *anyopaque, decorator: BranchDecorator) anyerror!void {
+ return Globals.workbench.registerBranchDecorator(decorator);
}
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index 7d069a8d..d69c49e6 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -1,16 +1,13 @@
const std = @import("std");
const builtin = @import("builtin");
-const dvui = @import("dvui");
-const sdk = @import("sdk");
-const pixelart = @import("pixelart");
-const fizzy = @import("../../../fizzy.zig");
+const wb = @import("../workbench.zig");
+const dvui = wb.dvui;
+const wdvui = wb.wdvui;
+const sdk = wb.sdk;
const Globals = @import("Globals.zig");
const icons = @import("icons");
-const App = fizzy.App;
-const Editor = fizzy.Editor;
-
/// Workspaces are drawn recursively inside of the explorer paned widget
/// second pane, and contains drag/drop enabled tabs. Tabs can freely be dragged to
/// panes or other tab bars.
@@ -50,14 +47,14 @@ const handle_dist = 60;
const opacity = 60;
-const color_0 = fizzy.math.Color.initBytes(0, 0, 0, 0);
-const color_1 = fizzy.math.Color.initBytes(230, 175, 137, opacity);
-const color_2 = fizzy.math.Color.initBytes(216, 145, 115, opacity);
-const color_3 = fizzy.math.Color.initBytes(41, 23, 41, opacity);
-const color_4 = fizzy.math.Color.initBytes(194, 109, 92, opacity);
-const color_5 = fizzy.math.Color.initBytes(180, 89, 76, opacity);
+const color_0 = wb.math.Color.initBytes(0, 0, 0, 0);
+const color_1 = wb.math.Color.initBytes(230, 175, 137, opacity);
+const color_2 = wb.math.Color.initBytes(216, 145, 115, opacity);
+const color_3 = wb.math.Color.initBytes(41, 23, 41, opacity);
+const color_4 = wb.math.Color.initBytes(194, 109, 92, opacity);
+const color_5 = wb.math.Color.initBytes(180, 89, 76, opacity);
-const logo_colors: [12]fizzy.math.Color = [_]fizzy.math.Color{
+const logo_colors: [12]wb.math.Color = [_]wb.math.Color{
color_1, color_1, color_1,
color_2, color_2, color_3,
color_4, color_3, color_0,
@@ -224,7 +221,7 @@ fn drawTabs(self: *Workspace) void {
defer hbox.deinit();
- const tab_hovered = fizzy.dvui.hovered(hbox.data());
+ const tab_hovered = wdvui.hovered(hbox.data());
if (selected) {
if (!reorderable.floating()) {
@@ -251,14 +248,14 @@ fn drawTabs(self: *Workspace) void {
if (prev_same_group_index) |prev_index| {
if (i == prev_index) {
// This tab is directly to the left of the active tab.
- fizzy.dvui.drawEdgeShadow(hbox.data().rectScale(), .right, .{});
+ wdvui.drawEdgeShadow(hbox.data().rectScale(), .right, .{});
}
}
if (next_same_group_index) |next_index| {
if (i == next_index) {
// This tab is directly to the right of the active tab.
- fizzy.dvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{});
+ wdvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{});
}
}
}
@@ -271,9 +268,9 @@ fn drawTabs(self: *Workspace) void {
if (is_fizzy_file) {
const ui_atlas = Globals.host.uiAtlas();
- const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default];
- const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
- _ = fizzy.core.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{
+ const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default];
+ const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
+ _ = wb.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{
.gravity_y = 0.5,
.padding = dvui.Rect.all(4),
});
@@ -292,8 +289,8 @@ fn drawTabs(self: *Workspace) void {
.gravity_y = 0.5,
});
- const close_inner = fizzy.dvui.windowHeaderCloseInnerSide();
- const close_pad = fizzy.dvui.window_header_close_margin;
+ const close_inner = wdvui.windowHeaderCloseInnerSide();
+ const close_pad = wdvui.window_header_close_margin;
const tab_status_slot = close_inner + close_pad.x + close_pad.w;
const status_close_box = dvui.box(@src(), .{ .dir = .horizontal }, .{
@@ -310,14 +307,14 @@ fn drawTabs(self: *Workspace) void {
// atomic load — the write side uses an atomic store in matching `save*` paths.
const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc);
const save_in_check_phase = if (save_flash_elapsed) |elapsed|
- fizzy.dvui.bubbleSpinnerSaveInCheckPhase(elapsed)
+ wdvui.bubbleSpinnerSaveInCheckPhase(elapsed)
else
false;
const save_blocks_tab_close = doc.owner.isDocumentSaving(doc) or
(doc.owner.showsSaveStatusIndicator(doc) and !save_in_check_phase);
if (save_blocks_tab_close) {
- fizzy.dvui.bubbleSpinner(@src(), .{
+ wdvui.bubbleSpinner(@src(), .{
.id_extra = i *% 16 + 5,
.expand = .none,
.min_size_content = .{ .w = close_inner, .h = close_inner },
@@ -328,7 +325,7 @@ fn drawTabs(self: *Workspace) void {
.complete_elapsed_ns = save_flash_elapsed,
});
} else if (save_in_check_phase and !tab_hovered) {
- fizzy.dvui.bubbleSpinner(@src(), .{
+ wdvui.bubbleSpinner(@src(), .{
.id_extra = i *% 16 + 5,
.expand = .none,
.min_size_content = .{ .w = close_inner, .h = close_inner },
@@ -340,7 +337,7 @@ fn drawTabs(self: *Workspace) void {
});
} else if (tab_hovered) {
var tab_close_button: dvui.ButtonWidget = undefined;
- tab_close_button.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{
+ tab_close_button.init(@src(), .{ .draw_focus = false }, wdvui.windowHeaderCloseButtonOptions(.{
.expand = .none,
.min_size_content = .{ .w = close_inner, .h = close_inner },
.id_extra = i *% 16 + 1,
@@ -372,7 +369,7 @@ fn drawTabs(self: *Workspace) void {
} else if (selected and !doc.owner.isDirty(doc)) {
const tab_text = dvui.themeGet().color(.window, .text);
var ghost_close: dvui.ButtonWidget = undefined;
- ghost_close.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{
+ ghost_close.init(@src(), .{ .draw_focus = false }, wdvui.windowHeaderCloseButtonOptions(.{
.expand = .none,
.min_size_content = .{ .w = close_inner, .h = close_inner },
.id_extra = i *% 16 + 3,
@@ -837,7 +834,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
if (fizzy_color.value[3] < 1.0 and fizzy_color.value[3] > 0.0) {
const theme_bg = dvui.themeGet().color(.window, .fill);
- fizzy_color = fizzy_color.lerp(fizzy.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]);
+ fizzy_color = fizzy_color.lerp(wb.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]);
fizzy_color.value[3] = 1.0;
}
@@ -884,7 +881,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
button.processEvents();
button.drawBackground();
- fizzy.dvui.labelWithKeybind(
+ wdvui.labelWithKeybind(
"New File",
dvui.currentWindow().keybinds.get("new_file") orelse .{},
true,
@@ -911,7 +908,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
button.processEvents();
button.drawBackground();
- fizzy.dvui.labelWithKeybind(
+ wdvui.labelWithKeybind(
"Open Folder",
dvui.currentWindow().keybinds.get("open_folder") orelse .{},
true,
@@ -920,7 +917,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
);
if (button.clicked()) {
- fizzy.backend.showOpenFolderDialog(setProjectFolderCallback, null);
+ Globals.host.showOpenFolderDialog(setProjectFolderCallback, null);
}
}
@@ -939,7 +936,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
button.processEvents();
button.drawBackground();
- fizzy.dvui.labelWithKeybind(
+ wdvui.labelWithKeybind(
"Open Files",
dvui.currentWindow().keybinds.get("open_files") orelse .{},
true,
@@ -960,7 +957,7 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
// }
// }
- fizzy.backend.showOpenFileDialog(openFilesCallback, &.{
+ Globals.host.showOpenFileDialog(openFilesCallback, &.{
.{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" },
}, "", null);
}
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index 16f6d4ab..5a94574f 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -1,22 +1,19 @@
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
+const builtin = @import("builtin");
+const wb = @import("../workbench.zig");
const Globals = @import("Globals.zig");
const pixelart = @import("pixelart");
-const dvui = @import("dvui");
-const Editor = fizzy.Editor;
-const builtin = @import("builtin");
-
+const dvui = wb.dvui;
+const wdvui = wb.wdvui;
const icons = @import("icons");
-const nfd = @import("nfd");
-
pub var tree_removed_path: ?[]const u8 = null;
pub var selected_id: ?usize = null;
pub var edit_id: ?usize = null;
/// Multi-selection for the file tree. Maps `id_extra` (hash of absolute path) to the heap-owned
/// absolute path string. The primary `selected_id` is always a key here when set. Paths are
-/// allocated from `fizzy.app.allocator` so they outlive the dvui arena used during draw.
+/// allocated from `Globals.allocator()` so they outlive the dvui arena used during draw.
pub var selected_paths: std.AutoArrayHashMapUnmanaged(usize, []u8) = .empty;
pub var selection_anchor: ?usize = null;
@@ -64,7 +61,7 @@ pub fn draw() !void {
}
// `tab_drag` matches workspace tab strips so file rows can drop on the canvas like tabs (DVUI reorder_tree cross-widget pattern).
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = "tab_drag" }, .{ .background = false, .expand = .both });
+ var tree = wdvui.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = "tab_drag" }, .{ .background = false, .expand = .both });
defer tree.deinit();
// Same as tools pane header: first frame after open (or after Files wasn't drawn last frame)
@@ -100,7 +97,7 @@ pub fn draw() !void {
}
fn drawWeb() !void {
- var tree = fizzy.dvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both });
+ var tree = wdvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both });
defer tree.deinit();
const viewport_w = Globals.host.explorerViewportWidth();
@@ -130,7 +127,7 @@ fn drawWeb() !void {
.style = .highlight,
.min_size_content = .{ .w = 110, .h = 0 },
})) {
- fizzy.backend.showOpenFileDialog(
+ Globals.host.showOpenFileDialog(
struct {
fn cb(_: ?[][:0]const u8) void {}
}.cb,
@@ -141,7 +138,7 @@ fn drawWeb() !void {
}
}
-pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void {
+pub fn drawFiles(path: []const u8, tree: *wdvui.TreeWidget) !void {
const unique_id = dvui.parentGet().extendId(@src(), 0);
Globals.workbench.file_tree_data_id = unique_id;
@@ -252,7 +249,7 @@ pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void {
}
/// Context menu for the project root directory: close project, reveal on disk, new file / folder.
-fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u8, tree: *fizzy.dvui.TreeWidget) !void {
+fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u8, tree: *wdvui.TreeWidget) !void {
var fw2 = dvui.floatingMenu(@src(), .{ .from = dvui.Rect.Natural.fromPoint(point) }, .{ .box_shadow = .{
.color = .black,
.offset = .{ .x = 0, .y = 0 },
@@ -427,7 +424,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind
}
}
-pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidget, unique_id: dvui.Id, outer_filter_text: []const u8) !void {
+pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, unique_id: dvui.Id, outer_filter_text: []const u8) !void {
var color_i: usize = 0;
var id_extra: usize = 0;
@@ -435,7 +432,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
errdefer pending_file_shift_range = null;
const recursor = struct {
- fn search(directory: []const u8, tree: *fizzy.dvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*fizzy.dvui.TreeWidget.Branch) !void {
+ fn search(directory: []const u8, tree: *wdvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*wdvui.TreeWidget.Branch) !void {
const io = dvui.io;
var dir = std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }) catch return;
defer dir.close(io);
@@ -479,7 +476,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
}
inner_id_extra.* = dvui.Id.update(tree.data().id, abs_path).asUsize();
- try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path });
+ try visible_file_rows_order.append(Globals.allocator(), .{ .id = inner_id_extra.*, .path = abs_path });
var color = dvui.themeGet().color(.control, .fill);
if (pixelart.Globals.state.colors.palette) |*palette| {
@@ -534,7 +531,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
selected_id = inner_id_extra.*;
var close_rect = branch.button.data().borderRectScale().r;
close_rect.h = @max(10.0, close_rect.h);
- fizzy.dvui.dialog_close_rect_override = close_rect;
+ wdvui.dialog_close_rect_override = close_rect;
new_file_path = null;
}
}
@@ -578,11 +575,11 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
if (entry.kind == .file and tree.id_branch == inner_id_extra.*) {
if (Globals.workbench.tab_drag_from_tree_path) |old| {
if (!std.mem.eql(u8, old, abs_path)) {
- fizzy.app.allocator.free(old);
- Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
+ Globals.allocator().free(old);
+ Globals.workbench.tab_drag_from_tree_path = Globals.allocator().dupe(u8, abs_path) catch null;
}
} else {
- Globals.workbench.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null;
+ Globals.workbench.tab_drag_from_tree_path = Globals.allocator().dupe(u8, abs_path) catch null;
}
}
}
@@ -742,9 +739,9 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
if (ext == .fizzy) {
const ui_atlas = Globals.host.uiAtlas();
- const ui_sprite = ui_atlas.sprites[fizzy.atlas.sprites.logo_default];
- const logo_sprite = fizzy.core.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
- _ = fizzy.core.Sprite.draw(
+ const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default];
+ const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source };
+ _ = wb.Sprite.draw(
logo_sprite,
@src(),
ui_atlas.source,
@@ -778,7 +775,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg
if (Globals.host.docFromPath(abs_path)) |doc| {
const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc);
if (doc.owner.showsSaveStatusIndicator(doc)) {
- fizzy.dvui.bubbleSpinner(@src(), .{
+ wdvui.bubbleSpinner(@src(), .{
.id_extra = inner_id_extra.* +% 4001,
.expand = .none,
.min_size_content = .{ .w = 14, .h = 14 },
@@ -919,33 +916,33 @@ pub fn isFileSelected(id: usize) bool {
fn selectionFreeAll() void {
var it = selected_paths.iterator();
- while (it.next()) |e| fizzy.app.allocator.free(e.value_ptr.*);
+ while (it.next()) |e| Globals.allocator().free(e.value_ptr.*);
selected_paths.clearRetainingCapacity();
}
fn selectionPut(id: usize, path: []const u8) void {
if (selected_paths.getPtr(id)) |existing| {
if (std.mem.eql(u8, existing.*, path)) return;
- fizzy.app.allocator.free(existing.*);
- existing.* = fizzy.app.allocator.dupe(u8, path) catch return;
+ Globals.allocator().free(existing.*);
+ existing.* = Globals.allocator().dupe(u8, path) catch return;
return;
}
- const copy = fizzy.app.allocator.dupe(u8, path) catch return;
- selected_paths.put(fizzy.app.allocator, id, copy) catch {
- fizzy.app.allocator.free(copy);
+ const copy = Globals.allocator().dupe(u8, path) catch return;
+ selected_paths.put(Globals.allocator(), id, copy) catch {
+ Globals.allocator().free(copy);
};
}
fn selectionRemove(id: usize) bool {
if (selected_paths.fetchSwapRemove(id)) |kv| {
- fizzy.app.allocator.free(kv.value);
+ Globals.allocator().free(kv.value);
return true;
}
return false;
}
/// Apply a modifier-aware click to the file-tree selection. Indexed by id_extra (path hash).
-fn applyFileClick(id: usize, path: []const u8, mode: fizzy.dvui.TreeSelection.ClickMode) void {
+fn applyFileClick(id: usize, path: []const u8, mode: wdvui.TreeSelection.ClickMode) void {
switch (mode) {
.replace => {
selectionFreeAll();
@@ -1009,14 +1006,14 @@ fn applyFileShiftRange(clicked_id: usize, clicked_path: []const u8, anchor_id: u
/// Derive the click mode from the most recent pointer release event that falls within `rect`.
/// Used after `branch.button.clicked()` so we can honor ctrl/cmd/shift without intercepting the
/// button's own event handling.
-fn detectClickMode(rect: dvui.Rect.Physical) fizzy.dvui.TreeSelection.ClickMode {
- var mode: fizzy.dvui.TreeSelection.ClickMode = .replace;
+fn detectClickMode(rect: dvui.Rect.Physical) wdvui.TreeSelection.ClickMode {
+ var mode: wdvui.TreeSelection.ClickMode = .replace;
for (dvui.events()) |*e| {
if (e.evt != .mouse) continue;
const me = e.evt.mouse;
if (me.action != .release or !me.button.pointer()) continue;
if (!rect.contains(me.p)) continue;
- mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod);
+ mode = wdvui.TreeSelection.clickModeFromMod(me.mod);
}
return mode;
}
@@ -1140,7 +1137,7 @@ fn selectionBranchIdsForMultiDrag(arena: std.mem.Allocator) ![]const usize {
/// Move the drag source (and, for a multi-drag, every other selected path) into `target_dir`.
/// Renames files/folders on disk and rewrites open-file paths in-place. Clears the drag's
/// stashed `removed_path` when complete.
-fn applyFileMove(unique_id: dvui.Id, tree: *fizzy.dvui.TreeWidget, target_dir: []const u8) !void {
+fn applyFileMove(unique_id: dvui.Id, tree: *wdvui.TreeWidget, target_dir: []const u8) !void {
const arena = dvui.currentWindow().arena();
// The primary (floating) row's path is stashed here by the branch that reports `floating()`.
@@ -1228,7 +1225,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File
const path = doc.owner.documentPath(doc);
if (std.mem.containsAtLeast(u8, path, 1, full_path)) {
const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path";
- const new_full = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name });
+ const new_full = try std.fs.path.join(Globals.allocator(), &.{ new_path, file_name });
doc.owner.setDocumentPath(doc, new_full) catch {
dvui.log.err("Failed to update open document path", .{});
};
@@ -1281,7 +1278,7 @@ pub fn pruneMissingSelections() void {
continue;
};
if (selected_id == removed.key) selected_id = null;
- fizzy.app.allocator.free(removed.value);
+ Globals.allocator().free(removed.value);
continue;
};
i += 1;
diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig
index fff1c9a7..1dad6d7e 100644
--- a/src/plugins/workbench/src/plugin.zig
+++ b/src/plugins/workbench/src/plugin.zig
@@ -1,11 +1,8 @@
-//! The workbench plugin: file management. Phase 2 thin shim — its contributions
-//! point at the existing draw entry points through the `fizzy.*` globals rather
-//! than owning new code. Later phases move more behind it until it becomes a
-//! runtime-loaded dylib. Registered from `Editor.postInit`.
+//! The workbench plugin: file management. Registered from `Editor.postInit`.
const std = @import("std");
-const fizzy = @import("../../../fizzy.zig");
const dvui = @import("dvui");
-const sdk = fizzy.sdk;
+const wb = @import("../workbench.zig");
+const sdk = wb.sdk;
const Globals = @import("Globals.zig");
const files = @import("files.zig");
@@ -53,7 +50,7 @@ fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result {
/// global/region binds in `Keybinds.register`; this fills in the file half.
/// Platform: see `Keybinds.register` for why `fizzy.platform.isMacOS()` is used.
fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void {
- if (fizzy.platform.isMacOS()) {
+ if (wb.platform.isMacOS()) {
try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .command = true });
try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .command = true });
try win.keybinds.putNoClobber(win.gpa, "save", .{ .command = true, .key = .s });
diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig
index dd2b5ad6..c785bce8 100644
--- a/src/plugins/workbench/src/workbench_layout.zig
+++ b/src/plugins/workbench/src/workbench_layout.zig
@@ -1,8 +1,7 @@
//! Workspace map maintenance + recursive split drawing (Stage W2).
const std = @import("std");
const dvui = @import("dvui");
-const sdk = @import("sdk");
-const fizzy = @import("../../../fizzy.zig");
+const wbench = @import("../workbench.zig");
const Globals = @import("Globals.zig");
const Workbench = @import("Workbench.zig");
const Workspace = @import("Workspace.zig");
@@ -84,7 +83,7 @@ pub const PanelPanedState = struct {
pub fn drawWorkspaces(wb: *Workbench, panel: PanelPanedState, index: usize) !dvui.App.Result {
if (index >= wb.workspaces.count()) return .ok;
- var s = fizzy.dvui.paned(@src(), .{
+ var s = wbench.wdvui.paned(@src(), .{
.direction = .horizontal,
.collapsed_size = if (index == wb.workspaces.count() - 1) std.math.floatMax(f32) else 0,
.handle_size = handle_size,
diff --git a/src/plugins/workbench/workbench.zig b/src/plugins/workbench/workbench.zig
index 0ad8c505..8811d7a9 100644
--- a/src/plugins/workbench/workbench.zig
+++ b/src/plugins/workbench/workbench.zig
@@ -7,3 +7,12 @@ const std = @import("std");
pub const sdk = @import("sdk");
pub const core = @import("core");
pub const dvui = @import("dvui");
+
+pub const math = core.math;
+pub const atlas = core.atlas;
+pub const platform = core.platform;
+pub const perf = core.perf;
+pub const Sprite = core.Sprite;
+
+/// Shell's custom dvui widgets/helpers (TreeWidget, paned, labelWithKeybind, …).
+pub const wdvui = core.dvui;
diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig
index f24ae2b7..9df1a345 100644
--- a/src/sdk/EditorAPI.zig
+++ b/src/sdk/EditorAPI.zig
@@ -36,6 +36,9 @@ pub const SaveDialogFilter = extern struct {
/// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled.
pub const SaveDialogCallback = *const fn (?[][:0]const u8) void;
+/// Invoked when a native open-file/folder dialog resolves.
+pub const OpenPathsCallback = *const fn (?[][:0]const u8) void;
+
/// Grid dimensions for `createDocument`.
pub const NewDocGrid = struct {
columns: u32 = 1,
@@ -136,6 +139,16 @@ pub const VTable = struct {
setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void,
/// Draw workspace panes (center region); `index` is the root pane (usually 0).
drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result,
+ /// Native open-folder dialog (no-op on web).
+ showOpenFolderDialog: *const fn (ctx: *anyopaque, cb: OpenPathsCallback, default_folder: ?[]const u8) void,
+ /// Native open-file dialog (web: file picker).
+ showOpenFileDialog: *const fn (
+ ctx: *anyopaque,
+ cb: OpenPathsCallback,
+ filters: []const SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+ ) void,
// ---- document editing (active file) ----
accept: *const fn (ctx: *anyopaque) anyerror!void,
@@ -317,6 +330,20 @@ pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result {
return self.vtable.drawWorkspaces(self.ctx, index);
}
+pub fn showOpenFolderDialog(self: EditorAPI, cb: OpenPathsCallback, default_folder: ?[]const u8) void {
+ self.vtable.showOpenFolderDialog(self.ctx, cb, default_folder);
+}
+
+pub fn showOpenFileDialog(
+ self: EditorAPI,
+ cb: OpenPathsCallback,
+ filters: []const SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+) void {
+ self.vtable.showOpenFileDialog(self.ctx, cb, filters, default_filename, default_folder);
+}
+
pub fn accept(self: EditorAPI) !void {
return self.vtable.accept(self.ctx);
}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index d1dca264..c2c97cc9 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -250,6 +250,20 @@ pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result {
return if (self.shell_api) |a| try a.drawWorkspaces(index) else .ok;
}
+pub fn showOpenFolderDialog(self: *Host, cb: EditorAPI.OpenPathsCallback, default_folder: ?[]const u8) void {
+ if (self.shell_api) |a| a.showOpenFolderDialog(cb, default_folder);
+}
+
+pub fn showOpenFileDialog(
+ self: *Host,
+ cb: EditorAPI.OpenPathsCallback,
+ filters: []const EditorAPI.SaveDialogFilter,
+ default_filename: []const u8,
+ default_folder: ?[]const u8,
+) void {
+ if (self.shell_api) |a| a.showOpenFileDialog(cb, filters, default_filename, default_folder);
+}
+
pub fn accept(self: *Host) !void {
if (self.shell_api) |a| return a.accept();
}
From dc372c6730027563bde8d3d057aff28ffd21c1e2 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 10:44:42 -0500
Subject: [PATCH 31/49] finish workbench
---
HANDOFF.md | 104 +++++++++++++++---------
src/fizzy.zig | 2 -
src/plugins/workbench/module.zig | 6 +-
src/plugins/workbench/src/Workspace.zig | 12 ---
src/plugins/workbench/src/plugin.zig | 4 +
5 files changed, 71 insertions(+), 57 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index 939cd1cd..a64b96f3 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,18 +1,27 @@
-# Fizzy Modular-Plugin Refactor — Handoff (Phase 4, Stage D in progress)
+# Fizzy Modular-Plugin Refactor — Handoff (Phase 4 COMPLETE → Phase 5: runtime dylib plugins)
## TL;DR
-We are turning the monolithic editor into a **core shell + plugins** layout. Phase 4
-makes `core` a real, separately-wired Zig module with no dependency on the `fizzy`
-app hub, then (Stages B–E) lifts the pixel-art editor fully behind the plugin SDK so
-it can become its own compile-time module.
+We turned the monolithic editor into a **core shell + plugins** layout. **Phase 4 (compile-time
+modular separation) is COMPLETE:** `core`, `pixelart`, and `workbench` are all decoupled build
+modules; the shell imports plugins only via `@import("pixelart")` / `@import("workbench")` and
+talks to them through the SDK vtable + `Host`/`EditorAPI` registries. All three configs green.
-**Done:** Stage A1, A2, A3, B, and **Stage C (full)** — per-plugin settings, docs/tabs
-storage inversion, save/pack/editor-action decoupling, platform detection, explorer pane
-lift, sprites bottom-panel lift.
+**The next phase (Phase 5) is runtime dylib plugins** — see **"Phase 5 — Runtime dylib plugins"**
+immediately below. Everything under "Phase 4 history" further down is DONE reference material.
-**In progress:** **Stage D (substantially complete)** — module scaffold, `Globals` injection,
-Workspace decoupling, zero `fizzy.zig` imports in plugin, `b.addModule("pixelart")` wired.
+---
+
+### Phase 4 history (all DONE — reference)
+
+Phase 4 made `core` a standalone Zig module, then (Stages B–E) lifted the pixel-art editor fully
+behind the plugin SDK, then (Stage W) did the same for workbench.
+
+**Stage A1–A3, B, C (full)** — `core` module; per-plugin settings, docs/tabs storage inversion,
+save/pack/editor-action decoupling, platform detection, explorer pane lift, sprites bottom-panel lift.
+
+**Stage D — DONE** — module scaffold, `Globals` injection, Workspace decoupling, zero `fizzy.zig`
+imports in plugin, `b.addModule("pixelart")` wired.
**Stage E — polish complete** (see "Stage E polish — DONE" below): shell no longer imports
`pixelart.internal`; `pixelart_state` field access fully routed to lifecycle + vtable;
@@ -23,12 +32,24 @@ primitive + sprite-id index all in `core`; neither shell nor plugin reaches the
**Dialog-registry lift — DONE** (see "Multi-plugin readiness"): the shell no longer names any
pixel-art dialog. `pixelart.dialogs` is gone from `src/editor` + `src/plugins/workbench`.
-**Next:** wire `b.addModule("workbench", …)` + lift workbench off `fizzy.editor`
-(logo atlas draw, `fizzy.editor.host.requestNewDocument`, etc.).
-
-> **Read this first if you're a fresh agent:** Stage D/E + the dialog-registry lift are done.
-> Shell→pixelart surface is now only `pixelart.plugin` (vtable) + `State`/`Globals` (lifecycle).
-> All three build configs are green right now.
+**Workbench lift (Stage W1–W5) — DONE** (see "Stage W" below): workbench is now a real
+`@import("workbench")` build module (`wireWorkbenchModule` in `build.zig`, native/web/test).
+**Zero live `fizzy.*` refs in `src/plugins/workbench/**`** (was 225). Workspace/grouping/tab-drag
+state moved onto the `Workbench` struct; doc-collection + folder/settings/etc. route through
+`Globals.host` (EditorAPI) and `doc.owner`. Shell imports both plugins ONLY via
+`@import("pixelart")` / `@import("workbench")`.
+
+> **Read this first if you're a fresh agent:** the **compile-time modular-separation phase is
+> complete** — `core`, `pixelart`, `workbench` are all decoupled build modules; the only shell
+> path-import into a plugin tree is the documented build-time `process_assets.zig → Atlas.zig`.
+> Shell→plugin is now just the vtable/registry boundary plus the shell owning each plugin's
+> state struct on `Editor` (`pixelart_state`, `workbench`) for lifecycle — the same arrangement
+> for both. All three build configs are green.
+>
+> **Next big rock (not started):** runtime dylib plugins ("one source / two link modes" —
+> dynamic desktop, static web). Optional polish first: route the few remaining
+> `editor.workbench.` / `editor.pixelart_state.` direct reaches through
+> accessors/vtable (a "Stage E" for workbench), and consider symmetry cleanups in `fizzy.zig`.
All three build configs are green:
@@ -58,15 +79,15 @@ src/plugins//
| File | Role |
|------|------|
-| `module.zig` | Compile-time module root; shell reaches it via `fizzy.pixelart_mod` / future `@import("pixelart")` |
+| `module.zig` | Compile-time module root; shell imports via `@import("pixelart")` / `@import("workbench")` |
| `pixelart.zig` / `workbench.zig` | Hub named after the plugin folder; files in `src/**` import as `../.zig` or `../../.zig` |
-| `src/State.zig` | Plugin runtime state (`pixelart` only) |
-| `src/Globals.zig` | Runtime injection: `gpa`, `state`, `packer` (`pixelart` only) |
+| `src/State.zig` (pixelart) / `src/Workbench.zig` (workbench) | Plugin runtime state struct (owned on `Editor`) |
+| `src/Globals.zig` | Runtime injection — pixelart: `gpa`/`state`/`packer`; workbench: `gpa`/`host`/`workbench` |
| `src/plugin.zig` | Plugin registration + draw entry points |
| `src/deps/` | Third-party deps (`pixelart` only) |
-Shell still uses `fizzy.pixelart: *State` global during migration; plugin code uses
-`Globals.state`.
+Both plugins keep their state struct on `Editor` (`editor.pixelart_state`, `editor.workbench`)
+for lifecycle; plugin code reaches it + the Host through its `Globals`.
### macOS case-insensitive rename protocol
@@ -355,16 +376,17 @@ Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.draw
---
-## Stage W — workbench lift (IN PROGRESS, user signed off 2026-06-19)
+## Stage W — workbench lift — COMPLETE (signed off 2026-06-19)
-Workbench is the last "half-shell" plugin: 225 `fizzy` refs (163 `fizzy.editor`) across
-`files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`, `plugin.zig`. Unlike pixelart
-it has **no state-injection yet** — `plugin.state = undefined`, draw hooks call
-`fizzy.editor.*` directly, and the `Workbench` struct instance lives on `Editor`. Tab order *is*
-the order of `Editor.open_files`, which workbench mutates in place (`std.mem.swap` on
-values/keys at `Workspace.zig:467+`) — that's the deep coupling.
+Workbench was the last "half-shell" plugin: it started this stage at **225 `fizzy` refs**
+(163 `fizzy.editor`) across `files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`,
+`plugin.zig`, with no state-injection (`plugin.state = undefined`, draw hooks calling
+`fizzy.editor.*`), the `Workbench` struct on `Editor`, and tab order living in
+`Editor.open_files` (mutated in place via `std.mem.swap`). After W1–W5 below:
+**zero live `fizzy.*` refs remain** (comments only), workbench is a `@import("workbench")`
+build module, and all three configs are green. Verified 2026-06-19.
-**Plan (mirrors pixelart Stage C–E), each stage builds all 3 configs green:**
+**Plan (mirrored pixelart Stage C–E), each stage built all 3 configs green:**
- **W1 — host-injection seam + doc-collection routing — DONE.** Added
`workbench/src/Globals.zig` (`host: *sdk.Host`, `gpa`), injected in `App.zig` (path import
@@ -455,21 +477,23 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
## State of the tree
-**Uncommitted** — nothing in this Phase-4 effort has been committed (commit on request).
-
-Beyond Stages A–C, the working tree now also has Stage D scaffold changes:
-`module.zig`, `pixelart.zig`, `State.zig`, `Globals.zig`, hub re-exports in `fizzy.zig`,
-shell import migration, `State.docs` + explorer/bottom-panel fields, `bridge.zig` removed.
+**Committed** — Phase-4 is committed through the workbench lift (latest: `stage w4` +
+follow-up). The compile-time modular-separation phase is complete; working tree is clean
+apart from in-flight HANDOFF/cleanup edits.
-Sanity greps:
+Sanity greps (verified 2026-06-19):
```
+# pixelart — fully decoupled
grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live
-grep -rn 'fizzy\.platform' src/plugins/pixelart → 0
-grep -rn 'fizzy\.app\.allocator' src/plugins/pixelart → 0
-grep -rn 'bridge\.' src/plugins/pixelart → 0
-grep -rn '@import.*fizzy' src/plugins/pixelart → 0
-grep -rn 'editor/(dialogs|WebFileIo)' src/plugins/pixelart → 0
+grep -rn '@import.*fizzy' src/plugins/pixelart → 0
+
+# workbench — fully decoupled (Stage W)
+grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live
+grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports)
+
+# shell imports plugins only via build modules; only build-time exception:
+grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig
```
All three configs green: `zig build`, `zig build check-web`, `zig build test`.
diff --git a/src/fizzy.zig b/src/fizzy.zig
index 63fc8f0b..cfa78dbb 100644
--- a/src/fizzy.zig
+++ b/src/fizzy.zig
@@ -1,6 +1,4 @@
const std = @import("std");
-const mach = @import("mach");
-const Core = mach.Core;
/// Shared infrastructure module (gfx, math, fs, generated atlas, platform,
/// paths, the generic dvui hub + widgets). Consumed by the shell and plugins.
diff --git a/src/plugins/workbench/module.zig b/src/plugins/workbench/module.zig
index d29a7613..dbdfd671 100644
--- a/src/plugins/workbench/module.zig
+++ b/src/plugins/workbench/module.zig
@@ -1,8 +1,8 @@
//! Workbench plugin compile-time module root.
//!
-//! Wired in `build.zig` as `b.addModule("workbench", …)` (future). Shell code can
-//! import this as `@import("workbench")`. Plugin files inside `src/` import
-//! `../workbench.zig` for shared sdk/core access.
+//! Wired in `build.zig` via `wireWorkbenchModule` (`b.addModule("workbench", …)`) for the
+//! native, web, and test roots. Shell code imports this as `@import("workbench")`. Plugin
+//! files inside `src/` import `../workbench.zig` for shared sdk/core access.
pub const workbench = @import("workbench.zig");
pub const plugin = @import("src/plugin.zig");
pub const files = @import("src/files.zig");
diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig
index d69c49e6..69ced3ed 100644
--- a/src/plugins/workbench/src/Workspace.zig
+++ b/src/plugins/workbench/src/Workspace.zig
@@ -945,18 +945,6 @@ pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void {
);
if (button.clicked()) {
- // if (try dvui.dialogNativeFileOpenMultiple(dvui.currentWindow().arena(), .{
- // .title = "Open Files...",
- // .filter_description = ".pixi, .png",
- // .filters = &.{ "*.pixi", "*.png" },
- // })) |files| {
- // for (files) |file| {
- // _ = fizzy.editor.openFilePath(file, Globals.workbench.open_workspace_grouping) catch {
- // std.log.err("Failed to open file: {s}", .{file});
- // };
- // }
- // }
-
Globals.host.showOpenFileDialog(openFilesCallback, &.{
.{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" },
}, "", null);
diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig
index 1dad6d7e..9e83a078 100644
--- a/src/plugins/workbench/src/plugin.zig
+++ b/src/plugins/workbench/src/plugin.zig
@@ -10,6 +10,10 @@ const files = @import("files.zig");
pub const view_files = "workbench.files";
pub const center_workspaces = "workbench.workspaces";
+// `state` is intentionally unused: the workbench owns no documents (no doc vtable hooks, so
+// `DocHandle.owner` is never this plugin) and its registered hooks reach the `Workbench`
+// instance + Host through `Globals`, not the vtable `state` arg. Kept `undefined` so a stray
+// dereference fails loudly rather than reading a bogus pointer.
var plugin: sdk.Plugin = .{
.state = undefined,
.vtable = &vtable,
From 7bc1f8beb372db014424b38bcb472e0f2a936408 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 11:14:53 -0500
Subject: [PATCH 32/49] plan Phase 5
---
HANDOFF.md | 234 ++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 207 insertions(+), 27 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index a64b96f3..fb0d8d46 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -7,8 +7,10 @@ modular separation) is COMPLETE:** `core`, `pixelart`, and `workbench` are all d
modules; the shell imports plugins only via `@import("pixelart")` / `@import("workbench")` and
talks to them through the SDK vtable + `Host`/`EditorAPI` registries. All three configs green.
-**The next phase (Phase 5) is runtime dylib plugins** — see **"Phase 5 — Runtime dylib plugins"**
-immediately below. Everything under "Phase 4 history" further down is DONE reference material.
+**The next phase (Phase 5) is runtime dylib plugins** — desktop dynamic libraries
+(macOS/Linux/Windows, `arm64` + `x86_64`), web static, built-ins bundled with the app.
+See **"Phase 5 — Runtime dylib plugins"** below. Everything under "Phase 4 history"
+further down is DONE reference material.
---
@@ -46,10 +48,9 @@ state moved onto the `Workbench` struct; doc-collection + folder/settings/etc. r
> state struct on `Editor` (`pixelart_state`, `workbench`) for lifecycle — the same arrangement
> for both. All three build configs are green.
>
-> **Next big rock (not started):** runtime dylib plugins ("one source / two link modes" —
-> dynamic desktop, static web). Optional polish first: route the few remaining
-> `editor.workbench.` / `editor.pixelart_state.` direct reaches through
-> accessors/vtable (a "Stage E" for workbench), and consider symmetry cleanups in `fizzy.zig`.
+> **Next big rock:** Phase 5 runtime dylib plugins — see **"Phase 5 — Runtime dylib plugins"**
+> above. Optional polish first (5a): break workbench→pixelart compile-time link and route
+> remaining `editor.workbench.*` field pokes (workbench Stage E).
All three build configs are green:
@@ -64,6 +65,185 @@ the sandbox (network/file access).
---
+## Phase 5 — Runtime dylib plugins (NEXT — not started)
+
+### Goal
+
+**One source, two link modes:** each plugin compiles from the same Zig sources, but the
+link mode depends on the target:
+
+| Target | Link mode | Loader |
+|--------|-----------|--------|
+| macOS / Linux / Windows (`arm64` + `x86_64`) | **Dynamic** — plugin is a `.dylib` / `.so` / `.dll` | Host `dlopen`s at startup (built-ins) or on demand (3rd-party) |
+| Web (`wasm32`) | **Static** — plugin is a Zig module linked into the exe | No runtime loader; same as today |
+
+Phase 4 proved the **vtable + `Host` registry boundary** is the right seam. Phase 5 makes
+that boundary cross a real dynamic-library load on desktop without changing plugin logic.
+
+### Product decisions (locked for this phase)
+
+- **Built-in plugins always ship with Fizzy.** Pixelart, workbench, and future built-ins
+ (e.g. textedit) live in this repo under `src/plugins/`. We are **not** planning a
+ "shell-only" Fizzy distribution stripped of plugins.
+- **Built-in dylibs are bundled, not separately versioned.** The release artifact is one
+ Velopack/update unit: the exe plus its built-in plugin dylibs at matching versions.
+ Velopack does **not** sign or distribute each plugin independently; plugin dylibs ride
+ inside the same app package the exe does.
+- **3rd-party plugins are a later concern, but the architecture must allow them.** An
+ external Zig project should eventually be able to `@import` a published Fizzy plugin SDK,
+ write dvui-driven UI through the same `Plugin` vtable, build a dylib, and have Fizzy
+ load it at runtime — registering menus, sidebar views, bottom views, and doc handlers
+ through the same `Host` registries built-ins use today. A plugin store + hot-load path
+ is out of scope for the first Phase-5 milestones but should not be designed away.
+- **Reference plugins to demonstrate complexity:**
+ - **pixelart** — full editor plugin: docs, save/dirty, explorer panes, bottom panel,
+ dialogs, pack jobs; consumes **workbench-api** for tabs/splits (inter-plugin service).
+ - **textedit** (future built-in) — lighter editor plugin for `.txt` / `.json` / `.atlas`
+ etc., coexisting in tabs beside pixel-art docs (see "Multi-plugin readiness").
+ - **workbench** — infrastructure plugin (file tree, workspaces); likely stays a
+ built-in static or early-loaded dylib since it owns the center layout.
+
+### Dylib mechanism — Option 2: context injection (validated)
+
+The `spikes/shared-globals` spike ruled out **Mechanism A** (one shared `libdvui` /
+`rdynamic` symbol interposition — globals are not auto-shared across the dylib boundary on
+macOS two-level namespace, and the same applies on Linux/Windows).
+
+**Mechanism B (context injection) is the chosen approach:**
+
+- Host and plugin each compile their **own copy** of `dvui` + `sdk` + `core` (same pinned
+ Zig + source versions → identical struct layouts).
+- Host owns the live `dvui.Window`, arena, backend, and GPU path.
+- Before calling into a plugin's draw/tick hooks, the host **injects** the plugin-side
+ dvui globals (`current_window` per frame; `io` / `ft2lib` / `debug` at init — all
+ `pub var`, no dvui patch needed) with pointers into the host's live state.
+- Cross-boundary vtable types (`Plugin`, `DocHandle`, `Host`, `EditorAPI`, workbench-api
+ `Api`, …) are normal Zig structs, not strict C-ABI — host and plugin are pinned to the
+ same SDK build. Only the **dlopen entry symbols** need `callconv(.c)`.
+- Load-time **ABI version gate** rejects mismatched plugin builds before any vtable call.
+
+See `spikes/shared-globals/README.md` and `spikes/shared-globals/build.zig` for the
+minimal host+plugin dylib harness.
+
+### What already exists (Phase 4 carry-over)
+
+| Piece | Location | Phase-5 role |
+|-------|----------|--------------|
+| Plugin vtable | `src/sdk/Plugin.zig` | Same shape static or dylib; hooks already optional fn pointers |
+| Host registries | `src/sdk/Host.zig` | Menus / sidebar / bottom / center / settings — hot-load target |
+| EditorAPI | `src/sdk/EditorAPI.zig` | Shell reach-through; plugins never import `fizzy.zig` |
+| Globals injection | `src/plugins/*/src/Globals.zig` | Pattern for post-`dlopen` pointer wiring |
+| Inter-plugin service | `Workbench.Api` in `src/plugins/workbench/src/Workbench.zig` | pixelart → workbench without compile-time coupling (goal) |
+| Static registration | `Editor.postInit` | `workbench_mod.plugin.register` + `pixelart.plugin.register` — replace with loader on native |
+
+**No dylib build targets yet** — `build.zig` has no `addLibrary(.linkage = .dynamic)`.
+Plugins are still compile-time modules on all targets.
+
+### Remaining Phase-4 polish (do before or alongside Phase-5a)
+
+These are not blockers for a spike, but should be cleared so built-in and 3rd-party
+plugins share the same rules:
+
+1. **Break workbench → pixelart compile-time link (blocker for independent dylibs).**
+ - `build.zig` `wireWorkbenchModule` adds `pixelart` as a module dep.
+ - `workbench/src/files.zig` reads `pixelart.Globals.state.colors.palette` for file-row
+ tinting — the only live cross-plugin import in the workbench tree.
+ - Fix: register a file-row color hook via **workbench-api** (or a small `Host` callback
+ registry) that pixelart contributes during `register()`; drop the `pixelart` import
+ from the workbench module.
+
+2. **Workbench "Stage E" — route shell `editor.workbench.*` field pokes.**
+ Pixelart Stage E is done (`pixelart_state` is lifecycle-only in `App.zig`). Workbench
+ still has ~24 direct `editor.workbench.` reaches in `Editor.zig` plus a few in
+ `Explorer.zig`, `Keybinds.zig`, `WebFileIo.zig`, `singleton_native.zig` (mostly
+ `open_workspace_grouping` — callers should use `editor.currentGroupingID()` instead).
+ Extend `EditorAPI` / thin `Editor` delegators so the shell never names workbench internals.
+
+3. **Minor hygiene** (non-blocking): `web_main.zig` force-imports `pixelart.widgets.FileWidget`
+ for wasm link; `fizzy.zig` globals (`app`, `editor`, `packer`) shrink as the loader owns
+ more lifecycle.
+
+### Phase-5 implementation plan (incremental; all three configs green after each step)
+
+Each step ends with `zig build`, `zig build check-web`, `zig build test`.
+
+#### 5a — Pre-dylib decoupling (Phase-4 tail)
+
+| Step | Work | Done when |
+|------|------|-----------|
+| **5a.1** | Break workbench→pixelart link (palette row color via workbench-api hook; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green |
+| **5a.2** | Workbench Stage E: route `editor.workbench.*` / `fizzy.editor.workbench.*` through EditorAPI | `grep 'editor\.workbench\.' src/` → lifecycle + delegators only |
+
+#### 5b — Dylib scaffolding (native only; web unchanged)
+
+| Step | Work | Done when |
+|------|------|-----------|
+| **5b.1** | SDK **export surface** — `fizzy_plugin_abi_version()` + `fizzy_plugin_register(*Host)` (`callconv(.c)`); document ABI version constant | Spike + one plugin export compile |
+| **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static |
+| **5b.3** | **Host loader module** — `std.DynLib` open, ABI gate, resolve entry, call `register`; wire `Globals` after load | Loader unit test or dev-only flag loads a dylib and registers one sidebar view |
+| **5b.4** | **Dvui context injection** in shell frame loop — set plugin-side globals before plugin draw/tick (per spike Mechanism B) | Plugin draw mutates host `Window` in a loaded dylib (manual or integration test) |
+
+Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2
+lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is the same.
+
+#### 5c — Built-in plugins as bundled dylibs (desktop)
+
+| Step | Work | Done when |
+|------|------|-----------|
+| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web | App opens `.fiz` files via loaded dylib on macOS; web unchanged |
+| **5c.2** | Built-in workbench dylib (or keep static until pixelart path is stable — workbench owns center layout) | Tabs/splits work from loaded workbench |
+| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel |
+
+Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the
+`register()` path is identical either way.
+
+#### 5d — Reference plugins + 3rd-party path (later milestones)
+
+| Step | Work | Notes |
+|------|------|-------|
+| **5d.1** | **textedit** built-in plugin | Exercises multi-editor tabs, `fileTypePriority`, `registerBottomView`; forces "New > kind" chooser |
+| **5d.2** | **Published plugin SDK** (`fizzy-plugin-sdk` or similar) | External Zig project: import SDK + dvui, implement vtable, `zig build` → dylib |
+| **5d.3** | **User plugin directory** + discovery | Scan `~/.fizzy/plugins/` (or platform equivalent); load + ABI-gate |
+| **5d.4** | **Hot load** + plugin store | Reload dylib, refresh Host registries; trust/signing model TBD |
+
+### 3rd-party / distribution considerations (figure out later, don't block 5a–5c)
+
+- **Trust:** built-ins are co-signed with the app; 3rd-party plugins need a separate policy
+ (user opt-in, hash allowlist, dev-mode only, etc.) — not decided yet.
+- **Velopack:** app updates replace the whole `zig-out` tree including built-in dylibs; no
+ per-plugin update channel for built-ins.
+- **Version skew:** ABI gate + documented "built with Fizzy X.Y" requirement for 3rd-party
+ dylibs; plugin store would pin compatible versions.
+- **Hot load:** `Host` registries already support append; unload needs vtable `deinit` +
+ registry removal + no dangling `DocHandle.owner` — design when approaching 5d.4.
+
+### Phase-5 sanity greps (add to the checklist)
+
+```
+# no cross-plugin compile-time imports (after 5a.1)
+grep -rn '@import("pixelart")' src/plugins/workbench → 0
+grep -rn 'pixelart\.' src/plugins/workbench → 0
+
+# shell workbench field pokes routed (after 5a.2)
+grep -rn 'editor\.workbench\.' src/ → lifecycle/delegators only
+grep -rn 'fizzy\.editor\.workbench\.' src/ → 0
+
+# dylib entry exists (after 5b.1)
+grep -rn 'fizzy_plugin_' src/sdk src/plugins → export symbols present
+
+# web stays static (always)
+grep -rn 'DynLib\|dlopen' src/ → 0 on web code paths
+```
+
+### Where to begin (next session)
+
+**Start with 5a.1** — break the workbench→pixelart compile-time link. It is one focused
+change (palette row color hook + `build.zig` dep removal) and is the last hard coupling
+between plugins. Then **5a.2** (workbench Stage E), then **5b.1** (export surface +
+promote the spike pattern into the main tree).
+
+---
+
## Plugin directory layout (convention)
Every plugin follows the same shape:
@@ -268,23 +448,21 @@ src/web_main.zig → FileWidget.zig force-import (wasm link — mi
---
-## Stage D — remaining work (start here)
+## Stage D — remaining work — DONE (historical)
-1. **Route any straggler shell path imports** of pixel-art files through `pixelart_mod`
- or `@import("pixelart")` (mostly done; `process_assets.zig` stays separate).
+All items below were completed in Stage D/E/W. Kept for archaeology only.
-2. **Optional:** wire `b.addModule("workbench", …)` the same way.
-
-3. **Stage E cleanup:** shell `Editor.zig` still uses `fizzy.pixelart.*` extensively —
- shrink as plugin vtable / EditorAPI surface grows.
+1. ~~Route straggler shell path imports through `pixelart_mod` / `@import("pixelart")`.~~ DONE
+2. ~~Wire `b.addModule("workbench", …)`.~~ DONE (Stage W5)
+3. ~~Stage E cleanup in shell `Editor.zig`.~~ DONE (pixelart); workbench Stage E → Phase 5a.2
Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both
-`App.zig` and `fizzy.zig` via a third path; always go through `fizzy.pixelart_mod` in
-app code until the build module is fully wired.
+`App.zig` and `fizzy.zig` via a third path; shell code uses `@import("pixelart")` /
+`@import("workbench")` build modules.
---
-## Stage E — strip pixel-art names from shell hubs (in progress)
+## Stage E — strip pixel-art names from shell hubs — COMPLETE
**Done this session:**
- **`Editor.pixelart_state`** — shell reaches plugin state through the editor, not scattered `fizzy.pixelart.*` (53 → 0 direct field accesses in shell code; `fizzy.pixelart` global remains only in `App.zig` lifecycle).
@@ -436,10 +614,7 @@ End-state achieved. Verified this session:
- **Plugin** consumes `core.Atlas`/`core.Sprite` for its own rendering (composites,
reflections, `water_surface`) and builds its own packed `internal/Atlas.zig` at pack time.
- **Neither side reaches the other's atlas** — `grep 'editor.atlas|fizzy.atlas' src/plugins/pixelart/src` → 0.
-
-Residual: `workbench/files.zig` + `workbench/Workspace.zig` draw the logo via
-`fizzy.editor.atlas` — that's the workbench plugin still routing through `fizzy.editor`
-(a separate "workbench off the app hub" concern), not a sprite/atlas-in-core gap.
+- Workbench draws the logo via `Globals.host.uiAtlas()` (not `fizzy.editor.atlas`).
---
@@ -461,6 +636,8 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
| Path | Role |
|------|------|
| `HANDOFF.md` | This file |
+| `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) |
+| `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` |
| `src/plugins/pixelart/module.zig` | Pixel-art build module root |
| `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub |
| `src/plugins/pixelart/src/` | Pixel-art implementation tree |
@@ -477,21 +654,24 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
## State of the tree
-**Committed** — Phase-4 is committed through the workbench lift (latest: `stage w4` +
-follow-up). The compile-time modular-separation phase is complete; working tree is clean
-apart from in-flight HANDOFF/cleanup edits.
+**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5
+documented; implementation not started.**
-Sanity greps (verified 2026-06-19):
+Sanity greps (verified 2026-06-19; Phase-5 targets in **"Phase 5 sanity greps"** above):
```
-# pixelart — fully decoupled
-grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live
+# pixelart — fully decoupled from fizzy
+grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (comments only)
grep -rn '@import.*fizzy' src/plugins/pixelart → 0
-# workbench — fully decoupled (Stage W)
+# workbench — decoupled from fizzy; one cross-plugin link remains (Phase 5a.1)
grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live
+grep -rn '@import("pixelart")' src/plugins/workbench → 1 (files.zig — fix in 5a.1)
grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports)
+# shell workbench field pokes (Phase 5a.2)
+grep -rn 'editor\.workbench\.' src/editor src/backend → ~24 (route through EditorAPI)
+
# shell imports plugins only via build modules; only build-time exception:
grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig
```
From aae2667d05496c1162c3873aa878244c96215e9d Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 11:26:33 -0500
Subject: [PATCH 33/49] Phase 5 part a1
---
HANDOFF.md | 53 ++++++++++++++++++++-------
build.zig | 11 ++----
src/plugins/pixelart/src/plugin.zig | 8 ++++
src/plugins/workbench/src/Globals.zig | 2 +-
src/plugins/workbench/src/files.zig | 5 +--
src/sdk/Host.zig | 24 ++++++++++++
6 files changed, 78 insertions(+), 25 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index fb0d8d46..f0f3deb5 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -148,9 +148,10 @@ plugins share the same rules:
- `build.zig` `wireWorkbenchModule` adds `pixelart` as a module dep.
- `workbench/src/files.zig` reads `pixelart.Globals.state.colors.palette` for file-row
tinting — the only live cross-plugin import in the workbench tree.
- - Fix: register a file-row color hook via **workbench-api** (or a small `Host` callback
- registry) that pixelart contributes during `register()`; drop the `pixelart` import
- from the workbench module.
+ - Fix: register a file-row fill-color hook on **`Host`** (`registerFileRowFillColor`) that
+ pixelart contributes during `register()`; drop the `pixelart` import from the workbench
+ module. (Host registry chosen over workbench-api to avoid service init ordering and a
+ pixelart→workbench compile-time dep.)
2. **Workbench "Stage E" — route shell `editor.workbench.*` field pokes.**
Pixelart Stage E is done (`pixelart_state` is lifecycle-only in `App.zig`). Workbench
@@ -171,7 +172,7 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`.
| Step | Work | Done when |
|------|------|-----------|
-| **5a.1** | Break workbench→pixelart link (palette row color via workbench-api hook; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green |
+| **5a.1** | Break workbench→pixelart link (`Host.registerFileRowFillColor`; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green |
| **5a.2** | Workbench Stage E: route `editor.workbench.*` / `fizzy.editor.workbench.*` through EditorAPI | `grep 'editor\.workbench\.' src/` → lifecycle + delegators only |
#### 5b — Dylib scaffolding (native only; web unchanged)
@@ -235,12 +236,38 @@ grep -rn 'fizzy_plugin_' src/sdk src/plugins → export symbols pres
grep -rn 'DynLib\|dlopen' src/ → 0 on web code paths
```
+### On-disk layout (locked)
+
+Fizzy already separates **install dir** from **user config** (`core/paths.zig` →
+`configFolder()`; `App.zig` chdirs to the executable dir on native). Phase 5 keeps that
+split and adds two plugin locations:
+
+| Kind | Path | Writable | Updated by |
+|------|------|----------|------------|
+| **Built-in dylibs** | `/plugins/.{dylib,so,dll}` | No (install tree) | Velopack / app update (same unit as exe) |
+| **User / 3rd-party dylibs** | `/plugins//plugin.{dylib,so,dll}` | Yes | User / future plugin store |
+| **Plugin settings** | `/settings.json` → `"plugins": { … }` | Yes | App (already via `Host.plugin_settings`) |
+
+`` is where the binary lives (and where the app chdirs on launch). ``
+is the OS user config dir + `fizzy/` (e.g. `~/Library/Application Support/fizzy`,
+`~/.config/fizzy`) — **not** beside the exe.
+
+**Loader search order (native):**
+
+1. Built-ins — fixed list from `{exe_dir}/plugins/.`
+2. User plugins — scan `{config_folder}/plugins/*/plugin.`
+3. Dev override — env var e.g. `FIZZY_PLUGIN_PATH` (optional, for local dylib hacking)
+
+Web: no loader; plugins stay statically linked into the wasm binary.
+
+Built-in dylibs ship inside the same Velopack package as the exe (no per-plugin signing or
+update channel). User plugins survive app updates because they live under config, not install.
+
+Repo source tree `src/plugins/` is **build layout only** — unrelated to these runtime paths.
+
### Where to begin (next session)
-**Start with 5a.1** — break the workbench→pixelart compile-time link. It is one focused
-change (palette row color hook + `build.zig` dep removal) and is the last hard coupling
-between plugins. Then **5a.2** (workbench Stage E), then **5b.1** (export surface +
-promote the spike pattern into the main tree).
+**5a.1** — done. **Next: 5a.2** (workbench Stage E), then **5b.1** (export surface).
---
@@ -655,21 +682,21 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
## State of the tree
**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5
-documented; implementation not started.**
+documented; 5a.1 complete** (workbench no longer compile-time imports pixelart).
-Sanity greps (verified 2026-06-19; Phase-5 targets in **"Phase 5 sanity greps"** above):
+Sanity greps (Phase-5 targets in **"Phase 5 sanity greps"** above):
```
# pixelart — fully decoupled from fizzy
grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (comments only)
grep -rn '@import.*fizzy' src/plugins/pixelart → 0
-# workbench — decoupled from fizzy; one cross-plugin link remains (Phase 5a.1)
+# workbench — decoupled from fizzy and pixelart (5a.1 done)
grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live
-grep -rn '@import("pixelart")' src/plugins/workbench → 1 (files.zig — fix in 5a.1)
+grep -rn 'pixelart' src/plugins/workbench → 0
grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports)
-# shell workbench field pokes (Phase 5a.2)
+# shell workbench field pokes (Phase 5a.2 — next)
grep -rn 'editor\.workbench\.' src/editor src/backend → ~24 (route through EditorAPI)
# shell imports plugins only via build modules; only build-time exception:
diff --git a/build.zig b/build.zig
index dabd2f9c..087716e5 100644
--- a/build.zig
+++ b/build.zig
@@ -412,7 +412,7 @@ pub fn build(b: *std.Build) !void {
});
web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module);
- const pixelart_module_web = wirePixelartModule(b, web_target, optimize, .{
+ _ = wirePixelartModule(b, web_target, optimize, .{
.dvui = dvui_web_dep.module("dvui_web"),
.core = core_module_web,
.sdk = sdk_module_web,
@@ -428,7 +428,6 @@ pub fn build(b: *std.Build) !void {
.core = core_module_web,
.sdk = sdk_module_web,
.icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
- .pixelart = pixelart_module_web,
.backend = null,
}, web_exe.root_module);
@@ -868,7 +867,7 @@ pub fn build(b: *std.Build) !void {
}
fizzy_test_module.addImport("core", core_module_test);
const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
- const pixelart_module_test = wirePixelartModule(b, target, optimize, .{
+ _ = wirePixelartModule(b, target, optimize, .{
.dvui = dvui_testing_dep.module("dvui_testing"),
.core = core_module_test,
.sdk = sdk_module_test,
@@ -884,7 +883,6 @@ pub fn build(b: *std.Build) !void {
.core = core_module_test,
.sdk = sdk_module_test,
.icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
- .pixelart = pixelart_module_test,
.backend = dvui_testing_dep.module("testing"),
}, fizzy_test_module);
@@ -1218,7 +1216,7 @@ fn addFizzyExecutableForTarget(
core_module.addImport("icons", dep.module("icons"));
icons_module = dep.module("icons");
}
- const pixelart_module = wirePixelartModule(b, resolved_target, optimize, .{
+ _ = wirePixelartModule(b, resolved_target, optimize, .{
.dvui = dvui_dep.module("dvui_sdl3"),
.core = core_module,
.sdk = sdk_module,
@@ -1234,7 +1232,6 @@ fn addFizzyExecutableForTarget(
.core = core_module,
.sdk = sdk_module,
.icons = icons_module,
- .pixelart = pixelart_module,
.backend = dvui_dep.module("sdl3"),
}, exe.root_module);
@@ -1335,7 +1332,6 @@ const WorkbenchModuleDeps = struct {
core: *std.Build.Module,
sdk: *std.Build.Module,
icons: ?*std.Build.Module,
- pixelart: *std.Build.Module,
backend: ?*std.Build.Module,
};
@@ -1357,7 +1353,6 @@ fn wireWorkbenchModule(
workbench_module.addImport("dvui", deps.dvui);
workbench_module.addImport("core", deps.core);
workbench_module.addImport("sdk", deps.sdk);
- workbench_module.addImport("pixelart", deps.pixelart);
if (deps.icons) |icons| workbench_module.addImport("icons", icons);
if (deps.backend) |backend| workbench_module.addImport("backend", backend);
consumer.addImport("workbench", workbench_module);
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index 196e4d98..26db1cad 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -326,6 +326,7 @@ pub fn register(host: *sdk.Host) !void {
// these before State.init, but register re-syncs after postInit ordering).
plugin.state = @ptrCast(@alignCast(Globals.state));
try host.registerPlugin(&plugin);
+ try host.registerFileRowFillColor(.{ .color = &fileRowFillColor });
try host.registerSidebarView(.{
.id = view_tools,
.owner = &plugin,
@@ -367,6 +368,13 @@ pub fn pluginPtr() *sdk.Plugin {
return &plugin;
}
+fn fileRowFillColor(_: ?*anyopaque, color_index: usize) ?dvui.Color {
+ if (Globals.state.colors.palette) |*palette| {
+ return palette.getDVUIColor(color_index);
+ }
+ return null;
+}
+
fn drawTools(_: ?*anyopaque) anyerror!void {
try Globals.state.tools_pane.draw();
}
diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig
index 8ec402f9..7dd20dd3 100644
--- a/src/plugins/workbench/src/Globals.zig
+++ b/src/plugins/workbench/src/Globals.zig
@@ -2,7 +2,7 @@
//!
//! The shell sets these once during `App` startup so workbench code can reach the
//! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`.
-//! Mirrors `plugins/pixelart/src/Globals.zig`.
+//! Mirrors the pixel-art plugin's `Globals.zig` injection pattern.
const std = @import("std");
const wb_mod = @import("../workbench.zig");
const sdk = wb_mod.sdk;
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index 5a94574f..5ecee49b 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -2,7 +2,6 @@ const std = @import("std");
const builtin = @import("builtin");
const wb = @import("../workbench.zig");
const Globals = @import("Globals.zig");
-const pixelart = @import("pixelart");
const dvui = wb.dvui;
const wdvui = wb.wdvui;
const icons = @import("icons");
@@ -479,8 +478,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u
try visible_file_rows_order.append(Globals.allocator(), .{ .id = inner_id_extra.*, .path = abs_path });
var color = dvui.themeGet().color(.control, .fill);
- if (pixelart.Globals.state.colors.palette) |*palette| {
- color = palette.getDVUIColor(color_id.*);
+ if (Globals.host.fileRowFillColor(color_id.*)) |tint| {
+ color = tint;
}
const padding = dvui.Rect.all(2);
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index c2c97cc9..e227e8f6 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -25,6 +25,14 @@ pub const SettingsSection = regions.SettingsSection;
/// settings.json and never interprets them.
pub const PluginSettings = std.StringArrayHashMapUnmanaged([]const u8);
+/// Optional tint for a workbench file-tree row background. `color_index` is the row's
+/// stable index during the current tree draw (workbench increments per file). Return
+/// null to defer to the next resolver or the theme default.
+pub const FileRowFillColor = struct {
+ ctx: ?*anyopaque = null,
+ color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color,
+};
+
allocator: std.mem.Allocator,
/// All registered plugins (static today; runtime-loaded dylibs in Phase 4).
@@ -42,6 +50,9 @@ shell_api: ?EditorAPI = null,
/// Opaque per-plugin settings store (see `PluginSettings`).
plugin_settings: PluginSettings = .empty,
+/// File-tree row fill tints (workbench asks the Host; editor plugins register).
+file_row_fill_colors: std.ArrayListUnmanaged(FileRowFillColor) = .empty,
+
// ---- shell region registries (Phase 2) -------------------------------------
// The shell iterates these instead of hardcoded enums/switches. Items keep their
// registration order, which is the order they appear in the UI.
@@ -74,6 +85,7 @@ pub fn deinit(self: *Host) void {
self.center_providers.deinit(self.allocator);
self.menus.deinit(self.allocator);
self.settings_sections.deinit(self.allocator);
+ self.file_row_fill_colors.deinit(self.allocator);
{
var it = self.plugin_settings.iterator();
while (it.next()) |e| {
@@ -372,6 +384,18 @@ pub fn registerPlugin(self: *Host, plugin: *Plugin) !void {
try self.plugins.append(self.allocator, plugin);
}
+pub fn registerFileRowFillColor(self: *Host, resolver: FileRowFillColor) !void {
+ try self.file_row_fill_colors.append(self.allocator, resolver);
+}
+
+/// First non-null tint from registered resolvers, or null for the workbench theme default.
+pub fn fileRowFillColor(self: *Host, color_index: usize) ?dvui.Color {
+ for (self.file_row_fill_colors.items) |resolver| {
+ if (resolver.color(resolver.ctx, color_index)) |color| return color;
+ }
+ return null;
+}
+
pub fn registerService(self: *Host, name: []const u8, service: *anyopaque) !void {
try self.services.put(self.allocator, name, service);
}
From 4caec7665f00bc31c25a160e0c4083a502feb804 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 12:02:40 -0500
Subject: [PATCH 34/49] Phase 5 part a2
---
HANDOFF.md | 11 ++---
src/backend/singleton_native.zig | 2 +-
src/editor/Editor.zig | 53 +++++++++++++++----------
src/editor/Keybinds.zig | 2 +-
src/editor/WebFileIo.zig | 2 +-
src/editor/explorer/Explorer.zig | 3 +-
src/plugins/workbench/src/Workbench.zig | 24 +++++++++++
7 files changed, 65 insertions(+), 32 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index f0f3deb5..cd609f78 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these
### Where to begin (next session)
-**5a.1** — done. **Next: 5a.2** (workbench Stage E), then **5b.1** (export surface).
+**5a.1–5a.2** — done. **Next: 5b.1** (SDK export surface + promote dylib spike).
---
@@ -681,8 +681,8 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
## State of the tree
-**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5
-documented; 5a.1 complete** (workbench no longer compile-time imports pixelart).
+**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5a
+(5a.1–5a.2) complete** — plugins decoupled; shell workbench field pokes routed.
Sanity greps (Phase-5 targets in **"Phase 5 sanity greps"** above):
@@ -696,8 +696,9 @@ grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live
grep -rn 'pixelart' src/plugins/workbench → 0
grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports)
-# shell workbench field pokes (Phase 5a.2 — next)
-grep -rn 'editor\.workbench\.' src/editor src/backend → ~24 (route through EditorAPI)
+# shell workbench field pokes routed (5a.2 done)
+grep -rn 'fizzy\.editor\.workbench\.' src/ → 0
+grep -rn 'editor\.workbench\.' src/ → lifecycle + Editor delegators only (Editor.zig, App.zig Globals inject)
# shell imports plugins only via build modules; only build-time exception:
grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig
diff --git a/src/backend/singleton_native.zig b/src/backend/singleton_native.zig
index 749cd16e..7e7d6044 100644
--- a/src/backend/singleton_native.zig
+++ b/src/backend/singleton_native.zig
@@ -197,7 +197,7 @@ fn dispatchPath(path: []const u8) !void {
return err;
};
file.close(io);
- _ = try fizzy.editor.openFilePath(path, fizzy.editor.workbench.open_workspace_grouping);
+ _ = try fizzy.editor.openFilePath(path, fizzy.editor.currentGroupingID());
}
/// Walk upward from `file_path`'s parent directory, returning the first
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index c1fb56c1..d5a9a6f6 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -833,6 +833,20 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle {
return editor.workbench.activeDoc();
}
+pub fn clearFileTreeDataId(editor: *Editor) void {
+ editor.workbench.clearFileTreeDataId();
+}
+
+/// Files sidebar inactive — drop tree dvui stash and tab-drag state.
+pub fn resetFileTreeWhenFilesHidden(editor: *Editor) void {
+ editor.clearFileTreeDataId();
+ editor.clearFileTreeTabDragDropState();
+}
+
+pub fn clearAllWorkspaceCenter(editor: *Editor) void {
+ editor.workbench.clearAllWorkspaceCenter();
+}
+
/// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`).
pub fn docGrouping(_: *Editor, doc: sdk.DocHandle) u64 {
return doc.owner.documentGrouping(doc);
@@ -1521,9 +1535,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
} else {
// Explorer peek/collapse hides the workspace subtree, so `drawWorkspaces` does not
// run and `workspace.center` would otherwise stay latched from a prior panel animation.
- for (editor.workbench.workspaces.values()) |*ws| {
- ws.center = false;
- }
+ editor.clearAllWorkspaceCenter();
}
{ // Radial Menu (pixel-art plugin)
@@ -1630,7 +1642,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA
.filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" },
})) |files| {
for (files) |file| {
- _ = editor.openFilePath(file, editor.workbench.open_workspace_grouping) catch {
+ _ = editor.openFilePath(file, editor.currentGroupingID()) catch {
std.log.err("Failed to open file: {s}", .{file});
};
}
@@ -2588,16 +2600,14 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void {
const doc = editor.docAt(index) orelse return;
const grouping = editor.docGrouping(doc);
- if (editor.workbench.workspaces.getPtr(grouping)) |workspace| {
- if (workspace.open_file_index == index) {
- for (editor.open_files.values(), 0..) |d, i| {
- if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) {
- workspace.open_file_index = i;
- break;
- }
- }
+ const replacement_index: ?usize = blk: {
+ for (editor.open_files.values(), 0..) |d, i| {
+ if (i == index) continue;
+ if (editor.docGrouping(d) == grouping) break :blk i;
}
- }
+ break :blk null;
+ };
+ editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index);
editor.closeDocumentResources(doc);
editor.open_files.orderedRemoveAt(index);
@@ -2605,18 +2615,17 @@ pub fn rawCloseFile(editor: *Editor, index: usize) !void {
pub fn rawCloseFileID(editor: *Editor, id: u64) !void {
const doc = editor.open_files.get(id) orelse return;
+ const index = editor.open_files.getIndex(id) orelse return;
const grouping = editor.docGrouping(doc);
- if (editor.workbench.workspaces.getPtr(grouping)) |workspace| {
- if (workspace.open_file_index == editor.open_files.getIndex(doc.id)) {
- for (editor.open_files.values(), 0..) |d, i| {
- if (editor.docGrouping(d) == workspace.grouping and d.id != doc.id) {
- workspace.open_file_index = i;
- break;
- }
- }
+ const replacement_index: ?usize = blk: {
+ for (editor.open_files.values(), 0..) |d, i| {
+ if (i == index) continue;
+ if (editor.docGrouping(d) == grouping) break :blk i;
}
- }
+ break :blk null;
+ };
+ editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index);
editor.closeDocumentResources(doc);
_ = editor.open_files.orderedRemove(id);
diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig
index 64824f99..39a8bee6 100644
--- a/src/editor/Keybinds.zig
+++ b/src/editor/Keybinds.zig
@@ -63,7 +63,7 @@ pub fn tick() !void {
.{ .title = "Open Files...", .filter_description = ".fiz, .pixi, .png, .jpg, .jpeg", .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" } },
)) |files| {
for (files) |file| {
- _ = fizzy.editor.openFilePath(file, fizzy.editor.workbench.open_workspace_grouping) catch {
+ _ = fizzy.editor.openFilePath(file, fizzy.editor.currentGroupingID()) catch {
std.log.err("Failed to open file: {s}", .{file});
};
}
diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig
index eaac597c..29c21f0b 100644
--- a/src/editor/WebFileIo.zig
+++ b/src/editor/WebFileIo.zig
@@ -46,7 +46,7 @@ pub fn showOpenFileDialog(
) void {
if (comptime builtin.target.cpu.arch != .wasm32) return;
open_callback = cb;
- open_grouping = fizzy.editor.workbench.open_workspace_grouping;
+ open_grouping = fizzy.editor.currentGroupingID();
open_picker_id = dvui.Id.extendId(null, @src(), 0);
dvui.dialogWasmFileOpenMultiple(open_picker_id.?, .{ .accept = open_accept });
}
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index ae597de7..689308b3 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -109,8 +109,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
});
if (!fizzy.editor.host.isActiveSidebarView(workbench.plugin.view_files)) {
- fizzy.editor.workbench.file_tree_data_id = null;
- fizzy.editor.workbench.clearFileTreeTabDragDropState();
+ fizzy.editor.resetFileTreeWhenFilesHidden();
}
if (fizzy.editor.host.activeSidebarView()) |view| {
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
index 4a402049..917ed2ec 100644
--- a/src/plugins/workbench/src/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -77,6 +77,30 @@ pub fn clearFileTreeTabDragDropState(self: *Workbench) void {
}
}
+pub fn clearFileTreeDataId(self: *Workbench) void {
+ self.file_tree_data_id = null;
+}
+
+/// Explorer peek/collapse hides the workspace subtree; clear latched center flags.
+pub fn clearAllWorkspaceCenter(self: *Workbench) void {
+ for (self.workspaces.values()) |*ws| {
+ ws.center = false;
+ }
+}
+
+/// When the open doc at `closed_index` closes, pick another tab in the same workspace.
+pub fn adjustOpenFileIndexAfterClose(
+ self: *Workbench,
+ grouping: u64,
+ closed_index: usize,
+ replacement_index: ?usize,
+) void {
+ const workspace = self.workspaces.getPtr(grouping) orelse return;
+ if (workspace.open_file_index == closed_index) {
+ if (replacement_index) |idx| workspace.open_file_index = idx;
+ }
+}
+
pub fn rebuildWorkspaces(self: *Workbench) !void {
return workbench_layout.rebuildWorkspaces(self);
}
From ce6b4bbcc9583022260f1ee34120ddba93c86940 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 12:04:51 -0500
Subject: [PATCH 35/49] Phase 5 part b1
---
HANDOFF.md | 6 ++-
build.zig | 80 ++++++++++++++++++++++++++++++----
src/plugins/pixelart/dylib.zig | 16 +++++++
src/sdk/dylib.zig | 34 +++++++++++++++
src/sdk/sdk.zig | 3 ++
tests/root.zig | 1 +
6 files changed, 129 insertions(+), 11 deletions(-)
create mode 100644 src/plugins/pixelart/dylib.zig
create mode 100644 src/sdk/dylib.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index cd609f78..35426f69 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -179,7 +179,7 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`.
| Step | Work | Done when |
|------|------|-----------|
-| **5b.1** | SDK **export surface** — `fizzy_plugin_abi_version()` + `fizzy_plugin_register(*Host)` (`callconv(.c)`); document ABI version constant | Spike + one plugin export compile |
+| **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done |
| **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static |
| **5b.3** | **Host loader module** — `std.DynLib` open, ABI gate, resolve entry, call `register`; wire `Globals` after load | Loader unit test or dev-only flag loads a dylib and registers one sidebar view |
| **5b.4** | **Dvui context injection** in shell frame loop — set plugin-side globals before plugin draw/tick (per spike Mechanism B) | Plugin draw mutates host `Window` in a loaded dylib (manual or integration test) |
@@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these
### Where to begin (next session)
-**5a.1–5a.2** — done. **Next: 5b.1** (SDK export surface + promote dylib spike).
+**5a.1–5a.2** — done. **5b.1** — done (`sdk/dylib.zig`, `pixelart/dylib.zig`, `zig build pixelart-dylib`). **Next: 5b.2** (formalize dual-link in build; loader stub 5b.3).
---
@@ -664,6 +664,8 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
|------|------|
| `HANDOFF.md` | This file |
| `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) |
+| `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) |
+| `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) |
| `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` |
| `src/plugins/pixelart/module.zig` | Pixel-art build module root |
| `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub |
diff --git a/build.zig b/build.zig
index 087716e5..ec9953e7 100644
--- a/build.zig
+++ b/build.zig
@@ -528,6 +528,18 @@ pub fn build(b: *std.Build) !void {
b.getInstallStep().dependOn(&install_artifact.step);
}
+ if (main_fizzy.pixelart_dylib) |pixelart_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ });
+ const pixelart_dylib_step = b.step(
+ "pixelart-dylib",
+ "Build the pixelart plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ pixelart_dylib_step.dependOn(&install_pixelart_dylib.step);
+ }
+
const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run");
// The default native target on a Windows host resolves to x86_64-windows-gnu,
// for which `velopack_supported_for_target` is false — exe_for_package falls
@@ -781,6 +793,7 @@ pub fn build(b: *std.Build) !void {
.{ "fizzy-grid-validate", "src/plugins/pixelart/src/internal/grid_layout_validate.zig" },
.{ "fizzy-animation", "src/plugins/pixelart/src/Animation.zig" },
.{ "fizzy-window-layout", "src/backend/window_layout.zig" },
+ .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" },
}) |entry| {
tests_module.addAnonymousImport(entry[0], .{
.root_source_file = b.path(entry[1]),
@@ -1111,6 +1124,8 @@ const FizzyExecutable = struct {
zstbi_module: *std.Build.Module,
msf_gif_module: *std.Build.Module,
known_folders: *std.Build.Module,
+ /// Native-only; `null` on wasm targets.
+ pixelart_dylib: ?*std.Build.Step.Compile = null,
};
fn addFizzyExecutableForTarget(
@@ -1235,6 +1250,20 @@ fn addFizzyExecutableForTarget(
.backend = dvui_dep.module("sdl3"),
}, exe.root_module);
+ const pixelart_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ break :blk addPixelartDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .assets = assets_module,
+ .zip = zip_pkg.module,
+ .zstbi = zstbi_module,
+ .msf_gif = msf_gif_module,
+ .icons = icons_module,
+ .backend = dvui_dep.module("sdl3"),
+ });
+ } else null;
+
const singleton_app_dep = b.dependency("dvui_singleton_app", .{
.target = resolved_target,
.optimize = optimize,
@@ -1294,6 +1323,7 @@ fn addFizzyExecutableForTarget(
.zstbi_module = zstbi_module,
.msf_gif_module = msf_gif_module,
.known_folders = known_folders,
+ .pixelart_dylib = pixelart_dylib,
};
}
@@ -1359,6 +1389,46 @@ fn wireWorkbenchModule(
}
/// Pixel-art plugin (`src/plugins/pixelart/module.zig`).
+fn applyPixelartModuleImports(module: *std.Build.Module, deps: PixelartModuleDeps) void {
+ module.addImport("dvui", deps.dvui);
+ module.addImport("core", deps.core);
+ module.addImport("sdk", deps.sdk);
+ module.addImport("assets", deps.assets);
+ module.addImport("zip", deps.zip);
+ module.addImport("zstbi", deps.zstbi);
+ module.addImport("msf_gif", deps.msf_gif);
+ if (deps.icons) |icons| module.addImport("icons", icons);
+ if (deps.backend) |backend| module.addImport("backend", backend);
+}
+
+/// Native dynamic library for the pixel-art plugin (`src/plugins/pixelart/dylib.zig`).
+fn addPixelartDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ deps: PixelartModuleDeps,
+) *std.Build.Step.Compile {
+ const dylib_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/plugins/pixelart/dylib.zig"),
+ .link_libc = true,
+ });
+ applyPixelartModuleImports(dylib_module, deps);
+ const lib = b.addLibrary(.{
+ .name = "pixelart",
+ .linkage = .dynamic,
+ .root_module = dylib_module,
+ });
+ // Resolve dvui/sdk symbols from the host at load time (Mechanism B).
+ lib.linker_allow_shlib_undefined = true;
+ lib.root_module.export_symbol_names = &[_][]const u8{
+ "fizzy_plugin_abi_version",
+ "fizzy_plugin_register",
+ };
+ return lib;
+}
+
fn wirePixelartModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
@@ -1373,15 +1443,7 @@ fn wirePixelartModule(
.link_libc = target.result.cpu.arch != .wasm32,
.single_threaded = target.result.cpu.arch == .wasm32,
});
- pixelart_module.addImport("dvui", deps.dvui);
- pixelart_module.addImport("core", deps.core);
- pixelart_module.addImport("sdk", deps.sdk);
- pixelart_module.addImport("assets", deps.assets);
- pixelart_module.addImport("zip", deps.zip);
- pixelart_module.addImport("zstbi", deps.zstbi);
- pixelart_module.addImport("msf_gif", deps.msf_gif);
- if (deps.icons) |icons| pixelart_module.addImport("icons", icons);
- if (deps.backend) |backend| pixelart_module.addImport("backend", backend);
+ applyPixelartModuleImports(pixelart_module, deps);
consumer.addImport("pixelart", pixelart_module);
return pixelart_module;
}
diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig
new file mode 100644
index 00000000..ea949913
--- /dev/null
+++ b/src/plugins/pixelart/dylib.zig
@@ -0,0 +1,16 @@
+//! Dynamic-library root for the pixel-art plugin (Phase 5b).
+//!
+//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use
+//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported.
+const sdk = @import("sdk");
+const plugin = @import("src/plugin.zig");
+
+export fn fizzy_plugin_abi_version() callconv(.c) u32 {
+ return sdk.dylib.abi_version;
+}
+
+export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 {
+ if (host == null) return @intFromEnum(sdk.dylib.RegisterStatus.err_null_host);
+ plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register);
+ return @intFromEnum(sdk.dylib.RegisterStatus.ok);
+}
diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig
new file mode 100644
index 00000000..79e8bf1e
--- /dev/null
+++ b/src/sdk/dylib.zig
@@ -0,0 +1,34 @@
+//! Runtime dynamic-library contract for Fizzy plugins (Phase 5b).
+//!
+//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core` (Mechanism B:
+//! context injection — see `spikes/shared-globals/README.md`). Cross-boundary vtables use
+//! normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry symbols
+//! below use C calling convention.
+//!
+//! **Bump `abi_version` when any of these change:** `Host`, `Plugin`, `DocHandle`,
+//! `EditorAPI` layouts, or the semantics/signature of an entry symbol.
+pub const abi_version: u32 = 1;
+
+/// `std.DynLib.lookup` names for the host loader (5b.3+).
+pub const symbol_abi_version = "fizzy_plugin_abi_version";
+pub const symbol_register = "fizzy_plugin_register";
+
+/// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers.
+pub const RegisterStatus = enum(u32) {
+ ok = 0,
+ err_register = 1,
+ err_null_host = 2,
+ /// Reserved for the host loader when `fizzy_plugin_abi_version()` != `abi_version`.
+ err_abi_mismatch = 3,
+};
+
+pub fn abiMatches(plugin_abi: u32) bool {
+ return plugin_abi == abi_version;
+}
+
+test "plugin ABI version is locked" {
+ const std = @import("std");
+ try std.testing.expect(abi_version == 1);
+ try std.testing.expect(abiMatches(abi_version));
+ try std.testing.expect(!abiMatches(abi_version + 1));
+}
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index aae47821..222fe73c 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -27,3 +27,6 @@ pub const UiAtlasView = EditorAPI.UiAtlasView;
pub const WorkbenchPane = @import("WorkbenchPane.zig");
pub const WorkbenchPaneView = WorkbenchPane.WorkbenchPaneView;
pub const pane_layout = @import("pane_layout.zig");
+
+/// Runtime dylib entry contract (`fizzy_plugin_abi_version` / `fizzy_plugin_register`).
+pub const dylib = @import("dylib.zig");
diff --git a/tests/root.zig b/tests/root.zig
index 54386d37..1606de7c 100644
--- a/tests/root.zig
+++ b/tests/root.zig
@@ -16,4 +16,5 @@ comptime {
_ = @import("fizzy-grid-validate");
_ = @import("fizzy-animation");
_ = @import("fizzy-window-layout");
+ _ = @import("fizzy-plugin-dylib");
}
From 78b772289c01a64bacdff5b79ef365b058445ccb Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 12:08:22 -0500
Subject: [PATCH 36/49] 5b.3
---
HANDOFF.md | 7 +-
build.zig | 41 ++++++++++++
src/editor/Editor.zig | 53 ++++++++++++++-
src/editor/PluginLoader.zig | 99 +++++++++++++++++++++++++++++
src/editor/PluginLoader_stub.zig | 16 +++++
src/plugins/pixelart/dylib.zig | 10 +++
src/sdk/Host.zig | 26 ++++++++
src/sdk/dvui_context.zig | 44 +++++++++++++
src/sdk/dylib.zig | 2 +
src/sdk/sdk.zig | 2 +
tests/plugin_loader_integration.zig | 26 ++++++++
11 files changed, 320 insertions(+), 6 deletions(-)
create mode 100644 src/editor/PluginLoader.zig
create mode 100644 src/editor/PluginLoader_stub.zig
create mode 100644 src/sdk/dvui_context.zig
create mode 100644 tests/plugin_loader_integration.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 35426f69..96431ea8 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -181,8 +181,8 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`.
|------|------|-----------|
| **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done |
| **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static |
-| **5b.3** | **Host loader module** — `std.DynLib` open, ABI gate, resolve entry, call `register`; wire `Globals` after load | Loader unit test or dev-only flag loads a dylib and registers one sidebar view |
-| **5b.4** | **Dvui context injection** in shell frame loop — set plugin-side globals before plugin draw/tick (per spike Mechanism B) | Plugin draw mutates host `Window` in a loaded dylib (manual or integration test) |
+| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `-Dload-pixelart-dylib` / `FIZZY_LOAD_PIXELART_DYLIB` / `FIZZY_PLUGIN_PATH`; `zig build test-plugin-loader` | ✅ Done |
+| **5b.4** | **Dvui context injection** — `sdk/dvui_context.zig`, `fizzy_plugin_set_dvui_context`, `Host.syncPluginDvuiContext` in frame loop | ✅ Done |
Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2
lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is the same.
@@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these
### Where to begin (next session)
-**5a.1–5a.2** — done. **5b.1** — done (`sdk/dylib.zig`, `pixelart/dylib.zig`, `zig build pixelart-dylib`). **Next: 5b.2** (formalize dual-link in build; loader stub 5b.3).
+**5a.1–5a.2** — done. **5b.1–5b.4** — done (dylib export, loader, dvui context injection). **Next: 5c.1** (load built-in pixelart dylib by default on native; route Editor delegators through `host.pluginById`).
---
@@ -664,6 +664,7 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
|------|------|
| `HANDOFF.md` | This file |
| `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) |
+| `src/sdk/dvui_context.zig` | Mechanism B — inject host dvui globals into plugin dylib copy |
| `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) |
| `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) |
| `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` |
diff --git a/build.zig b/build.zig
index ec9953e7..3a1775eb 100644
--- a/build.zig
+++ b/build.zig
@@ -239,6 +239,12 @@ pub fn build(b: *std.Build) !void {
build_opts.addOption([]const u8, "app_repo_url", app_repo_url);
build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
build_opts.addOption(bool, "velopack_enabled", velopack_enabled);
+ const load_pixelart_dylib = b.option(
+ bool,
+ "load-pixelart-dylib",
+ "Load pixelart from plugins/libpixelart.{dylib,so,dll} instead of static register (native dev)",
+ ) orelse false;
+ build_opts.addOption(bool, "load_pixelart_dylib", load_pixelart_dylib);
const step = b.step("update", "update git dependencies");
step.makeFn = update_step;
@@ -538,6 +544,38 @@ pub fn build(b: *std.Build) !void {
"Build the pixelart plugin as a dynamic library into zig-out//plugins/ (native only)",
);
pixelart_dylib_step.dependOn(&install_pixelart_dylib.step);
+
+ const plugin_loader_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/editor/PluginLoader.zig"),
+ });
+ plugin_loader_module.addImport("sdk", main_fizzy.sdk_module);
+
+ const plugin_loader_test_opts = b.addOptions();
+ plugin_loader_test_opts.addOptionPath("pixelart_dylib", pixelart_dylib.getEmittedBin());
+
+ const plugin_loader_test_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests/plugin_loader_integration.zig"),
+ });
+ plugin_loader_test_module.addImport("sdk", main_fizzy.sdk_module);
+ plugin_loader_test_module.addImport("plugin_loader", plugin_loader_module);
+ plugin_loader_test_module.addOptions("plugin_loader_test_opts", plugin_loader_test_opts);
+
+ const plugin_loader_tests = b.addTest(.{
+ .name = "plugin-loader-tests",
+ .root_module = plugin_loader_test_module,
+ });
+ const run_plugin_loader_tests = b.addRunArtifact(plugin_loader_tests);
+ run_plugin_loader_tests.step.dependOn(&pixelart_dylib.step);
+
+ const test_plugin_loader_step = b.step(
+ "test-plugin-loader",
+ "Build pixelart dylib and run dlopen/register integration test",
+ );
+ test_plugin_loader_step.dependOn(&run_plugin_loader_tests.step);
}
const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run");
@@ -1124,6 +1162,7 @@ const FizzyExecutable = struct {
zstbi_module: *std.Build.Module,
msf_gif_module: *std.Build.Module,
known_folders: *std.Build.Module,
+ sdk_module: *std.Build.Module,
/// Native-only; `null` on wasm targets.
pixelart_dylib: ?*std.Build.Step.Compile = null,
};
@@ -1323,6 +1362,7 @@ fn addFizzyExecutableForTarget(
.zstbi_module = zstbi_module,
.msf_gif_module = msf_gif_module,
.known_folders = known_folders,
+ .sdk_module = sdk_module,
.pixelart_dylib = pixelart_dylib,
};
}
@@ -1425,6 +1465,7 @@ fn addPixelartDylib(
lib.root_module.export_symbol_names = &[_][]const u8{
"fizzy_plugin_abi_version",
"fizzy_plugin_register",
+ "fizzy_plugin_set_dvui_context",
};
return lib;
}
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index d5a9a6f6..3c1de856 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -13,6 +13,8 @@ const comfortaa_bold_ttf = assets.files.fonts.@"Comfortaa-Bold.ttf";
const plus_jakarta_sans_ttf = assets.files.fonts.@"PlusJakartaSans-Regular.ttf";
const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf";
+const build_opts = @import("build_opts");
+
const fizzy = @import("../fizzy.zig");
const pixelart = @import("pixelart");
const dvui = @import("dvui");
@@ -28,6 +30,10 @@ pub const Dialogs = @import("dialogs/Dialogs.zig");
pub const Keybinds = @import("Keybinds.zig");
const workbench_mod = @import("workbench");
+const PluginLoader = if (builtin.target.cpu.arch == .wasm32)
+ @import("PluginLoader_stub.zig")
+else
+ @import("PluginLoader.zig");
pub const Workspace = workbench_mod.Workspace;
pub const Explorer = @import("explorer/Explorer.zig");
@@ -63,6 +69,9 @@ pixelart_state: *pixelart.State,
/// File-management workbench (per-branch explorer decorations, …)
workbench: Workbench,
+/// Keeps plugin dylibs mapped while their vtables are live (Phase 5b.3+; native only).
+loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty,
+
settings: Settings = undefined,
recents: Recents = undefined,
@@ -443,6 +452,35 @@ pub fn init(
/// Stable shell-builtin contribution id.
pub const view_settings = "shell.settings";
+fn loadPixelartFromDylibEnabled() bool {
+ if (comptime builtin.target.cpu.arch == .wasm32) return false;
+ if (comptime build_opts.load_pixelart_dylib) return true;
+ if (std.process.getEnvVar("FIZZY_LOAD_PIXELART_DYLIB")) |v| {
+ return v.len > 0 and v[0] != '0';
+ }
+ return false;
+}
+
+/// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry.
+pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixelart");
+ errdefer fizzy.app.allocator.free(path);
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path);
+ try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded);
+ editor.host.installPluginDvuiContext(loaded.set_dvui_context);
+}
+
+fn unloadPluginLibs(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ editor.host.plugin_set_dvui_context = null;
+ for (editor.loaded_plugin_libs.items) |*entry| {
+ entry.lib.close();
+ fizzy.app.allocator.free(entry.path);
+ }
+ editor.loaded_plugin_libs.deinit(fizzy.app.allocator);
+}
+
pub fn postInit(editor: *Editor) !void {
// Install the shell's read/utility surface so plugins reach shared shell state
// (per-frame arena, project folder, content opacity, settings dirty-mark) through
@@ -463,9 +501,15 @@ pub fn postInit(editor: *Editor) !void {
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
// editor tick already runs on wasm. Order = sidebar order.
try workbench_mod.plugin.register(&editor.host);
-const pixelart_plugin = pixelart.plugin;
- try pixelart_plugin.register(&editor.host);
- try pixelart_plugin.pluginPtr().initPlugin();
+ if (loadPixelartFromDylibEnabled()) {
+ try editor.loadPixelartDylib(fizzy.app.root_path);
+ const pa = editor.host.pluginById("pixelart") orelse return error.MissingPlugin;
+ try pa.initPlugin();
+ } else {
+ const pixelart_plugin = pixelart.plugin;
+ try pixelart_plugin.register(&editor.host);
+ try pixelart_plugin.pluginPtr().initPlugin();
+ }
// Shell built-in: Settings (owner = null; not a plugin).
try editor.host.registerSidebarView(.{
@@ -487,6 +531,7 @@ const pixelart_plugin = pixelart.plugin;
// keybind map. The shell already registered its global/navigation/region binds
// in `Keybinds.register` (during `init`, before this runs), so the two halves
// are disjoint — no `putNoClobber` clash. Runs on all targets (web included).
+ editor.host.syncPluginDvuiContext();
const window = dvui.currentWindow();
for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window);
@@ -1134,6 +1179,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
editor.setTitlebarColor();
editor.setWindowStyle();
+ editor.host.syncPluginDvuiContext();
for (editor.host.plugins.items) |plugin| plugin.beginFrame();
if (fizzy.perf.record) fizzy.perf.beginFrame();
defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog();
@@ -2682,6 +2728,7 @@ pub fn deinit(editor: *Editor) !void {
editor.explorer.deinit();
editor.workbench.deinitWorkspaces();
+ editor.unloadPluginLibs();
editor.host.deinit();
editor.workbench.deinit();
diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig
new file mode 100644
index 00000000..4852ce8d
--- /dev/null
+++ b/src/editor/PluginLoader.zig
@@ -0,0 +1,99 @@
+//! Native runtime loader for Fizzy plugin dylibs (Phase 5b.3).
+//!
+//! Opens a prebuilt plugin library, checks the SDK ABI version, and calls
+//! `fizzy_plugin_register`. The returned `std.DynLib` must stay open for the
+//! app's lifetime — vtable hooks live in the dylib image.
+//!
+//! **Native targets only.** Wasm imports `PluginLoader_stub.zig` instead.
+const std = @import("std");
+const builtin = @import("builtin");
+
+const sdk = @import("sdk");
+const Host = sdk.Host;
+const dylib_api = sdk.dylib;
+const dvui_context = sdk.dvui_context;
+
+pub const LoadError = error{
+ DylibOpenFailed,
+ AbiSymbolMissing,
+ RegisterSymbolMissing,
+ SetDvuiContextSymbolMissing,
+ AbiMismatch,
+ RegisterRejected,
+};
+
+pub const LoadedLib = struct {
+ lib: std.DynLib,
+ path: []const u8,
+ set_dvui_context: dvui_context.SetContextFn,
+};
+
+/// `{exe_dir}/plugins/{pluginFilename(name)}`
+pub fn builtinPluginPath(
+ allocator: std.mem.Allocator,
+ exe_dir: []const u8,
+ name: []const u8,
+) ![]const u8 {
+ const file_name = switch (builtin.os.tag) {
+ .windows => try std.fmt.allocPrint(allocator, "{s}.dll", .{name}),
+ .macos => try std.fmt.allocPrint(allocator, "lib{s}.dylib", .{name}),
+ else => try std.fmt.allocPrint(allocator, "lib{s}.so", .{name}),
+ };
+ defer allocator.free(file_name);
+ return std.fs.path.join(allocator, &.{ exe_dir, "plugins", file_name });
+}
+
+/// Resolve a plugin dylib path: `FIZZY_PLUGIN_PATH` when set, else the built-in layout above.
+pub fn resolvePluginPath(
+ allocator: std.mem.Allocator,
+ exe_dir: []const u8,
+ builtin_name: []const u8,
+) ![]const u8 {
+ if (std.process.getEnvVarOwned(allocator, "FIZZY_PLUGIN_PATH")) |override| {
+ return override;
+ } else |_| {}
+ return builtinPluginPath(allocator, exe_dir, builtin_name);
+}
+
+pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib {
+ var lib = std.DynLib.open(path) catch return error.DylibOpenFailed;
+ errdefer lib.close();
+
+ const abi_fn = lib.lookup(
+ *const fn () callconv(.c) u32,
+ dylib_api.symbol_abi_version,
+ ) orelse return error.AbiSymbolMissing;
+ if (!dylib_api.abiMatches(abi_fn())) return error.AbiMismatch;
+
+ const reg_fn = lib.lookup(
+ *const fn (?*Host) callconv(.c) u32,
+ dylib_api.symbol_register,
+ ) orelse return error.RegisterSymbolMissing;
+ const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host));
+ switch (status) {
+ .ok => {},
+ .err_abi_mismatch => return error.AbiMismatch,
+ else => return error.RegisterRejected,
+ }
+
+ const set_ctx = lib.lookup(
+ dvui_context.SetContextFn,
+ dylib_api.symbol_set_dvui_context,
+ ) orelse return error.SetDvuiContextSymbolMissing;
+
+ return .{
+ .lib = lib,
+ .path = path,
+ .set_dvui_context = set_ctx,
+ };
+}
+
+test "builtin plugin path joins exe_dir/plugins" {
+ const path = try builtinPluginPath(std.testing.allocator, "/app", "pixelart");
+ defer std.testing.allocator.free(path);
+ switch (builtin.os.tag) {
+ .windows => try std.testing.expectEqualStrings("/app/plugins/pixelart.dll", path),
+ .macos => try std.testing.expectEqualStrings("/app/plugins/libpixelart.dylib", path),
+ else => try std.testing.expectEqualStrings("/app/plugins/libpixelart.so", path),
+ }
+}
diff --git a/src/editor/PluginLoader_stub.zig b/src/editor/PluginLoader_stub.zig
new file mode 100644
index 00000000..753211c9
--- /dev/null
+++ b/src/editor/PluginLoader_stub.zig
@@ -0,0 +1,16 @@
+//! Wasm stub — dynamic plugin loading is native-only.
+const std = @import("std");
+
+pub const LoadError = error{Unsupported};
+
+pub const LoadedLib = struct {
+ path: []const u8,
+};
+
+pub fn resolvePluginPath(_: std.mem.Allocator, _: []const u8, _: []const u8) ![]const u8 {
+ return error.Unsupported;
+}
+
+pub fn loadAndRegister(_: anytype, _: []const u8) LoadError!void {
+ return error.Unsupported;
+}
diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig
index ea949913..dba5be2a 100644
--- a/src/plugins/pixelart/dylib.zig
+++ b/src/plugins/pixelart/dylib.zig
@@ -3,6 +3,7 @@
//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use
//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported.
const sdk = @import("sdk");
+const dvui = @import("dvui");
const plugin = @import("src/plugin.zig");
export fn fizzy_plugin_abi_version() callconv(.c) u32 {
@@ -14,3 +15,12 @@ export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 {
plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register);
return @intFromEnum(sdk.dylib.RegisterStatus.ok);
}
+
+export fn fizzy_plugin_set_dvui_context(
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+) callconv(.c) void {
+ sdk.dvui_context.inject(window, io, ft2lib, debug);
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index e227e8f6..aab6385c 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -8,6 +8,7 @@
const std = @import("std");
const dvui = @import("dvui");
const Plugin = @import("Plugin.zig");
+const dvui_context = @import("dvui_context.zig");
const regions = @import("regions.zig");
const EditorAPI = @import("EditorAPI.zig");
const DocHandle = @import("DocHandle.zig");
@@ -33,6 +34,9 @@ pub const FileRowFillColor = struct {
color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color,
};
+/// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static.
+plugin_set_dvui_context: ?dvui_context.SetContextFn = null,
+
allocator: std.mem.Allocator,
/// All registered plugins (static today; runtime-loaded dylibs in Phase 4).
@@ -103,6 +107,20 @@ pub fn installShell(self: *Host, api: EditorAPI) void {
self.shell_api = api;
}
+/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once
+/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`.
+pub fn installPluginDvuiContext(self: *Host, setter: dvui_context.SetContextFn) void {
+ self.plugin_set_dvui_context = setter;
+ dvui_context.syncHostIntoPlugin(setter);
+}
+
+/// Re-push host dvui pointers into the loaded plugin image. Call at the top of each
+/// frame before plugin draw/tick (updates `current_window` every frame).
+pub fn syncPluginDvuiContext(self: *Host) void {
+ const setter = self.plugin_set_dvui_context orelse return;
+ dvui_context.syncHostIntoPlugin(setter);
+}
+
/// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed.
pub fn arena(self: *Host) std.mem.Allocator {
return self.shell_api.?.arena();
@@ -384,6 +402,14 @@ pub fn registerPlugin(self: *Host, plugin: *Plugin) !void {
try self.plugins.append(self.allocator, plugin);
}
+/// Lookup a registered plugin by stable id (`"pixelart"`, `"workbench"`, …).
+pub fn pluginById(self: *Host, id: []const u8) ?*Plugin {
+ for (self.plugins.items) |plugin| {
+ if (std.mem.eql(u8, plugin.id, id)) return plugin;
+ }
+ return null;
+}
+
pub fn registerFileRowFillColor(self: *Host, resolver: FileRowFillColor) !void {
try self.file_row_fill_colors.append(self.allocator, resolver);
}
diff --git a/src/sdk/dvui_context.zig b/src/sdk/dvui_context.zig
new file mode 100644
index 00000000..37e92f0a
--- /dev/null
+++ b/src/sdk/dvui_context.zig
@@ -0,0 +1,44 @@
+//! Mechanism B: wire the plugin dylib's dvui globals to the host's live state.
+//!
+//! Host and plugin each compile their own `dvui` copy; before plugin draw/tick the host
+//! calls the plugin's `fizzy_plugin_set_dvui_context` export (see `dylib.zig`).
+const dvui = @import("dvui");
+
+/// C ABI setter type shared by host loader and plugin dylib export.
+pub const SetContextFn = *const fn (
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+) callconv(.c) void;
+
+/// Set this compilation unit's dvui globals from host-owned pointers.
+pub fn inject(
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+) void {
+ if (window) |w| dvui.current_window = w;
+ if (io) |i| {
+ const io_ptr: *@TypeOf(dvui.io) = @ptrCast(@alignCast(i));
+ dvui.io = io_ptr.*;
+ }
+ if (comptime dvui.useFreeType) {
+ if (ft2lib) |ft| {
+ const ft_ptr: *@TypeOf(dvui.ft2lib) = @ptrCast(@alignCast(ft));
+ dvui.ft2lib = ft_ptr.*;
+ }
+ }
+ if (debug) |d| dvui.debug = d.*;
+}
+
+/// Push the host exe's current dvui state into a loaded plugin image.
+pub fn syncHostIntoPlugin(setter: SetContextFn) void {
+ setter(
+ dvui.current_window,
+ @ptrCast(&dvui.io),
+ if (comptime dvui.useFreeType) @ptrCast(&dvui.ft2lib) else null,
+ &dvui.debug,
+ );
+}
diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig
index 79e8bf1e..f9baea3d 100644
--- a/src/sdk/dylib.zig
+++ b/src/sdk/dylib.zig
@@ -12,6 +12,8 @@ pub const abi_version: u32 = 1;
/// `std.DynLib.lookup` names for the host loader (5b.3+).
pub const symbol_abi_version = "fizzy_plugin_abi_version";
pub const symbol_register = "fizzy_plugin_register";
+/// Mechanism B — host calls each frame (and once at init) before plugin draw/tick.
+pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context";
/// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers.
pub const RegisterStatus = enum(u32) {
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 222fe73c..10e3ff8e 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -30,3 +30,5 @@ pub const pane_layout = @import("pane_layout.zig");
/// Runtime dylib entry contract (`fizzy_plugin_abi_version` / `fizzy_plugin_register`).
pub const dylib = @import("dylib.zig");
+/// Dvui global injection for loaded plugin images (Mechanism B).
+pub const dvui_context = @import("dvui_context.zig");
diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig
new file mode 100644
index 00000000..4a33f1b2
--- /dev/null
+++ b/tests/plugin_loader_integration.zig
@@ -0,0 +1,26 @@
+//! Integration test: dlopen the pixelart dylib and register into a Host.
+const std = @import("std");
+const builtin = @import("builtin");
+
+const sdk = @import("sdk");
+const PluginLoader = @import("plugin_loader");
+const test_opts = @import("plugin_loader_test_opts");
+
+test "load pixelart dylib and register" {
+ if (comptime builtin.target.cpu.arch == .wasm32) return error.SkipZigTest;
+
+ var host = sdk.Host.init(std.testing.allocator);
+ defer host.deinit();
+
+ const before = host.plugins.items.len;
+ var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib);
+ defer loaded.lib.close();
+
+ try std.testing.expect(host.plugins.items.len == before + 1);
+ const pa = host.pluginById("pixelart") orelse return error.TestExpectedEqual;
+ try std.testing.expectEqualStrings("pixelart", pa.id);
+ try std.testing.expect(host.sidebar_views.items.len >= 3);
+
+ // Mechanism B: context setter is required and callable (no window needed for init io/debug).
+ loaded.set_dvui_context(null, null, null, null);
+}
From 448462ad40e0c2e57fe1a802ef4b0872c0c0ee49 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 12:16:42 -0500
Subject: [PATCH 37/49] 5c.1
---
HANDOFF.md | 6 +-
build.zig | 26 ++++++--
src/App.zig | 1 +
src/editor/Editor.zig | 89 ++++++++++++++++------------
src/editor/PluginLoader.zig | 48 ++++++++++++---
src/plugins/pixelart/dylib.zig | 13 ++++
src/plugins/pixelart/src/Globals.zig | 11 ++++
src/sdk/Host.zig | 33 ++++++++---
src/sdk/dylib.zig | 9 +++
tests/plugin_loader_integration.zig | 11 +++-
10 files changed, 186 insertions(+), 61 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index 96431ea8..00ca4bcc 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -181,7 +181,7 @@ Each step ends with `zig build`, `zig build check-web`, `zig build test`.
|------|------|-----------|
| **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done |
| **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static |
-| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `-Dload-pixelart-dylib` / `FIZZY_LOAD_PIXELART_DYLIB` / `FIZZY_PLUGIN_PATH`; `zig build test-plugin-loader` | ✅ Done |
+| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `FIZZY_PLUGIN_PATH`; `-Dstatic-pixelart` / `FIZZY_STATIC_PIXELART`; `zig build test-plugin-loader` | ✅ Done |
| **5b.4** | **Dvui context injection** — `sdk/dvui_context.zig`, `fizzy_plugin_set_dvui_context`, `Host.syncPluginDvuiContext` in frame loop | ✅ Done |
Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2
@@ -191,7 +191,7 @@ lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is
| Step | Work | Done when |
|------|------|-----------|
-| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web | App opens `.fiz` files via loaded dylib on macOS; web unchanged |
+| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done |
| **5c.2** | Built-in workbench dylib (or keep static until pixelart path is stable — workbench owns center layout) | Tabs/splits work from loaded workbench |
| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel |
@@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these
### Where to begin (next session)
-**5a.1–5a.2** — done. **5b.1–5b.4** — done (dylib export, loader, dvui context injection). **Next: 5c.1** (load built-in pixelart dylib by default on native; route Editor delegators through `host.pluginById`).
+**5c.1** — done (native default dylib load + Globals injection + `pixelartPlugin()` routing). **Next: 5c.2** (workbench dylib) or **5c.3** (Velopack bundle polish).
---
diff --git a/build.zig b/build.zig
index 3a1775eb..ba72c31a 100644
--- a/build.zig
+++ b/build.zig
@@ -239,12 +239,12 @@ pub fn build(b: *std.Build) !void {
build_opts.addOption([]const u8, "app_repo_url", app_repo_url);
build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
build_opts.addOption(bool, "velopack_enabled", velopack_enabled);
- const load_pixelart_dylib = b.option(
+ const static_pixelart = b.option(
bool,
- "load-pixelart-dylib",
- "Load pixelart from plugins/libpixelart.{dylib,so,dll} instead of static register (native dev)",
+ "static-pixelart",
+ "Keep pixelart statically registered on native (skip built-in dylib load)",
) orelse false;
- build_opts.addOption(bool, "load_pixelart_dylib", load_pixelart_dylib);
+ build_opts.addOption(bool, "static_pixelart", static_pixelart);
const step = b.step("update", "update git dependencies");
step.makeFn = update_step;
@@ -521,6 +521,13 @@ pub fn build(b: *std.Build) !void {
if (no_emit) {
b.getInstallStep().dependOn(&exe.step);
+ if (main_fizzy.pixelart_dylib) |pixelart_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ });
+ b.getInstallStep().dependOn(&install_pixelart_dylib.step);
+ }
} else {
const install_artifact = b.addInstallArtifact(exe, .{
.dest_dir = .{ .override = zig_out_install_dir },
@@ -532,6 +539,15 @@ pub fn build(b: *std.Build) !void {
run_cmd.step.dependOn(&install_artifact.step);
run_step.dependOn(&run_cmd.step);
b.getInstallStep().dependOn(&install_artifact.step);
+
+ if (main_fizzy.pixelart_dylib) |pixelart_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ });
+ b.getInstallStep().dependOn(&install_pixelart_dylib.step);
+ run_cmd.step.dependOn(&install_pixelart_dylib.step);
+ }
}
if (main_fizzy.pixelart_dylib) |pixelart_dylib| {
@@ -539,6 +555,7 @@ pub fn build(b: *std.Build) !void {
const install_pixelart_dylib = b.addInstallArtifact(pixelart_dylib, .{
.dest_dir = .{ .override = plugins_install_dir },
});
+
const pixelart_dylib_step = b.step(
"pixelart-dylib",
"Build the pixelart plugin as a dynamic library into zig-out//plugins/ (native only)",
@@ -1466,6 +1483,7 @@ fn addPixelartDylib(
"fizzy_plugin_abi_version",
"fizzy_plugin_register",
"fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_globals",
};
return lib;
}
diff --git a/src/App.zig b/src/App.zig
index f957caab..db20e8c2 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -194,6 +194,7 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.packer.* = Packer.init(allocator) catch unreachable;
pixelart.Globals.packer = fizzy.packer;
+ fizzy.editor.syncLoadedPixelartGlobals();
// Hand the window to the listener thread and queue our own argv so the
// first frame opens any files / project folder supplied on the command line.
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 3c1de856..9ebaaeb6 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -454,11 +454,27 @@ pub const view_settings = "shell.settings";
fn loadPixelartFromDylibEnabled() bool {
if (comptime builtin.target.cpu.arch == .wasm32) return false;
- if (comptime build_opts.load_pixelart_dylib) return true;
- if (std.process.getEnvVar("FIZZY_LOAD_PIXELART_DYLIB")) |v| {
- return v.len > 0 and v[0] != '0';
- }
- return false;
+ if (comptime build_opts.static_pixelart) return false;
+ if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_PIXELART")) |v| {
+ defer fizzy.app.allocator.free(v);
+ return v.len == 0 or v[0] == '0';
+ } else |_| {}
+ return true;
+}
+
+/// Registered pixelart plugin (dylib or static). Panics if missing after `postInit`.
+pub fn pixelartPlugin(editor: *Editor) *sdk.Plugin {
+ return editor.host.pluginById("pixelart") orelse @panic("pixelart plugin not registered");
+}
+
+/// Re-inject host-owned Globals into a loaded pixelart dylib (e.g. after `Packer` init).
+pub fn syncLoadedPixelartGlobals(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ editor.host.syncPluginGlobals(
+ &fizzy.app.allocator,
+ @ptrCast(editor.pixelart_state),
+ @ptrCast(fizzy.packer),
+ );
}
/// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry.
@@ -466,14 +482,19 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixelart");
errdefer fizzy.app.allocator.free(path);
- const loaded = try PluginLoader.loadAndRegister(&editor.host, path);
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, .{
+ .gpa = &fizzy.app.allocator,
+ .state = @ptrCast(editor.pixelart_state),
+ .packer = null,
+ });
try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded);
- editor.host.installPluginDvuiContext(loaded.set_dvui_context);
+ editor.host.installPluginDylibHooks(loaded.set_globals, loaded.set_dvui_context);
}
fn unloadPluginLibs(editor: *Editor) void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
editor.host.plugin_set_dvui_context = null;
+ editor.host.plugin_set_globals = null;
for (editor.loaded_plugin_libs.items) |*entry| {
entry.lib.close();
fizzy.app.allocator.free(entry.path);
@@ -502,13 +523,14 @@ pub fn postInit(editor: *Editor) !void {
// editor tick already runs on wasm. Order = sidebar order.
try workbench_mod.plugin.register(&editor.host);
if (loadPixelartFromDylibEnabled()) {
- try editor.loadPixelartDylib(fizzy.app.root_path);
- const pa = editor.host.pluginById("pixelart") orelse return error.MissingPlugin;
- try pa.initPlugin();
+ editor.loadPixelartDylib(fizzy.app.root_path) catch |err| {
+ dvui.log.warn("pixelart dylib load failed ({s}); falling back to static plugin", .{@errorName(err)});
+ try pixelart.plugin.register(&editor.host);
+ };
+ try pixelartPlugin(editor).initPlugin();
} else {
- const pixelart_plugin = pixelart.plugin;
- try pixelart_plugin.register(&editor.host);
- try pixelart_plugin.pluginPtr().initPlugin();
+ try pixelart.plugin.register(&editor.host);
+ try pixelartPlugin(editor).initPlugin();
}
// Shell built-in: Settings (owner = null; not a plugin).
@@ -1585,7 +1607,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
}
{ // Radial Menu (pixel-art plugin)
- const pa = pixelart.plugin.pluginPtr();
+ const pa = pixelartPlugin(editor);
try pa.tickKeybinds();
Keybinds.tick() catch {
dvui.log.err("Failed to tick hotkeys", .{});
@@ -1628,7 +1650,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
};
if (comptime builtin.target.cpu.arch == .wasm32) {
- pixelart.plugin.pluginPtr().runPackWorkers();
+ pixelartPlugin(editor).runPackWorkers();
}
_ = editor.arena.reset(.retain_capacity);
@@ -1944,21 +1966,21 @@ pub fn close(app: *App, editor: *Editor) void {
pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
if (editor.folder) |folder| {
editor.ignore.deinit(fizzy.app.allocator);
- pixelart.plugin.pluginPtr().persistProjectFolder();
+ pixelartPlugin(editor).persistProjectFolder();
fizzy.app.allocator.free(folder);
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
editor.host.setActiveSidebarView(workbench_mod.plugin.view_files);
- pixelart.plugin.pluginPtr().reloadProjectFolder(fizzy.app.allocator);
+ pixelartPlugin(editor).reloadProjectFolder(fizzy.app.allocator);
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
}
pub fn closeProjectFolder(editor: *Editor) void {
if (editor.folder) |folder| {
editor.ignore.deinit(fizzy.app.allocator);
- pixelart.plugin.pluginPtr().persistProjectFolder();
+ pixelartPlugin(editor).persistProjectFolder();
fizzy.app.allocator.free(folder);
editor.folder = null;
}
@@ -2144,20 +2166,17 @@ pub fn processLoadingJobs(editor: *Editor) void {
/// Kick off an async project-pack via the pixel-art plugin vtable.
pub fn startPackProject(editor: *Editor) !void {
- _ = editor;
- try pixelart.plugin.pluginPtr().startPackProject();
+ try pixelartPlugin(editor).startPackProject();
}
/// True while a pack is queued, running, or finished but not yet installed.
-pub fn isPackingActive(editor: *const Editor) bool {
- _ = editor;
- return pixelart.plugin.pluginPtr().isPackingActive();
+pub fn isPackingActive(editor: *Editor) bool {
+ return pixelartPlugin(editor).isPackingActive();
}
/// Per-frame pack-job sweep (delegates to the pixel-art plugin).
pub fn processPackJob(editor: *Editor) void {
- _ = editor;
- pixelart.plugin.pluginPtr().tickPackJobs();
+ pixelartPlugin(editor).tickPackJobs();
}
pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical {
@@ -2354,7 +2373,7 @@ pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid
return error.FileAlreadyExists;
}
- const owner = pixelart.plugin.pluginPtr();
+ const owner = pixelartPlugin(editor);
const staging = try owner.allocDocumentBuffer(fizzy.app.allocator);
defer fizzy.app.allocator.free(staging.backing);
@@ -2412,34 +2431,28 @@ pub fn forceCloseFile(editor: *Editor, index: usize) !void {
}
pub fn accept(editor: *Editor) !void {
- _ = editor;
- pixelart.plugin.pluginPtr().acceptEdit();
+ pixelartPlugin(editor).acceptEdit();
}
pub fn cancel(editor: *Editor) !void {
- _ = editor;
- pixelart.plugin.pluginPtr().cancelEdit();
+ pixelartPlugin(editor).cancelEdit();
}
pub fn copy(editor: *Editor) !void {
- _ = editor;
- try pixelart.plugin.pluginPtr().copy();
+ try pixelartPlugin(editor).copy();
}
pub fn paste(editor: *Editor) !void {
- _ = editor;
- try pixelart.plugin.pluginPtr().paste();
+ try pixelartPlugin(editor).paste();
}
pub fn deleteSelectedContents(editor: *Editor) void {
- _ = editor;
- pixelart.plugin.pluginPtr().deleteSelection();
+ pixelartPlugin(editor).deleteSelection();
}
/// Begins a transform operation on the currently active file.
pub fn transform(editor: *Editor) !void {
- _ = editor;
- try pixelart.plugin.pluginPtr().transform();
+ try pixelartPlugin(editor).transform();
}
/// Performs a save operation on the currently open file.
diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig
index 4852ce8d..ed518497 100644
--- a/src/editor/PluginLoader.zig
+++ b/src/editor/PluginLoader.zig
@@ -17,6 +17,7 @@ pub const LoadError = error{
DylibOpenFailed,
AbiSymbolMissing,
RegisterSymbolMissing,
+ SetGlobalsSymbolMissing,
SetDvuiContextSymbolMissing,
AbiMismatch,
RegisterRejected,
@@ -25,9 +26,17 @@ pub const LoadError = error{
pub const LoadedLib = struct {
lib: std.DynLib,
path: []const u8,
+ set_globals: dylib_api.SetGlobalsFn,
set_dvui_context: dvui_context.SetContextFn,
};
+/// Host-owned pointers injected into the plugin image immediately before `register`.
+pub const PreRegister = struct {
+ gpa: ?*const std.mem.Allocator = null,
+ state: ?*anyopaque = null,
+ packer: ?*anyopaque = null,
+};
+
/// `{exe_dir}/plugins/{pluginFilename(name)}`
pub fn builtinPluginPath(
allocator: std.mem.Allocator,
@@ -49,13 +58,23 @@ pub fn resolvePluginPath(
exe_dir: []const u8,
builtin_name: []const u8,
) ![]const u8 {
- if (std.process.getEnvVarOwned(allocator, "FIZZY_PLUGIN_PATH")) |override| {
+ if (std.process.Environ.getAlloc(nativeEnviron(), allocator, "FIZZY_PLUGIN_PATH")) |override| {
return override;
} else |_| {}
return builtinPluginPath(allocator, exe_dir, builtin_name);
}
-pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib {
+fn nativeEnviron() std.process.Environ {
+ if (builtin.os.tag == .windows) {
+ return .{ .block = .global };
+ }
+ var n: usize = 0;
+ while (std.c.environ[n] != null) : (n += 1) {}
+ const slice: [:null]const ?[*:0]const u8 = @as([*:null]const ?[*:0]const u8, @ptrCast(std.c.environ))[0..n :null];
+ return .{ .block = .{ .slice = slice } };
+}
+
+pub fn loadAndRegister(host: *Host, path: []const u8, pre: ?PreRegister) LoadError!LoadedLib {
var lib = std.DynLib.open(path) catch return error.DylibOpenFailed;
errdefer lib.close();
@@ -65,10 +84,29 @@ pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib {
) orelse return error.AbiSymbolMissing;
if (!dylib_api.abiMatches(abi_fn())) return error.AbiMismatch;
+ const set_globals = lib.lookup(
+ dylib_api.SetGlobalsFn,
+ dylib_api.symbol_set_globals,
+ ) orelse return error.SetGlobalsSymbolMissing;
+
const reg_fn = lib.lookup(
*const fn (?*Host) callconv(.c) u32,
dylib_api.symbol_register,
) orelse return error.RegisterSymbolMissing;
+
+ const set_ctx = lib.lookup(
+ dvui_context.SetContextFn,
+ dylib_api.symbol_set_dvui_context,
+ ) orelse return error.SetDvuiContextSymbolMissing;
+
+ if (pre) |inject| {
+ set_globals(
+ if (inject.gpa) |gpa| @ptrCast(gpa) else null,
+ inject.state,
+ inject.packer,
+ );
+ }
+
const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host));
switch (status) {
.ok => {},
@@ -76,14 +114,10 @@ pub fn loadAndRegister(host: *Host, path: []const u8) LoadError!LoadedLib {
else => return error.RegisterRejected,
}
- const set_ctx = lib.lookup(
- dvui_context.SetContextFn,
- dylib_api.symbol_set_dvui_context,
- ) orelse return error.SetDvuiContextSymbolMissing;
-
return .{
.lib = lib,
.path = path,
+ .set_globals = set_globals,
.set_dvui_context = set_ctx,
};
}
diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig
index dba5be2a..6b42e888 100644
--- a/src/plugins/pixelart/dylib.zig
+++ b/src/plugins/pixelart/dylib.zig
@@ -24,3 +24,16 @@ export fn fizzy_plugin_set_dvui_context(
) callconv(.c) void {
sdk.dvui_context.inject(window, io, ft2lib, debug);
}
+
+export fn fizzy_plugin_set_globals(
+ gpa: ?*const anyopaque,
+ state: ?*anyopaque,
+ packer: ?*anyopaque,
+) callconv(.c) void {
+ const Globals = @import("src/Globals.zig");
+ Globals.installRuntime(
+ if (gpa) |p| @ptrCast(@alignCast(p)) else null,
+ if (state) |p| @ptrCast(@alignCast(p)) else null,
+ if (packer) |p| @ptrCast(@alignCast(p)) else null,
+ );
+}
diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig
index 16ce8f0d..e64ca5b2 100644
--- a/src/plugins/pixelart/src/Globals.zig
+++ b/src/plugins/pixelart/src/Globals.zig
@@ -13,3 +13,14 @@ pub var packer: *Packer = undefined;
pub fn allocator() std.mem.Allocator {
return gpa;
}
+
+/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`.
+pub fn installRuntime(
+ gpa_ptr: ?*const std.mem.Allocator,
+ state_ptr: ?*State,
+ packer_ptr: ?*Packer,
+) void {
+ if (gpa_ptr) |a| gpa = a.*;
+ if (state_ptr) |s| state = s;
+ if (packer_ptr) |p| packer = p;
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index aab6385c..165ddb6f 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -9,6 +9,7 @@ const std = @import("std");
const dvui = @import("dvui");
const Plugin = @import("Plugin.zig");
const dvui_context = @import("dvui_context.zig");
+const dylib_api = @import("dylib.zig");
const regions = @import("regions.zig");
const EditorAPI = @import("EditorAPI.zig");
const DocHandle = @import("DocHandle.zig");
@@ -36,6 +37,8 @@ pub const FileRowFillColor = struct {
/// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static.
plugin_set_dvui_context: ?dvui_context.SetContextFn = null,
+/// Host-owned Globals injection into a loaded plugin image (pixelart today).
+plugin_set_globals: ?dylib_api.SetGlobalsFn = null,
allocator: std.mem.Allocator,
@@ -107,13 +110,6 @@ pub fn installShell(self: *Host, api: EditorAPI) void {
self.shell_api = api;
}
-/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once
-/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`.
-pub fn installPluginDvuiContext(self: *Host, setter: dvui_context.SetContextFn) void {
- self.plugin_set_dvui_context = setter;
- dvui_context.syncHostIntoPlugin(setter);
-}
-
/// Re-push host dvui pointers into the loaded plugin image. Call at the top of each
/// frame before plugin draw/tick (updates `current_window` every frame).
pub fn syncPluginDvuiContext(self: *Host) void {
@@ -121,6 +117,29 @@ pub fn syncPluginDvuiContext(self: *Host) void {
dvui_context.syncHostIntoPlugin(setter);
}
+/// Re-push host-owned pixelart Globals (`gpa`, `state`, `packer`) into the dylib.
+pub fn syncPluginGlobals(
+ self: *Host,
+ gpa: *const std.mem.Allocator,
+ state: *anyopaque,
+ packer: ?*anyopaque,
+) void {
+ const setter = self.plugin_set_globals orelse return;
+ setter(@ptrCast(gpa), state, packer);
+}
+
+/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once
+/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`.
+pub fn installPluginDylibHooks(
+ self: *Host,
+ set_globals: dylib_api.SetGlobalsFn,
+ set_dvui_context: dvui_context.SetContextFn,
+) void {
+ self.plugin_set_globals = set_globals;
+ self.plugin_set_dvui_context = set_dvui_context;
+ dvui_context.syncHostIntoPlugin(set_dvui_context);
+}
+
/// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed.
pub fn arena(self: *Host) std.mem.Allocator {
return self.shell_api.?.arena();
diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig
index f9baea3d..3cc30203 100644
--- a/src/sdk/dylib.zig
+++ b/src/sdk/dylib.zig
@@ -14,6 +14,15 @@ pub const symbol_abi_version = "fizzy_plugin_abi_version";
pub const symbol_register = "fizzy_plugin_register";
/// Mechanism B — host calls each frame (and once at init) before plugin draw/tick.
pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context";
+/// Host-owned pixelart `Globals` (allocator, state, packer) injected before `register`.
+pub const symbol_set_globals = "fizzy_plugin_set_globals";
+
+/// C ABI — wire plugin-side `Globals` to host-owned pointers (pixelart today).
+pub const SetGlobalsFn = *const fn (
+ gpa: ?*const anyopaque,
+ state: ?*anyopaque,
+ packer: ?*anyopaque,
+) callconv(.c) void;
/// Returned by `fizzy_plugin_register`. Stable unsigned values for C callers.
pub const RegisterStatus = enum(u32) {
diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig
index 4a33f1b2..3dbfa75c 100644
--- a/tests/plugin_loader_integration.zig
+++ b/tests/plugin_loader_integration.zig
@@ -12,8 +12,15 @@ test "load pixelart dylib and register" {
var host = sdk.Host.init(std.testing.allocator);
defer host.deinit();
+ // Stand-in for app-owned `pixelart.State` — register only stores the pointer.
+ var state_buf: [8192]u8 align(16) = undefined;
+
const before = host.plugins.items.len;
- var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib);
+ var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib, .{
+ .gpa = &std.testing.allocator,
+ .state = &state_buf,
+ .packer = null,
+ });
defer loaded.lib.close();
try std.testing.expect(host.plugins.items.len == before + 1);
@@ -21,6 +28,6 @@ test "load pixelart dylib and register" {
try std.testing.expectEqualStrings("pixelart", pa.id);
try std.testing.expect(host.sidebar_views.items.len >= 3);
- // Mechanism B: context setter is required and callable (no window needed for init io/debug).
loaded.set_dvui_context(null, null, null, null);
+ loaded.set_globals(@ptrCast(&std.testing.allocator), &state_buf, null);
}
From fd15a23baa2487da6eea406eef820484ba270aca Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 12:23:00 -0500
Subject: [PATCH 38/49] 5c.2
---
HANDOFF.md | 5 +-
build.zig | 88 +++++++++++++++++++++++++--
src/editor/Editor.zig | 85 ++++++++++++++++++++++----
src/editor/PluginLoader.zig | 10 ++-
src/editor/explorer/Explorer.zig | 2 +-
src/plugins/workbench/dylib.zig | 40 ++++++++++++
src/plugins/workbench/src/Globals.zig | 11 ++++
src/sdk/Host.zig | 2 +
tests/plugin_loader_integration.zig | 2 +-
9 files changed, 223 insertions(+), 22 deletions(-)
create mode 100644 src/plugins/workbench/dylib.zig
diff --git a/HANDOFF.md b/HANDOFF.md
index 00ca4bcc..71b5744f 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -192,7 +192,7 @@ lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is
| Step | Work | Done when |
|------|------|-----------|
| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done |
-| **5c.2** | Built-in workbench dylib (or keep static until pixelart path is stable — workbench owns center layout) | Tabs/splits work from loaded workbench |
+| **5c.2** | Built-in workbench dylib loaded by host on native; `workbenchPlugin()` / `workbench_files_view` routing | ✅ Done |
| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel |
Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the
@@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these
### Where to begin (next session)
-**5c.1** — done (native default dylib load + Globals injection + `pixelartPlugin()` routing). **Next: 5c.2** (workbench dylib) or **5c.3** (Velopack bundle polish).
+**5c.1–5c.2** — done (pixelart + workbench built-in dylibs on native). **Next: 5c.3** (Velopack bundle polish) or **5d**.
---
@@ -667,6 +667,7 @@ the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atl
| `src/sdk/dvui_context.zig` | Mechanism B — inject host dvui globals into plugin dylib copy |
| `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) |
| `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) |
+| `src/plugins/workbench/dylib.zig` | Workbench dynamic-library root (exports only) |
| `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` |
| `src/plugins/pixelart/module.zig` | Pixel-art build module root |
| `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub |
diff --git a/build.zig b/build.zig
index ba72c31a..140f5f8a 100644
--- a/build.zig
+++ b/build.zig
@@ -245,6 +245,12 @@ pub fn build(b: *std.Build) !void {
"Keep pixelart statically registered on native (skip built-in dylib load)",
) orelse false;
build_opts.addOption(bool, "static_pixelart", static_pixelart);
+ const static_workbench = b.option(
+ bool,
+ "static-workbench",
+ "Keep workbench statically registered on native (skip built-in dylib load)",
+ ) orelse false;
+ build_opts.addOption(bool, "static_workbench", static_workbench);
const step = b.step("update", "update git dependencies");
step.makeFn = update_step;
@@ -528,6 +534,13 @@ pub fn build(b: *std.Build) !void {
});
b.getInstallStep().dependOn(&install_pixelart_dylib.step);
}
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_workbench_dylib = b.addInstallArtifact(workbench_dylib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ });
+ b.getInstallStep().dependOn(&install_workbench_dylib.step);
+ }
} else {
const install_artifact = b.addInstallArtifact(exe, .{
.dest_dir = .{ .override = zig_out_install_dir },
@@ -548,6 +561,26 @@ pub fn build(b: *std.Build) !void {
b.getInstallStep().dependOn(&install_pixelart_dylib.step);
run_cmd.step.dependOn(&install_pixelart_dylib.step);
}
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_workbench_dylib = b.addInstallArtifact(workbench_dylib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ });
+ b.getInstallStep().dependOn(&install_workbench_dylib.step);
+ run_cmd.step.dependOn(&install_workbench_dylib.step);
+ }
+ }
+
+ if (main_fizzy.workbench_dylib) |workbench_dylib| {
+ const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) };
+ const install_workbench_dylib = b.addInstallArtifact(workbench_dylib, .{
+ .dest_dir = .{ .override = plugins_install_dir },
+ });
+ const workbench_dylib_step = b.step(
+ "workbench-dylib",
+ "Build the workbench plugin as a dynamic library into zig-out//plugins/ (native only)",
+ );
+ workbench_dylib_step.dependOn(&install_workbench_dylib.step);
}
if (main_fizzy.pixelart_dylib) |pixelart_dylib| {
@@ -1182,6 +1215,7 @@ const FizzyExecutable = struct {
sdk_module: *std.Build.Module,
/// Native-only; `null` on wasm targets.
pixelart_dylib: ?*std.Build.Step.Compile = null,
+ workbench_dylib: ?*std.Build.Step.Compile = null,
};
fn addFizzyExecutableForTarget(
@@ -1320,6 +1354,16 @@ fn addFizzyExecutableForTarget(
});
} else null;
+ const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
+ break :blk addWorkbenchDylib(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ .icons = icons_module,
+ .backend = dvui_dep.module("sdl3"),
+ });
+ } else null;
+
const singleton_app_dep = b.dependency("dvui_singleton_app", .{
.target = resolved_target,
.optimize = optimize,
@@ -1381,6 +1425,7 @@ fn addFizzyExecutableForTarget(
.known_folders = known_folders,
.sdk_module = sdk_module,
.pixelart_dylib = pixelart_dylib,
+ .workbench_dylib = workbench_dylib,
};
}
@@ -1423,6 +1468,14 @@ const WorkbenchModuleDeps = struct {
};
/// Workbench plugin (`src/plugins/workbench/module.zig`).
+fn applyWorkbenchModuleImports(module: *std.Build.Module, deps: WorkbenchModuleDeps) void {
+ module.addImport("dvui", deps.dvui);
+ module.addImport("core", deps.core);
+ module.addImport("sdk", deps.sdk);
+ if (deps.icons) |icons| module.addImport("icons", icons);
+ if (deps.backend) |backend| module.addImport("backend", backend);
+}
+
fn wireWorkbenchModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
@@ -1437,14 +1490,39 @@ fn wireWorkbenchModule(
.link_libc = target.result.cpu.arch != .wasm32,
.single_threaded = target.result.cpu.arch == .wasm32,
});
- workbench_module.addImport("dvui", deps.dvui);
- workbench_module.addImport("core", deps.core);
- workbench_module.addImport("sdk", deps.sdk);
- if (deps.icons) |icons| workbench_module.addImport("icons", icons);
- if (deps.backend) |backend| workbench_module.addImport("backend", backend);
+ applyWorkbenchModuleImports(workbench_module, deps);
consumer.addImport("workbench", workbench_module);
}
+/// Native dynamic library for the workbench plugin (`src/plugins/workbench/dylib.zig`).
+fn addWorkbenchDylib(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ deps: WorkbenchModuleDeps,
+) *std.Build.Step.Compile {
+ const dylib_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/plugins/workbench/dylib.zig"),
+ .link_libc = true,
+ });
+ applyWorkbenchModuleImports(dylib_module, deps);
+ const lib = b.addLibrary(.{
+ .name = "workbench",
+ .linkage = .dynamic,
+ .root_module = dylib_module,
+ });
+ lib.linker_allow_shlib_undefined = true;
+ lib.root_module.export_symbol_names = &[_][]const u8{
+ "fizzy_plugin_abi_version",
+ "fizzy_plugin_register",
+ "fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_globals",
+ };
+ return lib;
+}
+
/// Pixel-art plugin (`src/plugins/pixelart/module.zig`).
fn applyPixelartModuleImports(module: *std.Build.Module, deps: PixelartModuleDeps) void {
module.addImport("dvui", deps.dvui);
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 9ebaaeb6..b7881fda 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -462,19 +462,73 @@ fn loadPixelartFromDylibEnabled() bool {
return true;
}
+fn loadWorkbenchFromDylibEnabled() bool {
+ if (comptime builtin.target.cpu.arch == .wasm32) return false;
+ if (comptime build_opts.static_workbench) return false;
+ if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_WORKBENCH")) |v| {
+ defer fizzy.app.allocator.free(v);
+ return v.len == 0 or v[0] == '0';
+ } else |_| {}
+ return true;
+}
+
+/// Stable workbench sidebar view id (matches `workbench.plugin.view_files`).
+pub const workbench_files_view = workbench_mod.plugin.view_files;
+
+/// Registered workbench plugin (dylib or static). Panics if missing after `postInit`.
+pub fn workbenchPlugin(editor: *Editor) *sdk.Plugin {
+ return editor.host.pluginById("workbench") orelse @panic("workbench plugin not registered");
+}
+
/// Registered pixelart plugin (dylib or static). Panics if missing after `postInit`.
pub fn pixelartPlugin(editor: *Editor) *sdk.Plugin {
return editor.host.pluginById("pixelart") orelse @panic("pixelart plugin not registered");
}
+/// Mechanism B: push host dvui state into every loaded plugin dylib image.
+pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ for (editor.loaded_plugin_libs.items) |loaded| {
+ sdk.dvui_context.syncHostIntoPlugin(loaded.set_dvui_context);
+ }
+}
+
+fn syncLoadedPluginGlobals(editor: *Editor, plugin_id: []const u8, arg_b: *anyopaque, arg_c: ?*anyopaque) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ for (editor.loaded_plugin_libs.items) |loaded| {
+ if (!std.mem.eql(u8, loaded.plugin_id, plugin_id)) continue;
+ loaded.set_globals(@ptrCast(&fizzy.app.allocator), arg_b, arg_c);
+ }
+}
+
/// Re-inject host-owned Globals into a loaded pixelart dylib (e.g. after `Packer` init).
pub fn syncLoadedPixelartGlobals(editor: *Editor) void {
+ syncLoadedPluginGlobals(editor, "pixelart", @ptrCast(editor.pixelart_state), @ptrCast(fizzy.packer));
+}
+
+/// Re-inject host-owned Globals into a loaded workbench dylib.
+pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void {
+ syncLoadedPluginGlobals(editor, "workbench", @ptrCast(&editor.host), @ptrCast(&editor.workbench));
+}
+
+fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void {
+ try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded);
+ editor.host.plugin_set_globals = loaded.set_globals;
+ editor.host.plugin_set_dvui_context = loaded.set_dvui_context;
+}
+
+/// Load `{exe_dir}/plugins/libworkbench.*` and register via dylib entry.
+pub fn loadWorkbenchDylib(editor: *Editor, exe_dir: []const u8) !void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
- editor.host.syncPluginGlobals(
- &fizzy.app.allocator,
- @ptrCast(editor.pixelart_state),
- @ptrCast(fizzy.packer),
- );
+ const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "workbench");
+ errdefer fizzy.app.allocator.free(path);
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "workbench", .{
+ .gpa = &fizzy.app.allocator,
+ .state = @ptrCast(&editor.host),
+ .packer = @ptrCast(&editor.workbench),
+ });
+ try appendLoadedPluginLib(editor, loaded);
+ syncLoadedPluginDvuiContexts(editor);
}
/// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry.
@@ -482,13 +536,13 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixelart");
errdefer fizzy.app.allocator.free(path);
- const loaded = try PluginLoader.loadAndRegister(&editor.host, path, .{
+ const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixelart", .{
.gpa = &fizzy.app.allocator,
.state = @ptrCast(editor.pixelart_state),
.packer = null,
});
- try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded);
- editor.host.installPluginDylibHooks(loaded.set_globals, loaded.set_dvui_context);
+ try appendLoadedPluginLib(editor, loaded);
+ syncLoadedPluginDvuiContexts(editor);
}
fn unloadPluginLibs(editor: *Editor) void {
@@ -521,7 +575,14 @@ pub fn postInit(editor: *Editor) !void {
// near-empty shell's content: it iterates the Host registries rather than
// hardcoding panes. Web-safe — the draw fns reach the same inline code the
// editor tick already runs on wasm. Order = sidebar order.
- try workbench_mod.plugin.register(&editor.host);
+ if (loadWorkbenchFromDylibEnabled()) {
+ editor.loadWorkbenchDylib(fizzy.app.root_path) catch |err| {
+ dvui.log.warn("workbench dylib load failed ({s}); falling back to static plugin", .{@errorName(err)});
+ try workbench_mod.plugin.register(&editor.host);
+ };
+ } else {
+ try workbench_mod.plugin.register(&editor.host);
+ }
if (loadPixelartFromDylibEnabled()) {
editor.loadPixelartDylib(fizzy.app.root_path) catch |err| {
dvui.log.warn("pixelart dylib load failed ({s}); falling back to static plugin", .{@errorName(err)});
@@ -553,7 +614,7 @@ pub fn postInit(editor: *Editor) !void {
// keybind map. The shell already registered its global/navigation/region binds
// in `Keybinds.register` (during `init`, before this runs), so the two halves
// are disjoint — no `putNoClobber` clash. Runs on all targets (web included).
- editor.host.syncPluginDvuiContext();
+ syncLoadedPluginDvuiContexts(editor);
const window = dvui.currentWindow();
for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window);
@@ -1201,7 +1262,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result {
editor.setTitlebarColor();
editor.setWindowStyle();
- editor.host.syncPluginDvuiContext();
+ syncLoadedPluginDvuiContexts(editor);
for (editor.host.plugins.items) |plugin| plugin.beginFrame();
if (fizzy.perf.record) fizzy.perf.beginFrame();
defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog();
@@ -1971,7 +2032,7 @@ pub fn setProjectFolder(editor: *Editor, path: []const u8) !void {
}
editor.folder = try fizzy.app.allocator.dupe(u8, path);
try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path));
- editor.host.setActiveSidebarView(workbench_mod.plugin.view_files);
+ editor.host.setActiveSidebarView(workbench_files_view);
pixelartPlugin(editor).reloadProjectFolder(fizzy.app.allocator);
editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path);
diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig
index ed518497..90c93a49 100644
--- a/src/editor/PluginLoader.zig
+++ b/src/editor/PluginLoader.zig
@@ -26,6 +26,8 @@ pub const LoadError = error{
pub const LoadedLib = struct {
lib: std.DynLib,
path: []const u8,
+ /// Built-in plugin id (`"pixelart"`, `"workbench"`, …).
+ plugin_id: []const u8,
set_globals: dylib_api.SetGlobalsFn,
set_dvui_context: dvui_context.SetContextFn,
};
@@ -74,7 +76,12 @@ fn nativeEnviron() std.process.Environ {
return .{ .block = .{ .slice = slice } };
}
-pub fn loadAndRegister(host: *Host, path: []const u8, pre: ?PreRegister) LoadError!LoadedLib {
+pub fn loadAndRegister(
+ host: *Host,
+ path: []const u8,
+ plugin_id: []const u8,
+ pre: ?PreRegister,
+) LoadError!LoadedLib {
var lib = std.DynLib.open(path) catch return error.DylibOpenFailed;
errdefer lib.close();
@@ -117,6 +124,7 @@ pub fn loadAndRegister(host: *Host, path: []const u8, pre: ?PreRegister) LoadErr
return .{
.lib = lib,
.path = path,
+ .plugin_id = plugin_id,
.set_globals = set_globals,
.set_dvui_context = set_ctx,
};
diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig
index 689308b3..3d754170 100644
--- a/src/editor/explorer/Explorer.zig
+++ b/src/editor/explorer/Explorer.zig
@@ -108,7 +108,7 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result {
.background = false,
});
- if (!fizzy.editor.host.isActiveSidebarView(workbench.plugin.view_files)) {
+ if (!fizzy.editor.host.isActiveSidebarView(fizzy.Editor.workbench_files_view)) {
fizzy.editor.resetFileTreeWhenFilesHidden();
}
diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig
new file mode 100644
index 00000000..e26529d6
--- /dev/null
+++ b/src/plugins/workbench/dylib.zig
@@ -0,0 +1,40 @@
+//! Dynamic-library root for the workbench plugin (Phase 5c).
+//!
+//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use
+//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported.
+const sdk = @import("sdk");
+const dvui = @import("dvui");
+const plugin = @import("src/plugin.zig");
+
+export fn fizzy_plugin_abi_version() callconv(.c) u32 {
+ return sdk.dylib.abi_version;
+}
+
+export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 {
+ if (host == null) return @intFromEnum(sdk.dylib.RegisterStatus.err_null_host);
+ plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register);
+ return @intFromEnum(sdk.dylib.RegisterStatus.ok);
+}
+
+export fn fizzy_plugin_set_dvui_context(
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+) callconv(.c) void {
+ sdk.dvui_context.inject(window, io, ft2lib, debug);
+}
+
+/// Workbench convention: `gpa`, `host`, `workbench` (see `Globals.installRuntime`).
+export fn fizzy_plugin_set_globals(
+ gpa: ?*const anyopaque,
+ host: ?*anyopaque,
+ workbench: ?*anyopaque,
+) callconv(.c) void {
+ const Globals = @import("src/Globals.zig");
+ Globals.installRuntime(
+ if (gpa) |p| @ptrCast(@alignCast(p)) else null,
+ if (host) |p| @ptrCast(@alignCast(p)) else null,
+ if (workbench) |p| @ptrCast(@alignCast(p)) else null,
+ );
+}
diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig
index 7dd20dd3..dfae2380 100644
--- a/src/plugins/workbench/src/Globals.zig
+++ b/src/plugins/workbench/src/Globals.zig
@@ -15,3 +15,14 @@ pub var workbench: *Workbench = undefined;
pub fn allocator() std.mem.Allocator {
return gpa;
}
+
+/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`.
+pub fn installRuntime(
+ gpa_ptr: ?*const std.mem.Allocator,
+ host_ptr: ?*sdk.Host,
+ workbench_ptr: ?*Workbench,
+) void {
+ if (gpa_ptr) |a| gpa = a.*;
+ if (host_ptr) |h| host = h;
+ if (workbench_ptr) |w| workbench = w;
+}
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index 165ddb6f..d276a765 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -36,8 +36,10 @@ pub const FileRowFillColor = struct {
};
/// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static.
+/// Deprecated: prefer `Editor.syncLoadedPluginDvuiContexts` when multiple dylibs are loaded.
plugin_set_dvui_context: ?dvui_context.SetContextFn = null,
/// Host-owned Globals injection into a loaded plugin image (pixelart today).
+/// Deprecated: prefer per-lib `LoadedLib.set_globals` when multiple dylibs are loaded.
plugin_set_globals: ?dylib_api.SetGlobalsFn = null,
allocator: std.mem.Allocator,
diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig
index 3dbfa75c..7dbbf195 100644
--- a/tests/plugin_loader_integration.zig
+++ b/tests/plugin_loader_integration.zig
@@ -16,7 +16,7 @@ test "load pixelart dylib and register" {
var state_buf: [8192]u8 align(16) = undefined;
const before = host.plugins.items.len;
- var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib, .{
+ var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixelart_dylib, "pixelart", .{
.gpa = &std.testing.allocator,
.state = &state_buf,
.packer = null,
From 0fb1f765c6ffe9e0e5fabe643efcfa300e36bae0 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Fri, 19 Jun 2026 12:26:50 -0500
Subject: [PATCH 39/49] 5c.3
---
HANDOFF.md | 4 +-
build.zig | 72 +++++-
docs/PLUGINS.md | 233 ++++++++++++++++++
src/App.zig | 2 +-
src/editor/Editor.zig | 19 +-
src/editor/PluginLoader.zig | 2 +-
src/plugins/pixelart/dylib.zig | 2 +-
src/plugins/pixelart/module.zig | 2 +-
src/plugins/pixelart/pixelart.zig | 2 +-
src/plugins/pixelart/src/CanvasData.zig | 4 +-
src/plugins/pixelart/src/Docs.zig | 2 +-
src/plugins/pixelart/src/Globals.zig | 4 +-
src/plugins/pixelart/src/State.zig | 4 +-
src/plugins/pixelart/src/plugin.zig | 15 +-
src/plugins/workbench/dylib.zig | 2 +-
src/plugins/workbench/src/Globals.zig | 4 +-
src/plugins/workbench/src/Workbench.zig | 13 +-
.../workbench/src/workbench_layout.zig | 2 +-
src/sdk/DocHandle.zig | 3 -
src/sdk/Host.zig | 56 +----
src/sdk/Plugin.zig | 2 +-
src/sdk/dvui_context.zig | 2 +-
src/sdk/dylib.zig | 14 +-
src/sdk/sdk.zig | 10 +-
24 files changed, 352 insertions(+), 123 deletions(-)
create mode 100644 docs/PLUGINS.md
diff --git a/HANDOFF.md b/HANDOFF.md
index 71b5744f..45952f43 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -193,7 +193,7 @@ lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is
|------|------|-----------|
| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done |
| **5c.2** | Built-in workbench dylib loaded by host on native; `workbenchPlugin()` / `workbench_files_view` routing | ✅ Done |
-| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | Release package contains exe + `pixelart.{dylib,so,dll}` etc.; single update channel |
+| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | ✅ Done |
Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the
`register()` path is identical either way.
@@ -267,7 +267,7 @@ Repo source tree `src/plugins/` is **build layout only** — unrelated to these
### Where to begin (next session)
-**5c.1–5c.2** — done (pixelart + workbench built-in dylibs on native). **Next: 5c.3** (Velopack bundle polish) or **5d**.
+**5c.1–5c.3** — done (built-in dylibs on native + Velopack packDir includes `plugins/`). **Next: 5d**.
---
diff --git a/build.zig b/build.zig
index 140f5f8a..64296b79 100644
--- a/build.zig
+++ b/build.zig
@@ -513,17 +513,19 @@ pub fn build(b: *std.Build) !void {
const msf_gif_module = main_fizzy.msf_gif_module;
const known_folders = main_fizzy.known_folders;
- const exe_for_package: *std.Build.Step.Compile = package_blk: {
- if (velopack_enabled) break :package_blk exe;
- if (!velopack_supported_for_target) break :package_blk exe;
+ const package_fizzy: FizzyExecutable = package_blk: {
+ if (velopack_enabled) break :package_blk main_fizzy;
+ if (!velopack_supported_for_target) break :package_blk main_fizzy;
const pack_opts = b.addOptions();
pack_opts.addOption([]const u8, "app_version", app_version);
pack_opts.addOption([]const u8, "app_repo_url", app_repo_url);
pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback);
pack_opts.addOption(bool, "velopack_enabled", true);
- const pack_fizzy = try addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true);
- break :package_blk pack_fizzy.exe;
+ pack_opts.addOption(bool, "static_pixelart", static_pixelart);
+ pack_opts.addOption(bool, "static_workbench", static_workbench);
+ break :package_blk try addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true);
};
+ const exe_for_package = package_fizzy.exe;
if (no_emit) {
b.getInstallStep().dependOn(&exe.step);
@@ -716,8 +718,20 @@ pub fn build(b: *std.Build) !void {
// the full install path, which produced `.zig-cache\o\\C:\...`
// on Windows (BadPathName).
const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop");
+ // Stage exe + built-in plugin dylibs under zig-out//.pack-input/
+ // so vpk ships plugins/ next to the main binary.
+ const pack_input_subdir = b.fmt("{s}/.pack-input", .{zig_out_subdir});
+ const pack_plugins_subdir = b.fmt("{s}/.pack-input/plugins", .{zig_out_subdir});
+ const pack_stage_tail = addVelopackPackDirInstall(
+ b,
+ exe_for_package,
+ package_fizzy,
+ pack_input_subdir,
+ pack_plugins_subdir,
+ &strip_release_sh.step,
+ );
vpk_pkg_sh.addArg("--packDir");
- vpk_pkg_sh.addDirectoryArg(exe_for_package.getEmittedBin().dirname());
+ vpk_pkg_sh.addArg(b.getInstallPath(.{ .custom = pack_input_subdir }, ""));
switch (target.result.os.tag) {
.windows => {
// Sets the installer's icon and the Start Menu shortcut icon. The
@@ -773,7 +787,7 @@ pub fn build(b: *std.Build) !void {
try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target);
//vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step);
- vpk_pkg_sh.step.dependOn(&strip_release_sh.step);
+ vpk_pkg_sh.step.dependOn(pack_stage_tail);
const build_package_install = b.addInstallDirectory(.{
.source_dir = vpk_pkg_out_dir,
@@ -847,9 +861,8 @@ pub fn build(b: *std.Build) !void {
// modules under test, so it compiles in well under a second
// and never needs dvui/SDL/assets.
//
- // 2. Integration tests (added in Phase 2 of the testing plan)
- // will use dvui's testing backend and exercise real fizzy
- // drawing functions in a headless Window.
+ // 2. Integration tests use dvui's testing backend and exercise
+ // real fizzy drawing functions in a headless Window.
//
// Both share the same `zig build test` and `zig build check`
// entry points.
@@ -1207,6 +1220,43 @@ fn applyMsvcIncludesToReachableTranslateC(
}
}
+/// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`.
+fn addVelopackPackDirInstall(
+ b: *std.Build,
+ exe: *std.Build.Step.Compile,
+ fizzy: FizzyExecutable,
+ pack_input_subdir: []const u8,
+ pack_plugins_subdir: []const u8,
+ after_step: *std.Build.Step,
+) *std.Build.Step {
+ const pack_exe_install_dir: std.Build.InstallDir = .{ .custom = pack_input_subdir };
+ const pack_plugins_install_dir: std.Build.InstallDir = .{ .custom = pack_plugins_subdir };
+
+ const install_pack_exe = b.addInstallArtifact(exe, .{
+ .dest_dir = .{ .override = pack_exe_install_dir },
+ });
+ install_pack_exe.step.dependOn(after_step);
+
+ var tail: *std.Build.Step = &install_pack_exe.step;
+
+ if (fizzy.pixelart_dylib) |dylib| {
+ const install_pixelart = b.addInstallArtifact(dylib, .{
+ .dest_dir = .{ .override = pack_plugins_install_dir },
+ });
+ install_pixelart.step.dependOn(tail);
+ tail = &install_pixelart.step;
+ }
+ if (fizzy.workbench_dylib) |dylib| {
+ const install_workbench = b.addInstallArtifact(dylib, .{
+ .dest_dir = .{ .override = pack_plugins_install_dir },
+ });
+ install_workbench.step.dependOn(tail);
+ tail = &install_workbench.step;
+ }
+
+ return tail;
+}
+
const FizzyExecutable = struct {
exe: *std.Build.Step.Compile,
zstbi_module: *std.Build.Module,
@@ -1555,7 +1605,7 @@ fn addPixelartDylib(
.linkage = .dynamic,
.root_module = dylib_module,
});
- // Resolve dvui/sdk symbols from the host at load time (Mechanism B).
+ // Resolve dvui/sdk symbols from the host at load time.
lib.linker_allow_shlib_undefined = true;
lib.root_module.export_symbol_names = &[_][]const u8{
"fizzy_plugin_abi_version",
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
new file mode 100644
index 00000000..2b1c86dc
--- /dev/null
+++ b/docs/PLUGINS.md
@@ -0,0 +1,233 @@
+# Fizzy Plugin System
+
+Fizzy is a near-empty **shell** that owns a window, a menu/sidebar/panel layout, and a
+document model — but no features of its own. Everything the user sees (the pixel-art editor,
+the file explorer, tabs/splits) 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.
+
+---
+
+## 1. General structure
+
+```
+ ┌─────────────────────────────────────────────────────────┐
+ │ 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 │ │ pixelart plugin │
+ │ file tree · tabs/splits │ │ canvas editor │
+ └──────────────────────────┘ └────────────────────────┘
+ plugins never import each other — they meet only at the SDK
+```
+
+The SDK (`src/sdk/`) is the entire contract between shell and plugins:
+
+| 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`. |
+| `EditorAPI` | The shell's read/utility surface a plugin reaches back through (`arena`, `folder`, open-doc collection, save dialogs, …). Reached via `Host`. |
+| `regions` | The contribution structs a plugin registers: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `SettingsSection`. |
+| `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.
+
+### Two link modes (one source)
+
+| Mode | Who | Targets | How it registers |
+|------|-----|---------|------------------|
+| **Static** | Built-in plugins (pixelart, workbench, …) — always shipped with the app | all, incl. web | shell calls `plugin.register(&host)` directly at startup |
+| **Dynamic** | Third-party plugins | desktop only (no dlopen on web) | shell `dlopen`s 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 exists so an external Zig project can
+depend on the SDK, implement the same `Plugin` interface, and ship a loadable library.
+
+---
+
+## 2. Anatomy of a plugin
+
+### Directory layout
+
+```
+src/plugins//
+ module.zig # static build root — what the shell imports as @import("")
+ dylib.zig # dynamic build root — exports the C entry symbols only
+ .zig # intra-plugin hub: re-exports sdk/core/dvui + shared types
+ src/
+ plugin.zig # register(host) + the vtable + draw entry points
+ Globals.zig # runtime-injected pointers (allocator, host, plugin state)
+ State.zig # the plugin's own runtime state (whatever it needs)
+ … # implementation
+```
+
+Files inside `src/**` import the hub (`../.zig`) for `sdk`/`core`/`dvui`, **never**
+`fizzy.zig`. That import-discipline is what lets the plugin compile as a standalone library.
+
+### The `register(host)` entry — the one required surface
+
+`register` wires the plugin into the shell. A minimal plugin just registers itself; a
+real one adds contributions:
+
+```zig
+pub fn register(host: *sdk.Host) !void {
+ plugin.state = …; // adopt the plugin's runtime state
+ try host.registerPlugin(&plugin); // identity + vtable
+ try host.registerSidebarView(.{ … }); // a left-rail pane
+ try host.registerBottomView(.{ … }); // a bottom-panel tab
+ try host.registerSettingsSection(.{ … });
+ // …whatever else it contributes
+}
+```
+
+`Host.register*` methods: `registerPlugin`, `registerSidebarView`, `registerBottomView`,
+`registerCenterProvider`, `registerMenu`, `registerSettingsSection`, `registerService`,
+`registerFileRowFillColor`. Each takes a struct with 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 a region).
+
+### The `Plugin` vtable — optional hooks the shell calls
+
+Every field is an optional fn pointer taking the plugin's opaque `state`. Group by purpose:
+
+- **Lifecycle** — `deinit`, `initPlugin`.
+- **Document ownership** — `fileTypePriority(ext)` (claim file extensions), `loadDocument` /
+ `loadDocumentFromBytes` / `createDocument`, `saveDocument`, `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 keep `DocHandle` opaque so
+ the file-management plugin never sees a plugin-specific type.
+- **Rendering** — `drawDocument(doc)` (the document's content in a tab/pane),
+ `drawDocumentInfobar(doc)`.
+- **Per-frame** — `beginFrame`, `tickKeybinds`, `tickOpenDocuments`, … (the shell calls these
+ for every plugin each frame).
+- **Contributions** — `contributeMenu`, `contributeKeybinds`.
+- **Dialogs** — `requestNewDocumentDialog`, `requestGridLayoutDialog`,
+ `requestFlatRasterSaveWarning` (the shell dispatches; the plugin owns the dialog).
+
+A file-management plugin (workbench) implements none of the document hooks. An editor plugin
+(pixelart) implements the document + rendering hooks but contributes no file tree.
+
+### Reaching the shell: `Globals` injection
+
+Plugin code can't import the shell, so the shell **injects pointers** into the plugin once at
+startup (`Globals.gpa`, `Globals.host`, and the plugin's own `state`). Plugin code then uses
+`Globals.host.` to read shell state (open folder, active doc, arena allocator) and
+`Globals.state` for its own data. In a dynamic build the host pushes these across the library
+boundary via the `fizzy_plugin_set_globals` C export.
+
+### Building as a dynamic library
+
+`dylib.zig` exports the C entry symbols the loader looks up (`src/sdk/dylib.zig`):
+
+- `fizzy_plugin_abi_version` → must equal the host's `dylib.abi_version` or the load is rejected.
+- `fizzy_plugin_register(*Host)` → calls the plugin's `register`.
+- `fizzy_plugin_set_globals` / `fizzy_plugin_set_dvui_context` → host injects allocator/state
+ and its live dvui context into the plugin image (host and plugin each compile their own
+ `dvui`/`sdk`/`core`; the host's pointers are pushed in before draw/tick each frame).
+
+Bump `abi_version` whenever the `Host`/`Plugin`/`DocHandle`/`EditorAPI` layouts or an entry
+symbol's meaning change.
+
+---
+
+## 3. How pixelart flows — and uses workbench
+
+**The crucial property: pixelart and workbench do not import each other.** They collaborate
+entirely through the SDK. `grep` confirms zero cross-imports in either `src/` tree.
+
+### What each contributes
+
+`pixelart.register` (`src/plugins/pixelart/src/plugin.zig`):
+- Claims its file types via the `fileTypePriority` vtable hook (`.fiz`, `.png`, …).
+- `registerSidebarView` ×3 — **Tools**, **Sprites**, **Project**. (Project also sets
+ `draw_workspace`, letting it take over the center pane to show the packed atlas.)
+- `registerBottomView` — the **Sprites** panel tab.
+- `registerSettingsSection` — "Pixel Art".
+- `registerFileRowFillColor` — a resolver the file tree calls to tint pixel-art file rows.
+- Implements the document + rendering vtable hooks (load/save/undo/`drawDocument`/…).
+
+`workbench.register`:
+- `registerSidebarView` — the **Files** tree.
+- `registerCenterProvider` — owns the entire center region: the tabs/splits + canvas layout.
+- `registerService("workbench", …)` — the file-management API (see below).
+
+### Opening and drawing a pixel-art document
+
+```
+user double-clicks foo.fiz in workbench's Files tree
+ │
+ ▼
+host.pluginForExtension(".fiz") ──► pixelart (highest fileTypePriority)
+ │
+ ▼
+pixelart.loadDocument(path) ──► builds its File, returns an opaque buffer
+ │
+ ▼
+shell inserts DocHandle{ id, ptr=File, owner=pixelart } into Editor.open_files
+ │
+ ▼
+workbench (center provider) draws a tab for it, and to render the body calls
+ doc.owner.drawDocument(doc) // Workspace.zig
+ │
+ ▼
+pixelart draws its canvas inside the workbench tab/split
+```
+
+Every later action follows the same rule — the shell and workbench only ever call
+`doc.owner.(doc)`. Save, dirty-dot, undo/redo, grouping, path, and the infobar status
+all route to pixelart because it is the `owner`; workbench never knows it's a pixel-art file.
+Reordering a tab is the one mutation of document order, done through `EditorAPI.swapDocs`.
+
+### The `workbench-api` service (inter-plugin file management)
+
+Workbench registers a service (`Workbench.Api`, key `"workbench"`) so any plugin can drive the
+file explorer without importing workbench:
+
+```zig
+const api: *Workbench.Api = @ptrCast(@alignCast(host.getService(Workbench.Api.service_name).?));
+_ = try api.open(path, api.currentGrouping()); // open a file into the focused tab group
+```
+
+Its vtable covers open/close/save, listing open docs by path/index (no plugin type crosses the
+boundary), file-tree ops (create/rename/delete/move), and `registerBranchDecorator` for drawing
+a per-row icon (the built-in "unsaved" dot is one). Pixelart doesn't need it today, but it's the
+sanctioned way a second editor plugin would place documents into tabs and decorate file rows.
+
+### Why this is the model to copy
+
+A new editor plugin (e.g. textedit) drops in with **no shell or workbench changes**: register
+its file types, implement the document + `drawDocument` hooks, and optionally contribute
+sidebar/bottom/settings panes. Its documents then coexist in the same tabs/splits beside
+pixel-art documents, because the whole system is keyed on `DocHandle.owner` and the Host
+registries — not on any plugin knowing about another.
+
+---
+
+### Key files
+
+| 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 contribution structs |
+| `src/sdk/dylib.zig`, `dvui_context.zig` | Runtime-library C entry contract + dvui injection |
+| `src/plugins/pixelart/` | Reference editor plugin (owns documents, renders canvas) |
+| `src/plugins/workbench/` | Reference file-management plugin (tree + tabs/splits + service) |
+| `src/editor/Editor.zig` | The shell: frame loop, `postInit` plugin registration, dylib loading |
diff --git a/src/App.zig b/src/App.zig
index db20e8c2..ec80bcf2 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -168,7 +168,7 @@ pub fn AppInit(win: *dvui.Window) !void {
fizzy.editor = try allocator.create(Editor);
fizzy.editor.* = Editor.init(fizzy.app) catch unreachable;
- // Workbench plugin runtime injection (Stage W): host + allocator, so workbench code
+ // Workbench plugin runtime injection: host + allocator, so workbench code
// reaches the EditorAPI surface without importing `fizzy.zig`. Mirrors pixelart.Globals.
WorkbenchGlobals.gpa = allocator;
WorkbenchGlobals.host = &fizzy.editor.host;
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index b7881fda..71eb5936 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -47,8 +47,8 @@ pub const FileLoadJob = workbench_mod.FileLoadJob;
pub const sdk = fizzy.sdk;
pub const Host = sdk.Host;
-/// Workbench (Phase 1): file-management home — currently the per-branch
-/// decoration registry for the explorer; grows to own files + tabs/splits.
+/// Workbench: the file-management home — file tree, open/load flow, and the
+/// workspace/tabs/splits system, plus the per-branch explorer decoration registry.
pub const Workbench = workbench_mod.Workbench;
/// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels.
@@ -69,7 +69,7 @@ pixelart_state: *pixelart.State,
/// File-management workbench (per-branch explorer decorations, …)
workbench: Workbench,
-/// Keeps plugin dylibs mapped while their vtables are live (Phase 5b.3+; native only).
+/// Keeps plugin dylibs mapped while their vtables are live (native only).
loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty,
settings: Settings = undefined,
@@ -80,7 +80,6 @@ panel: *Panel,
last_titlebar_color: dvui.Color,
-/// Workspaces stored by their grouping ID (owned by `workbench`, Stage W2).
sidebar: Sidebar,
infobar: Infobar,
@@ -485,7 +484,7 @@ pub fn pixelartPlugin(editor: *Editor) *sdk.Plugin {
return editor.host.pluginById("pixelart") orelse @panic("pixelart plugin not registered");
}
-/// Mechanism B: push host dvui state into every loaded plugin dylib image.
+/// Push host dvui state into every loaded plugin dylib image.
pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
for (editor.loaded_plugin_libs.items) |loaded| {
@@ -513,8 +512,6 @@ pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void {
fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void {
try editor.loaded_plugin_libs.append(fizzy.app.allocator, loaded);
- editor.host.plugin_set_globals = loaded.set_globals;
- editor.host.plugin_set_dvui_context = loaded.set_dvui_context;
}
/// Load `{exe_dir}/plugins/libworkbench.*` and register via dylib entry.
@@ -547,8 +544,6 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void {
fn unloadPluginLibs(editor: *Editor) void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
- editor.host.plugin_set_dvui_context = null;
- editor.host.plugin_set_globals = null;
for (editor.loaded_plugin_libs.items) |*entry| {
entry.lib.close();
fizzy.app.allocator.free(entry.path);
@@ -602,9 +597,9 @@ pub fn postInit(editor: *Editor) !void {
.draw = drawSettingsPane,
});
- // Menu bar contributions (non-macOS in-app bar). The draw code still lives in
- // the shell's `Menu.zig`; Phase 3 moves the File/Edit bodies into the workbench
- // / pixel-art plugins, which will then self-register. Order = bar order.
+ // Menu bar contributions (non-macOS in-app bar). The File/Edit draw bodies still live
+ // in the shell's `Menu.zig`; a later step could move them into the workbench / pixel-art
+ // plugins so those self-register. Order = bar order.
try editor.host.registerMenu(.{ .id = "workbench.menu.file", .draw = Menu.drawFileMenu });
try editor.host.registerMenu(.{ .id = "pixelart.menu.edit", .draw = Menu.drawEditMenu });
try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu });
diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig
index 90c93a49..cd6df986 100644
--- a/src/editor/PluginLoader.zig
+++ b/src/editor/PluginLoader.zig
@@ -1,4 +1,4 @@
-//! Native runtime loader for Fizzy plugin dylibs (Phase 5b.3).
+//! Native runtime loader for Fizzy plugin dylibs.
//!
//! Opens a prebuilt plugin library, checks the SDK ABI version, and calls
//! `fizzy_plugin_register`. The returned `std.DynLib` must stay open for the
diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig
index 6b42e888..e84cada3 100644
--- a/src/plugins/pixelart/dylib.zig
+++ b/src/plugins/pixelart/dylib.zig
@@ -1,4 +1,4 @@
-//! Dynamic-library root for the pixel-art plugin (Phase 5b).
+//! Dynamic-library root for the pixel-art plugin.
//!
//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use
//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported.
diff --git a/src/plugins/pixelart/module.zig b/src/plugins/pixelart/module.zig
index 348139ab..a7e17fc2 100644
--- a/src/plugins/pixelart/module.zig
+++ b/src/plugins/pixelart/module.zig
@@ -1,4 +1,4 @@
-//! Pixel-art plugin compile-time module root (Phase 4 Stage D).
+//! Pixel-art plugin compile-time module root.
//!
//! Wired in `build.zig` as `b.addModule("pixelart", .{ .root_source_file = "module.zig" })`.
//! Shell code imports this as `@import("pixelart")`. Plugin files inside `src/` import
diff --git a/src/plugins/pixelart/pixelart.zig b/src/plugins/pixelart/pixelart.zig
index 7bb86e8c..817a67cb 100644
--- a/src/plugins/pixelart/pixelart.zig
+++ b/src/plugins/pixelart/pixelart.zig
@@ -1,4 +1,4 @@
-//! Intra-plugin import hub for the pixel-art plugin (Phase 4 Stage D).
+//! Intra-plugin import hub for the pixel-art plugin.
//!
//! Files inside `src/plugins/pixelart/src/**` import this as `../pixelart.zig` (or
//! `../../pixelart.zig` from nested dirs) instead of `fizzy.zig` for sdk/core/Globals
diff --git a/src/plugins/pixelart/src/CanvasData.zig b/src/plugins/pixelart/src/CanvasData.zig
index 2c13e4a9..0d869641 100644
--- a/src/plugins/pixelart/src/CanvasData.zig
+++ b/src/plugins/pixelart/src/CanvasData.zig
@@ -56,8 +56,8 @@ pub fn init(grouping: u64) CanvasData {
}
/// The drag names are intentionally not freed here: `init` may have fallen back to a static
-/// string literal on (effectively impossible) OOM, and freeing a literal is UB. This matches
-/// the pre-relocation behavior where the names lived on `Workspace` and were never freed.
+/// string literal on (effectively impossible) OOM, and freeing a literal is UB. The names are
+/// short-lived and never freed.
pub fn deinit(_: *CanvasData) void {}
/// Per-pane chrome for `grouping`, lazily allocated on first document draw.
diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixelart/src/Docs.zig
index 7ce735de..c40ec736 100644
--- a/src/plugins/pixelart/src/Docs.zig
+++ b/src/plugins/pixelart/src/Docs.zig
@@ -1,4 +1,4 @@
-//! Open-document registry for the pixel-art plugin (Phase 4 docs/tabs inversion).
+//! Open-document registry for the pixel-art plugin.
//!
//! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the
//! concrete `Internal.File` values their `ptr` fields point at.
diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig
index e64ca5b2..924ae05e 100644
--- a/src/plugins/pixelart/src/Globals.zig
+++ b/src/plugins/pixelart/src/Globals.zig
@@ -1,4 +1,4 @@
-//! Runtime injection points for the pixel-art plugin (Phase 4 Stage D).
+//! Runtime injection points for the pixel-art plugin.
//!
//! The shell sets these once during `App` startup so plugin code can reach the
//! app allocator and singletons without importing `fizzy.zig`.
@@ -14,7 +14,7 @@ pub fn allocator() std.mem.Allocator {
return gpa;
}
-/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`.
+/// For a loaded dylib build, the host calls `fizzy_plugin_set_globals` on the image before `register`.
pub fn installRuntime(
gpa_ptr: ?*const std.mem.Allocator,
state_ptr: ?*State,
diff --git a/src/plugins/pixelart/src/State.zig b/src/plugins/pixelart/src/State.zig
index 79d6290b..039b1144 100644
--- a/src/plugins/pixelart/src/State.zig
+++ b/src/plugins/pixelart/src/State.zig
@@ -1,4 +1,4 @@
-//! Pixel-art plugin runtime state (Phase 4 Stage B/D).
+//! Pixel-art plugin runtime state.
//!
//! Owns the pixel-art-specific editor state that used to live as top-level fields
//! on `src/editor/Editor.zig`: the active tools, color/palette state, the open
@@ -44,7 +44,7 @@ settings: Settings = .{},
tools: Tools,
colors: Colors = .{},
-/// Explorer sidebar panes (lifted off the shell `Explorer` in Phase 4 Stage C). The "tools"
+/// Explorer sidebar panes. The "tools"
/// view (layers + palette) and the "sprites" view (animations/frames) are pixel-art-specific
/// UI state; the shell only routes the registered sidebar view's `draw` to them.
tools_pane: ToolsPane = .{},
diff --git a/src/plugins/pixelart/src/plugin.zig b/src/plugins/pixelart/src/plugin.zig
index 26db1cad..2b77a2df 100644
--- a/src/plugins/pixelart/src/plugin.zig
+++ b/src/plugins/pixelart/src/plugin.zig
@@ -1,7 +1,6 @@
-//! The pixel-art editor plugin. Phase 2 thin shim — the pixel-art stack still
-//! lives inline under `src/editor/` (Phase 3 relocates it whole behind this
-//! plugin). For now its contributions point at the existing draw entry points
-//! through the `Globals` injection. Registered from `Editor.postInit`.
+//! The pixel-art editor plugin: registration + draw entry points. Its contributions
+//! reach the plugin's state through the `Globals` injection. Registered from
+//! `Editor.postInit`.
const std = @import("std");
const builtin = @import("builtin");
const dvui = @import("dvui");
@@ -126,9 +125,8 @@ fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 {
return null;
}
-/// Load `path` into the shell-owned `*Internal.File` at `out_doc`. Runs on the shell's
-/// load worker thread; `File.fromPath` is the pixel-art loader (still resident in the
-/// editor tree, relocated whole into this plugin in Phase 3b/3c).
+/// Load `path` into the plugin-owned `*Internal.File` at `out_doc`. Runs on the shell's
+/// load worker thread; `File.fromPath` is the pixel-art loader.
fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void {
// Web loads via bytes only (`loadDocumentFromBytes`); the comptime guard keeps the
// disk-reading `File.fromPath` path (Dir.cwd / posix.AT) out of the wasm binary.
@@ -172,8 +170,7 @@ fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void {
// Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit
// the pending reorder and clear the per-frame drag indices after the whole document (incl.
- // the file widget) has drawn. Registered first so they run last, matching the order the
- // workbench `Workspace.draw` used before this view was relocated here.
+ // the file widget) has drawn. Registered first so they run last.
defer chrome.columns_drag_index = null;
defer chrome.rows_drag_index = null;
defer chrome.processColumnReorder(file);
diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig
index e26529d6..552d9b46 100644
--- a/src/plugins/workbench/dylib.zig
+++ b/src/plugins/workbench/dylib.zig
@@ -1,4 +1,4 @@
-//! Dynamic-library root for the workbench plugin (Phase 5c).
+//! Dynamic-library root for the workbench plugin.
//!
//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use
//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported.
diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig
index dfae2380..af11cc62 100644
--- a/src/plugins/workbench/src/Globals.zig
+++ b/src/plugins/workbench/src/Globals.zig
@@ -1,4 +1,4 @@
-//! Runtime injection points for the workbench plugin (Stage W).
+//! Runtime injection points for the workbench plugin.
//!
//! The shell sets these once during `App` startup so workbench code can reach the
//! app allocator and the Host (EditorAPI surface) without importing `fizzy.zig`.
@@ -16,7 +16,7 @@ pub fn allocator() std.mem.Allocator {
return gpa;
}
-/// Mechanism B: host calls `fizzy_plugin_set_globals` on the dylib image before `register`.
+/// For a loaded dylib build, the host calls `fizzy_plugin_set_globals` on the image before `register`.
pub fn installRuntime(
gpa_ptr: ?*const std.mem.Allocator,
host_ptr: ?*sdk.Host,
diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig
index 917ed2ec..0b5081c7 100644
--- a/src/plugins/workbench/src/Workbench.zig
+++ b/src/plugins/workbench/src/Workbench.zig
@@ -1,9 +1,8 @@
-//! The Workbench is the file-management home of the editor. Its module now owns
-//! the file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the
-//! workspace/tabs/splits system (`Workspace.zig`); in a later phase it becomes a
-//! standalone plugin. It exposes its capabilities to other plugins through the
-//! `workbench-api` Host service (`Workbench.Api`) so they never reach into the
-//! `fizzy.editor` globals.
+//! The Workbench is the file-management home of the editor. This plugin owns the
+//! file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the
+//! workspace/tabs/splits system (`Workspace.zig`). It exposes its capabilities to
+//! other plugins through the `workbench-api` Host service (`Workbench.Api`) so they
+//! never reach into the editor globals.
//!
//! Per-branch decorations let any plugin draw a right-justified icon on a file row
//! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after
@@ -30,7 +29,7 @@ pub const BranchDecorator = struct {
allocator: std.mem.Allocator,
decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty,
-/// Workspaces keyed by tab-grouping id (Stage W2: owned here, not on the shell Editor).
+/// Workspaces keyed by tab-grouping id (owned here, not on the shell Editor).
workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty,
open_workspace_grouping: u64 = 0,
grouping_id_counter: u64 = 0,
diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig
index c785bce8..8d1104b0 100644
--- a/src/plugins/workbench/src/workbench_layout.zig
+++ b/src/plugins/workbench/src/workbench_layout.zig
@@ -1,4 +1,4 @@
-//! Workspace map maintenance + recursive split drawing (Stage W2).
+//! Workspace map maintenance + recursive split drawing.
const std = @import("std");
const dvui = @import("dvui");
const wbench = @import("../workbench.zig");
diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig
index edbc2e35..5af748ab 100644
--- a/src/sdk/DocHandle.zig
+++ b/src/sdk/DocHandle.zig
@@ -2,9 +2,6 @@
//! and never inspects `ptr` — it only routes operations to `owner` (the plugin
//! that opened the document and knows how to render/save/undo it). For pixel art
//! `ptr` is a `*pixelart.internal.File`; a text plugin would point it at its own type.
-//!
-//! Phase 0: defined but not yet produced/consumed anywhere (see the modular-editor
-//! plan). Wired into the open/render/save path in Phase 3.
const Plugin = @import("Plugin.zig");
pub const DocHandle = @This();
diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig
index d276a765..7f89686e 100644
--- a/src/sdk/Host.zig
+++ b/src/sdk/Host.zig
@@ -1,15 +1,10 @@
//! The services the shell exposes to plugins, and the registries it owns. Plugins
-//! receive a `*Host` instead of reaching into editor globals. Today the Host is
-//! embedded in `Editor`; as the shell shrinks (Phases 1-3) more of the editor's
-//! responsibilities move behind it.
-//!
-//! Phase 0: holds the plugin registry + service locator. Nothing is registered
-//! yet — the existing pixel-art code still uses globals directly.
+//! receive a `*Host` instead of reaching into editor globals; it holds the plugin
+//! registry, the shell region registries, and a service locator. The Host is
+//! embedded in `Editor`.
const std = @import("std");
const dvui = @import("dvui");
const Plugin = @import("Plugin.zig");
-const dvui_context = @import("dvui_context.zig");
-const dylib_api = @import("dylib.zig");
const regions = @import("regions.zig");
const EditorAPI = @import("EditorAPI.zig");
const DocHandle = @import("DocHandle.zig");
@@ -35,16 +30,9 @@ pub const FileRowFillColor = struct {
color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color,
};
-/// Mechanism B: setter from a loaded plugin dylib; null when all plugins are static.
-/// Deprecated: prefer `Editor.syncLoadedPluginDvuiContexts` when multiple dylibs are loaded.
-plugin_set_dvui_context: ?dvui_context.SetContextFn = null,
-/// Host-owned Globals injection into a loaded plugin image (pixelart today).
-/// Deprecated: prefer per-lib `LoadedLib.set_globals` when multiple dylibs are loaded.
-plugin_set_globals: ?dylib_api.SetGlobalsFn = null,
-
allocator: std.mem.Allocator,
-/// All registered plugins (static today; runtime-loaded dylibs in Phase 4).
+/// All registered plugins (statically compiled in, or loaded from a runtime dylib).
plugins: std.ArrayListUnmanaged(*Plugin) = .empty,
/// Service locator for inter-plugin APIs: name -> opaque service vtable. E.g. the
@@ -62,7 +50,7 @@ plugin_settings: PluginSettings = .empty,
/// File-tree row fill tints (workbench asks the Host; editor plugins register).
file_row_fill_colors: std.ArrayListUnmanaged(FileRowFillColor) = .empty,
-// ---- shell region registries (Phase 2) -------------------------------------
+// ---- shell region registries -----------------------------------------------
// The shell iterates these instead of hardcoded enums/switches. Items keep their
// registration order, which is the order they appear in the UI.
@@ -112,36 +100,6 @@ pub fn installShell(self: *Host, api: EditorAPI) void {
self.shell_api = api;
}
-/// Re-push host dvui pointers into the loaded plugin image. Call at the top of each
-/// frame before plugin draw/tick (updates `current_window` every frame).
-pub fn syncPluginDvuiContext(self: *Host) void {
- const setter = self.plugin_set_dvui_context orelse return;
- dvui_context.syncHostIntoPlugin(setter);
-}
-
-/// Re-push host-owned pixelart Globals (`gpa`, `state`, `packer`) into the dylib.
-pub fn syncPluginGlobals(
- self: *Host,
- gpa: *const std.mem.Allocator,
- state: *anyopaque,
- packer: ?*anyopaque,
-) void {
- const setter = self.plugin_set_globals orelse return;
- setter(@ptrCast(gpa), state, packer);
-}
-
-/// Wire a loaded plugin dylib's dvui globals to the host (Mechanism B). Called once
-/// after `dlopen` + `fizzy_plugin_register`; also primes `io` / `ft2lib` / `debug`.
-pub fn installPluginDylibHooks(
- self: *Host,
- set_globals: dylib_api.SetGlobalsFn,
- set_dvui_context: dvui_context.SetContextFn,
-) void {
- self.plugin_set_globals = set_globals;
- self.plugin_set_dvui_context = set_dvui_context;
- dvui_context.syncHostIntoPlugin(set_dvui_context);
-}
-
/// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed.
pub fn arena(self: *Host) std.mem.Allocator {
return self.shell_api.?.arena();
@@ -532,7 +490,7 @@ pub fn activeCenter(self: *Host) ?*CenterProvider {
}
/// The registered plugin with the highest priority (lowest value) for `ext`, or
-/// null if none claims it. Used in Phase 3 to route file opens to the right plugin.
+/// null if none claims it. Routes file opens to the right plugin.
pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin {
var best: ?*Plugin = null;
var best_priority: u8 = 255;
@@ -550,7 +508,7 @@ pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin {
/// Open a "new document" dialog. `parent_path` (when set) targets an on-disk folder; `id_extra`
/// disambiguates launches from distinct explorer rows. Dispatches to the first plugin that
/// provides a new-document dialog.
-/// TODO(multi-plugin): with >1 editor plugin, present a typed "New > " chooser instead of
+/// TODO: with more than one editor plugin, present a typed "New > " chooser instead of
/// picking the first provider.
pub fn requestNewDocument(self: *Host, parent_path: ?[]const u8, id_extra: usize) void {
for (self.plugins.items) |plugin| {
diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig
index a2cf5eaf..f5c8b9b5 100644
--- a/src/sdk/Plugin.zig
+++ b/src/sdk/Plugin.zig
@@ -95,7 +95,7 @@ pub const VTable = struct {
/// Open the owner's "new document" dialog. Not doc-scoped — the host dispatches to a plugin
/// that provides one (see `Host.requestNewDocument`). `parent_path` (when set) creates the
/// document on disk in that folder; `id_extra` disambiguates per-explorer-row launches.
- /// TODO(multi-plugin): with >1 editor plugin this becomes a typed "New > " chooser.
+ /// TODO: with more than one editor plugin this becomes a typed "New > " chooser.
requestNewDocumentDialog: ?*const fn (state: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void = null,
/// Open the owner's grid-layout dialog for `doc` (pixel-art specific; the shell only
/// resolves the active doc and dispatches here so it never names the plugin's dialog).
diff --git a/src/sdk/dvui_context.zig b/src/sdk/dvui_context.zig
index 37e92f0a..f13ad8f9 100644
--- a/src/sdk/dvui_context.zig
+++ b/src/sdk/dvui_context.zig
@@ -1,4 +1,4 @@
-//! Mechanism B: wire the plugin dylib's dvui globals to the host's live state.
+//! Wire a loaded plugin dylib's dvui globals to the host's live state.
//!
//! Host and plugin each compile their own `dvui` copy; before plugin draw/tick the host
//! calls the plugin's `fizzy_plugin_set_dvui_context` export (see `dylib.zig`).
diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig
index 3cc30203..7be08236 100644
--- a/src/sdk/dylib.zig
+++ b/src/sdk/dylib.zig
@@ -1,18 +1,18 @@
-//! Runtime dynamic-library contract for Fizzy plugins (Phase 5b).
+//! Runtime dynamic-library contract for Fizzy plugins.
//!
-//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core` (Mechanism B:
-//! context injection — see `spikes/shared-globals/README.md`). Cross-boundary vtables use
-//! normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry symbols
-//! below use C calling convention.
+//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core`; the host injects
+//! its live dvui context into the plugin image (see `dvui_context.zig`). Cross-boundary
+//! vtables use normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry
+//! symbols below use C calling convention.
//!
//! **Bump `abi_version` when any of these change:** `Host`, `Plugin`, `DocHandle`,
//! `EditorAPI` layouts, or the semantics/signature of an entry symbol.
pub const abi_version: u32 = 1;
-/// `std.DynLib.lookup` names for the host loader (5b.3+).
+/// `std.DynLib.lookup` names for the host loader.
pub const symbol_abi_version = "fizzy_plugin_abi_version";
pub const symbol_register = "fizzy_plugin_register";
-/// Mechanism B — host calls each frame (and once at init) before plugin draw/tick.
+/// Host calls each frame (and once at init) before plugin draw/tick.
pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context";
/// Host-owned pixelart `Globals` (allocator, state, packer) injected before `register`.
pub const symbol_set_globals = "fizzy_plugin_set_globals";
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 10e3ff8e..355cc260 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -1,9 +1,9 @@
//! Fizzy plugin SDK — the surface a plugin module depends on.
//!
-//! Phase 0 of the modular-editor plan: type definitions + registries only.
-//! Nothing routes through these yet; the shell still drives pixel art directly.
-//! Subsequent phases move file management, the workspace/tabs system, and the
-//! pixel-art editor behind this boundary, ending with runtime dylib loading.
+//! A plugin receives a `*Host` and registers its menus, panes, document types, and
+//! settings through these types instead of reaching into editor globals. File
+//! management, the workspace/tabs system, and the editors (pixel art, …) all live
+//! behind this boundary, which also supports loading plugins as runtime dylibs.
pub const Host = @import("Host.zig");
pub const Plugin = @import("Plugin.zig");
pub const DocHandle = @import("DocHandle.zig");
@@ -30,5 +30,5 @@ pub const pane_layout = @import("pane_layout.zig");
/// Runtime dylib entry contract (`fizzy_plugin_abi_version` / `fizzy_plugin_register`).
pub const dylib = @import("dylib.zig");
-/// Dvui global injection for loaded plugin images (Mechanism B).
+/// Dvui global injection for loaded plugin images.
pub const dvui_context = @import("dvui_context.zig");
From 95353f65324de84e94b05659d57ac5f5a3277629 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Mon, 22 Jun 2026 09:59:30 -0500
Subject: [PATCH 40/49] fix dylib link
---
build.zig.zon | 6 +-
docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md | 299 +++++++++++++++++++++++++++
2 files changed, 302 insertions(+), 3 deletions(-)
create mode 100644 docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md
diff --git a/build.zig.zon b/build.zig.zon
index faf88e34..f6c3e553 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -27,9 +27,9 @@
.lazy = true,
},
.dvui = .{
- .url = "https://github.com/foxnne/dvui-dev/archive/2f81423945d7076796023a7802f2680226dd9bd4.tar.gz",
- .hash = "dvui-0.5.0-dev-AQFJmdw09wCp9ts4oaBV7Rkn7YuMKxDiaCLaweO-HPuS",
- //.path = "../dvui-dev",
+ //.url = "https://github.com/foxnne/dvui-dev/archive/2f81423945d7076796023a7802f2680226dd9bd4.tar.gz",
+ //.hash = "dvui-0.5.0-dev-AQFJmdw09wCp9ts4oaBV7Rkn7YuMKxDiaCLaweO-HPuS",
+ .path = "../dvui-dev",
},
.assetpack = .{
.url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz",
diff --git a/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md b/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md
new file mode 100644
index 00000000..c877c7cd
--- /dev/null
+++ b/docs/PLUGIN_RENDER_BRIDGE_HANDOFF.md
@@ -0,0 +1,299 @@
+# Handoff: plugin render bridge (keep SDL/GPU in the shell)
+
+> **Goal:** make Fizzy's runtime-loaded plugin **dylibs render correctly** without each one
+> linking its own copy of SDL. Today every plugin dylib bakes in its own dvui SDL backend +
+> its own SDL, which produces `SDL_RenderGeometryRaw ... Parameter 'renderer' is invalid` on
+> every plugin draw (only shell-owned UI renders). The fix is a **forwarding/proxy dvui
+> backend**: the plugin's dvui turns widgets into draw calls that are forwarded, through an
+> injected C-ABI function table, to the **host's** real backend. SDL/GPU stay entirely in the
+> shell; plugins link zero SDL.
+>
+> This work spans **two repos**:
+> - **`dvui-dev`** (the `foxnne/dvui-dev` fork — checked out locally at `dev/dvui-dev`): add a
+> `proxy` backend + expose a `dvui_proxy` module. **This is the part to do first.**
+> - **`fizzy`**: define the bridge table, implement host-side thunks, inject the table into each
+> loaded dylib, and switch plugin dylibs to import `dvui_proxy`. (Outlined here; do after dvui.)
+
+Until this lands, plugins work in **static** mode (`FIZZY_STATIC_WORKBENCH=1
+FIZZY_STATIC_PIXELART=1 ./fizzy`), where they share the shell's dvui/SDL directly.
+
+---
+
+## 1. Why this is needed (root cause)
+
+dvui binds its backend **at compile time**. In `dvui-dev/src/Backend.zig`:
+
+```zig
+const Implementation = @import("backend"); // chosen when the dvui module is built
+impl: *Implementation,
+pub fn drawClippedTriangles(self: Backend, ...) { try self.impl.drawClippedTriangles(...); }
+```
+
+`self.impl.drawClippedTriangles(...)` is a **static call** into whichever backend the dvui
+module was compiled with. Fizzy builds each plugin dylib against `dvui_sdl3`, so the dylib
+contains its own copy of dvui's SDL backend (`sdl.drawClippedTriangles`) **and statically links
+SDL** (confirmed: `nm libworkbench.dylib` shows `_SDL_RenderGeometryRaw` defined in `__TEXT`).
+
+The host injects its live `current_window` into each plugin (see
+`fizzy/src/sdk/dvui_context.zig`), so the plugin's dvui has the host's window — which holds the
+host's SDL **renderer pointer**. But the *code* that consumes it is the plugin's own SDL backend
+calling the plugin's own SDL. Passing the host's renderer handle to the plugin's separate SDL
+runtime → "renderer is invalid", every frame, for every plugin draw.
+
+Static plugins render fine because they're compiled into the exe and share the one true SDL.
+
+**Conclusion:** plugins don't need SDL. They need a backend that converts dvui draw calls into
+calls back to the host. That backend is the deliverable.
+
+---
+
+## 2. Architecture
+
+```
+ plugin dylib (its own dvui, NO SDL) host exe (the one real dvui + SDL)
+ ┌───────────────────────────────┐ ┌──────────────────────────────────┐
+ │ widgets (textEntry, box, …) │ │ real dvui_sdl3 backend (SDL) │
+ │ │ dvui immediate mode │ │ drawClippedTriangles → SDL │
+ │ ▼ │ C-ABI │ textureCreate → SDL_Texture │
+ │ proxy backend Implementation │ ─────────► │ … │
+ │ drawClippedTriangles(...) ─── calls ───────►│ thunk → host_window.backend.draw… │
+ │ textureCreate(...) ─── table ────────►│ thunk → host backend.textureCreate│
+ └───────────────────────────────┘ (RenderBridge)└──────────────────────────────────┘
+```
+
+- The plugin's dvui is compiled with a **`proxy` backend** instead of `sdl3`.
+- The proxy backend's methods forward to a **`RenderBridge`** — a struct of
+ `*const fn(...) callconv(.c)` pointers the host fills in and injects into the plugin (exactly
+ like the existing `fizzy_plugin_set_dvui_context` mechanism).
+- The host implements each bridge fn as a thin thunk over its **real** `dvui.Backend`
+ (the SDL one). All GPU/SDL state and calls stay in the host process's one SDL runtime.
+- **Textures cross the boundary as opaque handles.** `dvui.Texture` is
+ `{ ptr: *anyopaque, width, height, interpolation }`; `ptr` is the host backend's texture
+ (e.g. `SDL_Texture*`). The proxy never interprets it — it just hands it back to the host on
+ `drawClippedTriangles`. `dvui.Texture`/`Texture.Target` layout is identical in host and plugin
+ because both compile the same dvui source.
+
+### Key design insight — the proxy backend is **stateless**
+
+The host injects its own `current_window` into the plugin, so the plugin's
+`current_window.backend.impl` actually points at the **host's** backend instance, reinterpreted
+through the plugin's `Implementation = ProxyBackend` type. That's fine **as long as the proxy's
+methods never dereference `self`/the Context pointer** — they must forward to a **module-global
+`RenderBridge`** set at injection time. Write every proxy method to ignore its receiver and use
+the global table. (`begin`/`end`/`renderPresent` are driven by the host's dvui on the host's
+window and generally won't be invoked from the plugin; implement them as no-ops or forwards.)
+
+---
+
+## 3. Part 1 — Changes in `dvui-dev` (do this first)
+
+### 3a. Add the proxy backend: `src/backends/proxy.zig`
+
+**Template:** copy the structure of `src/backends/testing.zig` — it is a complete, non-SDL
+backend that already implements the entire interface headlessly. The proxy is the same shape,
+but its rendering/size/clipboard methods forward to the injected `RenderBridge` instead of
+no-op/test-buffer behavior.
+
+The backend must implement **the same method set as `testing.zig`** (that set is authoritative —
+it's every method `Backend.zig` calls on `self.impl`). For reference, the methods and how each
+should behave in the proxy:
+
+| Method | Proxy behavior |
+|--------|----------------|
+| `pub const kind` | add a new `dvui.enums.Backend` tag, e.g. `.proxy` (see 3c) |
+| `pub const Context = *ProxyBackend` | a tiny struct; methods ignore it (stateless) |
+| `init` / `deinit` | trivial; `init` returns an empty `ProxyBackend` |
+| **`drawClippedTriangles(texture, vtx, idx, clipr)`** | **forward to bridge** (the core render op) |
+| **`textureCreate(pixels, opts) → Texture`** | **forward**; wrap returned host `ptr` in `dvui.Texture` |
+| **`textureUpdateSubRect(texture, pixels, x,y,w,h)`** | **forward** |
+| **`textureDestroy(texture)`** | **forward** |
+| **`textureCreateTarget(opts) → TextureTarget`** | **forward** |
+| **`textureReadTarget(target, pixels_out)`** | **forward** |
+| **`textureDestroyTarget(target)`** | **forward** |
+| **`textureFromTarget` / `textureFromTargetTemp` / `textureClearTarget`** | **forward** |
+| **`renderTarget(?target)`** | **forward** |
+| `pixelSize` / `windowSize` / `contentScale` | **forward** (host owns the window) |
+| `clipboardText` / `clipboardTextSet` / `openURL` | **forward** (host owns the OS) |
+| `setCursor` / `textInputRect` | forward or no-op (cosmetic) |
+| `preferredColorScheme` / `prefersReducedMotion` | forward or sensible default |
+| `nanoTime` / `sleep` | local is fine (`std.time`) — no need to forward |
+| `begin` / `end` / `renderPresent` / `refresh` | no-op or forward; host drives the frame |
+| `accessKitInitInBegin` / `accessKitShouldInitialize` / `native` | match `testing.zig` (likely off/no-op) |
+| `backend(self) → dvui.Backend` | `return Backend.init(self)` (mirror testing) |
+
+> Confirm the exact list against the installed dvui by reading `testing.zig`'s `pub fn`s plus
+> `grep -oE 'self\.impl\.[a-zA-Z_]+' src/Backend.zig`. If the interface gains/loses a method in a
+> future dvui bump, the proxy must track it (a missing method is a compile error — good).
+
+### 3b. The `RenderBridge` table
+
+Define the C-ABI table the proxy forwards through. Put it where both the dvui backend and the
+host can reference the **same definition** — simplest is a small file in the proxy backend, e.g.
+`src/backends/proxy_bridge.zig`, exporting the struct type and a module-global setter:
+
+```zig
+// src/backends/proxy_bridge.zig (illustrative — match real dvui types/signatures)
+const dvui = @import("dvui");
+
+pub const RenderBridge = extern struct {
+ ctx: ?*anyopaque, // host-side backend handle, passed back to every fn
+
+ draw_clipped_triangles: *const fn (ctx: ?*anyopaque, texture_ptr: ?*anyopaque,
+ vtx: [*]const dvui.Vertex, vtx_len: usize,
+ idx: [*]const dvui.Vertex.Index, idx_len: usize,
+ clip: ?*const dvui.Rect.Physical) callconv(.c) void,
+
+ texture_create: *const fn (ctx: ?*anyopaque, pixels: [*]const u8,
+ width: u32, height: u32, interpolation: u8) callconv(.c) ?*anyopaque,
+ texture_update_sub_rect: *const fn (ctx: ?*anyopaque, texture_ptr: ?*anyopaque,
+ pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) callconv(.c) void,
+ texture_destroy: *const fn (ctx: ?*anyopaque, texture_ptr: ?*anyopaque) callconv(.c) void,
+
+ texture_create_target: *const fn (ctx: ?*anyopaque, width: u32, height: u32,
+ interpolation: u8) callconv(.c) ?*anyopaque,
+ texture_read_target: *const fn (ctx: ?*anyopaque, target_ptr: ?*anyopaque,
+ pixels_out: [*]u8) callconv(.c) bool, // false = error
+ texture_destroy_target: *const fn (ctx: ?*anyopaque, target_ptr: ?*anyopaque) callconv(.c) void,
+ render_target: *const fn (ctx: ?*anyopaque, target_ptr: ?*anyopaque) callconv(.c) void,
+
+ pixel_size_w: ... , pixel_size_h: ... , // or one fn returning a small struct
+ // clipboard_text / clipboard_text_set / open_url / content_scale / window_size … as needed
+};
+
+/// Module-global, set once by the host via the dylib's C entry (see fizzy Part 2).
+pub var bridge: ?*const RenderBridge = null;
+```
+
+Notes:
+- Use plain `extern`/C-ABI scalar params (slices → `ptr,len`; enums → `u8`). The proxy methods
+ marshal dvui types into these calls.
+- Texture handles: `dvui.Texture.ptr` ⇄ the host's `?*anyopaque`. `textureCreate` returns the
+ host pointer; the proxy builds `dvui.Texture{ .ptr = host_ptr, .width=…, .height=…,
+ .interpolation=… }`. `drawClippedTriangles`/destroy pass `texture.ptr` back.
+- Error mapping: render ops that can fail (`textureCreate`, `textureReadTarget`) signal failure
+ via null/bool; the proxy converts to dvui's `TextureError`.
+
+### 3c. Register `.proxy` as a backend and expose a `dvui_proxy` module
+
+1. Add a `proxy` variant to the build `Backend` enum and to `dvui.enums.Backend` (mirror how
+ `testing`/`sdl3` are listed).
+2. In `build.zig`'s `buildBackend`, add a `.proxy =>` arm that mirrors the **`.testing`** arm:
+
+ ```zig
+ .proxy => {
+ dvui_opts.setDefaults(.{ .libc = true, .freetype = true, .stb_image = true, .tree_sitter = true });
+ const proxy_mod = b.addModule("proxy", .{
+ .root_source_file = b.path("src/backends/proxy.zig"),
+ .target = target, .optimize = optimize,
+ });
+ const dvui_proxy = addDvuiModule("dvui_proxy", dvui_opts);
+ linkBackend(dvui_proxy, proxy_mod); // <-- the supported custom-backend hook
+ },
+ ```
+ `linkBackend(dvui_mod, backend_mod)` (build.zig:1002) does `dvui_mod.addImport("backend", backend_mod)`
+ — this is the *intended* extension point (`build.zig:375` even documents it).
+3. Make sure the `dvui_proxy` and `proxy` modules are reachable to consumers via
+ `dvui_dep.module("dvui_proxy")` (and the bridge type, if it lives in `proxy_bridge.zig`,
+ via a module too). Crucially: the proxy backend **must not link SDL** — it links nothing
+ platform-specific (no `linkLibrary(SDL3)`), so a dylib built against `dvui_proxy` has **zero
+ SDL**. That's the whole point.
+
+**Acceptance for Part 1:** a throwaway exe/lib that imports `dvui_proxy` compiles and contains
+**no** SDL symbols (`nm | grep SDL` → empty), and the proxy backend implements the full
+`Implementation` interface (no missing-method compile errors when used as a dvui backend).
+
+---
+
+## 4. Part 2 — Changes in `fizzy` (after dvui exposes `dvui_proxy`)
+
+1. **SDK bridge + injection symbol.** Mirror the existing dvui-context plumbing:
+ - `src/sdk/dvui_context.zig` already injects window/io/ft2lib/debug via the C export
+ `fizzy_plugin_set_dvui_context` (declared in `src/sdk/dylib.zig`, called from
+ `Editor.syncLoadedPluginDvuiContexts`). Add a sibling: a `fizzy_plugin_set_render_bridge`
+ C export (symbol name listed in `dylib.zig`, exported by each plugin's `dylib.zig`) that
+ stores the `*const RenderBridge` into the proxy backend's global `bridge`.
+ - The `RenderBridge` type comes from dvui's `proxy_bridge.zig` (single source of truth) — the
+ SDK and host reference the same type.
+
+2. **Host thunks.** In the shell, implement a `RenderBridge` whose `ctx` is the host and whose
+ fns call the host's real `dvui.Backend` (the SDL one for native). e.g.
+ `draw_clipped_triangles` → reconstruct slices/`Texture` and call
+ `host_window.backend.drawClippedTriangles(...)`. Build this once; the host's backend instance
+ is stable, so the bridge can be **injected once at load** (no per-frame push needed, unlike
+ `current_window`).
+
+3. **Inject at load.** In `Editor.loadWorkbenchDylib` / `loadPixelartDylib` (and the generic
+ loader), after `installRuntime`/`set_dvui_context`, look up and call the dylib's
+ `fizzy_plugin_set_render_bridge` with `&host_bridge`. Store nothing per-frame.
+ - `PluginLoader.LoadedLib` (in `src/editor/PluginLoader.zig`) currently holds `set_globals`
+ and `set_dvui_context`; add `set_render_bridge` alongside.
+
+4. **Build wiring.** Switch the **plugin dylib** modules from `dvui_sdl3` → `dvui_proxy`:
+ - In `build.zig`, `addWorkbenchDylib` / `addPixelartDylib` (and a future `addCodeDylib`) pass
+ `.dvui = dvui_dep.module("dvui_proxy")` instead of `dvui_sdl3`. The **static** module
+ wiring (`wireWorkbenchModule` etc., used for the in-exe fallback and web) keeps `dvui_sdl3`
+ / the normal dvui — only the **dylib** roots change.
+ - The dylib now links no SDL; keep `linker_allow_shlib_undefined = true` so the remaining
+ dvui/sdk/core symbols still resolve from the host at load.
+ - `core` also re-exports dvui (`core.dvui`); make sure the dylib's `core` is built against the
+ same `dvui_proxy` so there's one dvui flavor inside the dylib.
+
+5. **Texture/format sanity.** Confirm `dvui.Texture`/`Texture.Target`/`Vertex`/`Rect.Physical`
+ have identical layout in the host's `dvui_sdl3` and the plugin's `dvui_proxy` (same dvui
+ source + same relevant build options → they will, but the interpolation enum and any
+ `default_options` that affect struct layout must match).
+
+---
+
+## 5. Verification
+
+- `nm zig-out//plugins/libworkbench.dylib | grep -i SDL` → **empty** (no SDL in the dylib).
+- `otool -L` (macOS) on the dylib → no SDL; only libSystem/libobjc + `@rpath/...`.
+- Run **dylib mode** (the default — no `FIZZY_STATIC_*`): the file tree, canvas, and pixel-art
+ panes render correctly (no `renderer is invalid` spam).
+- Open a `.zig`/`.json` with the **code** plugin and a pixel-art file side by side; both render.
+- `zig build test` still green (static/testing path unaffected).
+
+---
+
+## 6. Reference (exact, from the pinned dvui)
+
+- dvui fork: `foxnne/dvui-dev`; pinned in `fizzy/build.zig.zon` (`dvui-0.5.0-dev-…`); vendored copy
+ for reading at `fizzy/zig-pkg/dvui-0.5.0-dev-AQFJmdw09w…/`.
+- Backend interface & dispatch: `src/Backend.zig` (note `render_backend.kind == .default` → all
+ rendering goes through `self.impl`, i.e. the proxy).
+- Complete backend template: `src/backends/testing.zig`.
+- Custom-backend hook: `linkBackend(dvui_mod, backend_mod)` at `build.zig:1002`; usage documented
+ at `build.zig:375`; `.testing` arm (the pattern to copy) around `build.zig:395–417`.
+- Types crossing the boundary: `src/Texture.zig` — `Texture { ptr: *anyopaque, width: u32,
+ height: u32, interpolation }`, `Texture.Target { ptr, width, height, interpolation }`,
+ `CreateOptions { width, height, interpolation = .linear }`; `dvui.Vertex`, `dvui.Vertex.Index`,
+ `dvui.Rect.Physical`.
+
+### Fizzy-side files to mirror/extend
+| File | Role |
+|------|------|
+| `src/sdk/dylib.zig` | C entry symbol names + `abi_version` (bump it when adding `set_render_bridge`) |
+| `src/sdk/dvui_context.zig` | existing per-image dvui injection — pattern to copy for the bridge |
+| `src/plugins//dylib.zig` | each plugin's C exports (`fizzy_plugin_set_dvui_context`, …) — add the bridge setter |
+| `src/editor/PluginLoader.zig` | `LoadedLib` (add `set_render_bridge`) + symbol lookup at load |
+| `src/editor/Editor.zig` | `loadWorkbenchDylib`/`loadPixelartDylib`, `syncLoadedPluginDvuiContexts` |
+| `build.zig` | `addWorkbenchDylib`/`addPixelartDylib` → switch dylib `dvui` dep to `dvui_proxy` |
+
+---
+
+## 7. Notes / decisions for the implementer
+
+- **Do dvui Part 1 fully first** and prove "import `dvui_proxy` ⇒ no SDL symbols" before touching
+ fizzy. That de-risks the whole effort.
+- **Stateless proxy is mandatory** (see §2 insight): methods must use the module-global bridge,
+ never `self`, because the injected `current_window.backend.impl` actually points at the host's
+ backend instance.
+- **One SDL, in the host, forever** — this is also exactly what a **third-party** plugin needs: it
+ will import the Fizzy SDK + `dvui_proxy` and draw, never touching SDL/GPU libraries.
+- Keep **static mode** working throughout (it's the fallback and the test path); only the dylib
+ build flavor changes.
+- If a clean proxy backend proves hard to land quickly, a stopgap that *shares one SDL* (host
+ exports SDL; dylib built `-undefined dynamic_lookup` with SDL not statically linked, or a shared
+ `libSDL3.dylib`) would also fix rendering — but it keeps SDL in the plugin's build graph and is
+ worse for the third-party SDK story. The proxy backend is the real answer.
From 0cec1f0262e777ebe446bbb656e62ddcd0fd9fe8 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Mon, 22 Jun 2026 10:22:38 -0500
Subject: [PATCH 41/49] fix dylib part 2
---
build.zig | 76 ++++-
src/editor/Editor.zig | 10 +
src/editor/PluginLoader.zig | 8 +
src/plugins/pixelart/dylib.zig | 4 +
src/plugins/pixelart/src/Globals.zig | 6 +-
.../pixelart/src/widgets/FileWidget.zig | 1 -
src/plugins/workbench/dylib.zig | 4 +
src/plugins/workbench/src/Globals.zig | 6 +-
src/sdk/dylib.zig | 6 +-
src/sdk/render_bridge.zig | 263 ++++++++++++++++++
src/sdk/sdk.zig | 2 +
11 files changed, 368 insertions(+), 18 deletions(-)
create mode 100644 src/sdk/render_bridge.zig
diff --git a/build.zig b/build.zig
index 64296b79..49a2f6c1 100644
--- a/build.zig
+++ b/build.zig
@@ -300,6 +300,7 @@ pub fn build(b: *std.Build) !void {
.backend = .web,
.freetype = false,
});
+ const dvui_web_proxy_bridge = addProxyBridgeModule(b, web_target, optimize, dvui_web_dep, dvui_web_dep.module("dvui_web"));
const web_exe = b.addExecutable(.{
.name = "web",
@@ -370,7 +371,7 @@ pub fn build(b: *std.Build) !void {
core_module_web.addImport("icons", dep.module("icons"));
}
web_exe.root_module.addImport("core", core_module_web);
- const sdk_module_web = wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), web_exe.root_module);
+ const sdk_module_web = wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), dvui_web_proxy_bridge, web_exe.root_module);
// Three editor files have `const sdl3 = @import("backend").c;` at file
// scope. After refactoring all `sdl3.SDL_DialogFileFilter` references
@@ -944,6 +945,7 @@ pub fn build(b: *std.Build) !void {
.backend = .testing,
.accesskit = accesskit,
});
+ const dvui_test_proxy_bridge = addProxyBridgeModule(b, target, optimize, dvui_testing_dep, dvui_testing_dep.module("dvui_testing"));
// Build a module rooted at `src/fizzy.zig` carrying all the same
// imports the production exe carries. Because fizzy.zig's transitive
@@ -980,7 +982,7 @@ pub fn build(b: *std.Build) !void {
core_module_test.addImport("icons", dep.module("icons"));
}
fizzy_test_module.addImport("core", core_module_test);
- const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), fizzy_test_module);
+ const sdk_module_test = wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), dvui_test_proxy_bridge, fizzy_test_module);
_ = wirePixelartModule(b, target, optimize, .{
.dvui = dvui_testing_dep.module("dvui_testing"),
.core = core_module_test,
@@ -1293,6 +1295,16 @@ fn addFizzyExecutableForTarget(
else
b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit });
+ const dvui_proxy_dep = b.dependency("dvui", .{
+ .target = resolved_target,
+ .optimize = optimize,
+ .backend = .proxy,
+ .accesskit = .off,
+ });
+ const dvui_proxy_mod = dvui_proxy_dep.module("dvui_proxy");
+ const proxy_bridge_host_mod = addProxyBridgeModule(b, resolved_target, optimize, dvui_dep, dvui_dep.module("dvui_sdl3"));
+ const proxy_bridge_plugin_mod = dvui_proxy_dep.module("proxy_bridge");
+
const zstbi_lib = b.addLibrary(.{
.name = "zstbi",
.root_module = b.addModule("zstbi", .{
@@ -1364,13 +1376,25 @@ fn addFizzyExecutableForTarget(
core_module.addImport("dvui", dvui_dep.module("dvui_sdl3"));
core_module.addImport("known-folders", known_folders);
exe.root_module.addImport("core", core_module);
- const sdk_module = wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), exe.root_module);
+
var icons_module: ?*std.Build.Module = null;
if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| {
exe.root_module.addImport("icons", dep.module("icons"));
core_module.addImport("icons", dep.module("icons"));
icons_module = dep.module("icons");
}
+
+ const core_proxy_module = b.createModule(.{
+ .target = resolved_target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/core/core.zig"),
+ });
+ core_proxy_module.addImport("dvui", dvui_proxy_mod);
+ core_proxy_module.addImport("known-folders", known_folders);
+ if (icons_module) |icons| core_proxy_module.addImport("icons", icons);
+
+ const sdk_module = wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), proxy_bridge_host_mod, exe.root_module);
+ const sdk_proxy_module = wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, null);
_ = wirePixelartModule(b, resolved_target, optimize, .{
.dvui = dvui_dep.module("dvui_sdl3"),
.core = core_module,
@@ -1392,25 +1416,27 @@ fn addFizzyExecutableForTarget(
const pixelart_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
break :blk addPixelartDylib(b, resolved_target, optimize, .{
- .dvui = dvui_dep.module("dvui_sdl3"),
- .core = core_module,
- .sdk = sdk_module,
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
.assets = assets_module,
.zip = zip_pkg.module,
.zstbi = zstbi_module,
.msf_gif = msf_gif_module,
.icons = icons_module,
- .backend = dvui_dep.module("sdl3"),
+ .backend = null,
});
} else null;
const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
break :blk addWorkbenchDylib(b, resolved_target, optimize, .{
- .dvui = dvui_dep.module("dvui_sdl3"),
- .core = core_module,
- .sdk = sdk_module,
+ .dvui = dvui_proxy_mod,
+ .core = core_proxy_module,
+ .sdk = sdk_proxy_module,
+ .proxy_bridge = proxy_bridge_plugin_mod,
.icons = icons_module,
- .backend = dvui_dep.module("sdl3"),
+ .backend = null,
});
} else null;
@@ -1480,12 +1506,29 @@ fn addFizzyExecutableForTarget(
}
/// Plugin SDK (`src/sdk/sdk.zig`). Depends only on `dvui`.
+fn addProxyBridgeModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ dvui_dep: *std.Build.Dependency,
+ dvui_module: *std.Build.Module,
+) *std.Build.Module {
+ const mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = dvui_dep.path("src/backends/proxy_bridge.zig"),
+ });
+ mod.addImport("dvui", dvui_module);
+ return mod;
+}
+
fn wireSdkModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
dvui_module: *std.Build.Module,
- consumer: *std.Build.Module,
+ proxy_bridge_module: *std.Build.Module,
+ consumer: ?*std.Build.Module,
) *std.Build.Module {
const sdk_module = b.createModule(.{
.target = target,
@@ -1493,7 +1536,8 @@ fn wireSdkModule(
.root_source_file = b.path("src/sdk/sdk.zig"),
});
sdk_module.addImport("dvui", dvui_module);
- consumer.addImport("sdk", sdk_module);
+ sdk_module.addImport("proxy_bridge", proxy_bridge_module);
+ if (consumer) |c| c.addImport("sdk", sdk_module);
return sdk_module;
}
@@ -1501,6 +1545,7 @@ const PixelartModuleDeps = struct {
dvui: *std.Build.Module,
core: *std.Build.Module,
sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
assets: *std.Build.Module,
zip: *std.Build.Module,
zstbi: *std.Build.Module,
@@ -1513,6 +1558,7 @@ const WorkbenchModuleDeps = struct {
dvui: *std.Build.Module,
core: *std.Build.Module,
sdk: *std.Build.Module,
+ proxy_bridge: ?*std.Build.Module = null,
icons: ?*std.Build.Module,
backend: ?*std.Build.Module,
};
@@ -1522,6 +1568,7 @@ fn applyWorkbenchModuleImports(module: *std.Build.Module, deps: WorkbenchModuleD
module.addImport("dvui", deps.dvui);
module.addImport("core", deps.core);
module.addImport("sdk", deps.sdk);
+ if (deps.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
if (deps.icons) |icons| module.addImport("icons", icons);
if (deps.backend) |backend| module.addImport("backend", backend);
}
@@ -1568,6 +1615,7 @@ fn addWorkbenchDylib(
"fizzy_plugin_abi_version",
"fizzy_plugin_register",
"fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_render_bridge",
"fizzy_plugin_set_globals",
};
return lib;
@@ -1578,6 +1626,7 @@ fn applyPixelartModuleImports(module: *std.Build.Module, deps: PixelartModuleDep
module.addImport("dvui", deps.dvui);
module.addImport("core", deps.core);
module.addImport("sdk", deps.sdk);
+ if (deps.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge);
module.addImport("assets", deps.assets);
module.addImport("zip", deps.zip);
module.addImport("zstbi", deps.zstbi);
@@ -1611,6 +1660,7 @@ fn addPixelartDylib(
"fizzy_plugin_abi_version",
"fizzy_plugin_register",
"fizzy_plugin_set_dvui_context",
+ "fizzy_plugin_set_render_bridge",
"fizzy_plugin_set_globals",
};
return lib;
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index 71eb5936..d2d8861f 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -492,6 +492,14 @@ pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void {
}
}
+/// Inject the host render bridge into every loaded plugin dylib (proxy backend).
+pub fn syncLoadedPluginRenderBridge(editor: *Editor) void {
+ if (comptime builtin.target.cpu.arch == .wasm32) return;
+ for (editor.loaded_plugin_libs.items) |loaded| {
+ sdk.render_bridge.syncHostIntoPlugin(loaded.set_render_bridge);
+ }
+}
+
fn syncLoadedPluginGlobals(editor: *Editor, plugin_id: []const u8, arg_b: *anyopaque, arg_c: ?*anyopaque) void {
if (comptime builtin.target.cpu.arch == .wasm32) return;
for (editor.loaded_plugin_libs.items) |loaded| {
@@ -526,6 +534,7 @@ pub fn loadWorkbenchDylib(editor: *Editor, exe_dir: []const u8) !void {
});
try appendLoadedPluginLib(editor, loaded);
syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
}
/// Load `{exe_dir}/plugins/libpixelart.*` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry.
@@ -540,6 +549,7 @@ pub fn loadPixelartDylib(editor: *Editor, exe_dir: []const u8) !void {
});
try appendLoadedPluginLib(editor, loaded);
syncLoadedPluginDvuiContexts(editor);
+ syncLoadedPluginRenderBridge(editor);
}
fn unloadPluginLibs(editor: *Editor) void {
diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig
index cd6df986..9afc7645 100644
--- a/src/editor/PluginLoader.zig
+++ b/src/editor/PluginLoader.zig
@@ -19,6 +19,7 @@ pub const LoadError = error{
RegisterSymbolMissing,
SetGlobalsSymbolMissing,
SetDvuiContextSymbolMissing,
+ SetRenderBridgeSymbolMissing,
AbiMismatch,
RegisterRejected,
};
@@ -30,6 +31,7 @@ pub const LoadedLib = struct {
plugin_id: []const u8,
set_globals: dylib_api.SetGlobalsFn,
set_dvui_context: dvui_context.SetContextFn,
+ set_render_bridge: sdk.render_bridge.SetRenderBridgeFn,
};
/// Host-owned pointers injected into the plugin image immediately before `register`.
@@ -106,6 +108,11 @@ pub fn loadAndRegister(
dylib_api.symbol_set_dvui_context,
) orelse return error.SetDvuiContextSymbolMissing;
+ const set_bridge = lib.lookup(
+ sdk.render_bridge.SetRenderBridgeFn,
+ dylib_api.symbol_set_render_bridge,
+ ) orelse return error.SetRenderBridgeSymbolMissing;
+
if (pre) |inject| {
set_globals(
if (inject.gpa) |gpa| @ptrCast(gpa) else null,
@@ -127,6 +134,7 @@ pub fn loadAndRegister(
.plugin_id = plugin_id,
.set_globals = set_globals,
.set_dvui_context = set_ctx,
+ .set_render_bridge = set_bridge,
};
}
diff --git a/src/plugins/pixelart/dylib.zig b/src/plugins/pixelart/dylib.zig
index e84cada3..a09430aa 100644
--- a/src/plugins/pixelart/dylib.zig
+++ b/src/plugins/pixelart/dylib.zig
@@ -25,6 +25,10 @@ export fn fizzy_plugin_set_dvui_context(
sdk.dvui_context.inject(window, io, ft2lib, debug);
}
+export fn fizzy_plugin_set_render_bridge(bridge: ?*const @import("proxy_bridge").RenderBridge) callconv(.c) void {
+ @import("proxy_bridge").setBridge(bridge);
+}
+
export fn fizzy_plugin_set_globals(
gpa: ?*const anyopaque,
state: ?*anyopaque,
diff --git a/src/plugins/pixelart/src/Globals.zig b/src/plugins/pixelart/src/Globals.zig
index 924ae05e..3c4de8a8 100644
--- a/src/plugins/pixelart/src/Globals.zig
+++ b/src/plugins/pixelart/src/Globals.zig
@@ -5,6 +5,7 @@
const std = @import("std");
const State = @import("State.zig");
const Packer = @import("Packer.zig");
+const core = @import("core");
pub var gpa: std.mem.Allocator = undefined;
pub var state: *State = undefined;
@@ -20,7 +21,10 @@ pub fn installRuntime(
state_ptr: ?*State,
packer_ptr: ?*Packer,
) void {
- if (gpa_ptr) |a| gpa = a.*;
+ if (gpa_ptr) |a| {
+ gpa = a.*;
+ core.gpa = a.*;
+ }
if (state_ptr) |s| state = s;
if (packer_ptr) |p| packer = p;
}
diff --git a/src/plugins/pixelart/src/widgets/FileWidget.zig b/src/plugins/pixelart/src/widgets/FileWidget.zig
index 6aa49d85..af56d986 100644
--- a/src/plugins/pixelart/src/widgets/FileWidget.zig
+++ b/src/plugins/pixelart/src/widgets/FileWidget.zig
@@ -2,7 +2,6 @@ const std = @import("std");
const math = std.math;
const dvui = @import("dvui");
const builtin = @import("builtin");
-const sdl3 = @import("backend").c;
const Options = dvui.Options;
const Rect = dvui.Rect;
diff --git a/src/plugins/workbench/dylib.zig b/src/plugins/workbench/dylib.zig
index 552d9b46..da517a17 100644
--- a/src/plugins/workbench/dylib.zig
+++ b/src/plugins/workbench/dylib.zig
@@ -25,6 +25,10 @@ export fn fizzy_plugin_set_dvui_context(
sdk.dvui_context.inject(window, io, ft2lib, debug);
}
+export fn fizzy_plugin_set_render_bridge(bridge: ?*const @import("proxy_bridge").RenderBridge) callconv(.c) void {
+ @import("proxy_bridge").setBridge(bridge);
+}
+
/// Workbench convention: `gpa`, `host`, `workbench` (see `Globals.installRuntime`).
export fn fizzy_plugin_set_globals(
gpa: ?*const anyopaque,
diff --git a/src/plugins/workbench/src/Globals.zig b/src/plugins/workbench/src/Globals.zig
index af11cc62..77353152 100644
--- a/src/plugins/workbench/src/Globals.zig
+++ b/src/plugins/workbench/src/Globals.zig
@@ -7,6 +7,7 @@ const std = @import("std");
const wb_mod = @import("../workbench.zig");
const sdk = wb_mod.sdk;
const Workbench = @import("Workbench.zig");
+const core = @import("core");
pub var gpa: std.mem.Allocator = undefined;
pub var host: *sdk.Host = undefined;
@@ -22,7 +23,10 @@ pub fn installRuntime(
host_ptr: ?*sdk.Host,
workbench_ptr: ?*Workbench,
) void {
- if (gpa_ptr) |a| gpa = a.*;
+ if (gpa_ptr) |a| {
+ gpa = a.*;
+ core.gpa = a.*;
+ }
if (host_ptr) |h| host = h;
if (workbench_ptr) |w| workbench = w;
}
diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig
index 7be08236..a5ae9f2f 100644
--- a/src/sdk/dylib.zig
+++ b/src/sdk/dylib.zig
@@ -7,13 +7,15 @@
//!
//! **Bump `abi_version` when any of these change:** `Host`, `Plugin`, `DocHandle`,
//! `EditorAPI` layouts, or the semantics/signature of an entry symbol.
-pub const abi_version: u32 = 1;
+pub const abi_version: u32 = 2;
/// `std.DynLib.lookup` names for the host loader.
pub const symbol_abi_version = "fizzy_plugin_abi_version";
pub const symbol_register = "fizzy_plugin_register";
/// Host calls each frame (and once at init) before plugin draw/tick.
pub const symbol_set_dvui_context = "fizzy_plugin_set_dvui_context";
+/// Host calls once at load so plugin proxy backend forwards draws to the shell SDL backend.
+pub const symbol_set_render_bridge = "fizzy_plugin_set_render_bridge";
/// Host-owned pixelart `Globals` (allocator, state, packer) injected before `register`.
pub const symbol_set_globals = "fizzy_plugin_set_globals";
@@ -39,7 +41,7 @@ pub fn abiMatches(plugin_abi: u32) bool {
test "plugin ABI version is locked" {
const std = @import("std");
- try std.testing.expect(abi_version == 1);
+ try std.testing.expect(abi_version == 2);
try std.testing.expect(abiMatches(abi_version));
try std.testing.expect(!abiMatches(abi_version + 1));
}
diff --git a/src/sdk/render_bridge.zig b/src/sdk/render_bridge.zig
new file mode 100644
index 00000000..f552d424
--- /dev/null
+++ b/src/sdk/render_bridge.zig
@@ -0,0 +1,263 @@
+//! Host-side thunks for the dvui proxy render bridge.
+//!
+//! Loaded plugin dylibs draw through `proxy_bridge.RenderBridge` into the shell's real
+//! SDL backend. `ctx` is the host `dvui.Window` pointer (stable for the session).
+const std = @import("std");
+const dvui = @import("dvui");
+const proxy_bridge = @import("proxy_bridge");
+
+pub const SetRenderBridgeFn = *const fn (?*const proxy_bridge.RenderBridge) callconv(.c) void;
+
+var table: proxy_bridge.RenderBridge = undefined;
+var table_ready = false;
+
+fn emptyTextureDesc() proxy_bridge.TextureDesc {
+ return std.mem.zeroes(proxy_bridge.TextureDesc);
+}
+
+fn windowFromCtx(ctx: ?*anyopaque) *dvui.Window {
+ return @ptrCast(@alignCast(ctx orelse @panic("render bridge ctx is null")));
+}
+
+fn textureFromDesc(desc: *const proxy_bridge.TextureDesc) !dvui.Texture {
+ return proxy_bridge.textureFromDesc(desc.*);
+}
+
+fn targetFromDesc(desc: *const proxy_bridge.TextureDesc) !dvui.TextureTarget {
+ return proxy_bridge.targetFromDesc(desc.*);
+}
+
+fn clipFromDesc(has_clip: u8, clip: proxy_bridge.ClipRect) ?dvui.Rect.Physical {
+ if (has_clip == 0) return null;
+ return .{ .x = clip.x, .y = clip.y, .w = clip.w, .h = clip.h };
+}
+
+fn drawClippedTriangles(
+ ctx: ?*anyopaque,
+ texture: ?*const proxy_bridge.TextureDesc,
+ vtx: [*]const dvui.Vertex,
+ vtx_len: usize,
+ idx: [*]const dvui.Vertex.Index,
+ idx_len: usize,
+ has_clip: u8,
+ clip: proxy_bridge.ClipRect,
+) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ const tex: ?dvui.Texture = if (texture) |desc| textureFromDesc(desc) catch return 0 else null;
+ win.backend.drawClippedTriangles(
+ tex,
+ vtx[0..vtx_len],
+ idx[0..idx_len],
+ clipFromDesc(has_clip, clip),
+ ) catch return 0;
+ return 1;
+}
+
+fn textureCreate(
+ ctx: ?*anyopaque,
+ pixels: [*]const u8,
+ options: proxy_bridge.CreateOptions,
+) callconv(.c) proxy_bridge.TextureDesc {
+ const win = windowFromCtx(ctx);
+ const created = win.backend.textureCreate(pixels, .{
+ .width = options.width,
+ .height = options.height,
+ .format = @enumFromInt(options.format),
+ .interpolation = @enumFromInt(options.interpolation),
+ .wrap_u = @enumFromInt(options.wrap_u),
+ .wrap_v = @enumFromInt(options.wrap_v),
+ }) catch return emptyTextureDesc();
+ return proxy_bridge.textureDescFrom(created);
+}
+
+fn textureUpdate(
+ ctx: ?*anyopaque,
+ texture: *const proxy_bridge.TextureDesc,
+ pixels: [*]const u8,
+) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ const tex = textureFromDesc(texture) catch return 0;
+ win.backend.textureUpdate(tex, pixels) catch return 0;
+ return 1;
+}
+
+fn textureUpdateSubRect(
+ ctx: ?*anyopaque,
+ texture: *const proxy_bridge.TextureDesc,
+ pixels: [*]const u8,
+ x: u32,
+ y: u32,
+ w: u32,
+ h: u32,
+) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ const tex = textureFromDesc(texture) catch return 0;
+ win.backend.textureUpdateSubRect(tex, pixels, x, y, w, h) catch return 0;
+ return 1;
+}
+
+fn textureDestroy(ctx: ?*anyopaque, texture: *const proxy_bridge.TextureDesc) callconv(.c) void {
+ const win = windowFromCtx(ctx);
+ const tex = textureFromDesc(texture) catch return;
+ win.backend.textureDestroy(tex);
+}
+
+fn textureCreateTarget(ctx: ?*anyopaque, options: proxy_bridge.CreateOptions) callconv(.c) proxy_bridge.TextureDesc {
+ const win = windowFromCtx(ctx);
+ const target = win.backend.textureCreateTarget(.{
+ .width = options.width,
+ .height = options.height,
+ .format = @enumFromInt(options.format),
+ .interpolation = @enumFromInt(options.interpolation),
+ .wrap_u = @enumFromInt(options.wrap_u),
+ .wrap_v = @enumFromInt(options.wrap_v),
+ }) catch return emptyTextureDesc();
+ return proxy_bridge.textureDescFromTarget(target);
+}
+
+fn textureReadTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc, pixels_out: [*]u8) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ const tex_target = targetFromDesc(target) catch return 0;
+ win.backend.textureReadTarget(tex_target, pixels_out) catch return 0;
+ return 1;
+}
+
+fn textureDestroyTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) void {
+ const win = windowFromCtx(ctx);
+ const tex_target = targetFromDesc(target) catch return;
+ win.backend.textureDestroyTarget(tex_target);
+}
+
+fn textureClearTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) void {
+ const win = windowFromCtx(ctx);
+ const tex_target = targetFromDesc(target) catch return;
+ win.backend.textureClearTarget(tex_target);
+}
+
+fn textureFromTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) proxy_bridge.TextureDesc {
+ const win = windowFromCtx(ctx);
+ const tex_target = targetFromDesc(target) catch return emptyTextureDesc();
+ const tex = win.backend.textureFromTarget(tex_target) catch return emptyTextureDesc();
+ return proxy_bridge.textureDescFrom(tex);
+}
+
+fn textureFromTargetTemp(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) proxy_bridge.TextureDesc {
+ const win = windowFromCtx(ctx);
+ const tex_target = targetFromDesc(target) catch return emptyTextureDesc();
+ const tex = win.backend.textureFromTargetTemp(tex_target) catch return emptyTextureDesc();
+ return proxy_bridge.textureDescFrom(tex);
+}
+
+fn renderTarget(ctx: ?*anyopaque, target: ?*const proxy_bridge.TextureDesc) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ const tex_target: ?dvui.TextureTarget = if (target) |desc| targetFromDesc(desc) catch return 0 else null;
+ win.backend.renderTarget(tex_target) catch return 0;
+ return 1;
+}
+
+fn pixelSize(ctx: ?*anyopaque) callconv(.c) proxy_bridge.SizePair {
+ const win = windowFromCtx(ctx);
+ const size = win.backend.pixelSize();
+ return .{ .w = size.w, .h = size.h };
+}
+
+fn windowSize(ctx: ?*anyopaque) callconv(.c) proxy_bridge.SizePair {
+ const win = windowFromCtx(ctx);
+ const size = win.backend.windowSize();
+ return .{ .w = size.w, .h = size.h };
+}
+
+fn contentScale(ctx: ?*anyopaque) callconv(.c) f32 {
+ const win = windowFromCtx(ctx);
+ return win.backend.contentScale();
+}
+
+threadlocal var clipboard_scratch: [8192]u8 = undefined;
+
+fn clipboardText(ctx: ?*anyopaque) callconv(.c) proxy_bridge.TextSlice {
+ const win = windowFromCtx(ctx);
+ const text = win.backend.clipboardText() catch return .{ .ptr = &.{}, .len = 0 };
+ const len = @min(text.len, clipboard_scratch.len);
+ @memcpy(clipboard_scratch[0..len], text[0..len]);
+ return .{ .ptr = clipboard_scratch[0..len].ptr, .len = len };
+}
+
+fn clipboardTextSet(ctx: ?*anyopaque, text: [*]const u8, text_len: usize) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ win.backend.clipboardTextSet(text[0..text_len]) catch return 0;
+ return 1;
+}
+
+fn openURL(ctx: ?*anyopaque, url: [*]const u8, url_len: usize, new_window: u8) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ win.backend.openURL(url[0..url_len], new_window != 0) catch return 0;
+ return 1;
+}
+
+fn setCursor(ctx: ?*anyopaque, cursor: u8) callconv(.c) void {
+ const win = windowFromCtx(ctx);
+ win.backend.setCursor(@enumFromInt(cursor));
+}
+
+fn textInputRect(ctx: ?*anyopaque, has_rect: u8, rect: proxy_bridge.ClipRect) callconv(.c) void {
+ const win = windowFromCtx(ctx);
+ const natural: ?dvui.Rect.Natural = if (has_rect != 0)
+ .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = rect.h }
+ else
+ null;
+ win.backend.textInputRect(natural);
+}
+
+fn preferredColorScheme(ctx: ?*anyopaque) callconv(.c) i8 {
+ const win = windowFromCtx(ctx);
+ const scheme = win.backend.preferredColorScheme();
+ if (scheme) |s| {
+ return switch (s) {
+ .light => 0,
+ .dark => 1,
+ };
+ }
+ return -1;
+}
+
+fn prefersReducedMotion(ctx: ?*anyopaque) callconv(.c) u8 {
+ const win = windowFromCtx(ctx);
+ return @intFromBool(win.backend.prefersReducedMotion());
+}
+
+fn ensureTable() void {
+ if (table_ready) return;
+ table = .{
+ .ctx = null,
+ .draw_clipped_triangles = drawClippedTriangles,
+ .texture_create = textureCreate,
+ .texture_update = textureUpdate,
+ .texture_update_sub_rect = textureUpdateSubRect,
+ .texture_destroy = textureDestroy,
+ .texture_create_target = textureCreateTarget,
+ .texture_read_target = textureReadTarget,
+ .texture_destroy_target = textureDestroyTarget,
+ .texture_clear_target = textureClearTarget,
+ .texture_from_target = textureFromTarget,
+ .texture_from_target_temp = textureFromTargetTemp,
+ .render_target = renderTarget,
+ .pixel_size = pixelSize,
+ .window_size = windowSize,
+ .content_scale = contentScale,
+ .clipboard_text = clipboardText,
+ .clipboard_text_set = clipboardTextSet,
+ .open_url = openURL,
+ .set_cursor = setCursor,
+ .text_input_rect = textInputRect,
+ .preferred_color_scheme = preferredColorScheme,
+ .prefers_reduced_motion = prefersReducedMotion,
+ };
+ table_ready = true;
+}
+
+/// Push the host render bridge table into a loaded plugin dylib (once at load).
+pub fn syncHostIntoPlugin(setter: SetRenderBridgeFn) void {
+ ensureTable();
+ table.ctx = @ptrCast(dvui.current_window);
+ setter(&table);
+}
diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig
index 355cc260..302e70e4 100644
--- a/src/sdk/sdk.zig
+++ b/src/sdk/sdk.zig
@@ -32,3 +32,5 @@ pub const pane_layout = @import("pane_layout.zig");
pub const dylib = @import("dylib.zig");
/// Dvui global injection for loaded plugin images.
pub const dvui_context = @import("dvui_context.zig");
+/// Host thunks that forward plugin proxy draws to the shell backend.
+pub const render_bridge = @import("render_bridge.zig");
From ed6a5a83faadf1ea921c0f8570ae157cc6218e12 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Mon, 22 Jun 2026 09:11:16 -0500
Subject: [PATCH 42/49] Begin adding code plugin
---
build.zig | 46 +++++++
src/App.zig | 8 ++
src/editor/Editor.zig | 6 +
src/plugins/code/code.zig | 13 ++
src/plugins/code/dylib.zig | 40 ++++++
src/plugins/code/module.zig | 10 ++
src/plugins/code/src/Document.zig | 64 ++++++++++
src/plugins/code/src/Globals.zig | 28 ++++
src/plugins/code/src/State.zig | 32 +++++
src/plugins/code/src/plugin.zig | 206 ++++++++++++++++++++++++++++++
10 files changed, 453 insertions(+)
create mode 100644 src/plugins/code/code.zig
create mode 100644 src/plugins/code/dylib.zig
create mode 100644 src/plugins/code/module.zig
create mode 100644 src/plugins/code/src/Document.zig
create mode 100644 src/plugins/code/src/Globals.zig
create mode 100644 src/plugins/code/src/State.zig
create mode 100644 src/plugins/code/src/plugin.zig
diff --git a/build.zig b/build.zig
index 49a2f6c1..feda858c 100644
--- a/build.zig
+++ b/build.zig
@@ -443,6 +443,11 @@ pub fn build(b: *std.Build) !void {
.icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null,
.backend = null,
}, web_exe.root_module);
+ wireCodeModule(b, web_target, optimize, .{
+ .dvui = dvui_web_dep.module("dvui_web"),
+ .core = core_module_web,
+ .sdk = sdk_module_web,
+ }, web_exe.root_module);
const web_install_dir: std.Build.InstallDir = .{ .custom = "web" };
const install_wasm = b.addInstallArtifact(web_exe, .{
@@ -1001,6 +1006,11 @@ pub fn build(b: *std.Build) !void {
.icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null,
.backend = dvui_testing_dep.module("testing"),
}, fizzy_test_module);
+ wireCodeModule(b, target, optimize, .{
+ .dvui = dvui_testing_dep.module("dvui_testing"),
+ .core = core_module_test,
+ .sdk = sdk_module_test,
+ }, fizzy_test_module);
if (target.result.os.tag == .macos) {
if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| {
@@ -1413,6 +1423,11 @@ fn addFizzyExecutableForTarget(
.icons = icons_module,
.backend = dvui_dep.module("sdl3"),
}, exe.root_module);
+ wireCodeModule(b, resolved_target, optimize, .{
+ .dvui = dvui_dep.module("dvui_sdl3"),
+ .core = core_module,
+ .sdk = sdk_module,
+ }, exe.root_module);
const pixelart_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: {
break :blk addPixelartDylib(b, resolved_target, optimize, .{
@@ -1591,6 +1606,37 @@ fn wireWorkbenchModule(
consumer.addImport("workbench", workbench_module);
}
+const CodeModuleDeps = struct {
+ dvui: *std.Build.Module,
+ core: *std.Build.Module,
+ sdk: *std.Build.Module,
+};
+
+/// Code plugin (`src/plugins/code/module.zig`).
+fn applyCodeModuleImports(module: *std.Build.Module, deps: CodeModuleDeps) void {
+ module.addImport("dvui", deps.dvui);
+ module.addImport("core", deps.core);
+ module.addImport("sdk", deps.sdk);
+}
+
+fn wireCodeModule(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ deps: CodeModuleDeps,
+ consumer: *std.Build.Module,
+) void {
+ const code_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/plugins/code/module.zig"),
+ .link_libc = target.result.cpu.arch != .wasm32,
+ .single_threaded = target.result.cpu.arch == .wasm32,
+ });
+ applyCodeModuleImports(code_module, deps);
+ consumer.addImport("code", code_module);
+}
+
/// Native dynamic library for the workbench plugin (`src/plugins/workbench/dylib.zig`).
fn addWorkbenchDylib(
b: *std.Build,
diff --git a/src/App.zig b/src/App.zig
index ec80bcf2..8782c644 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -10,7 +10,9 @@ const icon = assets.files.@"icon.png";
const fizzy = @import("fizzy.zig");
const workbench = @import("workbench");
const pixelart = @import("pixelart");
+const code = @import("code");
const WorkbenchGlobals = workbench.Globals;
+const CodeGlobals = code.Globals;
const auto_update = @import("backend/auto_update.zig");
const update_notify = @import("backend/update_notify.zig");
const singleton = @import("backend/singleton.zig");
@@ -174,6 +176,12 @@ pub fn AppInit(win: *dvui.Window) !void {
WorkbenchGlobals.host = &fizzy.editor.host;
WorkbenchGlobals.workbench = &fizzy.editor.workbench;
+ // Code plugin runtime injection: host + allocator + its open-document registry,
+ // which lives on `Editor.code`. The plugin's `register` adopts it as its `state`.
+ CodeGlobals.gpa = allocator;
+ CodeGlobals.host = &fizzy.editor.host;
+ CodeGlobals.state = &fizzy.editor.code;
+
// Pixel-art plugin state (tools/colors/project/clipboard/pack jobs). Created
// before `postInit` so the pixel-art plugin's `register` can adopt it as its
// `state`. Owned on `Editor`; torn down in `AppDeinit`.
diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig
index d2d8861f..59e0110c 100644
--- a/src/editor/Editor.zig
+++ b/src/editor/Editor.zig
@@ -30,6 +30,7 @@ pub const Dialogs = @import("dialogs/Dialogs.zig");
pub const Keybinds = @import("Keybinds.zig");
const workbench_mod = @import("workbench");
+const code_mod = @import("code");
const PluginLoader = if (builtin.target.cpu.arch == .wasm32)
@import("PluginLoader_stub.zig")
else
@@ -69,6 +70,10 @@ pixelart_state: *pixelart.State,
/// File-management workbench (per-branch explorer decorations, …)
workbench: Workbench,
+/// Code plugin runtime state (open text documents). Owned here; `code.Globals.state`
+/// points at it. Torn down via the plugin's `deinit` vtable hook.
+code: code_mod.State = .{},
+
/// Keeps plugin dylibs mapped while their vtables are live (native only).
loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty,
@@ -598,6 +603,7 @@ pub fn postInit(editor: *Editor) !void {
try pixelart.plugin.register(&editor.host);
try pixelartPlugin(editor).initPlugin();
}
+ try code_mod.plugin.register(&editor.host);
// Shell built-in: Settings (owner = null; not a plugin).
try editor.host.registerSidebarView(.{
diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig
new file mode 100644
index 00000000..a753e49a
--- /dev/null
+++ b/src/plugins/code/code.zig
@@ -0,0 +1,13 @@
+//! Intra-plugin import hub for the code plugin.
+//!
+//! Files inside `src/plugins/code/src/**` import this as `../code.zig` (or
+//! `../../code.zig` from nested dirs). The compile-time module root is `module.zig`.
+const std = @import("std");
+
+pub const sdk = @import("sdk");
+pub const core = @import("core");
+pub const dvui = @import("dvui");
+
+pub const Globals = @import("src/Globals.zig");
+pub const State = @import("src/State.zig");
+pub const Document = @import("src/Document.zig");
diff --git a/src/plugins/code/dylib.zig b/src/plugins/code/dylib.zig
new file mode 100644
index 00000000..99691a33
--- /dev/null
+++ b/src/plugins/code/dylib.zig
@@ -0,0 +1,40 @@
+//! Dynamic-library root for the code plugin.
+//!
+//! Static/desktop and web builds link `module.zig` into the exe. Native dylib builds use
+//! this file as `addLibrary(.dynamic)` root so only the C entry symbols are exported.
+const sdk = @import("sdk");
+const dvui = @import("dvui");
+const plugin = @import("src/plugin.zig");
+
+export fn fizzy_plugin_abi_version() callconv(.c) u32 {
+ return sdk.dylib.abi_version;
+}
+
+export fn fizzy_plugin_register(host: ?*sdk.Host) callconv(.c) u32 {
+ if (host == null) return @intFromEnum(sdk.dylib.RegisterStatus.err_null_host);
+ plugin.register(host.?) catch return @intFromEnum(sdk.dylib.RegisterStatus.err_register);
+ return @intFromEnum(sdk.dylib.RegisterStatus.ok);
+}
+
+export fn fizzy_plugin_set_dvui_context(
+ window: ?*dvui.Window,
+ io: ?*anyopaque,
+ ft2lib: ?*anyopaque,
+ debug: ?*dvui.Debug,
+) callconv(.c) void {
+ sdk.dvui_context.inject(window, io, ft2lib, debug);
+}
+
+/// Code convention: `gpa`, `host`, `state` (see `Globals.installRuntime`).
+export fn fizzy_plugin_set_globals(
+ gpa: ?*const anyopaque,
+ host: ?*anyopaque,
+ state: ?*anyopaque,
+) callconv(.c) void {
+ const Globals = @import("src/Globals.zig");
+ Globals.installRuntime(
+ if (gpa) |p| @ptrCast(@alignCast(p)) else null,
+ if (host) |p| @ptrCast(@alignCast(p)) else null,
+ if (state) |p| @ptrCast(@alignCast(p)) else null,
+ );
+}
diff --git a/src/plugins/code/module.zig b/src/plugins/code/module.zig
new file mode 100644
index 00000000..55f18f33
--- /dev/null
+++ b/src/plugins/code/module.zig
@@ -0,0 +1,10 @@
+//! Code plugin compile-time module root.
+//!
+//! Wired in `build.zig` via `wireCodeModule` (`b.addModule("code", …)`) for the native,
+//! web, and test roots. Shell code imports this as `@import("code")`. Plugin files inside
+//! `src/` import `../code.zig` for shared sdk/core access.
+pub const code = @import("code.zig");
+pub const plugin = @import("src/plugin.zig");
+pub const State = @import("src/State.zig");
+pub const Document = @import("src/Document.zig");
+pub const Globals = @import("src/Globals.zig");
diff --git a/src/plugins/code/src/Document.zig b/src/plugins/code/src/Document.zig
new file mode 100644
index 00000000..19d70a88
--- /dev/null
+++ b/src/plugins/code/src/Document.zig
@@ -0,0 +1,64 @@
+//! A single open text document: its path, contents, and grouping. The contents are kept
+//! in an `ArrayList(u8)` so the editing widget can grow/shrink it in place; the shell stores
+//! only an opaque `DocHandle` whose `id` maps back to the registered `Document`.
+const std = @import("std");
+const builtin = @import("builtin");
+const code = @import("../code.zig");
+const dvui = code.dvui;
+const Globals = code.Globals;
+
+const is_wasm = builtin.target.cpu.arch == .wasm32;
+
+const Document = @This();
+
+/// Shell document id (monotonic, allocated from the host).
+id: u64,
+/// Absolute path on disk, heap-owned.
+path: []u8,
+/// Tab grouping (which split/tab group this document lives in).
+grouping: u64 = 0,
+/// File contents. The text-editing widget reads from and writes back to `items`.
+text: std.ArrayList(u8) = .empty,
+/// Unsaved-edits flag, set when the editing widget reports a change.
+dirty: bool = false,
+
+/// 64 MiB — generous for source files; guards against opening something huge by mistake.
+const max_file_bytes: usize = 64 * 1024 * 1024;
+
+/// Build a document from in-memory bytes (browser file picker, or after reading from disk).
+pub fn fromBytes(path: []const u8, bytes: []const u8) !Document {
+ const gpa = Globals.allocator();
+ var text: std.ArrayList(u8) = .empty;
+ errdefer text.deinit(gpa);
+ try text.appendSlice(gpa, bytes);
+ const path_copy = try gpa.dupe(u8, path);
+ errdefer gpa.free(path_copy);
+ return .{
+ .id = Globals.host.allocDocId(),
+ .path = path_copy,
+ .text = text,
+ };
+}
+
+/// Build a document by reading `path` from disk. Runs on the shell's load worker thread.
+/// Web has no filesystem; documents there are opened from bytes (`fromBytes`) instead.
+pub fn fromPath(path: []const u8) !Document {
+ if (comptime is_wasm) return error.Unsupported;
+ const gpa = Globals.allocator();
+ const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, path, gpa, .limited(max_file_bytes));
+ defer gpa.free(bytes);
+ return fromBytes(path, bytes);
+}
+
+pub fn deinit(self: *Document) void {
+ const gpa = Globals.allocator();
+ gpa.free(self.path);
+ self.text.deinit(gpa);
+}
+
+/// Write the current contents back to `path`.
+pub fn save(self: *Document) !void {
+ if (comptime is_wasm) return error.Unsupported;
+ try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = self.path, .data = self.text.items });
+ self.dirty = false;
+}
diff --git a/src/plugins/code/src/Globals.zig b/src/plugins/code/src/Globals.zig
new file mode 100644
index 00000000..2ed70f21
--- /dev/null
+++ b/src/plugins/code/src/Globals.zig
@@ -0,0 +1,28 @@
+//! Runtime injection points for the code plugin.
+//!
+//! The shell sets these once during `App` startup so plugin code can reach the
+//! app allocator, the Host (EditorAPI surface), and the plugin's own state without
+//! importing `fizzy.zig`. Mirrors the pixel-art plugin's `Globals.zig` injection pattern.
+const std = @import("std");
+const code = @import("../code.zig");
+const sdk = code.sdk;
+const State = @import("State.zig");
+
+pub var gpa: std.mem.Allocator = undefined;
+pub var host: *sdk.Host = undefined;
+pub var state: *State = undefined;
+
+pub fn allocator() std.mem.Allocator {
+ return gpa;
+}
+
+/// For a loaded dylib build, the host calls `fizzy_plugin_set_globals` on the image before `register`.
+pub fn installRuntime(
+ gpa_ptr: ?*const std.mem.Allocator,
+ host_ptr: ?*sdk.Host,
+ state_ptr: ?*State,
+) void {
+ if (gpa_ptr) |a| gpa = a.*;
+ if (host_ptr) |h| host = h;
+ if (state_ptr) |s| state = s;
+}
diff --git a/src/plugins/code/src/State.zig b/src/plugins/code/src/State.zig
new file mode 100644
index 00000000..709cac28
--- /dev/null
+++ b/src/plugins/code/src/State.zig
@@ -0,0 +1,32 @@
+//! Code plugin runtime state: the registry of open text documents.
+//!
+//! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the
+//! concrete `Document` values their `id`s map back to.
+const std = @import("std");
+const code = @import("../code.zig");
+const sdk = code.sdk;
+const Document = @import("Document.zig");
+
+const State = @This();
+
+docs: std.AutoArrayHashMapUnmanaged(u64, Document) = .empty,
+
+pub fn deinit(self: *State, allocator: std.mem.Allocator) void {
+ for (self.docs.values()) |*doc| doc.deinit();
+ self.docs.deinit(allocator);
+}
+
+pub fn docById(self: *State, id: u64) ?*Document {
+ return self.docs.getPtr(id);
+}
+
+pub fn docFrom(self: *State, doc: sdk.DocHandle) ?*Document {
+ return self.docs.getPtr(doc.id);
+}
+
+pub fn docByPath(self: *State, path: []const u8) ?*Document {
+ for (self.docs.values()) |*doc| {
+ if (std.mem.eql(u8, doc.path, path)) return doc;
+ }
+ return null;
+}
diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig
new file mode 100644
index 00000000..b429327d
--- /dev/null
+++ b/src/plugins/code/src/plugin.zig
@@ -0,0 +1,206 @@
+//! The code editor plugin: owns text documents (`.zig`/`.json`/…) and renders them as
+//! editable, monospace tabs. Registration + the document vtable. Registered from
+//! `Editor.postInit`; document state lives in `State.docs`.
+const std = @import("std");
+const code = @import("../code.zig");
+const sdk = code.sdk;
+const dvui = code.dvui;
+const Globals = code.Globals;
+const State = code.State;
+const Document = code.Document;
+const DocHandle = sdk.DocHandle;
+
+var plugin: sdk.Plugin = .{
+ .state = undefined,
+ .vtable = &vtable,
+ .id = "code",
+ .display_name = "Code",
+};
+
+const vtable: sdk.Plugin.VTable = .{
+ .deinit = deinit,
+ .fileTypePriority = fileTypePriority,
+ // document staging buffer (shell allocates, plugin fills, then registers)
+ .documentStackSize = documentStackSize,
+ .documentStackAlign = documentStackAlign,
+ .loadDocument = loadDocument,
+ .loadDocumentFromBytes = loadDocumentFromBytes,
+ .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer,
+ .documentIdFromBuffer = documentIdFromBuffer,
+ .deinitDocumentBuffer = deinitDocumentBuffer,
+ // open-document registry
+ .registerOpenDocument = registerOpenDocument,
+ .documentPtr = documentPtr,
+ .documentByPath = documentByPath,
+ .unregisterDocument = unregisterDocument,
+ // document metadata (shell/workbench routing)
+ .documentGrouping = documentGrouping,
+ .setDocumentGrouping = setDocumentGrouping,
+ .documentPath = documentPath,
+ .setDocumentPath = setDocumentPath,
+ .bindDocumentToPane = bindDocumentToPane,
+ .documentHasNativeExtension = documentHasNativeExtension,
+ .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension,
+ // rendering + lifecycle
+ .drawDocument = drawDocument,
+ .closeDocument = closeDocument,
+ .isDirty = isDirty,
+ .saveDocument = saveDocument,
+ // text saves are small and synchronous, so the async path just saves in place
+ .saveDocumentAsync = saveDocument,
+ .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename,
+};
+
+pub fn register(host: *sdk.Host) !void {
+ // Adopt the app-owned state as this plugin's vtable `state` (mirrors pixelart).
+ plugin.state = @ptrCast(Globals.state);
+ try host.registerPlugin(&plugin);
+}
+
+/// Stable `*Plugin` for constructing `DocHandle.owner` fields / lookups.
+pub fn pluginPtr() *sdk.Plugin {
+ return &plugin;
+}
+
+fn deinit(state: *anyopaque) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ st.deinit(Globals.allocator());
+}
+
+// ---- file type ownership -----------------------------------------------------
+
+/// Text/source extensions this plugin opens. Lower priority value wins; pixel-art
+/// owns image/`.fiz` extensions, so there is no overlap.
+const text_extensions = [_][]const u8{
+ ".zig", ".zon", ".json", ".txt", ".md", ".toml", ".yaml", ".yml",
+ ".glsl", ".c", ".h", ".cpp", ".hpp", ".js", ".ts", ".css",
+ ".html", ".xml", ".sh", ".py", ".lua",
+};
+
+fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 {
+ for (text_extensions) |e| {
+ if (std.ascii.eqlIgnoreCase(ext, e)) return 50;
+ }
+ return null;
+}
+
+// ---- document staging buffer -------------------------------------------------
+
+fn documentStackSize(_: *anyopaque) usize {
+ return @sizeOf(Document);
+}
+fn documentStackAlign(_: *anyopaque) usize {
+ return @alignOf(Document);
+}
+fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void {
+ docBuf(out_doc).* = try Document.fromPath(path);
+}
+fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void {
+ docBuf(out_doc).* = try Document.fromBytes(path, bytes);
+}
+fn setDocumentGroupingOnBuffer(_: *anyopaque, doc: *anyopaque, grouping: u64) void {
+ docBuf(doc).grouping = grouping;
+}
+fn documentIdFromBuffer(_: *anyopaque, doc: *anyopaque) u64 {
+ return docBuf(doc).id;
+}
+fn deinitDocumentBuffer(_: *anyopaque, doc: *anyopaque) void {
+ docBuf(doc).deinit();
+}
+
+// ---- open-document registry --------------------------------------------------
+
+fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ const doc = docBuf(file);
+ try st.docs.put(Globals.allocator(), doc.id, doc.*);
+ return st.docs.getPtr(doc.id).?;
+}
+fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ return st.docById(id);
+}
+fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque {
+ const st: *State = @ptrCast(@alignCast(state));
+ return st.docByPath(path);
+}
+fn unregisterDocument(state: *anyopaque, id: u64) void {
+ const st: *State = @ptrCast(@alignCast(state));
+ _ = st.docs.swapRemove(id);
+}
+
+// ---- document metadata -------------------------------------------------------
+
+fn documentGrouping(_: *anyopaque, handle: DocHandle) u64 {
+ return (docFrom(handle) orelse return 0).grouping;
+}
+fn setDocumentGrouping(_: *anyopaque, handle: DocHandle, grouping: u64) void {
+ (docFrom(handle) orelse return).grouping = grouping;
+}
+fn documentPath(_: *anyopaque, handle: DocHandle) []const u8 {
+ return (docFrom(handle) orelse return "").path;
+}
+fn setDocumentPath(_: *anyopaque, handle: DocHandle, path: []const u8) anyerror!void {
+ const doc = docFrom(handle) orelse return error.DocumentNotFound;
+ const gpa = Globals.allocator();
+ const new_path = try gpa.dupe(u8, path);
+ gpa.free(doc.path);
+ doc.path = new_path;
+}
+fn bindDocumentToPane(_: *anyopaque, _: DocHandle, _: dvui.Id, _: *anyopaque, _: bool) void {
+ // Text editing needs no pane/canvas binding; the text widget manages its own state.
+}
+fn documentHasNativeExtension(_: *anyopaque, _: DocHandle) bool {
+ return true;
+}
+fn documentHasRecognizedSaveExtension(_: *anyopaque, _: DocHandle) bool {
+ return true; // a text document always saves in place over its own file
+}
+
+// ---- rendering + lifecycle ---------------------------------------------------
+
+fn drawDocument(_: *anyopaque, handle: DocHandle) anyerror!void {
+ const doc = docFrom(handle) orelse return;
+ const gpa = Globals.allocator();
+
+ var te = dvui.textEntry(@src(), .{
+ .multiline = true,
+ .break_lines = false,
+ .text = .{ .array_list = .{ .backing = &doc.text, .allocator = gpa, .limit = max_text_bytes } },
+ }, .{
+ .expand = .both,
+ .font = dvui.Font.theme(.mono),
+ // Key the widget by document id so its cursor/scroll follow the document across
+ // tab switches within a pane, not the pane slot.
+ .id_extra = @intCast(handle.id),
+ .background = false,
+ });
+ defer te.deinit();
+
+ if (te.text_changed) doc.dirty = true;
+}
+
+fn closeDocument(_: *anyopaque, handle: DocHandle) void {
+ (docFrom(handle) orelse return).deinit();
+}
+fn isDirty(_: *anyopaque, handle: DocHandle) bool {
+ return (docFrom(handle) orelse return false).dirty;
+}
+fn saveDocument(_: *anyopaque, handle: DocHandle) anyerror!void {
+ try (docFrom(handle) orelse return).save();
+}
+fn documentDefaultSaveAsFilename(_: *anyopaque, handle: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 {
+ const doc = docFrom(handle) orelse return error.DocumentNotFound;
+ return allocator.dupe(u8, std.fs.path.basename(doc.path));
+}
+
+// ---- helpers -----------------------------------------------------------------
+
+const max_text_bytes: usize = 64 * 1024 * 1024;
+
+fn docBuf(buf: *anyopaque) *Document {
+ return @ptrCast(@alignCast(buf));
+}
+fn docFrom(handle: DocHandle) ?*Document {
+ return Globals.state.docById(handle.id);
+}
From 014a1604576c644eebbd1cd03bbc218bbd548dc6 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Mon, 22 Jun 2026 10:36:09 -0500
Subject: [PATCH 43/49] Fix extension filter
---
src/plugins/code/src/Globals.zig | 6 +++++-
src/plugins/code/src/plugin.zig | 2 +-
src/plugins/pixelart/src/Docs.zig | 2 +-
src/plugins/workbench/src/files.zig | 20 ++++++--------------
4 files changed, 13 insertions(+), 17 deletions(-)
diff --git a/src/plugins/code/src/Globals.zig b/src/plugins/code/src/Globals.zig
index 2ed70f21..5a95fbf0 100644
--- a/src/plugins/code/src/Globals.zig
+++ b/src/plugins/code/src/Globals.zig
@@ -6,6 +6,7 @@
const std = @import("std");
const code = @import("../code.zig");
const sdk = code.sdk;
+const core = code.core;
const State = @import("State.zig");
pub var gpa: std.mem.Allocator = undefined;
@@ -22,7 +23,10 @@ pub fn installRuntime(
host_ptr: ?*sdk.Host,
state_ptr: ?*State,
) void {
- if (gpa_ptr) |a| gpa = a.*;
+ if (gpa_ptr) |a| {
+ gpa = a.*;
+ core.gpa = a.*;
+ }
if (host_ptr) |h| host = h;
if (state_ptr) |s| state = s;
}
diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig
index b429327d..62a9d7ee 100644
--- a/src/plugins/code/src/plugin.zig
+++ b/src/plugins/code/src/plugin.zig
@@ -72,7 +72,7 @@ fn deinit(state: *anyopaque) void {
/// Text/source extensions this plugin opens. Lower priority value wins; pixel-art
/// owns image/`.fiz` extensions, so there is no overlap.
const text_extensions = [_][]const u8{
- ".zig", ".zon", ".json", ".txt", ".md", ".toml", ".yaml", ".yml",
+ ".zig", ".zon", ".json", ".atlas", ".txt", ".md", ".toml", ".yaml", ".yml",
".glsl", ".c", ".h", ".cpp", ".hpp", ".js", ".ts", ".css",
".html", ".xml", ".sh", ".py", ".lua",
};
diff --git a/src/plugins/pixelart/src/Docs.zig b/src/plugins/pixelart/src/Docs.zig
index c40ec736..7a351b04 100644
--- a/src/plugins/pixelart/src/Docs.zig
+++ b/src/plugins/pixelart/src/Docs.zig
@@ -18,7 +18,7 @@ pub fn fileFrom(self: *Docs, doc: sdk.DocHandle) *Internal.File {
pub fn activeFile(self: *Docs, host: *sdk.Host) ?*Internal.File {
const doc = host.activeDoc() orelse return null;
- return self.fileFrom(doc);
+ return self.fileById(doc.id);
}
pub fn fileById(self: *Docs, id: u64) ?*Internal.File {
diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig
index 5ecee49b..26c45b7e 100644
--- a/src/plugins/workbench/src/files.zig
+++ b/src/plugins/workbench/src/files.zig
@@ -802,15 +802,10 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u
if (branch.button.clicked()) {
const mode = detectClickMode(branch.button.data().borderRectScale().r);
applyFileClick(inner_id_extra.*, abs_path, mode);
- if (mode == .replace) {
- switch (ext) {
- .fizzy, .png, .jpg => {
- _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| {
- dvui.log.err("{any}: {s}", .{ err, abs_path });
- };
- },
- else => {},
- }
+ if (mode == .replace and openablePath(abs_path)) {
+ _ = Globals.host.openFilePath(abs_path, Globals.workbench.currentGroupingID()) catch |err| {
+ dvui.log.err("{any}: {s}", .{ err, abs_path });
+ };
}
}
},
@@ -1060,13 +1055,10 @@ fn pathIsDirAbsolute(abs: []const u8) bool {
return true;
}
-/// Same file kinds as primary-click open in the tree (not directories).
+/// True when some registered plugin claims this file extension (not directories).
fn openablePath(abs_path: []const u8) bool {
if (pathIsDirAbsolute(abs_path)) return false;
- return switch (extension(abs_path)) {
- .fizzy, .png, .jpg => true,
- else => false,
- };
+ return Globals.host.pluginForExtension(std.fs.path.extension(abs_path)) != null;
}
fn appendOpenableFilesInTree(arena: std.mem.Allocator, root_abs: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void {
From ac0c4f15499fc6851cb370c0cbfd5af68f6c36d2 Mon Sep 17 00:00:00 2001
From: foxnne
Date: Mon, 22 Jun 2026 10:45:28 -0500
Subject: [PATCH 44/49] Improve code editor
---
spikes/ts_highlight_test | Bin 0 -> 988480 bytes
src/core/dvui.zig | 12 +-
src/core/widgets/TextEntryWidget.zig | 1846 +++++++++++++++++
src/editor/Menu.zig | 2 +-
src/editor/Settings.zig | 2 +-
src/plugins/code/code.zig | 2 +
src/plugins/code/queries/json.scm | 16 +
src/plugins/code/queries/zig.scm | 315 +++
src/plugins/code/src/CodeEditor.zig | 126 ++
src/plugins/code/src/Document.zig | 10 +-
src/plugins/code/src/SyntaxHighlight.zig | 159 ++
src/plugins/code/src/plugin.zig | 42 +-
src/plugins/pixelart/src/Tools.zig | 2 +-
src/plugins/pixelart/src/dialogs/Export.zig | 2 +-
src/plugins/pixelart/src/dialogs/NewFile.zig | 2 +-
src/plugins/pixelart/src/explorer/sprites.zig | 9 +-
src/plugins/pixelart/src/infobar_status.zig | 2 +-
.../pixelart/src/widgets/FileWidget.zig | 2 +-
src/plugins/workbench/src/Workbench.zig | 1 +
src/plugins/workbench/src/Workspace.zig | 2 +-
src/plugins/workbench/src/files.zig | 15 +-
src/sdk/Host.zig | 5 +-
src/sdk/Plugin.zig | 11 +-
23 files changed, 2521 insertions(+), 64 deletions(-)
create mode 100755 spikes/ts_highlight_test
create mode 100644 src/core/widgets/TextEntryWidget.zig
create mode 100644 src/plugins/code/queries/json.scm
create mode 100644 src/plugins/code/queries/zig.scm
create mode 100644 src/plugins/code/src/CodeEditor.zig
create mode 100644 src/plugins/code/src/SyntaxHighlight.zig
diff --git a/spikes/ts_highlight_test b/spikes/ts_highlight_test
new file mode 100755
index 0000000000000000000000000000000000000000..374e8764b99b49c6e43acff00464dbb219eb5a15
GIT binary patch
literal 988480
zcmc${3!GF}mG6J5iw;!~!aKZa2ovcJMvNrVZQxQ(8v&yxfh2_ROi%-&Mg}$X#Y~!1
z)WkR=Wf-I4Xg3|lYNBHtxj@JQ^XKzv>YRP{S$plZ*Ly$C5C8Mme>^`3Vgdi^_#4OHP4z+W!C)3wK`@2C<@{~j
zcx~<8Tt(&7oBk@I6tG}#*Nosb<_3LkHhybs4fh`dr%4dpDJwJ_};he
zeD7e@T6mwNvfxeM5VuOYSHCL%h6Np!Yc4i!-0|M`-MXVvR15FX&2fX*;X0}ND7=J&
z2Mz7t#*LeIy!TJHzWcVyeRY2?e_IHzu{Erq`{=(YY?TA=_IK|X>~Jl9oBle4_xj65
z7#Q7GLySTfy!XBPj;-&0>&C6`e*5hPzZ$=Xemctf`|4rgd%Ca1DGE{JS~NJXZ@s>C
zW7{=vS>;Ce0)rEzd~G+r?p`?RPq4@Ew{hcbw-5GG_d~-Q0Z{q$&W-1yd8-g`^h24IO%ua$n#|+Er!NSW@%*Mk7X({g8w4XMyH@YZxUIg3BZ)BN}C_|y6C+Z;vl{`8D4@b68gQ7X8ALwv
z?|s|5UbXa9;I;Kus}#I~AHnZ@5c&bK$=!=y`ZvSfANr3Af{vM1bk6?st~r@s@5BU9yjW5L&SofsbMNz|vxK`dqU
z`r|>SF&31&V!`vXQ?JOp7Z~q1IH~gKT}yjtCvzyi@h7$I_0f*n>;v|@RDbwjyG?bj
z-QlV7k+ZeSdx<-f`)b?29#}UTyqDhowXS{jv#Q^;KUBX}ud3Z=UXgh%Fq(&IHy%4$
zXb8%E(}MD`NkRGe_@G>DfObLfsL__|;^?3Z6HG`1(Ds%KGrG5Dqf_N3=q>sdr$yf3
zx(NL$@64W@(Y+rld#9dvCPm)ix(JO2-L1bj&!^2vC|lZmFDed=S8{v{@+^8M4o_8#|qt@5#yPYTO72O%EloC%FV$4Yo%
zoc^+>8k$w+aZl6-W7U@*3=B+4)YLOPPHk*PIgC5KtF5O(o3{F+f4F^o&*@zgdQQjd
zGYQdiw)hQtr)QQr(RO?+Q(PA0
z?Y=%$#{Vc4+g@KP&ZGV{seEy5kVk7y5d0o+)VM$9aT5%U!|@iZ9kFNJtdQ*PCw5Q@R8uFKh~$GBKR$E`0W681F&`P@#}3Z6_Lr}T;5;c@Qcu?
zf}h?Ier>>D+zp1u4_#m|;4Q&813%y!emtw-w+dME!m+r>^FA~cul0j_lkr`0+bpLC
z3s=*|67k9OQZb#%XX7(+UFmhD{c(6D|az_lNoHD)gl->DAYY2bS^_^yTC!gopu
z8iMas@LdDGH>7C*njYC5&
zOB>Ra(xI{?J=o^6wfPa+l-(|%KTq#^J?(n^Uq`#c(f?<*+eKUZu?f}=_KosJ>^cu2KkewWl+?kCCwr}-v;>+rT?fqPP8&YE{o)7lSt=NjXIJBoJ-4i@pF)^?@iPlu9u|6oz
z@%l&aI{AJq?ra;nP4#3?i@>mJ*(U0mPN6<}gnp;Wv*GDFbnw?+9qbhigy%_qw?K>j
zc;Y}4GO;xon4V5z;|lRZi)Y0;7RxVbOolf7T;n&@cxD;4sm}Rk;+OOA1!Lw5cG);F
z{`B4KbbUyJ?(@RFn13j`)CZRp+_;EuG*)U?G&{ik8+lK52>Ojwc_Z}FyYg?Me5UJJ
z=;T;yYiSR%rhat76T#tDY~F^_dnnUbbiza8AJN+55cMN&bz@|G4?4!FJzZzhGjiEp
z{;-{T*UJ{UhzV72VqrhGIylxR$~^7{3tSV2!}0cT493`MS{)yXF4P&4E@K^Cy!^o<
zzO7k|z33txRK({hAS0qnANqorV{bn)SirZGf7pnhScQwpPIB8BbfG=LFG>&0kat+PA^1a4aFCI;`@=Jb+8>8HJ!NM$7$Vyg%5IdYhFG`7e2^-e30TkmE!|ksq$&q)0@%t
zYfFXr@XY)EIPjpJqYKf2t$DR;{$T`ukB4t?@EHuwRN$`x{>`PLwTm6McBcUUCg86z
z`0E9q=fYjjg}cVd{}rHFAM{y6Ukmf;*Zfu+zeF*bR@t*XgQZ_yr+Tl+>AlluOE6$7MkI!k%fBg>w4+_5U
zD9*?BI$uXVdl5WTK5*Bt7U2VLTh_)z
z+PIK5Zor398#m^PBWWY;+87zOp?Cj>ZwHu{hcGRktH7lE5ra7am=l3{eW|!hFmK2e
ze^38+Ihems8Q$bQCJ2viduKfFOyHeWrQ$ZdbA7J(6k`L-efE4D&&Tt8WvRGX&sXJ&
z-vj2h6nr_hRD4Rl&$wJ?`a0^Vj>^ZBir-WD*xdf~wWaR(b*02G=yMk`nU0lQ`9{k3QQljZKen;8e0+0j`4<|`
z)xcWIv(?mF3#_jK>l?sY%RAzOZ}3h;CPdSE`k4?P;)euo>{Y&8xLpIhRlvIjcx}Aj
z4ZQn-M?B*7LGi=HmAtF|OJ9247L5M@r)TkH^-SY)f_rOge55VPFI8L1XzOa)T1Hz}
z($)jCwTrfP)U?IB))xA`(iSkDinLYyD*9?ysIPjQE*CzB=xf>GQ{;I+^`!4Bb%67A
zSEqQ_>suO~e3(vI0&dbN>AHO15_E7IcuJQoq#PYp+)AKgQ9(d63
zRGn`6qdMq2D?_jJqhq`ud-pJB4{OS6{M$!@;6nMgk0=*X*Jl3hBb#?#nCX1d`M1E7
z-zdFR$%QPW?S=T%_-FmdzWRH_+c)ar&y6i&Bgjim(BIGfctNi}^W#O{pb&(5LF2g7
z%7c~Bd_v=ohnwCgVxN>J?3Vn@XG|ABpZU;gK6q?innxz{MQoMod|GwpQfD4@=2B-a
zbyiVl6?N8v@71Aic9GYA^Hb!}sJl6ZPG%e$7>7A}kMc>BucCYb<1mSFm==!1&(OUW
ziZ_AP4@~vH0#EOacJIvr-e};B2cFs)PPy6{54_>P8yUj;Ch#0`ZD
zGPZ@0sXV?z=Jc*sV!p?+mRpSv#$EEQqwi<&q2x<~p}@^>YFOW>W6hZeta;>i0`(4jE;(Bc$yI6yloN1K$R&4Kv(
z(ulD^IoTBC8@>{>B)=A9mc)YghJOfJ8m5H)akQUNIvqQzm^c#O#N)&_BZ*7vGSPQL
zo5CFEH3ylXpY(BiKi5sVhBs|osa9nILQ?pIPRMW5@ypEsUg04-Mqc~bZj)V1|0?-4%$2E+nPTM+df9W
zkBb-aKd+~(lRgF)AU9v29=_-4QCxQi?dm)6hs|kZDaU@;^>XAP+cqP2C-hex${!V7
zROen-=OEADr}6o6s=fc=RJ(Yk&+E}IP}Uumb+|Is-%5Sq@)p{!8cX=k#`0=t-3HCC
z3G%}8^;R$9pHEXg{Bz{U@?Xf3;Qeeg{n;4y?Z>)aD|)Og&0?GjUk+LXYlf~L=9==i
zstz%q%5Tv%F<`d$=A74uvxhtTqkas0PI9WL%y9=Crwn~AG2|=UdorP9W3kg
z_KbRp_llD~H+^d4%c&OL8wZ^$dC{1ArvB8^hmKfi
zj~I`ZdtF|-2cCehOS7gBKl8VCJ#=?=OMaQgSTUR*ZyVz_VjSW*i;d4C$2=PuMgG9?
zq}RdUdjgsf>s9f0?=W)vV~Op?kwY0K9aDK%Ikv)rP<|>p23b0bj(H?_ex7v99@+I{
z7dpF+jyXWPCZD`(I>x~`L&uDFI%ZO+W28SC-!7hu@DlplcxfKIguI)cK;ES%BuicB
z3F#h->E@|?I{ITR`eR+G&0tNX4zfx-r}!1w@O)XV(_|Cnb6g2u?{GQ|y(gSLdcJU)
z0-UM9A@^!J?dDLY@m@AfT*rIa_!MxuiF&G|a`LmL(`CbiA*D?t?mQB{ugu@*2aOUOSI*zmRdC
zgl!?#t{L~vG;vbf#GK-!sIHgaE1uS0cE^Ry1^Dpbf{D$-RlinWHVFIC!@FnYmF2Ij
z@XFl{gS^t0|Qq!=uK-
z@ThO^nUOX8X*p~7vyD3Nlkq3~RK=g9EEWubl%dBkny5A6izA3s2UeT=hh5U0nT4HFMZZ#LqKu@+{6
z-8UJJj%{oT`9ja5d`*7RV&E)1ZT<|p#&~R1%6QDvx(-`+$mwRAXH*;l{~15H@&EER
z_zxW+9dN|iWZ|IuM*Kn5wfgke;uv`MMi;+i7u?Xi{8wR2QY(9UC%fauX86zh-b3nv
zH~)~lnR0x{?CD*r(E~T)-w;P?Z9|**#KL=3iFcH{Uzzx8W0;t!EteqmR4eZa$ZB
zGhW6P+guy^O?L5^_bKr4l;1PE5C4h&^0%!Fdnul@z8n7`2gZNOcdv7EI_Ps~cP_g&
zk^T+N=`3eX$IT721xE|WCVdZmWbAeUyFEY-)B2v4?Hhv*#QwFNY5O|kQ?SEpO#hKb
zFx{Yg`ShLqZi4U7e^%bZePe{Dv{pfNV(``H$>*t$-{w!hD!*>%cei_fw04o11JqrC
z%t-&OKwheR@;Oci&P5KW_sxNU2eI{r&%?B1FnHH=G0)GcixaP^=;Hma7}Ujo|AWwO
zdtB5hOw*qa)aXy-)$}Lw8r7dm-zr!U{b{-wA7C@E
z&^xvcz_mLPJ71CQWv+kA24(vUn-O<51GzVw!S7jZ26c_^zIyjE7Dm!53Mo=vQD==~vxHWI_Fm%0r*aDb-QF7(I6`A7&;zczV}$=&Wmd2JlZq
zxz+lwa4tgfjZa&ykMO?c(N(sSer@t=J-By%jOBOwxpz2LWUito6#Mf0tn%`vL3vp^
zb5LHM|Gx8c)fVIBVR(6wtyBI=wDY+-wJCif8W~QEujEzv3Y95WGLUxqsyY`?rK>fM
zR>1dMXX6%$x6NNB-kyi;=(>uI&MwHF$|hFqfh(`vzesz(f#;&_!QXA!uYf8#C
zCr1udCxBln<=|797i3J(G5N&eV0)XNhg8|=5GHhyoR$;2p*J>K@@Kq8xztOQ-(+#E3;9&EwAH>fGPNIEl4qLChP|B1sMmSi*;RuN~dk&YVvRvetquwfE0UwFIrnODZ&u
zj6FJba9ktheoTA4{{(*|ZoxM}cHF$}le$)$1#Cx!H(m_J`{={VfDz1&;1`eZ2K4XR
z+^}Y};O{48c5999^vRiH$&Quq-}9T7PtNT9$57tH3tgKxg44#GtWDm_I)l8eC72E0
zOai8h(M;xu^ZIGKQMib<_2E7II<8T8sf^sR)d4@zxoRzBFEWTu?OPJGAA<+r^>%o(
z+%zS`eRIP3E|eu^&_2&kaQ#M}+j^2U@VL{p335B+-qH8ieq>F&mQ2z%*YE*+9JCK0
z55`~E+%*hig~T5={ivV0
z_5RJVoN&SCg|5N&@B6Xi-q!!T(Av&g@M-2(jVE?Rr#7h!dw|}SP0mXvR(ODQBpRPv
zUEb03V>@)v7_@KQ+0%<2<$0eQKj}U3w*IshSZhRT-`NrbR|vjhGvAIM&*N^4Wh(p&;C&m{7B_~-fbjy)1iP>VUa0sz5&aXPH|w&`)IF4W
z-Lu=ZdGL6{&Q}Rvo*8|ep1IZKcm25S=lSu?L1wOWh~!bS$(Z#=WJU5Se=f0xxMw(Z
z=A^Wq*yvYWFqlKi9^bH2wo*FV_>pV%3;mMMf)Bom(OS3-=Q@hRv&4&?l&gN{@r^sH
z^iP!cYIJc_4<4TrWInESB=C>ZW%Pz{q9BICcZW~Bin21
zT&Uw|Tvg}$VV$w+r^Xq+WzMhMi@(oUi}#OA^Yi7?K=1r9)*714cyw}R%=gSM495t2FZwTWH2(~^F^|wb
zK;PA_^?y#N13%*C4m37>^ygXXnxFGk{G5ffJKx4ToG)IOYH9Sb27EwY$Mu63a|Os=
za@!_HOZMo5@)zklI>+XnSUXn1{rGp`LC;r;5oO0>CL?Oc`eJfiBR}e!WXt!(lrMMX
zl8r6y+VJY#n5!D|v*7g;jTyWpo?_fD_kA@T2(6+0^;RYu(e&TvF^zv-ZO#qa)u$r-
zV({UU{<+vP?4ic^fYT@9K^q&jO`TXfy2A0~yELxo^~A8?GS(=CcuJ?&MaI;~9zG>GOD>p6rX-t#+3LlS?jX5~}>|A3WADnl${Vv!r=`7Qa7NaJ&vChiy
zlT5PBP5Y-FK}VN^;T=y(Uj(rwKhgX=@uRH`_BxDQitW!){3Rb%v7F{MFQf0OTkXR~
z=DHbglZ8q6SJ$A6u7SSf54~@tIql+u_~*1Oc_ru6a>VC-sb~Hc@0{Se%HI;N=(+qY
z^HuP-&>!+a6Zl)^d+FNwT;?;VzI+!C&-pIqvm}ET^uEzMl+AJyUr~BCSGI{R{q|FPU`h}%Idh+
z#$gn5)zDRAP!(?uHLmb)R^w#jyP?#?7&f^vKJLai0be6G6+gk&(rR3>aT+t_gcR%9
z*h2rm#K%$l%DdLV4}bS~uvfAXg(3P3`UsJ6ZX3+YYP^fkQ1C0UOl=>?L+{@Ve{P09
z)8PLixO*Q*YYS}62W@onUKj6^n=sydoA7tMDf~b3a_~=Aj|VoLK7=|PnIvBo$=OiX
zdsAuMHsx9FmorF(j^^=46RbarY-nUl$
zXnB+mgx?DAyNEyWgTYgMa8keR{ub~?hc*J!=)k?ojZcv~>DHn6RIpzIOi#NC4)o7t
zQ+>H2)xPBq1DET*QWk~pe8IcDF4jBnVq&%%3*NW*k?|Fu@~p*hMn>&ku_Es@BE!N%
z@|$otNsbLSU|1cV35Qzx7+!agD?t7#KJQR60^SuFISUVZS<-Ke`X9z_MftIM9ryU7
z%=_+9U)5)u?3JdWE3vZJ9{IRN!Z>yF@q@)oxS=3R_IcE`r%d(R7H(VoAV
zKG(bTGO8boXNuS%JsUynnPktVyNXRu$F>*0@VTjartvIbKMu5Q
z$lZtU-1N&=W$u*^{pYXB%mc>Bxk3Joj5U9MI&AEHpY|~N&}9>h1XaV92^
z|M3mU9kkr`j;2bDO~?^Cv~dl4o#>PLrS}Xc@f_l$~TJcvFlx;&6OFo*6u1TI9*{$!bUMiC5OcD}wbpV4*AQ
zx#pM(E{EZ9N#yA>KDe7x9#Y42e22a;ey4Xu_%340r5}svLh)5UzfD~sT`PGt%EWuV
zPSWq`mL1W18V_`|=QFJ>qo3ns)0Br%pLFlb4EL8{BMcwGb@)`xclQp)``VwRa||Rq
z>Wg$w7T>55{bTr{Yqr$6eg5DZplhB%@Ba$eQT=okbn^8@2k9pLN;heJuGdf1ah>Eh
z+_QHEf8(>+c?0v|+xh4Vj^D7pGj|0ozNYJ}_E7gtF}u?!zNKyxW{U3Cwu#$eM0-_lhWnuOS9k=*;Ua?K5KB9#zw!CgSX-**@Nf44ZVS*
zcN)92j&eQvZ+$6=PU*qcNvCK}te4kokp^NO}G!eW}v1ZG$lgF`Dsw!hp$vV7#L2L_xnApl$mbfd4-qp!N^w{&qmwb>esNkXXGy{{CuBZyQ*i%K=i%i
zP7YLNaCv^>0c@S@iR$AohPH00;V+&P{!zReKOfkza#h
z?E(Ar%ugexf7EzX_t4Mq>Iv!S=N_$Y2KBNDQF3)ClW6GL&s=$XPk?d)hYU
z#1nRo0z89_FuJO~=wSTHwdCan$qU!=$=rL)$1A>~Y`4wv+q=bpJmKMH+xNiPPT@N|
zPasQ&9i6H3L?XkQL+qgD3p@<`*XlYiur?Wf*7^mjbCq~1?6+v3_eBGE-1yk8kxzU@
zi(@VCL%;TeV`y9Xl?)1J#vhz6JM4UX$xLEwu-*G`noqe8Ik39)xzbnhA2wET^4Z`E
zkE%9YzoT?jovJe^oK0enc{t9Z{|or`!`-;&qjqV~rXGpxX;E%fHWe75y=-#!a!n}T
z%1!ryH^-9=u9bmy4?A8L9TV5l9=}ajh5F(mr!Q2d=Och=@(fJLv!}mwHS`?I_~Z?a
zr>`G#`T>3IxxM51)uejx;Ny{c#^>;y>WOC2K2rCj&jozqn>BVleh#{z=laAK;t$bh
z4m25WxDumvtn3%wKF^LmL>9JN+(Eg=7h7erbT)kc6nu(t?=wZ_+G=!!bjn+xd6gdV
zr
z{^PfvHlRmilHbv(_Qp|Ro7~%&bB}*{_Hnq}#vx)0=nJ$mzb*(;gZ`8+r;mwYDflGF
zCldUQ1~+I@>0erYSCV`VIzn)$|2TZ!G#kE*$=8L?>9?J=!Tz*P#=SHC8PUFz=L*9+!aZ#4Wm^XS>ufWfEub|cY6Bk=y%1SDH^Qk(`ND90)JAf|(~xsw@}jO8GtCc6o{~u?%Tu5i
z<;Iinax_l2@rG8)Gl<8}h3j{~b#R^C5Mu`%#>RX-!KxacUX9NP_6)tq_@rl`cSZkL
zrog@)jZa}($KpS8<0D^rsPTE4=YD)1;o9#zv%FwDmg#3~$fxA`85{XnG0vx%_*?45
zu}9((@2@yq
zWf!n(_MX!n<-{m#dp+|g!1-Ullk7)w3vGk(NHD5eUJMu38D#yu>s*^h$on;Y=<9a+
zYy3c;24(Fm{rQz}e2M+JGtwX8SbJ}%{``7~{)m1Whs60|zbmqhtopO|OztGd(Anr2
zM^598PmtI3Y7;%C+}=NMT@}Mc`sdp+o#0{B$ov~4eL29jwG9tsp@qj$WAk^led!gx
zVZOrT)$zpxf`MF$mWF#;`ErLl`($6`+n|p54#1Y4i?;RTzYjd9Gh_Ng{ZnqdbIhv#
zAH{Ym55#)-mL#;B1J8(`SUWK;ft?!0Z#d5siOJgrb&P0fYxGt%GE|_dfw1=aP>5FnN_{vr{wAGG=WX$;S<|_$J(w9h2_C*FK&4
zk^HD5?&{5+X*XR2d-i7m5vlwKFVBUhd`LR}WTP?ZyV8NB#+T9@N^F{FRQzI*vaP7{6|
zh_3I9ui`$;;~}F7>2l$}hIyqs>#CkPxiY__K)$mgFA+XBUEuidl<*mp<1kMq*bzPR
zI`~U#Ryr4;zu*VfS~iP5z{%ShJ?b0p*2v0Tf(`G8@5o=HD+hfb`m`DRKZhkC%)|n5izp%6Hv}fPjq?EH)o$;rD6UH8$@n0;Gf3OzbB!0E=
zP+nAZ6tn1gXZ*8@T_zR5NjLV`>&gvi
z4gj3;{lr1?KaPW+)(>$09Lw-JRyMx~PWKv4gE3F_x`^RNMNji@Xh(R+H_;j@%};bP
z=AH3Ludn~Bff&wJTZtD74WA&;_uyMlMxpRwWaDa40m3T;jv
zmUS%t5c9*b6)XI?8?^7+D8%EL!h*1!!b0AWJf-V0h3H=XbUgD{;9j_rI5Ez%IUS1&
zOSqofvG~u?3!CC@ZrHb?ou!`^SMyA5ssCz6{nr?v
zFH9Hc+Gycmv2QzffH71%S_>lmeINNl`b?hc%xB%6)*jy)UHLjkFY{p|K34I!;ht}`
zukjPD3hC8s!j^bXHdt(l2O&bApsfB0D1_|EaX=q#T9-S6RP
z^nvCd+lO(EiOZ`?HkZT#)3fqJ8|#DeNXawhQF%@#LwQb4Vg2I_{$}%cakv)p6{0!1
z#rv_EBa3|yJ9WJ~Gju-iDQm20NADP&daHHxdT>?y!Pp>QsnZtfWaArj^3Bu{?z38B
zr6u6A1bmi&5A#~KwoCLomyUbyLFu^2Ud3LWnLd%d<(#pRuFq8KIL=jBO&)F)`gnDy
zuaCHM0Sep?yPffL_Cdaqj~}u-CN_Uo{1Cnq^{1XBXB3`S;o$Z2+k~EP!v8qqyb9_X
zUttrh-VAbE+9P!h>uTr6wC)yuOO?NeZrS9{(z7-vGRNiiTl#x@AOB5j1rssGi1!6s
z<&9R(x;|)W?Fl!vr8PHxZ6jq`XC-)+vv+MDV64OZdU*DW=DYish-WtB`qQkbA^z)0
zPcJ3Ng;kyXvIJe4<~+}|$_LMW`KEYhl6Ww(_j{T6l)c{%FwV=s7oIFWKn@h1G<;Wq
z6LKNkf_n1i;HK+m=C`(o?N4#-PsJXt!5-esIgGK)r~WhKw?g_`?yQP$oBg&vVo&T>
zHre&Hr25EsC+Eg=appiz`leDpxk>ih=la`xUnjY?Cg(cStEqQWiM%a+z0jSRFbN!P
z1c%k7`_$Lbf)_dKa<2NyS(mHm>s;5@RbgLG(5K~$OOtqw{QYwHAK4;iO^y)Hi5A%%
zH#AGv$uEfVa@5X$^?#dBkq>zH^sXCOn|A}bwKLE4#^xXJzNE=(99fJz9X=dA3_X18
zd22khCw^|g){kAr*b*be!AtuNgkKmd4BB(f@5Lu{YfR+7LT}$@*QuGrz-Q{y$J8!1
zK6`3IGizT=pK3kP@vFl%6pC5so2^Zf%^r`=kgc;h0-hPq5WiI9mHk$8z)f`%)GaIt
z*!zTx_&p0MM@JfOQGU$}et!x1ANHVfW@JkgmiAW&pM&5dy+EJxhn${Jc?0r_&l2K6
zE<5_JjbUZKR>T*Ji%#KpFZyjYGVya+s521(aDX^EZJDbM|{GZkjp_u;=#1+
z$tZI6_~DWp;K>gato;ibngjVx__=}kO`lyP`=zx3A#UrMk2rrrYakA$r{{{@tmVXa
zzY%{~bu3p+Sr7Lqzm6K6on5f8Im9!JLkv%H&GG}3XYO#en=?gne!lY-=PSJuxGERl
zbB;t#<0U&lo+Nib<+AZhup7P&XE&@3?1tL-582-0bWWX`V}C
zr}g_)@oRcG_G&uzY6jysJk#G@myyqHa)Zt?+f7U-9JCI9i|1kVOxUmL`Ilk9Cl3(L
z2gB!a@bBea&As{f5q@*;hW>J3botbxLK%H%yeR#qGq!Fv`@erq>}Ype?C96$T
zPh98VMdCQx=|=|egUxpf&&gw-CHQJgzU#)s%Et!mXVW=gL&Nzga2^ECZ=t{SS#2F=
zpR$AXeGg0JV}lnsBl@hcbk?SPalx{^L2dD)U2Z%(b)Bv|`fuC^qrJ}g2{9wSGCp!=
z{HKcwS9UDk2TeNDKGO3A)Pf0?`|*t5dQliu^Ig#21ReMN7V7S&aSmS
zk0J+C?|pj1JL72xA78J2YJ585KXGyAPt9)K!~5Dhp>pq&8(wocdka3<&R8>7Rc`dY
zfyM!zl}twCiZ{12fE(8;0iOS`qW%a;uC4t0aQ
zk8W7Ud(z!oeZSqi-p96j#Q9qH@pbCzynvU2`wDPRI^0E1|MwwiC>!6(bCsRmwI2Jp
z4p|-^t}{c=v2VTHH&^j&P(CjI0DmQc4^{MX#Mo%hyx{0}B5fJsdGDm0;#<#hTnu@!yiA3s$jiV}HYdCcIxR!*_-|b4ns=|%yYZWm@tGy@rf@*+#%Af;
ztL4S#2vnp>`FksU3Yo!tBgiT^pVwg}cU!czY6aS(8K~-7D{Qde<8G
z^CtMyofFnu<4>WxEB;jP%v?pMV>4tAe9lWcT{*99C8yKT3yM{gk5WvxKOG$H?hcNo
z&>_Yxz#`+!Ia~$!F;MdA}JF`bxZ-77J{YN)G)$YHT1DS&a6N#>QTmu
z_O|zUJAqGm#POoynuPOVEeE0dD>N=I;?t)8-^k=2@l9>qkB2gO9Wr_@nM|M)<@d}*
zc3-Yc{t0&M<;di%@P*FD{C_2rzAsuM`d^XB*Xh0gzhp8k{tVYE$lop?lNFyEKHZO8
zR_2qy2tdeXs4L4GM7NE
zLHFXNLUbL%S>(v{M2!sH!rZ%${WyEVY;;xZ_fq5}KD1wK{dDbFKapAa=Ck4D+PRae
zd~kZ?QTf55E%r6kd+5q8=GE1Q+Ood_`KS`%`$F3LJqR&x}%G+Q3p?j
zF?MJCpBKp=?2JEv-qxJJ-bL42sqjpwAE({t6luPwqtPy{P|`?zHmEy!I^7?
zt2-7Kc5r=7$KsFZ8RzWtEr|+D%U#|ztVQ1%Am$8xF?|DoI%W^&GCz-BV!AF`{^77(
z`~oi2b+Pm=VC{N+%MrIO-0%{ejM0E&Eu)=a@|=<3E32_2Boy
z9n`x6J&Ro_I2st-I~>eiVcz#*aLKYxBJ4Z<1b&3|9iK!x?*w$6<@%^Po$<^fJy%XX
zw7+AT<(E}p#DOu#!I&Guc!%>nlg>8O!Z~+xJ*@ch%aN#2!iT+(7{>2Y|7jmTXf%M2sb#D8m>j1^X(gAVwO;t?%e_f9xkM?gK
zR*9?6s!!U&`6cnN{I}`wu+O!xXd}ml+!ABh8RR|q*F));N0=`)9h9ExbddDQUdBx@
z|8kLZ&)=e3vg!EI>Co`tsj?i?BO>7-*P?3`z+bO
zbSUq$ppjw_$@_2U2Qn1$s&8wR)0OB_wU4}CK$IS@2?I4PjuBd
z=1m_i8=MY!C-rqdkWT)$b-;v|s003M`BBXY
z@eSQw%nM<2<+Di#D5mUcV@`bm{)FX7S;uTS(!X*xWm@yW9g|Hyv!~WJXGezoc<|J;
zX1?Q-k^eEu$)D(8c~Z(ZQr78ms($Q!{>J14zWnQdLkG0s8!&&|
zF*Zjs(S%HFaz5Z<+8cx&+8=`rf5fS0{OLN6>+FGYLd7|ZWn1j%
zMkl)k?nQgqq*+O)Z}$;Td^q4wYfeIZd`|P<+?W_2IlsvG4j%101@G{Vc-nl^(RD4w
z(Mwy3KK>Rw@jU+UacqwHrIE5N&JI20+ECpJ&pIE=^Z2*b*6@%|J#IQHQT!s@L?6`^
zeX^(4lbc~Z5IQdOm&iNeFG;4ZM^*!GXP~d@g}U+=rYpsFQF@9mLtb13FRtuijNN|K
zv-9GM@sD^?{PW&go@AVS9t1!8Ko_|bV6p!~b49F)k?pME#jZ5*CNXC7w$
z1#jLp$eRW;y`l85p5a4_H+|U!^9S3}_su+ydAsa+a<1B07%*qEq13l9RsLiIzq9h@
zj6{`Js2+K0#ZbXS$bhYpRz{Np73tbD)%w^EAD6fa~b@i9;Cd
zc_II!`EkuL>ASn{Jce#k>`Q;`ySw!1O_XU}n`B413_F7v{ib<+a;4U$_fKuEoi;uE
z1mEt}JCY6ksqcz0phY>f86jP&*7>I+`?z%0P(5~ob$YRU$CKJeAev||!ax67u?S~P
zqk~xckZ*|9Gat&n(|Y%NXB~ZGT>HjnVs#x)-c7yNYdk2^cxJnUqXB31D7P-T=uH1|
zQDXLZ_SAN;W-eSmY41~}^_q5lOI=xgI2fP7c(9)6gJBz%>zNbFXr57P*ZLYb?$VkYgMmw|tCz|8erg4}hD>Pw$HCGwU5(k1e`tZv}HndF58r
z&!$n~_YDdWL%*3a8%e2CFhcUH`7yw1NZ&%SfSzRKQN#(D%}UsT;lomn+?#2>!h
z#!+ETu<8tu^x&Rp@hyL>31KdS9C+|daC;D-H-~jB-ru`17mIZ~`MAk_%JYezE(V
zf?ffB4F6R8c?>^yA9x?bKM?$&HORy`2j&yevfY&NotzA1R_8GH^mplb9V}|8{sLBj{HZ)f
z`IW4jFg{T|#%{=ZlwaoRSzJQ9(l^Y3**f4x*7R99?V^X-V-#7BQX>z-6W{J@YV#`o
zBtKd+%(c@8OUw7T>%^T)E8i^9d(t~4p6OlPCmyeP-g;r?`RMa+EiK>c`fcyiZ~ew7
zKTSQ!n9i}39+nR9aD(=x<9hn0Lp0-7-9
zj~cJ2ZSL(1I_i$q8B{LUn1C+uknOe7S=XFDYIaKR3GRCwz5)2kmyXso`d8LREoI%B
z+dmeegY=B(EFF2o$$`-wxt2}wW0)9Q^Bfu98hIYfPMIx>A+vhF?&4HAkL(_73)iw~
z?UCtgY_EI(e|}MIdGs5foA{n?F#WZeTl8Jsb+64FTiCI3GuN9x@Y>9GA05O^=WPg1
z5*-!rEd#Y5#bZpft4p1P=C{G=7x;6kXZln9pOPxy>(0uN
zKA(WkWTTn)4DFDsb1wYvZg%*OS(y2>!(TjPbfJCD)X&dqe@BIP1zY%PKmRWsz5(>*
zj7l39@p*!4kB{g3DxQznB>7BgPx|YK<30bb^o{JJAP5e6Dr9(yx3U)^xqekLE*|BU$d^LdN)spfp8e{KV|#<1-g6Ga5ay
z`SK~5>l}^LufwblWM075UYR|BKWD@5b=Z|3jt*^vcv$OhbtYZol$6dvKnJr{m-YqY
z4Q~kc>bnKunpvIk!S{6AFF{|1G9|cLdy5Qh?;jQBXRHjkvQsN~*2FrC6~NP3>6&Y|
zHQg77@AQoD?`&w!4$db&DgRRbh3eY;J@wC6zf2wDIqZqfUD7v7P4?PYgTZ@E)&}1o
z^XDa{*Oz*DrgZ@F<@t7Ti>JGCn_5TvQs}*Zxuf^%r)K{Bt071~R~4Cd1E*MTYg6|-Y*i4}oXphioTLYkm6QS#YNOu)|p~j@JDs&a$xq
zX2WjcJN+td(y!ts{VHzaS22%bAI%2@TZxG%V@-U<&H$elJ;&HHY0zDN?Ds*0P?`{wT=pZM&y?>xYdsGP;C=ak!+UrerxT&sK``QOnx
zF5U|FA3}4(<>HRT(K^v{qVTEAUL^iukRPlK?*IL7LmA~h-xw<`6ulV7yxI5SI#np
za#4(&9ht~4tvX9m&l9%{+EwcZWmA7;bL-Yu4@dRP-U7q>sn*wg9ln0VzP8oqE_ZKr
zvCD!XAG$~%8k6u8JY=y?TCvaIxxw;t+{NMJ+o(P5wulQpZZF2q${@B~y*s84;
z^T)n;MaTzH9T)Y-G>&H5@#n<5KjnL*iW5BShk3pQKT>A|VUNddg%(lYFF{}XphXeh
zS6r#{Uv-A2Iqi_DB5M
z%6afgJ-(#>HaTmk@#l%JZZ1i;e&QM8U+4SSyXeVi-Sd21%DZ?+b?SKUe5c!s!0!|+
z^3z>yT7&bT%f)|k(OP(3^&38={1U(9S?rhmjNbVpan2@xE+O|idl|efc2qsdY}V^&
z?sYzDyn}PHWcr7jy?m%XsMy#Ep3_J5_Hubh%O%Eb9=;l(>r>?SisLp?DxTgG!cw(pd%8$&>P07?Hc8BrM
zFQ#NZ;S
z-V!=DI;D+A+W58LosQeC!@dR5S>%K9t%_}evp!e6
zA2|1iV>^$rJsBT$U>@UpGJgJnZHrSMpNBoUWE5jgUa)4&_uTWIX_@jPp$-`B#z3+z
zJT&i9ho5m9_{{S2(L75;aM+>1@P?x>=?4&L!TulMTe(wX#^+rxe@y!TIlb`o*u~2@@EDtPOtQVK116V
zBwNb|-F3j4-4mILOi+^nY0(H!7s)fw!tl{E!!-KK2m}2s)Gmm~bj44bX$6byEe>o95)UqY+&S7`=
ziTgMY$nkm6mu-)2@AGBgBb(kavSa1J(8D}Ul}k5n)3N&jaNJGt)uVp;1bcVk=Vj;p!`??3uE@Z%W*eFT{!4+j}@4hx6V!
zFRs(+G2Q>U+cQ=bqek$^&apGz@b%rCCvlMa9rATU=8u1ato!>z!!!S>dw5Z2>OVs{
z=jyMN-`?@&NWEvqWuB*ARhw;*JOKR}j3H~{Wn>+CTg=ItSH=ySu&_#>KN8$juW{R>
zT>aAZHg0=1Z)wgvO`9ew;HB75a1^iUyTj~hG9D-}e&^yJmsdDf`+I^{Hdore^~%Ba
z4@TP8*unGp5wzcQ=Yf5Rty>j)v{$!17(YjKt(`wa?xwE+S~Nfla>-|*#hze7O9hu>
z5nMjwap7IV1vpV$lAQV3Pi~0$g*wISAtkJvpG^`PTPhC!ENPBP(
zZ7CljJ~>yMTir*+xcG~0oHMatdb4~+>He;^bve!ZcE+dgjFx>(W#p^7lnYOJT`etS~??1%5iA`#I67Q|9
zdKVpJzDagRcy>{C$Li*U`;O8<$~-Rnfq8|&P&{>`!BF4i+u|QtemX(FfEUi!vBoQ5
z@%)<7y)Mo&Sm3oEoVvmLM%E!240LPgo3GP3nZ)aBO8<*9X8P{ZcR$H@ws9UMeOTFN
z>raWL7#H5@0w=}hdUiF>ygs&ex?@N8kB=SIeEL0`(E~%xDT3qE#N!8dT-XfmyWH3o$3A5n@Z*rYTjWLeoLBlUij9~|8kS@UC1+W6nnX^uiSI~dFO=4
z{V47y1=s&fcHqIK6}#|Hx_@A4Wo24t<6^aYG{F9P!p#-a!=y2
z%n!e$F<)2u6**nuGfizQYvs&}v%_ty#Ka-o6H8j#6$2OfW^!ZfjCkmgdzvrHe9g7<
zJ=c!f(iq;xx~qitOuBXc!bNW8wRlHe
zOBPtvF}W!2&^DwrSUSl;27|Z1LK*i54Zp78ph-fcyGtjmRHc{;ruOsD?Gjrf0q7b;pgH$
zmASQ-h<<%z5%{wcn?i(R>~>)_5M*c_@+n
z66%}q@Vp>d{?)DFIQf1H=0CiB=rR4kS;q7P{Zl_B!;C9=n29-Zh?B_a=>D=#4|46
z7>?1TX8D^o4$^<~Emq%ITpjZMvrDmGs~ztXA4$iJD}-|V;2-JBjvFgy+K6A#!H-tj
zW`3!QveoF-gAljzbHI?lPcxK;`QeEc2B%^1^_wBhS@tVy4{9>q6
z_c$L`ur!}1dcWm=Sj>UF;tX}=tb{)_Qaid{;xyk9RalqEvurPkES$`bY{&~)0+ck-G81?L1W8V$F*nWI6`HI2ra4lXP>qx)E
z{xSJz$Vr&?izV3b@i|w#J|9f0+-j-zNRA!Tj2XxL3~S&KwZolj>x
zFi$w1S?65ivGi>&rh4_r%sX6v3-cHY(T;Y)aao(Yk62l;-H6$IBR1kUSwCit%v|GX
zMf=c`^;pw#|K#$A25S^BEBX!C+$UJ`p>vaz8|y}vI+=s3^!>=l%
zD^nC(vevBPdnrD-8UJ-nZUo;SKLfW{o}X#-xKYP&1LkuMw@Q1d^D{H4=jF@t1FU!b
z5^F?N#{5b7VScy5FIwkg`2l1jF)?|6z&c>f+374*8y|SNCq9X_6r4+B`v`wl*&8Ds
z3h6UF_eyX$czuO&4VDas5}{eBDGcmA^oJ6t3h6o+HM!
z>tCkwC;j!$#e1&2FP!K3b^L+@m*RWB65SG=-?9GRb3tY+ZTUGfnDxRqu1h3g6G7KvL$XYWr~TmLvZ^A_4FwhitP
zs?cWEn9MTQXZZzN96bwM|AMuyXVCJdF_|kP^}TJY!29Hw%mSW=YonblJ2WQqD#`@2
zi#4hV>~irM@^x;UR32m85bi5`c+q`hgC?ZNUu@gUEo>$1S!
zX?ln?my~UI7r6G4`)gC~Z$tBPKi}J{HLI*&L~rbOI$Gy>D}N~(9!$Hnt!bSv25i~r
z^Fy4p_J{R)xn;oC8AN^Xx~=P)gxtW_-M#Be54CM5{ZBm~3Ge&7)5y>_6P}NIovbo)
zO)0-uTlahmuY=e(1s-NQp`E9Z`t(imlhOX_8s9i~x$&-}3;6A)yZ~)H%_^|eA4enn
zT^2j*8}mhfrl9CD1P!v4yiQ1iGy46e5t-et-w#ImUA;~t?1TCq=7iYu0e#mI|5_Q>
zDXr}%Cv+#UJkIQ~%2jsYrw2faTx
z#ojmJQN2&V_(yPf7k#?@zG+T(QAYlb^>TFwlym>WdU#=|oICq?lmS
zjCr*^!Sm-UCVoQeIF6lkc5L0KOw!TS_sPSM9kcxj#PsqP_>L()T(JH7eosSsef9nX
z{1e-sKwX_v*mQ9ix3F*ZNqwuz&QYh1@f`k(;$+Sz7wl>{=;{}RLmh*IF86H>;yc~e
zlrIT$NSB0ptaYU;b&vi}w9Y!o`R9xWIVEz_m--wT_cqVeyEeB1x+jv4Pu2XBjZ>N!
zlfL&jz0k$I=%qZAe&g_m?C-b92@;PnU#0qdJGOTka?SOf_Kl+L>@~Z-0*^9se))<_
za&3K@o62W*Ol#IY!tNbwn>{aFd=5TX>10`LMR9-9$+O^kJ{6ycM_Kz4_ARdXJsdBy
zp`ISK<1+dsj0<(F59GW!KR4W0G9N#a9K~y&tClt8EnZC4POuKk^JNiz==-3#OD}7J
zeG7CyF&tS#PZs8p^B|w#eI=u}a6vX%2b23IG`WaB=~Bit%%dSoRzhLW8_OTLZ#iS6|bh}*TE0lT7kVf0S87gJ;Ssh{IBQ5Mt(%D31)Z|z&v84)pJ
zA9Aa<7NS2+oTGY4@V8&qA45mw?CS39*~eV`#}|CTc-ZEzwI8WKz3P3P!quPcUUxwI
zRUZOppU=}grgD1ncRS-xFPg=^0nzjT_bYy0hmS;F2|cAa>{wg%-v96F3>xH4ezVq#
zYwu+tjxP<20&-ZTk9!B#LPXad93QUSm0MpA?#G9x;D5f`#~eV_dV=`io-j{y`)+I7
z%-loqnU*AecyxlQ|;4F^Mj0E
zxqqIv4*B?(Ix%$r<+P`E>>b)M`thuRHVV)sx@My4Y{}jP>(a?X!>{|>X5_ll(@PJT
z-ri8U*ZUsus_Br$_&Z*=`m)G=+8%WK5%^3r*IB;bBZpIw-RXQAX>KTkTJx#gVNp7S
zv)W|Szr|WLe57)M9Qb2g4}2Nk-yF*i+{WM6x!%F=9sC{SI?eBg`TGvn5AgdSe?R29
zo8K?+_fxLF%J0|tdz$NBe!s)tvt0j(-=FgLTdt3*EXG;M8%p;32b3!<
z{!Vq`?MpgGx-v&))o)X1pE>BGJfR5MML1z
z##3BBjSd8F$>@)PEx8g8b2dr-Bjk@yAWzUXKhPa-x#Vp@hB@l?fiK5f3Jb$rz^ucE
z76pep7OAhrg~BEL*4Hy}U~Wd2-zK?PTaw;H#>f4DdVR9b@KXRU8O>W`nY(Fg3C~U-
zxBb9T`N*bJ`Ahs}p^b8DAI1KBaSi>r^uRvbYx&hW^iT3YAK!hu2-qZ$_7b<03%%dv
zyeM*^2ie~=Hf%$*PRPFlUX!lTA<;ZVN%#R%14EC-zmCLFPKO@Xd+q6)`e?0
z{5^PIcG$_LagG1|QY|a?I
zo9%deMTECk07p3<*31>5t=4E5AJZ?RAANi=`W@Tm=y#|g(;B58V=BJ%a}&x@7O$3U
z@LllX?wby}pOR|No)@`K^4#BxzVO0J#pSVgI$ZY5%FG-B7r_Fba2%I`&$1Aod)+wL
zJ@`;>d!M&cs&jFweM>B{&}bu^XOORkR_dQ%N}lzu`lvdUdcg8$k0tPlE9Z#$_wI6j
zENhOUeO&6lYgAKS!^e*}K7M32e7yhN*7rv?zh`#l>4zeGtZ@KNWgM=g-{jYWn;eE@!g-Fz_I><_mA)k#d{jlnDY1Zsrb{Nz4$xRH|;trjXsYn
zek0a0aFG0M_ZxD>&Cuy->V7Y1KgRl$mGG_S32c((|3!z!uUM{sY~xoPKRTNG??7TV
zEjF-sH5Sm{dsoG038*ON7X&P
z3VvrD-}4HHgYc2t8gklmXMRc&K4`N%-bnoRd=a0|_A8GH$7Dq%_Qt1CS$o5(Vw`$$
zu4f0^eeN$2L;q85e8@9&y^X1VF1Y@=_jQg@FUCFzy;J4SMDSHC!Fz5maEQC%9XR}l
zg}<&3_a_oTxnVMA#JneHNxV16$c8q2AZXdNt;UBv(rIIMN64Yd-O7=ARv1^n3-+{rh@OOl@oK;l<0N<%uTbk?#+S&aNC9g}E-lSqPj>
zE|(yl(VS$IcYelw)E{FVZ)JaPB8F`PMpHdF;tS{-2bFcc)U%jiB(`?6^f<8#b^Y^d
zzi#Bw?2fvl%8eJF!pB+?lye(wJ2wC0D{zhL%+zGt0G$&O_43%`Dz
zav<0q(e3ktYws&%?~85ka&m(YF;4Fs&^yS6{FZF*RAO|lKV|%5^z`=$v$=e`lxIWO
zjOT;g1+uxOtL~m`y6XAOlSgMB?e@9~xGUsShcKFemw0p2{lo(8{(G03D*TTPYz_UT
zI_3f9u_o5=;C-zH5R69QxF9Hl*Vx8S5lh9_l{j$tLFv8|=pKBJa%bDr+{3*$=N?W^
z-FdKk>Q3269}8D#@~+tS-P1!|A{bwJhjNhXN~?gSHM9rQQ%anz&H5O=yOy48dSH-V
zlZdleGf4gYz2rK+y0%%q(L=;+e9MJ(ynNFnZQlU7Y<4Aw=EjGynCZm%i2br})g;Q>e9K1}naXxOmOr{7ZU@=3k6g0_XERW_u9Vl}2EX
z9)>1c;HQc|i?3I)Px#dbL@W4SvMaxj^-JUA_gzSTG=BZ~+={zrp;xnQ^m(ktx9(^Q
zGTVh*+V}%sJlP2BgKiC?*5dVmr|}jx;v%1)bLB6_w@Vuw-=@Z7&hvar-+Opp-_5bT
zZn`#~MPqh`?^z`Ww|aR0HYW40`<)y(c;GmeoFX&~_s^MJs825f^X?F)`2oN5Frl~T
zuCenY8oM$qnMq9=X5-P!|_ZZBi0aqVZGPyE*eAJ+|of8l@AVO_!Y-5UmF^SfI^
z*?gyXEmEh$%aJdO>^rK|L(cnrJ`XuX-o|BjOlaNiK
z`>H;^M}2ht`e)#E(%vlEJNUr3W_t(wmyPqCG3tH~nd}%F+Ev{@!@aN56JJRjlsuX3
z9Sa?U`Gg(BIeQ1Xud|H$P$Qz$M$gawmLZ
zeyiG2y&nT>xwE%V!K=+0C$%>P*@f?R`~9T6N#_jD)Fh3VF1mi28#}4p@r=jIWa%o&(ywR1zfLC)
zweU%@a?6&IZimO9KV{#?KHL=FgNpw1-|K(znEI7H7QUP+cp2)Wtp8+wXFQZ63!`ZFG!dcTkUd_SQ-`i(3Nrb}{qJpCE`QK_w@92R+3v=$!X19XdZ
zOZfhTKCN=J_?c@b18=Eo=NGocbL5f!3Qh{T_D`LVf94f(%*t=Rc1nZtoz~7X+u|PF^a_CykJVLIxd~uu>=M3qiR8(07?EB_eC!fG
z9PUrKehLTG`6_eE!w0}?L%MZ%eY*7s|9f9%jmRreo+IYP7Z`&%->LV)Pep$Ci`3Wu
zHMD_Gv9^0#PRai}^TNVceEDiZJP&wX>gSXp!cSOfNp7lT2
zR9JwF^iEH=hSPJcs~m4ao>>kp#k>S<{)9HSxHfO6&2W9LRb%Ea`aBQs7Ae#E(XRc$
zk^1q~_d~B%t0y|rf6=7}TE_jZhyg-qB^*0l|JQ)4-jTe?282`7G_$JIJ)*@Jf8#4
z=SJdbbf!E@u4zE|CFF>Cdb`uN(aR6dl^0Cn9O}-h6)wN90$bqLiG-uoP3-LjW*MFL
zt#ov5=M3GiSJ2tPI#C}EBe&{@^#wUcwm+=jlE+TQz(*-dG0xhD?C(VX>>0BB@Bc^e
z@EO#rqt79-Y40O*KJJtZVTT`<97>zK9U92RXdIda8|G7KMd_^KASnSG97!xqZ5yu0!jn8vF^i@74
zxEnkyCSYD7xcOid6Wkib1l;dX?sqZ?U6b8<0=wn(`VWYW?QS#fD2rn_{f0K{ZExz>
zG2D7$f!@7Ut?bh*^Sj{_@)<#XIq+9GJC^hC60`_ecPRRtkQP(1i?k8bgZz@Gh0fkF
zT1+jCvssI7@zK{MGw|a_`1)XK6nDh-dv9dFk0s)O;$COJ7e@Abym-a?7HbrP?fCVp
zy{z?7cRc-*tchMVCQBO!uXeoupY-iq-hVyEeRB8XhaN1^J(*uwLY>}8*wIN%@jlsJ
z-huwydllKsj-=nDuE{me)b1}OhtN>*nQ-b~*i`tU_%a>7OmB|;Kj#9)K4y!<`Dc0;
zeF`Vg{siu~15Ul-$&ussG9B-~(sX=}^7p^`T7`}SC)V$~Bi}1Lig#nHviK;zWqV>f
zTADwI%6VVyZ}sakUHv3(yIm~hVZC4ctJan9KX3y4l}FX*WsN~)E8tBZ^O7&aXWcsh
z{U1)oU-v;%@hAC_41NxI5ZyJms%NoWeG}j3BoSrddcWAzx)~h5
zqBu*k%G|KY|7)6WI5~N(+EC(mu9DxtiRE=0c4ixTM4LaPy^!BYS!HZs`X86`u4J{#
z`&CyziPu4=yZ#;FpOh*6Rt_^MFFHTU=BB0xHn!s~x~Sdhw3o#Wx&66Xuc9@}FSwkq
z>|?mDDxb_nE`oLgQ-gwXlD}qtwQp);p*Q!^#oC_}euFr#Cm=5hUGHX&@E+!}T4xjU
z%sC(b>HeR||5^6m`!bDfTZc|;yR*zeYkj_QpJ7jAlfOe>4bSu_crwQzJYN)^tVi!%
zz#1Om`Oxo<=jFd6o}x*Tk7LDcRX4eq=R0TQpLnF)9tut@U+bMcTt7;D#vIE{UXFO4
zjL&=y*kUy5nf$X}eT&Nx+5Vo&cy5dst=Z%I<>~pmEAaKOk~nMsop`Ttf%aD^N4iF~
z;eF7k-?s^kHdnNH(llcJD~@3+XkGRv?02Ql{F@y=q0iC^Tt}mi&Nr*1kMvr6
zG8=|n|5t2m^4#u_IZv(EV~%Xp^I2xoZZGqk!#x%Ee6|HgG#621ezSK)(Aswq^V7Em
z4a7i-!CF_4b6J6(t#*39;^v@x#WeixMLeI$H)8_vZ$&L1^4QThNBNcNMapX*yOn##
z8T)KQ4>PT2P05U}vGUS*D5@9zm#s+tpH=Z+wj=p`X2t&*75}GK{8w%$dY|)IBe|E&
zW8cO;uPe+)vbI03wRTz7K3V^ugXxIsYTU}Zzkx@`JC|5S{lpI)^mC5P%iCPMD^J?4c{+=sZhoHJ^;;SjZ@zt(
z?m&C1Wy}2h=l{yvF6QiOi5E4$KN*@z)}YbfFb^oYCeP;8K1#zamCC=#9z52^Jgq&s
z!rPz2cDyA)p7Ez!&Sm|N>nraMRiDSW2>35CS7_^|(gAA+6r&KQ
zs?E`3;xgFH9`>7a^NSL&Gxg-cfTbLm)~>m8rFInQpLn**%dy%c&(_tmX@|(O
zG8xA1`?%q|&|G*+?$|?8cmce1rq|hg9-9>$`Ryg;YZl7p>2uSLgz3}rS_@fxwVO|QM(V&?gLR9q0c>F50ux}8qAM!o*~XeSXN6NiTTW1m9|&t;R3Zw@#l@pPF(0YJ5eOvd9m}
zj?c6_Sv|3TNc{u1&0MDEqAO+5-OVkoY+{s~QeJ9+buA@0_=lO1y=O0eM6-0XZ$Iig
z^Wl;~N0)s`8BCWy8;dUUpL6ms@4WnqQR#ABi7vN8m)pzf;$sluROg=6tFO>by8CIT
zyXPPSqJ_OrpFLf%!BITVv$DRY&MeJwv|P9BkjsG!Mm6n3^q&F!XGZj=ETX^s=8X8=
zNTy~kGg}Q^O!lDTQ?k{!U8gfeY!0TAHjr=2b+@-fYbLZ_V)y@a@+~?10`i^SP+Bj+
zUJI?4_@?h0{g&OzOmlO9>MuM>&M7SL;}`XpXY;o5ocA=Zu6EVKCZX(I|-ENQSXwSyt`@X*`@%@FbR@ffwKo{-A`tXOrQ05Q8GocShmlB_Dl0KP+0Q4mz@`I-;M>S}R)vlDoEB?`(Es&ls-s>NDcaC%fxg
zZ!w?Ik;oJ2j4MCCl3b^7%w*t`%kb02cYmIn(ULV6M(3|YK6bOq&5ZVJEIRLg-qHD6
z=jA_DLFf9_;H-8gR^Ok!4jMBzZ0n|=z4}QkV!C1d)HCL#>N}A06l-I!lbPzst|rf}
zIzpektu>h!EZJLR=(82iBAxKI<;?C!$?Zz^=3a?CzD7Q8s#~wHj(y6}ey?~>bWf>l
z%>MVd{@;6kexd8X_2&!f`#RR&+`@eQR6gs<it}yzCo5mHwO)Cpq
z+6xWR_Xpcg1-O55W
zIQY1ytL+Mt)o=stYyc*{;YlCsP`4AD)Ls!d)+RC`oOGY8lxW};-^n7e(WH;h>#830>Q@d~T
zIKmUNf2?~Azn-zNi1;6VeN_~*VE^jJqhq!8+rH@5by?qd&+@p`yY~l?4YqmqzaF+mq^Y+_cAy>saVJ>P*xDC%8R~Vi(t{~nP=ZenIaXK`gJ_>Is
z8^K%3V%|;~$KLCeg@H+qw>6HpHRZf5=2o>@J5yDm3Hl%;XTiWux4b@lp;oI_j3TLe>c;5C?r;@inhn~M*!CSp6x*9zAsQaUmd>SJ6
zssBMLusALpo>1cT@TDbQPm6e6{n3)&m);t$sTUsoK>oe|D;it%^7|>qeZexfp5j%W
zpB4Fr;b+rEzwp^yp-8#t*FVR>WSwEQ_7j5xJTu$cQ9YRHSTl$%Xb^9A%`DZEEXvNC
zAEu7Y@3(y*f3)QT`5)0Ity!$AT{Bo$n;kTM+5gpp^&P8$mpxX+`JA4v!|$dG!|$Xc
zIeRx3@$|uTA$%|w+mIDbzf>k%>RoPM?TCH@l&{5(tjT`=%z=B-g#q9kyC+vzld7p1
zXiJ+N8EDHDYS6_scRj(mytnMAuLU3MoXODrw0FPSPS_Iahu~)T1IyZ$4uIRw;G=I_
zHcIWC2zHs=(O;80+KlB+eV5$1Ha&g9cc^o!i;bwmTEfWAmgnd81krfqu*T&5hs=NM
zX*un~`MqCuaSF5&-xkZJ0>^YjaeK!E_-A&>NWMfjun9=d`zyv?Sy(g3hYt!nl?{HuRF8vPp^0XV@a<&
z_MSkmYsaC}D+e`~ok{AoFOwbRcfFp=u8pDB{l4Aa3-fKRU6TdD8joH(I5y949c#9n
zKJWDNU6OI?1htVqD+e;fzTyGOL+Y;9`@p%3?=OOnKilYj*1G$(Nl873NJFqx+&=&=GLAvZLDsVuI^nmx%yCbCVCq!*|xewo=jyqq?{0wc$fA-Us^1E6W@5dppUm6sCPG5F6
zF;99a=LjuqWPkCg`O~%6A3ST2n<8@Ked_?S<9#bUjeOyRa$gu4&xv1<4}X8r2B()!
zjwefw&!#Lqn$2&Z&%<{_eF*PpF8HzX?yFs#nJ61m2Y>8zkg@W{{~YOp;+(oK5SIZ%
zW99ITajoGSE2DVY<^INkdC-VnQ1H51@%?B;M$6H;Zx0p&JRRmc+t}8taGwPe}^xJUTdq^7uYzY{e8vDfg>My
zDSf$g6q->sf@YM(G+P19fHAPb(d;4zdo{GZh<7Jq3$eAub*`Np?d0@3K{M)w;AJ!e
zj_~j}#WWMXf+>8D1G9`~^wDTWTQSY_u4v}kifOi1?M3^xSGhIz)lX<(ZXppLT~^Y8nFg(?LFKf#}n+w&C8a8!vcxoK0e~%RAxGrbr%~
zt@U%Qvdfjvk~Es9x#egaYkOz+{mjolotOXl!wyI2EI5+4#r6EAtvKg?JM_7|G0M3g
zrJv~Zhx}X#@5RsD{M60Pj?Pfu{d4B1!qhy=ZL6#Xcv~scyj8Wvti&i=HFrgMY3?eT
zbBc8doQ#gp$M``%J6DFiD6tKE1)OCA-JX&ymHAutmKcs6XJ8a5
zi)|oh))av;&;##N
z1`6Nfz$~+Y^wDe}ZN)ZF@5%